diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt index 8a046e1043..ff40765f6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt @@ -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() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index 539296986b..875ff5f516 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -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? { + suspend fun loadDevices(): List? { 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 ) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt index bbe3e7b6d8..926b9aeab5 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt @@ -81,7 +81,7 @@ fun RequestResult.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) diff --git a/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt b/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt index 69958c5949..be42cf3f7d 100644 --- a/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt @@ -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> { - val request = WebSocketRequestMessage.get("/v1/devices") - return NetworkResult - .fromWebSocketRequest(authWebSocket, request, DeviceInfoList::class) - .map { it.getDevices() } + suspend fun getDevices(): RequestResult, 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 { - val request = WebSocketRequestMessage.delete("/v1/devices/$deviceId") - return NetworkResult.fromWebSocketSuspend(NetworkResult.DefaultWebSocketConverter(Unit::class)) { - authWebSocket.requestSuspend(request) + suspend fun removeDevice(deviceId: Int): RequestResult { + return authWebSocket.runCatchingWithChatConnection { connection -> + AuthDevicesService(connection).removeDevice(deviceId) } }