diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index ee05d55c6a..b2239c98f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -4204,7 +4204,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da Log.w(TAG, "Contact is marked as registered, but has no serviceId! Can't locally mark registered. (Phone: ${contact.proto.e164.nullIfBlank()}, Username: ${username?.isNotEmpty()})") } - if (isInsert) { + if (SignalStore.account.isLinkedDevice) { + val avatarColor = StorageSyncModels.remoteToLocalAvatarColor(contact.proto.avatarColor) ?: AvatarColorHash.forAddress(contact.proto.signalAci ?: contact.proto.signalPni, contact.proto.e164) + put(AVATAR_COLOR, avatarColor.serialize()) + } else if (isInsert) { put(AVATAR_COLOR, AvatarColorHash.forAddress(contact.proto.signalAci ?: contact.proto.signalPni, contact.proto.e164).serialize()) } } @@ -4251,7 +4254,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da putNull(STORAGE_SERVICE_PROTO) } - if (isInsert) { + if (SignalStore.account.isLinkedDevice) { + val avatarColor = StorageSyncModels.remoteToLocalAvatarColor(groupV2.proto.avatarColor) ?: AvatarColorHash.forGroupId(groupId) + put(AVATAR_COLOR, avatarColor.serialize()) + } else if (isInsert) { put(AVATAR_COLOR, AvatarColorHash.forGroupId(groupId).serialize()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index cafb98d553..8aa79ae825 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -177,7 +177,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc @Throws(IOException::class, RetryLaterException::class, UntrustedIdentityException::class) override fun onRun() { - if (!(SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut()) { + if (!(SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.restoredAccountEntropyPoolFromPrimary) && !SignalStore.svr.hasOptedOut()) { Log.i(TAG, "Doesn't have access to storage service. Skipping.") return } @@ -197,6 +197,11 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc return } + if (SignalStore.account.isLinkedDevice && !SignalStore.account.restoredAccountEntropyPoolFromPrimary) { + Log.w(TAG, "Have not restored AEP from primary, skipping.") + return + } + val (storageServiceKey, usingTempKey) = SignalStore.storageService.storageKeyForInitialDataRestore?.let { Log.i(TAG, "Using temporary storage key.") it to true @@ -228,7 +233,6 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc .enqueue() } else { Log.w(TAG, "Failed to decrypt remote storage! Requesting new keys from primary.", e) - SignalStore.storageService.clearStorageKeyFromPrimary() AppDependencies.signalServiceMessageSender.sendSyncMessage(SignalServiceSyncMessage.forRequest(RequestMessage.forType(SyncMessage.Request.Type.KEYS))) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index c4c39c0f28..f2487b734e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -88,6 +88,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool" private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool" + private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY = "account.restore_account_entropy_pool_primary" private val AEP_LOCK = ReentrantLock() } @@ -151,6 +152,17 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) } } + fun setAccountEntropyPoolFromPrimaryDevice(aep: AccountEntropyPool) { + AEP_LOCK.withLock { + Log.i(TAG, "Setting new AEP from primary device") + store + .beginWrite() + .putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value) + .putBoolean(KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY, true) + .commit() + } + } + fun restoreAccountEntropyPool(aep: AccountEntropyPool) { AEP_LOCK.withLock { store @@ -173,9 +185,10 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) } @get:JvmName("restoredAccountEntropyPool") - @get:Synchronized val restoredAccountEntropyPool by booleanValue(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, false) + val restoredAccountEntropyPoolFromPrimary by booleanValue(KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY, false) + /** The local user's [ACI]. */ val aci: ACI? get() = ACI.parseOrNull(getString(KEY_ACI, null)) @@ -300,18 +313,6 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) } } - /** When acting as a linked device, this method lets you store the identity keys sent from the primary device */ - fun setAciIdentityKeysFromPrimaryDevice(aciKeys: IdentityKeyPair) { - synchronized(this) { - require(isLinkedDevice) { "Must be a linked device!" } - store - .beginWrite() - .putBlob(KEY_ACI_IDENTITY_PUBLIC_KEY, aciKeys.publicKey.serialize()) - .putBlob(KEY_ACI_IDENTITY_PRIVATE_KEY, aciKeys.privateKey.serialize()) - .commit() - } - } - /** Set an identity key pair for the PNI identity via change number. */ fun setPniIdentityKeyAfterChangeNumber(key: IdentityKeyPair) { synchronized(this) { @@ -475,12 +476,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) Recipient.self().live().refresh() } - val deviceName: String? - get() = getString(KEY_DEVICE_NAME, null) - - fun setDeviceName(deviceName: String) { - putString(KEY_DEVICE_NAME, deviceName) - } + var deviceName: String? by stringValue(KEY_DEVICE_NAME, null) var deviceId: Int by integerValue(KEY_DEVICE_ID, SignalServiceAddress.DEFAULT_DEVICE_ID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt index 480898b1cf..9ee535982e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.keyvalue import org.signal.core.util.logging.Log import org.whispersystems.signalservice.api.storage.SignalStorageManifest import org.whispersystems.signalservice.api.storage.StorageKey -import org.whispersystems.signalservice.api.util.Preconditions class StorageServiceValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { companion object { @@ -12,9 +11,6 @@ class StorageServiceValues internal constructor(store: KeyValueStore) : SignalSt private const val LAST_SYNC_TIME = "storage.last_sync_time" private const val NEEDS_ACCOUNT_RESTORE = "storage.needs_account_restore" private const val MANIFEST = "storage.manifest" - - // TODO [linked-device] No need to track this separately -- we'd get the AEP from the primary - private const val SYNC_STORAGE_KEY = "storage.syncStorageKey" } public override fun onFirstEverAppLaunch() = Unit @@ -23,24 +19,9 @@ class StorageServiceValues internal constructor(store: KeyValueStore) : SignalSt val storageKey: StorageKey get() { - if (store.containsKey(SYNC_STORAGE_KEY)) { - return StorageKey(getBlob(SYNC_STORAGE_KEY, null)) - } return SignalStore.svr.masterKey.deriveStorageServiceKey() } - @Synchronized - fun setStorageKeyFromPrimary(storageKey: StorageKey) { - Preconditions.checkState(SignalStore.account.isLinkedDevice, "Can only set storage key directly on linked devices") - putBlob(SYNC_STORAGE_KEY, storageKey.serialize()) - } - - @Synchronized - fun clearStorageKeyFromPrimary() { - Preconditions.checkState(SignalStore.account.isLinkedDevice, "Can only clear storage key directly on linked devices") - remove(SYNC_STORAGE_KEY) - } - var lastSyncTime: Long by longValue(LAST_SYNC_TIME, 0) var needsAccountRestore: Boolean by booleanValue(NEEDS_ACCOUNT_RESTORE, false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index de6e3ff323..2d051cf4b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.messages import android.content.Context import com.mobilecoin.lib.exceptions.SerializationException -import okio.ByteString import org.signal.core.util.Base64 import org.signal.core.util.Hex import org.signal.core.util.isNotEmpty @@ -102,6 +101,8 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil import org.thoughtcrime.securesms.util.SignalE164Util import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.AccountEntropyPool +import org.whispersystems.signalservice.api.backup.MediaRootBackupKey import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer import org.whispersystems.signalservice.api.push.DistributionId @@ -109,7 +110,6 @@ import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI import org.whispersystems.signalservice.api.push.SignalServiceAddress -import org.whispersystems.signalservice.api.storage.StorageKey import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.AddressableMessage import org.whispersystems.signalservice.internal.push.Content @@ -166,6 +166,7 @@ object SyncMessageProcessor { syncMessage.messageRequestResponse != null -> handleSynchronizeMessageRequestResponse(syncMessage.messageRequestResponse!!, envelope.timestamp!!) syncMessage.outgoingPayment != null -> handleSynchronizeOutgoingPayment(syncMessage.outgoingPayment!!, envelope.timestamp!!) syncMessage.contacts != null -> handleSynchronizeContacts(syncMessage.contacts!!, envelope.timestamp!!) + syncMessage.keys != null -> handleSynchronizeKeys(syncMessage.keys!!, envelope.timestamp!!) syncMessage.callEvent != null -> handleSynchronizeCallEvent(syncMessage.callEvent!!, envelope.timestamp!!) syncMessage.callLinkUpdate != null -> handleSynchronizeCallLink(syncMessage.callLinkUpdate!!, envelope.timestamp!!) syncMessage.callLogEvent != null -> handleSynchronizeCallLogEvent(syncMessage.callLogEvent!!, envelope.timestamp!!) @@ -1255,7 +1256,7 @@ object SyncMessageProcessor { log("Inserted synchronized payment $uuid") } - private fun handleSynchronizeKeys(storageKey: ByteString, envelopeTimestamp: Long) { + private fun handleSynchronizeKeys(keys: SyncMessage.Keys, envelopeTimestamp: Long) { if (SignalStore.account.isLinkedDevice) { log(envelopeTimestamp, "Synchronize keys.") } else { @@ -1263,7 +1264,13 @@ object SyncMessageProcessor { return } - SignalStore.storageService.setStorageKeyFromPrimary(StorageKey(storageKey.toByteArray())) + if (keys.accountEntropyPool != null) { + SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(AccountEntropyPool(keys.accountEntropyPool!!)) + } + + if (keys.mediaRootBackupKey != null) { + SignalStore.backup.mediaRootBackupKey = MediaRootBackupKey(keys.mediaRootBackupKey!!.toByteArray()) + } } @Throws(IOException::class) diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/PniAccountInitializationMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/PniAccountInitializationMigrationJob.java index ce457d4fa4..a15d5e7993 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/PniAccountInitializationMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/PniAccountInitializationMigrationJob.java @@ -57,6 +57,11 @@ public class PniAccountInitializationMigrationJob extends MigrationJob { @Override public void performMigration() throws IOException { + if (SignalStore.account().isLinkedDevice()) { + Log.i(TAG, "Linked device, skipping"); + return; + } + PNI pni = SignalStore.account().getPni(); if (pni == null || SignalStore.account().getAci() == null || !Recipient.self().isRegistered()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt index e3c665316f..d0090f6f29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt @@ -9,6 +9,7 @@ import okio.ByteString.Companion.toByteString import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata import org.whispersystems.signalservice.api.account.PreKeyCollection @@ -17,7 +18,14 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection * and combines them into a proto-backed class [LocalRegistrationMetadata] so they can be serialized & stored. */ object LocalRegistrationMetadataUtil { - fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata { + fun createLocalRegistrationMetadata( + localAciIdentityKeyPair: IdentityKeyPair, + localPniIdentityKeyPair: IdentityKeyPair, + registrationData: RegistrationData, + remoteResult: AccountRegistrationResult, + reglockEnabled: Boolean, + linkedDeviceInfo: LinkedDeviceInfo? = null + ): LocalRegistrationMetadata { return LocalRegistrationMetadata.Builder().apply { aciIdentityKeyPair = localAciIdentityKeyPair.serialize().toByteString() aciSignedPreKey = remoteResult.aciPreKeyCollection.signedPreKey.serialize().toByteString() @@ -39,6 +47,7 @@ object LocalRegistrationMetadataUtil { profileKey = registrationData.profileKey.serialize().toByteString() servicePassword = registrationData.password this.reglockEnabled = reglockEnabled + this.linkedDeviceInfo = linkedDeviceInfo }.build() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegisterAsLinkedDeviceResponse.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegisterAsLinkedDeviceResponse.kt new file mode 100644 index 0000000000..36b88b60e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegisterAsLinkedDeviceResponse.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.data + +data class RegisterAsLinkedDeviceResponse( + val deviceId: Int, + val accountRegistrationResult: AccountRegistrationResult +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt index accbd065f2..c0f33f5168 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.registrationv3.data import android.app.backup.BackupManager import android.content.Context import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread import androidx.core.app.NotificationManagerCompat import com.google.android.gms.auth.api.phone.SmsRetriever import kotlinx.coroutines.Dispatchers @@ -32,8 +33,10 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.gcm.FcmUtil +import org.thoughtcrime.securesms.jobmanager.runJobBlocking import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob import org.thoughtcrime.securesms.jobs.PreKeysSyncJob +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob import org.thoughtcrime.securesms.jobs.RotateCertificateJob import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -50,6 +53,7 @@ import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUti import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciPreKeyCollection import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniIdentityKeyPair import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniPreKeyCollection +import org.thoughtcrime.securesms.registration.data.RegisterAsLinkedDeviceResponse import org.thoughtcrime.securesms.registration.data.RegistrationData import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult @@ -58,14 +62,17 @@ import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionC import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest +import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet import org.thoughtcrime.securesms.service.DirectoryRefreshListener import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.AccountEntropyPool import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.SvrNoDataException import org.whispersystems.signalservice.api.account.AccountAttributes import org.whispersystems.signalservice.api.account.PreKeyCollection +import org.whispersystems.signalservice.api.backup.MediaRootBackupKey import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess import org.whispersystems.signalservice.api.kbs.MasterKey import org.whispersystems.signalservice.api.kbs.PinHashUtil @@ -76,6 +83,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.api.registration.RegistrationApi import org.whispersystems.signalservice.api.svr.Svr3Credentials import org.whispersystems.signalservice.internal.push.AuthCredentials +import org.whispersystems.signalservice.internal.push.ProvisionMessage import org.whispersystems.signalservice.internal.push.PushServiceSocket import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse import org.whispersystems.signalservice.internal.push.VerifyAccountResponse @@ -207,6 +215,19 @@ object RegistrationRepository { saveOwnIdentityKey(selfId, aci, aciProtocolStore, now) saveOwnIdentityKey(selfId, pni, pniProtocolStore, now) + if (data.linkedDeviceInfo != null) { + SignalStore.account.deviceId = data.linkedDeviceInfo.deviceId + SignalStore.account.deviceName = data.linkedDeviceInfo.deviceName + + if (data.linkedDeviceInfo.accountEntropyPool != null) { + SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(AccountEntropyPool(data.linkedDeviceInfo.accountEntropyPool)) + } + + if (data.linkedDeviceInfo.mediaRootBackupKey != null) { + SignalStore.backup.mediaRootBackupKey = MediaRootBackupKey(data.linkedDeviceInfo.mediaRootBackupKey.toByteArray()) + } + } + SignalStore.account.setServicePassword(data.servicePassword) SignalStore.account.setRegistered(true) TextSecurePreferences.setPromptedPushRegistration(context, true) @@ -221,11 +242,23 @@ object RegistrationRepository { PreKeysSyncJob.enqueue() val jobManager = AppDependencies.jobManager - jobManager.add(DirectoryRefreshJob(false)) - jobManager.add(RotateCertificateJob()) - DirectoryRefreshListener.schedule(context) - RotateSignedPreKeyListener.schedule(context) + if (data.linkedDeviceInfo == null) { + jobManager.add(DirectoryRefreshJob(false)) + jobManager.add(RotateCertificateJob()) + + DirectoryRefreshListener.schedule(context) + RotateSignedPreKeyListener.schedule(context) + } else { + // TODO [linked-device] May want to have a different opt out mechanism for linked devices + SvrRepository.optOutOfPin() + + SignalStore.registration.hasUploadedProfile = true + jobManager.runJobBlocking(RefreshOwnProfileJob(), 30.seconds) + + jobManager.add(RotateCertificateJob()) + RotateSignedPreKeyListener.schedule(context) + } } @JvmStatic @@ -446,6 +479,62 @@ object RegistrationRepository { return@withContext RegisterAccountResult.from(result) } + @WorkerThread + fun registerAsLinkedDevice( + context: Context, + deviceName: String, + message: ProvisionMessage, + registrationData: RegistrationData, + aciIdentityKeyPair: IdentityKeyPair, + pniIdentityKeyPair: IdentityKeyPair + ): NetworkResult { + val aci = message.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(message.aci) + val pni = message.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(message.pni) + + val universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context) + val unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey) + + val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair) + + val accountAttributes = AccountAttributes( + signalingKey = null, + registrationId = getRegistrationId(), + fetchesMessages = registrationData.fcmToken == null, + registrationLock = null, + unidentifiedAccessKey = unidentifiedAccessKey, + unrestrictedUnidentifiedAccess = universalUnidentifiedAccess, + capabilities = AppCapabilities.getCapabilities(false), + discoverableByPhoneNumber = false, + name = Base64.encodeWithPadding(encryptedDeviceName), + pniRegistrationId = getPniRegistrationId(), + recoveryPassword = null + ) + + val aciPreKeys = generateSignedAndLastResortPreKeys(aciIdentityKeyPair, SignalStore.account.aciPreKeys) + val pniPreKeys = generateSignedAndLastResortPreKeys(pniIdentityKeyPair, SignalStore.account.pniPreKeys) + + return AccountManagerFactory + .getInstance() + .createUnauthenticated(context, message.number!!, -1, registrationData.password) + .registrationApi + .registerAsSecondaryDevice(message.provisioningCode!!, accountAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken) + .map { respone -> + RegisterAsLinkedDeviceResponse( + deviceId = respone.deviceId.toInt(), + accountRegistrationResult = AccountRegistrationResult( + uuid = aci.toString(), + pni = pni.toString(), + storageCapable = false, + number = message.number!!, + masterKey = MasterKey(message.masterKey!!.toByteArray()), + pin = null, + aciPreKeyCollection = aciPreKeys, + pniPreKeyCollection = pniPreKeys + ) + ) + } + } + private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult = withContext(Dispatchers.IO) { // TODO [regv2]: do not use event bus nor latch diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt index ee0e5c9b73..09c9e5d14e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt @@ -25,9 +25,13 @@ import kotlinx.coroutines.withContext import org.signal.core.util.Base64 import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.RestoreTimestampResult +import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob @@ -69,18 +73,26 @@ import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequ import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError import org.thoughtcrime.securesms.registration.ui.toE164 +import org.thoughtcrime.securesms.registration.util.RegistrationUtil import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.thoughtcrime.securesms.registrationv3.ui.link.RegisterLinkDeviceResult import org.thoughtcrime.securesms.registrationv3.ui.restore.StorageServiceRestore import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.dualsim.MccMncProducer import org.whispersystems.signalservice.api.AccountEntropyPool +import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.SvrNoDataException import org.whispersystems.signalservice.api.kbs.MasterKey +import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage import org.whispersystems.signalservice.api.svr.Svr3Credentials import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException import org.whispersystems.signalservice.internal.push.AuthCredentials +import org.whispersystems.signalservice.internal.push.ProvisionMessage +import org.whispersystems.signalservice.internal.push.SyncMessage import java.io.IOException import java.nio.charset.StandardCharsets import kotlin.jvm.optionals.getOrNull @@ -1051,6 +1063,104 @@ class RegistrationViewModel : ViewModel() { } } + suspend fun registerAsLinkedDevice(context: Context, message: ProvisionMessage): RegisterLinkDeviceResult { + val deviceName = "Android" + + val aciIdentityKeyPair = IdentityKeyPair(IdentityKey(message.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(message.aciIdentityKeyPrivate!!.toByteArray())) + val pniIdentityKeyPair = IdentityKeyPair(IdentityKey(message.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(message.pniIdentityKeyPrivate!!.toByteArray())) + + val profileKey = ProfileKey(message.profileKey!!.toByteArray()) + val serverAuthToken = Util.getSecret(18) + val fcmToken = RegistrationRepository.getFcmToken(context) + + val registrationData = RegistrationData( + code = "", + e164 = message.number!!, + password = serverAuthToken, + registrationId = RegistrationRepository.getRegistrationId(), + profileKey = profileKey, + fcmToken = fcmToken, + pniRegistrationId = RegistrationRepository.getPniRegistrationId(), + recoveryPassword = null + ) + + val result = RegistrationRepository.registerAsLinkedDevice( + context = context, + deviceName = deviceName, + message = message, + registrationData = registrationData, + aciIdentityKeyPair = aciIdentityKeyPair, + pniIdentityKeyPair = pniIdentityKeyPair + ) + + when (result) { + is NetworkResult.Success -> { + val data = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata( + localAciIdentityKeyPair = aciIdentityKeyPair, + localPniIdentityKeyPair = pniIdentityKeyPair, + registrationData = registrationData, + remoteResult = result.result.accountRegistrationResult, + reglockEnabled = false, + linkedDeviceInfo = LinkedDeviceInfo( + deviceId = result.result.deviceId, + deviceName = deviceName, + ephemeralBackupKey = message.ephemeralBackupKey, + accountEntropyPool = message.accountEntropyPool, + mediaRootBackupKey = message.mediaRootBackupKey + ) + ) + + if (message.readReceipts != null) { + TextSecurePreferences.setReadReceiptsEnabled(context, message.readReceipts!!) + } + + RegistrationRepository.registerAccountLocally(context, data) + } + is NetworkResult.ApplicationError -> return RegisterLinkDeviceResult.UnexpectedException(result.throwable) + is NetworkResult.NetworkError<*> -> return RegisterLinkDeviceResult.NetworkException(result.exception) + is NetworkResult.StatusCodeError -> { + return when (result.code) { + 403 -> RegisterLinkDeviceResult.IncorrectVerification + 409 -> RegisterLinkDeviceResult.MissingCapability + 411 -> RegisterLinkDeviceResult.MaxLinkedDevices + 422 -> RegisterLinkDeviceResult.InvalidRequest + 429 -> RegisterLinkDeviceResult.RateLimited(result.retryAfter()) + else -> RegisterLinkDeviceResult.UnexpectedException(result.exception) + } + } + } + + RegistrationUtil.maybeMarkRegistrationComplete() + + refreshRemoteConfig() + + for (type in SyncMessage.Request.Type.entries) { + if (type == SyncMessage.Request.Type.UNKNOWN) { + continue + } + + Log.i(TAG, "Sending sync request for $type") + AppDependencies.signalServiceMessageSender.sendSyncMessage( + SignalServiceSyncMessage.forRequest(RequestMessage(SyncMessage.Request(type = type))) + ) + } + + SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount + SignalStore.onboarding.clearAll() + + if (SignalStore.account.restoredAccountEntropyPoolFromPrimary) { + StorageServiceRestore.restore() + } + + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE + ) + } + + return RegisterLinkDeviceResult.Success + } + /** Converts the basic-auth creds we have locally into username:password pairs that are suitable for handing off to the service. */ private fun List.toSvrCredentials(): AuthCredentials? { return this diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceResult.kt new file mode 100644 index 0000000000..f92196cd35 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceResult.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.link + +import kotlin.time.Duration + +sealed interface RegisterLinkDeviceResult { + data object Success : RegisterLinkDeviceResult + data object IncorrectVerification : RegisterLinkDeviceResult + data object MissingCapability : RegisterLinkDeviceResult + data object MaxLinkedDevices : RegisterLinkDeviceResult + data object InvalidRequest : RegisterLinkDeviceResult + data class RateLimited(val retryAfter: Duration?) : RegisterLinkDeviceResult + data class NetworkException(val t: Throwable) : RegisterLinkDeviceResult + data class UnexpectedException(val t: Throwable) : RegisterLinkDeviceResult +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt index 0e98f77be9..9c784d964e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt @@ -121,7 +121,8 @@ class RestoreViaQrViewModel : ViewModel() { } } - return ProvisioningSocket.start( + return ProvisioningSocket.start( + mode = ProvisioningSocket.Mode.REREG, identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(), configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(), handler = { id, t -> @@ -152,9 +153,9 @@ class RestoreViaQrViewModel : ViewModel() { ) } - val result = socket.getRegistrationProvisioningMessage() + val result = socket.getProvisioningMessageDecryptResult() - if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) { + if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) { Log.i(TAG, "Saving restore method token: ***${result.message.restoreMethodToken.takeLast(4)}") SignalStore.registration.restoreMethodToken = result.message.restoreMethodToken SignalStore.registration.restoreBackupMediaSize = result.message.backupSizeBytes ?: 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt index ef3e9b0ddc..15d683be0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt @@ -231,7 +231,7 @@ class ContactRecordProcessor( nickname = remote.proto.nickname pniSignatureVerified = remote.proto.pniSignatureVerified || local.proto.pniSignatureVerified note = remote.proto.note.nullIfBlank() ?: "" - avatarColor = local.proto.avatarColor + avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor }.build().toSignalContactRecord(StorageId.forContact(keyGenerator.generate())) val matchesRemote = doParamsMatch(remote, merged) diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt index b24845c310..64cfd60248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.RecipientRecord import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.whispersystems.signalservice.api.storage.SignalGroupV2Record import org.whispersystems.signalservice.api.storage.SignalStorageRecord import org.whispersystems.signalservice.api.storage.StorageId @@ -58,7 +59,7 @@ class GroupV2RecordProcessor(private val recipientTable: RecipientTable, private dontNotifyForMentionsIfMuted = remote.proto.dontNotifyForMentionsIfMuted hideStory = remote.proto.hideStory storySendMode = remote.proto.storySendMode - avatarColor = local.proto.avatarColor + avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor }.build().toSignalGroupV2Record(StorageId.forGroupV2(keyGenerator.generate())) val matchesRemote = doParamsMatch(remote, merged) diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt index d25bffbe84..318c7eb496 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -390,6 +390,24 @@ object StorageSyncModels { } } + fun remoteToLocalAvatarColor(avatarColor: RemoteAvatarColor?): AvatarColor? { + return when (avatarColor) { + RemoteAvatarColor.A100 -> AvatarColor.A100 + RemoteAvatarColor.A110 -> AvatarColor.A110 + RemoteAvatarColor.A120 -> AvatarColor.A120 + RemoteAvatarColor.A130 -> AvatarColor.A130 + RemoteAvatarColor.A140 -> AvatarColor.A140 + RemoteAvatarColor.A150 -> AvatarColor.A150 + RemoteAvatarColor.A160 -> AvatarColor.A160 + RemoteAvatarColor.A170 -> AvatarColor.A170 + RemoteAvatarColor.A180 -> AvatarColor.A180 + RemoteAvatarColor.A190 -> AvatarColor.A190 + RemoteAvatarColor.A200 -> AvatarColor.A200 + RemoteAvatarColor.A210 -> AvatarColor.A210 + null -> null + } + } + fun localToRemoteChatFolder(folder: ChatFolderRecord, rawStorageId: ByteArray?): SignalChatFolderRecord { if (folder.chatFolderId == null) { throw AssertionError("Chat folder must have a chat folder id.") diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 811ea8f77d..b3a37f3b1a 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -564,6 +564,15 @@ message LocalRegistrationMetadata { bytes profileKey = 15; string servicePassword = 16; bool reglockEnabled = 17; + LinkedDeviceInfo linkedDeviceInfo = 18; +} + +message LinkedDeviceInfo { + uint32 deviceId = 1; + string deviceName = 2; + optional bytes ephemeralBackupKey = 3; + optional string accountEntropyPool = 4; + optional bytes mediaRootBackupKey = 5; } message RestoreDecisionState { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/VerifyDeviceResponse.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/RegisterAsSecondaryDeviceResponse.java similarity index 65% rename from libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/VerifyDeviceResponse.java rename to libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/RegisterAsSecondaryDeviceResponse.java index 7b455180c9..d187aada37 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/VerifyDeviceResponse.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/RegisterAsSecondaryDeviceResponse.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.UUID; -public class VerifyDeviceResponse { +public class RegisterAsSecondaryDeviceResponse { @JsonProperty private UUID uuid; @@ -12,11 +12,11 @@ public class VerifyDeviceResponse { private UUID pni; @JsonProperty - private int deviceId; + private String deviceId; - public VerifyDeviceResponse() {} + public RegisterAsSecondaryDeviceResponse() {} - public VerifyDeviceResponse(UUID uuid, UUID pni, int deviceId) { + public RegisterAsSecondaryDeviceResponse(UUID uuid, UUID pni, String deviceId) { this.uuid = uuid; this.pni = pni; this.deviceId = deviceId; @@ -30,7 +30,7 @@ public class VerifyDeviceResponse { return pni; } - public int getDeviceId() { + public String getDeviceId() { return deviceId; } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt index 3049101ede..3e9fde064d 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt @@ -30,6 +30,7 @@ import org.whispersystems.signalservice.api.buildOkHttpClient import org.whispersystems.signalservice.api.chooseUrl import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher +import org.whispersystems.signalservice.internal.push.ProvisionEnvelope import org.whispersystems.signalservice.internal.push.ProvisioningAddress import org.whispersystems.signalservice.internal.websocket.WebSocketMessage import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage @@ -43,7 +44,8 @@ import kotlin.time.Duration.Companion.seconds /** * A provisional web socket for communicating with a primary device during registration. */ -class ProvisioningSocket private constructor( +class ProvisioningSocket private constructor( + private val mode: Mode, val id: Int, identityKeyPair: IdentityKeyPair, configuration: SignalServiceConfiguration, @@ -56,19 +58,20 @@ class ProvisioningSocket private constructor( val LIFESPAN = 90.seconds - fun start( + fun start( + mode: Mode, identityKeyPair: IdentityKeyPair, configuration: SignalServiceConfiguration, handler: ProvisioningSocketExceptionHandler, - block: suspend CoroutineScope.(ProvisioningSocket) -> Unit + block: suspend CoroutineScope.(ProvisioningSocket) -> Unit ): Closeable { val socketId = nextSocketId++ val scope = CoroutineScope(Dispatchers.IO) + SupervisorJob() + CoroutineExceptionHandler { _, t -> handler.handleException(socketId, t) } scope.launch { - var socket: ProvisioningSocket? = null + var socket: ProvisioningSocket? = null try { - socket = ProvisioningSocket(socketId, identityKeyPair, configuration, scope) + socket = ProvisioningSocket(mode, socketId, identityKeyPair, configuration, scope) socket.connect() block(socket) } catch (e: CancellationException) { @@ -115,13 +118,13 @@ class ProvisioningSocket private constructor( private var webSocket: WebSocket? = null private val provisioningUrlDeferral: CompletableDeferred = CompletableDeferred() - private val provisioningMessageDeferral: CompletableDeferred = CompletableDeferred() + private val provisioningMessageDeferral: CompletableDeferred> = CompletableDeferred() suspend fun getProvisioningUrl(): String { return provisioningUrlDeferral.await() } - suspend fun getRegistrationProvisioningMessage(): SecondaryProvisioningCipher.RegistrationProvisionResult { + suspend fun getProvisioningMessageDecryptResult(): SecondaryProvisioningCipher.ProvisioningDecryptResult { return provisioningMessageDeferral.await() } @@ -172,6 +175,7 @@ class ProvisioningSocket private constructor( } } + @Suppress("UNCHECKED_CAST") override fun onMessage(webSocket: WebSocket, bytes: ByteString) { val message: WebSocketMessage = WebSocketMessage.ADAPTER.decode(bytes) @@ -207,8 +211,10 @@ class ProvisioningSocket private constructor( } "/v1/message" -> { - val result = cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(message.request.body)) - provisioningMessageDeferral.complete(result) + when (mode) { + Mode.REREG -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(message.request.body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult) + Mode.LINK -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(message.request.body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult) + } } else -> Log.w(TAG, "[$id] Unknown path requested") @@ -243,7 +249,7 @@ class ProvisioningSocket private constructor( private fun generateProvisioningUrl(deviceAddress: String): String { val encodedDeviceId = URLEncoder.encode(deviceAddress, "UTF-8") val encodedPubKey: String = URLEncoder.encode(Base64.encodeWithoutPadding(cipher.secondaryDevicePublicKey.serialize()), "UTF-8") - return "sgnl://rereg?uuid=$encodedDeviceId&pub_key=$encodedPubKey" + return "sgnl://${mode.host}?uuid=$encodedDeviceId&pub_key=$encodedPubKey" } private suspend fun keepAlive(webSocket: WebSocket) { @@ -282,6 +288,11 @@ class ProvisioningSocket private constructor( } } + enum class Mode(val host: String) { + REREG("rereg"), + LINK("linkdevice") + } + fun interface ProvisioningSocketExceptionHandler { fun handleException(id: Int, exception: Throwable) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt index e3339dd608..f10be4edf5 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt @@ -8,10 +8,15 @@ package org.whispersystems.signalservice.api.registration import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.account.AccountAttributes import org.whispersystems.signalservice.api.account.PreKeyCollection +import org.whispersystems.signalservice.api.messages.multidevice.RegisterAsSecondaryDeviceResponse import org.whispersystems.signalservice.api.provisioning.RestoreMethod +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity import org.whispersystems.signalservice.internal.push.BackupV2AuthCheckResponse import org.whispersystems.signalservice.internal.push.BackupV3AuthCheckResponse +import org.whispersystems.signalservice.internal.push.GcmRegistrationId +import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.push.RegisterAsSecondaryDeviceRequest import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse import org.whispersystems.signalservice.internal.push.VerifyAccountResponse import java.util.Locale @@ -133,4 +138,31 @@ class RegistrationApi( pushServiceSocket.setRestoreMethodChosen(token, RestoreMethodBody(method = method)) } } + + /** + * Registers a device as a linked device on a pre-existing account. + * + * `PUT /v1/devices/link` + * + * - 403: Incorrect account verification + * - 409: Device missing required account capability + * - 411: Account reached max number of linked devices + * - 422: Request is invalid + * - 429: Rate limited + */ + fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?): NetworkResult { + val request = RegisterAsSecondaryDeviceRequest( + verificationCode = verificationCode, + accountAttributes = attributes, + aciSignedPreKey = SignedPreKeyEntity(aciPreKeys.signedPreKey.id, aciPreKeys.signedPreKey.keyPair.publicKey, aciPreKeys.signedPreKey.signature), + pniSignedPreKey = SignedPreKeyEntity(pniPreKeys.signedPreKey.id, pniPreKeys.signedPreKey.keyPair.publicKey, pniPreKeys.signedPreKey.signature), + aciPqLastResortPreKey = KyberPreKeyEntity(aciPreKeys.lastResortKyberPreKey.id, aciPreKeys.lastResortKyberPreKey.keyPair.publicKey, aciPreKeys.lastResortKyberPreKey.signature), + pniPqLastResortPreKey = KyberPreKeyEntity(pniPreKeys.lastResortKyberPreKey.id, pniPreKeys.lastResortKyberPreKey.keyPair.publicKey, pniPreKeys.lastResortKyberPreKey.signature), + gcmToken = fcmToken?.let { GcmRegistrationId(it, true) } + ) + + return NetworkResult.fromFetch { + pushServiceSocket.registerAsSecondaryDevice(request) + } + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt index d7508fc758..6707644e4e 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt @@ -5,22 +5,19 @@ package org.whispersystems.signalservice.internal.crypto +import org.signal.core.util.isEmpty import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.protocol.ecc.ECPublicKey import org.signal.libsignal.protocol.kdf.HKDF -import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.signal.registration.proto.RegistrationProvisionEnvelope import org.signal.registration.proto.RegistrationProvisionMessage -import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.ProvisionEnvelope import org.whispersystems.signalservice.internal.push.ProvisionMessage import java.security.InvalidKeyException import java.security.MessageDigest import java.security.NoSuchAlgorithmException -import java.util.UUID import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec @@ -45,39 +42,40 @@ class SecondaryProvisioningCipher(private val secondaryIdentityKeyPair: Identity val secondaryDevicePublicKey: IdentityKey = secondaryIdentityKeyPair.publicKey - fun decrypt(envelope: ProvisionEnvelope): ProvisionDecryptResult { - val plaintext = decrypt(expectedVersion = 1, primaryEphemeralPublicKey = envelope.publicKey!!.toByteArray(), body = envelope.body!!.toByteArray()) + fun decrypt(envelope: ProvisionEnvelope): ProvisioningDecryptResult { + if (envelope.publicKey == null || envelope.body == null || envelope.publicKey.isEmpty() || envelope.body.isEmpty()) { + Log.w(TAG, "Public key or body is null or empty") + return ProvisioningDecryptResult.Error() + } + + val plaintext = decrypt(expectedVersion = 1, primaryEphemeralPublicKey = envelope.publicKey.toByteArray(), body = envelope.body.toByteArray()) if (plaintext == null) { Log.w(TAG, "Plaintext is null") - return ProvisionDecryptResult.Error + return ProvisioningDecryptResult.Error() } val provisioningMessage = ProvisionMessage.ADAPTER.decode(plaintext) - return ProvisionDecryptResult.Success( - uuid = UuidUtil.parseOrThrow(provisioningMessage.aci), - e164 = provisioningMessage.number!!, - identityKeyPair = IdentityKeyPair(IdentityKey(provisioningMessage.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(provisioningMessage.aciIdentityKeyPrivate!!.toByteArray())), - profileKey = ProfileKey(provisioningMessage.profileKey!!.toByteArray()), - areReadReceiptsEnabled = provisioningMessage.readReceipts == true, - primaryUserAgent = provisioningMessage.userAgent, - provisioningCode = provisioningMessage.provisioningCode!!, - provisioningVersion = provisioningMessage.provisioningVersion!! - ) + return ProvisioningDecryptResult.Success(provisioningMessage) } - fun decrypt(envelope: RegistrationProvisionEnvelope): RegistrationProvisionResult { + fun decrypt(envelope: RegistrationProvisionEnvelope): ProvisioningDecryptResult { + if (envelope.publicKey.isEmpty() || envelope.body.isEmpty()) { + Log.w(TAG, "Public key or body is empty") + return ProvisioningDecryptResult.Error() + } + val plaintext = decrypt(expectedVersion = 0, primaryEphemeralPublicKey = envelope.publicKey.toByteArray(), body = envelope.body.toByteArray()) if (plaintext == null) { Log.w(TAG, "Plaintext is null") - return RegistrationProvisionResult.Error + return ProvisioningDecryptResult.Error() } val provisioningMessage = RegistrationProvisionMessage.ADAPTER.decode(plaintext) - return RegistrationProvisionResult.Success(provisioningMessage) + return ProvisioningDecryptResult.Success(provisioningMessage) } private fun decrypt(expectedVersion: Int, primaryEphemeralPublicKey: ByteArray, body: ByteArray): ByteArray? { @@ -138,23 +136,8 @@ class SecondaryProvisioningCipher(private val secondaryIdentityKeyPair: Identity return cipher.doFinal(message) } - sealed interface ProvisionDecryptResult { - data object Error : ProvisionDecryptResult - - data class Success( - val uuid: UUID, - val e164: String, - val identityKeyPair: IdentityKeyPair, - val profileKey: ProfileKey, - val areReadReceiptsEnabled: Boolean, - val primaryUserAgent: String?, - val provisioningCode: String, - val provisioningVersion: Int - ) : ProvisionDecryptResult - } - - sealed interface RegistrationProvisionResult { - data object Error : RegistrationProvisionResult - data class Success(val message: RegistrationProvisionMessage) : RegistrationProvisionResult + sealed interface ProvisioningDecryptResult { + data class Success(val message: T) : ProvisioningDecryptResult + class Error() : ProvisioningDecryptResult } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 4f7739c9dd..2917c0f718 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -32,6 +32,7 @@ import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; import org.whispersystems.signalservice.api.messages.calls.CallingResponse; +import org.whispersystems.signalservice.api.messages.multidevice.RegisterAsSecondaryDeviceResponse; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException; @@ -344,6 +345,11 @@ public class PushServiceSocket { patchVerificationSession(sessionId, gcmRegistrationId, null, null, null, null); } + public RegisterAsSecondaryDeviceResponse registerAsSecondaryDevice(RegisterAsSecondaryDeviceRequest request) throws IOException { + String responseText = makeServiceRequest("/v1/devices/link", "PUT", JsonUtil.toJson(request)); + return JsonUtil.fromJson(responseText, RegisterAsSecondaryDeviceResponse.class); + } + public SendGroupMessageResponse sendGroupMessage(byte[] body, @Nonnull SealedSenderAccess sealedSenderAccess, long timestamp, boolean online, boolean urgent, boolean story) throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt new file mode 100644 index 0000000000..dd2615f66d --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import org.whispersystems.signalservice.api.account.AccountAttributes +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity + +class RegisterAsSecondaryDeviceRequest @JsonCreator constructor( + @JsonProperty val verificationCode: String, + @JsonProperty val accountAttributes: AccountAttributes, + @JsonProperty val aciSignedPreKey: SignedPreKeyEntity, + @JsonProperty val pniSignedPreKey: SignedPreKeyEntity, + @JsonProperty val aciPqLastResortPreKey: KyberPreKeyEntity, + @JsonProperty val pniPqLastResortPreKey: KyberPreKeyEntity, + @JsonProperty val gcmToken: GcmRegistrationId? +) diff --git a/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt index c94b767184..9c2855cf80 100644 --- a/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt +++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt @@ -13,7 +13,9 @@ import org.junit.Test import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.ecc.ECKeyPair +import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.ProvisionEnvelope import org.whispersystems.signalservice.internal.push.ProvisionMessage import org.whispersystems.signalservice.internal.push.ProvisioningVersion @@ -43,18 +45,18 @@ class SecondaryProvisioningCipherTest { val provisionMessage = ProvisionEnvelope.ADAPTER.decode(primaryProvisioningCipher.encrypt(message)) val result = provisioningCipher.decrypt(provisionMessage) - assertThat(result).isInstanceOf() + assertThat(result).isInstanceOf>() - val success = result as SecondaryProvisioningCipher.ProvisionDecryptResult.Success + val success = result as SecondaryProvisioningCipher.ProvisioningDecryptResult.Success - assertThat(message.aci).isEqualTo(success.uuid.toString()) - assertThat(message.number).isEqualTo(success.e164) - assertThat(primaryIdentityKeyPair.serialize()).isEqualTo(success.identityKeyPair.serialize()) - assertThat(primaryProfileKey.serialize()).isEqualTo(success.profileKey.serialize()) - assertThat(message.readReceipts).isEqualTo(success.areReadReceiptsEnabled) - assertThat(message.userAgent).isEqualTo(success.primaryUserAgent) - assertThat(message.provisioningCode).isEqualTo(success.provisioningCode) - assertThat(message.provisioningVersion).isEqualTo(success.provisioningVersion) + assertThat(message.aci).isEqualTo(UuidUtil.parseOrThrow(success.message.aci).toString()) + assertThat(message.number).isEqualTo(success.message.number) + assertThat(primaryIdentityKeyPair.serialize()).isEqualTo(IdentityKeyPair(IdentityKey(success.message.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(success.message.aciIdentityKeyPrivate!!.toByteArray())).serialize()) + assertThat(primaryProfileKey.serialize()).isEqualTo(ProfileKey(success.message.profileKey!!.toByteArray()).serialize()) + assertThat(message.readReceipts).isEqualTo(success.message.readReceipts == true) + assertThat(message.userAgent).isEqualTo(success.message.userAgent) + assertThat(message.provisioningCode).isEqualTo(success.message.provisioningCode!!) + assertThat(message.provisioningVersion).isEqualTo(success.message.provisioningVersion!!) } companion object {