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
@@ -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())
}
}
@@ -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)))
}
}
@@ -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)
@@ -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)
@@ -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)
@@ -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()) {
@@ -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()
}
@@ -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
)
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -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)
@@ -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)
@@ -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.")
+9
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 {
@@ -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;
}
}
@@ -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<T> 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 <T> start(
mode: Mode,
identityKeyPair: IdentityKeyPair,
configuration: SignalServiceConfiguration,
handler: ProvisioningSocketExceptionHandler,
block: suspend CoroutineScope.(ProvisioningSocket) -> Unit
block: suspend CoroutineScope.(ProvisioningSocket<T>) -> 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<T>? = 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<String> = CompletableDeferred()
private val provisioningMessageDeferral: CompletableDeferred<SecondaryProvisioningCipher.RegistrationProvisionResult> = CompletableDeferred()
private val provisioningMessageDeferral: CompletableDeferred<SecondaryProvisioningCipher.ProvisioningDecryptResult<T>> = CompletableDeferred()
suspend fun getProvisioningUrl(): String {
return provisioningUrlDeferral.await()
}
suspend fun getRegistrationProvisioningMessage(): SecondaryProvisioningCipher.RegistrationProvisionResult {
suspend fun getProvisioningMessageDecryptResult(): SecondaryProvisioningCipher.ProvisioningDecryptResult<T> {
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<T>)
Mode.LINK -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(message.request.body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
}
}
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)
}
@@ -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<RegisterAsSecondaryDeviceResponse> {
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)
}
}
}
@@ -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<ProvisionMessage> {
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<RegistrationProvisionMessage> {
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<T> {
data class Success<T>(val message: T) : ProvisioningDecryptResult<T>
class Error<T>() : ProvisioningDecryptResult<T>
}
}
@@ -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
{
@@ -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?
)
@@ -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<SecondaryProvisioningCipher.ProvisionDecryptResult.Success>()
assertThat(result).isInstanceOf<SecondaryProvisioningCipher.ProvisioningDecryptResult.Success<ProvisionMessage>>()
val success = result as SecondaryProvisioningCipher.ProvisionDecryptResult.Success
val success = result as SecondaryProvisioningCipher.ProvisioningDecryptResult.Success<ProvisionMessage>
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 {