Add device linking support to RegV5.

This commit is contained in:
Cody Henthorne
2026-07-01 15:10:22 -04:00
committed by Michelle Tang
parent 55b5997a4e
commit 91965d205a
44 changed files with 2111 additions and 182 deletions
@@ -2322,7 +2322,7 @@ object BackupRepository {
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
val context = AppDependencies.application
ArchiveRestoreProgress.onRestorePending()
@@ -2354,9 +2354,16 @@ object BackupRepository {
override fun shouldCancel() = cancellationSignal()
}
val cdn = response.cdn
val key = response.key
if (cdn == null || key == null) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.")
return RemoteRestoreResult.Failure
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
@@ -6,15 +6,15 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.core.util.roundedString
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import kotlin.time.Duration.Companion.days
@@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.Base64;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.core.util.logging.Log;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.thoughtcrime.securesms.AppCapabilities;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -16,13 +18,11 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberD
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.keyvalue.SvrValues;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
import org.thoughtcrime.securesms.registration.data.RegistrationRepository;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -86,7 +86,7 @@ class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob
return
}
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.restoredAccountEntropyPoolFromPrimary) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.")
return
}
@@ -4,6 +4,8 @@ import android.net.Uri
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.Stopwatch
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logD
@@ -20,14 +22,12 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobs.DeviceNameChangeJob
import org.thoughtcrime.securesms.jobs.E164FormattingJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.createAndUploadArchive
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse
import org.whispersystems.signalservice.api.link.TransferArchiveError
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse
@@ -26,6 +26,7 @@ import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.util.KeyHelper
@@ -70,13 +71,13 @@ 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.SvrNoDataException
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.kbs.PinHashUtil
@@ -97,6 +98,7 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
/**
@@ -488,23 +490,46 @@ object RegistrationRepository {
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)
return registerAsLinkedDevice(
context = context,
deviceName = deviceName,
number = message.number!!,
provisioningCode = message.provisioningCode!!,
aci = aci,
pni = pni,
accountEntropyPool = AccountEntropyPool(message.accountEntropyPool!!),
registrationData = registrationData,
aciIdentityKeyPair = aciIdentityKeyPair,
pniIdentityKeyPair = pniIdentityKeyPair
)
}
/**
* Registers this device as a linked (secondary) device using discrete fields rather than a raw
* [ProvisionMessage]. This is the form used by the regv5 registration module, which works with a
* decoupled provisioning message.
*/
@WorkerThread
fun registerAsLinkedDevice(
context: Context,
deviceName: String,
number: String,
provisioningCode: String,
aci: ACI,
pni: PNI,
accountEntropyPool: AccountEntropyPool,
registrationData: RegistrationData,
aciIdentityKeyPair: IdentityKeyPair,
pniIdentityKeyPair: IdentityKeyPair
): NetworkResult<RegisterAsLinkedDeviceResponse> {
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair)
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = getRegistrationId(),
val deviceAttributes = DeviceAttributes(
fetchesMessages = registrationData.fcmToken == null,
registrationLock = null,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(false),
discoverableByPhoneNumber = false,
name = Base64.encodeWithPadding(encryptedDeviceName),
registrationId = getRegistrationId(),
pniRegistrationId = getPniRegistrationId(),
recoveryPassword = null
name = Base64.encodeWithPadding(encryptedDeviceName),
capabilities = AppCapabilities.getCapabilities(false)
)
val aciPreKeys = generateSignedAndLastResortPreKeys(aciIdentityKeyPair, SignalStore.account.aciPreKeys)
@@ -512,20 +537,18 @@ object RegistrationRepository {
return AccountManagerFactory
.getInstance()
.createUnauthenticated(context, message.number!!, -1, registrationData.password)
.createUnauthenticated(context, number, -1, registrationData.password)
.registrationApi
.registerAsSecondaryDevice(message.provisioningCode!!, accountAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { respone ->
val aep = AccountEntropyPool(message.accountEntropyPool!!)
.registerAsSecondaryDevice(provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { response ->
RegisterAsLinkedDeviceResponse(
deviceId = respone.deviceId.toInt(),
deviceId = response.deviceId.toInt(),
accountRegistrationResult = AccountRegistrationResult(
uuid = aci.toString(),
pni = pni.toString(),
storageCapable = false,
number = message.number!!,
masterKey = aep.deriveMasterKey(),
number = number,
masterKey = accountEntropyPool.deriveMasterKey(),
pin = null,
aciPreKeyCollection = aciPreKeys,
pniPreKeyCollection = pniPreKeys,
@@ -674,7 +697,7 @@ object RegistrationRepository {
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
}
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? {
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
@@ -683,7 +706,8 @@ object RegistrationRepository {
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary")
// The primary has responded: either with an archive location, or an error telling us not to expect one.
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})")
return result.result
}
is NetworkResult.ApplicationError -> {
@@ -1,121 +0,0 @@
package org.thoughtcrime.securesms.registration.secondary
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.util.ByteUtil
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import java.nio.charset.Charset
import java.security.GeneralSecurityException
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Use to encrypt a secondary/linked device name.
*/
object DeviceNameCipher {
private val TAG = Log.tag(DeviceNameCipher::class.java)
private const val SYNTHETIC_IV_LENGTH = 16
@JvmStatic
fun encryptDeviceName(plaintext: ByteArray, identityKeyPair: IdentityKeyPair): ByteArray {
val ephemeralKeyPair: ECKeyPair = ECKeyPair.generate()
val masterSecret: ByteArray = ephemeralKeyPair.privateKey.calculateAgreement(identityKeyPair.publicKey.publicKey)
val syntheticIv: ByteArray = computeSyntheticIv(masterSecret, plaintext)
val cipherKey: ByteArray = computeCipherKey(masterSecret, syntheticIv)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(createEmptyByteArray(16)))
val cipherText = cipher.doFinal(plaintext)
return DeviceName(
ephemeralPublic = ephemeralKeyPair.publicKey.serialize().toByteString(),
syntheticIv = syntheticIv.toByteString(),
ciphertext = cipherText.toByteString()
).encode()
}
/**
* Decrypts a [DeviceName]. Returns null if data is invalid/undecryptable.
*/
@JvmStatic
fun decryptDeviceName(deviceName: DeviceName, identityKeyPair: IdentityKeyPair): ByteArray? {
if (deviceName.ephemeralPublic == null || deviceName.syntheticIv == null || deviceName.ciphertext == null) {
return null
}
return try {
val syntheticIv = deviceName.syntheticIv.toByteArray()
val cipherText = deviceName.ciphertext.toByteArray()
val identityKey: ECPrivateKey = identityKeyPair.privateKey
val ephemeralPublic = ECPublicKey(deviceName.ephemeralPublic.toByteArray())
val masterSecret = identityKey.calculateAgreement(ephemeralPublic)
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val cipherKeyPart1 = mac.doFinal("cipher".toByteArray())
mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256"))
val cipherKey = mac.doFinal(syntheticIv)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16)))
val plaintext = cipher.doFinal(cipherText)
mac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val verificationPart1 = mac.doFinal("auth".toByteArray())
mac.init(SecretKeySpec(verificationPart1, "HmacSHA256"))
val verificationPart2 = mac.doFinal(plaintext)
val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16)
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.")
}
plaintext
} catch (e: GeneralSecurityException) {
Log.w(TAG, "Failed to decrypt device name.", e)
null
} catch (e: InvalidKeyException) {
Log.w(TAG, "Failed to decrypt device name.", e)
null
}
}
private fun computeCipherKey(masterSecret: ByteArray, syntheticIv: ByteArray): ByteArray {
val input = "cipher".toByteArray(Charset.forName("UTF-8"))
val keyMac = Mac.getInstance("HmacSHA256")
keyMac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val cipherKeyKey: ByteArray = keyMac.doFinal(input)
val cipherMac = Mac.getInstance("HmacSHA256")
cipherMac.init(SecretKeySpec(cipherKeyKey, "HmacSHA256"))
return cipherMac.doFinal(syntheticIv)
}
private fun computeSyntheticIv(masterSecret: ByteArray, plaintext: ByteArray): ByteArray {
val input = "auth".toByteArray(Charset.forName("UTF-8"))
val keyMac = Mac.getInstance("HmacSHA256")
keyMac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val syntheticIvKey: ByteArray = keyMac.doFinal(input)
val ivMac = Mac.getInstance("HmacSHA256")
ivMac.init(SecretKeySpec(syntheticIvKey, "HmacSHA256"))
return ivMac.doFinal(plaintext).sliceArray(0 until SYNTHETIC_IV_LENGTH)
}
private fun createEmptyByteArray(length: Int): ByteArray = ByteArray(length)
}
@@ -6,8 +6,10 @@
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
@@ -16,25 +18,33 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.network.NetworkResult
import org.signal.registration.LinkAndSyncWaitResult
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.BackupMasterKeyError
import org.signal.registration.NetworkController.CheckSvrCredentialsError
import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.DeviceAttributes
import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.GetSvrCredentialsError
import org.signal.registration.NetworkController.LinkDeviceProvisioningEvent
import org.signal.registration.NetworkController.LinkDeviceProvisioningMessage
import org.signal.registration.NetworkController.LinkDeviceResponse
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.ProvisioningMessage
import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegisterAsLinkedDeviceError
import org.signal.registration.NetworkController.RegistrationLockResponse
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreAccountRecordError
@@ -68,21 +78,36 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.GcmRegistrationId
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegisterAsSecondaryDeviceRequest
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.Closeable
import java.io.IOException
import java.util.Locale
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes as ServiceDeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
import org.whispersystems.signalservice.api.provisioning.RestoreMethod as ServiceRestoreMethod
@@ -97,6 +122,7 @@ class AppRegistrationNetworkController(
companion object {
private val TAG = Log.tag(AppRegistrationNetworkController::class)
private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
private val RETRY_BACKOFF = 5.seconds
}
private val json = Json { ignoreUnknownKeys = true }
@@ -740,7 +766,7 @@ class AppRegistrationNetworkController(
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val socketHandles = mutableListOf<Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
@@ -804,7 +830,7 @@ class AppRegistrationNetworkController(
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
kotlinx.coroutines.delay(ProvisioningSocket.LIFESPAN / 2)
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
@@ -822,6 +848,250 @@ class AppRegistrationNetworkController(
}
}
override fun startLinkDeviceProvisioning(): Flow<LinkDeviceProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
val handle = ProvisioningSocket.start<ProvisionMessage>(
mode = ProvisioningSocket.Mode.LINK,
identityKeyPair = IdentityKeyPair.generate(),
configuration = configuration,
handler = { id, t ->
Log.w(TAG, "[startLinkDeviceProvisioning] Socket [$id] failed", t)
trySend(LinkDeviceProvisioningEvent.Error(t))
}
) { socket ->
val url = socket.getProvisioningUrl()
trySend(LinkDeviceProvisioningEvent.QrCodeReady(url))
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
val msg = result.message
val aci = msg.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(msg.aci)
val pni = msg.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(msg.pni)
trySend(
LinkDeviceProvisioningEvent.MessageReceived(
LinkDeviceProvisioningMessage(
e164 = msg.number!!,
provisioningCode = msg.provisioningCode!!,
aci = aci.toString(),
pni = pni.toString(),
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())),
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())),
profileKey = msg.profileKey!!.toByteArray(),
ephemeralBackupKey = msg.ephemeralBackupKey,
accountEntropyPool = msg.accountEntropyPool,
mediaRootBackupKey = msg.mediaRootBackupKey,
readReceipts = msg.readReceipts
)
)
)
channel.close()
} else {
Log.w(TAG, "[startLinkDeviceProvisioning] Failed to decrypt provisioning message")
trySend(LinkDeviceProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
}
}
synchronized(socketHandles) {
socketHandles += handle
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
startSocket()
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
Log.d(TAG, "[startLinkDeviceProvisioning] Rotated socket, count: $count")
}
}
}
awaitClose {
rotationJob.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
}
override suspend fun registerAsLinkedDevice(
e164: String,
password: String,
provisioningCode: String,
deviceAttributes: DeviceAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?
): RequestResult<LinkDeviceResponse, RegisterAsLinkedDeviceError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.registerAsSecondaryDevice(
e164,
password,
RegisterAsSecondaryDeviceRequest(
verificationCode = provisioningCode,
accountAttributes = deviceAttributes.toServiceDeviceAttributes(),
aciSignedPreKey = SignedPreKeyEntity(aciPreKeys.signedPreKey.id.toLong(), aciPreKeys.signedPreKey.keyPair.publicKey, aciPreKeys.signedPreKey.signature),
pniSignedPreKey = SignedPreKeyEntity(pniPreKeys.signedPreKey.id.toLong(), pniPreKeys.signedPreKey.keyPair.publicKey, pniPreKeys.signedPreKey.signature),
aciPqLastResortPreKey = KyberPreKeyEntity(aciPreKeys.lastResortKyberPreKey.id.toLong(), aciPreKeys.lastResortKyberPreKey.keyPair.publicKey, aciPreKeys.lastResortKyberPreKey.signature),
pniPqLastResortPreKey = KyberPreKeyEntity(pniPreKeys.lastResortKyberPreKey.id.toLong(), pniPreKeys.lastResortKyberPreKey.keyPair.publicKey, pniPreKeys.lastResortKyberPreKey.signature),
gcmToken = fcmToken?.let { GcmRegistrationId(it, true) }
)
).use { response ->
when (response.code) {
200 -> RequestResult.Success(json.decodeFromString<LinkDeviceResponse>(response.body.string()))
403 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.IncorrectVerification)
409 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.MissingCapability)
411 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.MaxLinkedDevices)
422 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.InvalidRequest(response.body.string()))
429 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.RateLimited(response.retryAfter()))
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun onLinkedDeviceRegistered() = withContext(Dispatchers.IO) {
try {
RemoteConfig.refreshSync()
} catch (e: IOException) {
Log.w(TAG, "[onLinkedDeviceRegistered] Failed to refresh remote config.", e)
}
for (type in SyncMessage.Request.Type.entries) {
if (type == SyncMessage.Request.Type.UNKNOWN) {
continue
}
Log.i(TAG, "[onLinkedDeviceRegistered] Sending sync request for $type")
try {
retryWithBackoff {
AppDependencies.signalServiceMessageSender.sendSyncMessage(
SignalServiceSyncMessage.forRequest(RequestMessage(SyncMessage.Request(type = type)))
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "[onLinkedDeviceRegistered] Failed to send sync request for $type after retries; continuing.", e)
}
}
}
private suspend fun <T> retryWithBackoff(maxAttempts: Int = 3, initialDelay: Duration = 1.seconds, block: suspend () -> T): T {
var attempt = 0
while (true) {
try {
return block()
} catch (e: IOException) {
attempt++
if (attempt >= maxAttempts) {
throw e
}
val backoff = initialDelay * attempt
Log.w(TAG, "[retryWithBackoff] Attempt $attempt failed; retrying in $backoff.", e)
delay(backoff)
}
}
}
override suspend fun restoreLinkedDeviceFromStorageService() = withContext(Dispatchers.IO) {
if (SignalStore.account.restoredAccountEntropyPoolFromPrimary) {
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account data from storage service.")
try {
StorageServiceRestore.restore()
} catch (e: CancellationException) {
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account cancelled.", e)
throw e
} catch (e: Exception) {
Log.w(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore failed.", e)
}
} else {
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] No account entropy pool from primary; skipping storage service restore.")
}
}
override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) {
val response = awaitTransferArchiveFromPrimary()
val result = when {
response == null -> LinkAndSyncWaitResult.ContinueWithoutBackup
response.error == TransferArchiveResponse.ERROR_RELINK_REQUESTED -> LinkAndSyncWaitResult.RelinkRequired
response.error == TransferArchiveResponse.ERROR_CONTINUE_WITHOUT_UPLOAD -> LinkAndSyncWaitResult.ContinueWithoutBackup
response.hasArchive -> LinkAndSyncWaitResult.ArchiveAvailable(cdn = response.cdn!!, key = response.key!!)
else -> LinkAndSyncWaitResult.ContinueWithoutBackup
}
Log.i(TAG, "[awaitLinkAndSyncArchive] Result: $result")
result
}
/**
* Waits for the primary device to make a link-and-sync transfer archive available, long-polling
* [org.signal.network.api.LinkDeviceApi.waitForPrimaryDevice] and retrying transient errors until
* [maxWaitTime] elapses. Returns null if no archive becomes available.
*/
private suspend fun awaitTransferArchiveFromPrimary(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
while (timeRemaining > 0 && coroutineContext.isActive) {
Log.d(TAG, "[awaitTransferArchiveFromPrimary] Willing to wait for $timeRemaining ms...")
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
Log.i(TAG, "[awaitTransferArchiveFromPrimary] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})")
return result.result
}
is NetworkResult.ApplicationError -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Error processing response", result.throwable)
return null
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Network error while waiting; will retry after $RETRY_BACKOFF.", result.exception)
delay(RETRY_BACKOFF)
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
400 -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Invalid timeout.")
return null
}
429 -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Rate-limited; will retry after ${result.retryAfter()}.")
result.retryAfter()?.let { delay(it) }
}
else -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Unexpected status ${result.code}; will retry after $RETRY_BACKOFF.")
delay(RETRY_BACKOFF)
}
}
}
}
timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime)
}
Log.w(TAG, "[awaitTransferArchiveFromPrimary] No transfer archive from primary within $maxWaitTime.")
return null
}
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes(
signalingKey,
@@ -832,7 +1102,7 @@ class AppRegistrationNetworkController(
unrestrictedUnidentifiedAccess,
capabilities?.toServiceCapabilities(),
discoverableByPhoneNumber,
name,
null,
pniRegistrationId,
recoveryPassword
)
@@ -848,6 +1118,16 @@ class AppRegistrationNetworkController(
)
}
private fun DeviceAttributes.toServiceDeviceAttributes(): ServiceDeviceAttributes {
return ServiceDeviceAttributes(
fetchesMessages = fetchesMessages,
registrationId = registrationId,
pniRegistrationId = pniRegistrationId,
name = name,
capabilities = capabilities?.toServiceCapabilities()
)
}
private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection {
return ServicePreKeyCollection(
identityKey = identityKey,
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -23,6 +24,9 @@ import org.greenrobot.eventbus.ThreadMode
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.AppUtil
import org.signal.core.util.Result
import org.signal.core.util.StreamUtil
import org.signal.core.util.crypto.AttachmentSecretProvider
import org.signal.core.util.logging.Log
@@ -32,6 +36,7 @@ import org.signal.registration.StorageController
import org.signal.registration.StoredProfileData
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -42,10 +47,12 @@ import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
@@ -55,6 +62,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
@@ -105,6 +113,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
Unit
}
override suspend fun clearLocalDataAndRestart() = withContext(Dispatchers.Main) {
Log.w(TAG, "[clearLocalDataAndRestart] Wiping all local app data and attempting to relaunch.")
AppUtil.clearAllDataAndRestart(context)
}
override suspend fun getStoredProfileData(): StoredProfileData = withContext(Dispatchers.IO) {
if (!SignalStore.account.isRegistered) {
return@withContext StoredProfileData()
@@ -125,9 +138,9 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
val discoverable: Boolean? = when (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode) {
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null
PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true
PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false
PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null
}
StoredProfileData(
@@ -173,7 +186,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
// than lazily generating a new AEP.
val accountEntropyPool: AccountEntropyPool? = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) }
if (accountEntropyPool != null) {
SignalStore.account.restoreAccountEntropyPool(accountEntropyPool)
if (data.linkedDeviceData != null) {
SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(accountEntropyPool)
} else {
SignalStore.account.restoreAccountEntropyPool(accountEntropyPool)
}
}
val masterKey: MasterKey? = accountEntropyPool?.deriveMasterKey()
@@ -215,15 +232,27 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
fcmEnabled = SignalStore.account.fcmEnabled
fcmToken = SignalStore.account.fcmToken ?: ""
reglockEnabled = data.registrationLockEnabled
data.linkedDeviceData?.let { linkData ->
linkedDeviceInfo = LinkedDeviceInfo(
deviceId = linkData.deviceId,
deviceName = linkData.deviceName,
ephemeralBackupKey = linkData.ephemeralBackupKey,
accountEntropyPool = data.accountEntropyPool,
mediaRootBackupKey = linkData.mediaRootBackupKey
)
}
}.build()
// TODO [greyson] Should probably move this stuff into this file as we get closer to being done
RegistrationRepository.registerAccountLocally(context, metadata)
SignalStore.registration.localRegistrationMetadata = metadata
data.linkedDeviceData?.readReceipts?.let { TextSecurePreferences.setReadReceiptsEnabled(context, it) }
}
// Handle PIN/master key
if (data.pin.isNotEmpty() && masterKey != null) {
if (data.pin.isNotEmpty() && masterKey != null && data.linkedDeviceData == null) {
SvrRepository.onRegistrationComplete(
masterKey,
data.pin,
@@ -321,11 +350,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is org.signal.core.util.Result.Success -> {
is Result.Success -> {
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V2 restore complete.")
}
is org.signal.core.util.Result.Failure -> {
is Result.Failure -> {
Log.w(TAG, "V2 restore failed: ${result.failure}")
emit(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}")))
}
@@ -451,6 +480,56 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
}
override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow<LinkAndSyncProgress> = callbackFlow {
val ephemeralBackupKeyBytes = SignalStore.registration.localRegistrationMetadata?.linkedDeviceInfo?.ephemeralBackupKey?.toByteArray()
if (ephemeralBackupKeyBytes == null) {
Log.i(TAG, "[restoreLinkAndSyncBackup] No ephemeral backup key present; nothing to restore.")
trySend(LinkAndSyncProgress.Complete)
channel.close()
return@callbackFlow
}
val subscriber = object {
@Subscribe(threadMode = ThreadMode.POSTING)
fun onRestoreEvent(event: RestoreV2Event) {
val progress = when (event.type) {
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> LinkAndSyncProgress.Downloading(event.count, event.estimatedTotalCount)
RestoreV2Event.Type.PROGRESS_RESTORE -> LinkAndSyncProgress.Restoring
RestoreV2Event.Type.PROGRESS_FINALIZING -> LinkAndSyncProgress.Restoring
}
trySend(progress)
}
}
EventBus.getDefault().register(subscriber)
launch(Dispatchers.IO) {
try {
when (val result = BackupRepository.restoreLinkAndSyncBackup(TransferArchiveResponse(cdn = cdn, key = key), MessageBackupKey(ephemeralBackupKeyBytes))) {
RemoteRestoreResult.Success -> send(LinkAndSyncProgress.Complete)
RemoteRestoreResult.Canceled -> Log.i(TAG, "[restoreLinkAndSyncBackup] Restore canceled.")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore did not succeed: $result")
send(LinkAndSyncProgress.Failed())
}
}
} catch (e: CancellationException) {
Log.d(TAG, "[restoreLinkAndSyncBackup] Restore cancelled, aborting.")
throw e
} catch (e: Exception) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore failed.", e)
send(LinkAndSyncProgress.Failed(e))
} finally {
channel.close()
}
}
awaitClose {
EventBus.getDefault().unregister(subscriber)
}
}
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
file.writeBytes(RegistrationData.ADAPTER.encode(data))