Migrate to libsignal getDevices/removeDevice.

Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
andrew-signal
2026-07-17 12:39:58 -04:00
committed by Greyson Parrelli
parent 70e768ba05
commit c0283fb75f
4 changed files with 84 additions and 49 deletions
@@ -5,18 +5,18 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.core.util.roundedString
import org.signal.libsignal.net.RequestResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
@@ -32,7 +32,7 @@ class LinkedDeviceInactiveCheckJob private constructor(
.setMaxAttempts(Parameters.UNLIMITED)
.addConstraint(NetworkConstraint.KEY)
.build()
) : Job(parameters) {
) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(LinkedDeviceInactiveCheckJob::class.java)
@@ -61,7 +61,7 @@ class LinkedDeviceInactiveCheckJob private constructor(
override fun getFactoryKey(): String = KEY
override fun run(): Result {
override suspend fun doRun(): Result {
if (!SignalStore.account.isRegistered) {
Log.i(TAG, "Not registered, skipping.")
return Result.success()
@@ -72,14 +72,11 @@ class LinkedDeviceInactiveCheckJob private constructor(
return Result.success()
}
val devices = try {
AppDependencies
.linkDeviceApi
.getDevices()
.successOrThrow()
.filter { it.id != SignalServiceAddress.DEFAULT_DEVICE_ID }
} catch (e: IOException) {
return Result.retry(defaultBackoff())
val devices = when (val result = AppDependencies.linkDeviceApi.getDevices()) {
is RequestResult.Success -> result.result.filter { it.id != SignalServiceAddress.DEFAULT_DEVICE_ID }
is RequestResult.RetryableNetworkError -> return Result.retry(defaultBackoff())
is RequestResult.ApplicationError -> throw result.cause
is RequestResult.NonSuccess -> error("Code branch is unreachable")
}
if (devices.isEmpty()) {
@@ -93,16 +90,16 @@ class LinkedDeviceInactiveCheckJob private constructor(
}
val leastActiveDevice: LeastActiveLinkedDevice? = devices
.filter { it.name != null }
.filter { it.encryptedName.isNotEmpty() }
.minByOrNull { it.lastSeen }
?.let {
val nameProto = DeviceName.ADAPTER.decode(Base64.decode(it.getName()))
val nameProto = DeviceName.ADAPTER.decode(it.encryptedName)
val decryptedBytes = DeviceNameCipher.decryptDeviceName(nameProto, AppDependencies.protocolStore.aci().identityKeyPair) ?: return@let null
val name = String(decryptedBytes)
LeastActiveLinkedDevice(
name = name,
lastActiveTimestamp = it.lastSeen
lastActiveTimestamp = it.lastSeen.toEpochMilli()
)
}
@@ -42,6 +42,7 @@ import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import org.signal.libsignal.net.LinkedDevice as LibSignalLinkedDevice
/**
* Repository for linked devices and its various actions (linking, unlinking, listing).
@@ -53,31 +54,35 @@ object LinkDeviceRepository {
suspend fun removeDevice(deviceId: Int): Boolean {
return when (val result = AppDependencies.linkDeviceApi.removeDevice(deviceId)) {
is NetworkResult.Success -> {
is RequestResult.Success -> {
LinkedDeviceInactiveCheckJob.enqueue()
true
}
else -> {
Log.w(TAG, "Unable to remove device", result.getCause())
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Unable to remove device", result.networkError)
false
}
is RequestResult.ApplicationError -> throw result.cause
is RequestResult.NonSuccess -> error("Code branch is unreachable")
}
}
fun loadDevices(): List<Device>? {
suspend fun loadDevices(): List<Device>? {
return when (val result = AppDependencies.linkDeviceApi.getDevices()) {
is NetworkResult.Success -> {
is RequestResult.Success -> {
result
.result
.filter { d: DeviceInfo -> d.getId() != SignalServiceAddress.DEFAULT_DEVICE_ID }
.map { deviceInfo: DeviceInfo -> deviceInfo.toDevice() }
.filter { it.id != SignalServiceAddress.DEFAULT_DEVICE_ID }
.map { it.toLocalDevice() }
.sortedBy { it.createdMillis }
.toList()
}
else -> {
Log.w(TAG, "Unable to load device", result.getCause())
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Unable to load device", result.networkError)
null
}
is RequestResult.ApplicationError -> throw result.cause
is RequestResult.NonSuccess -> error("Code branch is unreachable")
}
}
@@ -89,10 +94,10 @@ object LinkDeviceRepository {
lastSeen = response.lastSeen
registrationId = response.registrationId
createdAtCiphertext = response.createdAtCiphertext
}.toDevice()
}.toLocalDevice()
}
private fun DeviceInfo.toDevice(): Device {
private fun DeviceInfo.toLocalDevice(): Device {
val createdAt = this.getPlaintextCreatedAt()
val defaultDevice = Device(getId(), getName(), createdAt, getLastSeen(), getRegistrationId())
try {
@@ -120,11 +125,54 @@ object LinkDeviceRepository {
return defaultDevice
}
private fun LibSignalLinkedDevice.toLocalDevice(): Device {
val createdAt = getPlaintextCreatedAt()
val defaultDevice = Device(this.id, Base64.encodeWithPadding(this.encryptedName), createdAt, this.lastSeen.toEpochMilli(), this.registrationId)
try {
if (this.encryptedName.size < 4) {
Log.w(TAG, "Invalid LinkedDevice name.")
return defaultDevice
}
val deviceName = DeviceName.ADAPTER.decode(this.encryptedName)
if (deviceName.ciphertext == null || deviceName.ephemeralPublic == null || deviceName.syntheticIv == null) {
Log.w(TAG, "Got a DeviceName that wasn't properly populated.")
return defaultDevice
}
val plaintext = DeviceNameCipher.decryptDeviceName(deviceName, SignalStore.account.aciIdentityKey)
if (plaintext == null) {
Log.w(TAG, "Failed to decrypt device name.")
return defaultDevice
}
return Device(id, String(plaintext), createdAt, lastSeen.toEpochMilli(), registrationId)
} catch (e: Exception) {
Log.w(TAG, "Failed while reading the protobuf.", e)
}
return defaultDevice
}
private fun DeviceInfo.getPlaintextCreatedAt(): Long? {
return try {
val associatedData = byteArrayOf(getId().toByte()) + getRegistrationId().toByteArray()
val associatedData = byteArrayOf(getId().toByte()) + this.getRegistrationId().toByteArray()
val createdAtPlaintext = SignalStore.account.aciIdentityKey.privateKey.open(
ciphertext = Base64.decode(getCreatedAtCiphertext().toByteArray()),
ciphertext = Base64.decode(this.getCreatedAtCiphertext().toByteArray()),
info = DECRYPTION_INFO,
associatedData = associatedData
)
ByteBuffer.wrap(createdAtPlaintext).getLong()
} catch (e: Exception) {
Log.w(TAG, "Failed while reading the protobuf.", e)
null
}
}
private fun LibSignalLinkedDevice.getPlaintextCreatedAt(): Long? {
return try {
val associatedData = byteArrayOf(this.id.toByte()) + this.registrationId.toByteArray()
val createdAtPlaintext = SignalStore.account.aciIdentityKey.privateKey.open(
ciphertext = this.createdAtCiphertext,
info = DECRYPTION_INFO,
associatedData = associatedData
)
@@ -81,7 +81,7 @@ fun <T : Any> RequestResult<T, Nothing>.successOrThrow(): T {
return when (this) {
is RequestResult.Success -> result
is RequestResult.RetryableNetworkError -> throw networkError
is RequestResult.NonSuccess -> error("Branch is unreachable")
is RequestResult.NonSuccess -> error("Code branch is unreachable")
is RequestResult.ApplicationError -> throw when (val error = cause) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
@@ -14,12 +14,13 @@ import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.urlEncode
import org.signal.libsignal.net.AuthDevicesService
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.NetworkResult
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.delete
import org.signal.network.websocket.get
import org.signal.network.websocket.put
import org.whispersystems.signalservice.api.fromWebSocketRequest
@@ -29,15 +30,14 @@ import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveR
import org.whispersystems.signalservice.api.link.TransferArchiveError
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
import org.whispersystems.signalservice.api.provisioning.ProvisioningMessage
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.DeviceInfoList
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.ProvisioningVersion
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.signal.libsignal.net.LinkedDevice as LibSignalLinkedDevice
/**
* Class to interact with device-linking endpoints.
@@ -47,29 +47,19 @@ class LinkDeviceApi(
) {
/**
* Fetches a list of linked devices.
*
* GET /v1/devices
*
* - 200: Success
*/
fun getDevices(): NetworkResult<List<DeviceInfo>> {
val request = WebSocketRequestMessage.get("/v1/devices")
return NetworkResult
.fromWebSocketRequest(authWebSocket, request, DeviceInfoList::class)
.map { it.getDevices() }
suspend fun getDevices(): RequestResult<List<LibSignalLinkedDevice>, Nothing> {
return authWebSocket.runCatchingWithChatConnection { connection ->
AuthDevicesService(connection).getDevices()
}
}
/**
* Remove and unlink a linked device.
*
* DELETE /v1/devices/{id}
*
* - 200: Success
*/
suspend fun removeDevice(deviceId: Int): NetworkResult<Unit> {
val request = WebSocketRequestMessage.delete("/v1/devices/$deviceId")
return NetworkResult.fromWebSocketSuspend(NetworkResult.DefaultWebSocketConverter(Unit::class)) {
authWebSocket.requestSuspend(request)
suspend fun removeDevice(deviceId: Int): RequestResult<Unit, Nothing> {
return authWebSocket.runCatchingWithChatConnection { connection ->
AuthDevicesService(connection).removeDevice(deviceId)
}
}