Add device linking infrastructure.

This commit is contained in:
Cody Henthorne
2025-08-01 14:16:31 -04:00
parent e6e869e074
commit e29abdea91
23 changed files with 440 additions and 119 deletions

View File

@@ -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())
}
}

View File

@@ -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)))
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()) {

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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<RegisterAsLinkedDeviceResponse> {
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<RegistrationSessionMetadataResponse> =
withContext(Dispatchers.IO) {
// TODO [regv2]: do not use event bus nor latch

View File

@@ -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<String?>.toSvrCredentials(): AuthCredentials? {
return this

View File

@@ -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
}

View File

@@ -121,7 +121,8 @@ class RestoreViaQrViewModel : ViewModel() {
}
}
return ProvisioningSocket.start(
return ProvisioningSocket.start<RegistrationProvisionMessage>(
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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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 {