mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-07 06:25:16 +01:00
Add device linking infrastructure.
This commit is contained in:
@@ -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)
|
||||
|
||||
+5
@@ -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()) {
|
||||
|
||||
+10
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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
|
||||
)
|
||||
+93
-4
@@ -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
|
||||
|
||||
+110
@@ -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
|
||||
|
||||
+19
@@ -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
|
||||
}
|
||||
+4
-3
@@ -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.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -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)
|
||||
}
|
||||
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-38
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
{
|
||||
|
||||
+21
@@ -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?
|
||||
)
|
||||
+12
-10
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user