From d2c8b6e14c7fd556388b89fd1eac376e4bb1e3b7 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 16 Mar 2026 09:07:04 -0400 Subject: [PATCH] Improve the storage controller for regV5. --- .../dependencies/DemoStorageController.kt | 268 ++++++++---------- feature/registration/build.gradle.kts | 11 + .../registration/RegistrationRepository.kt | 172 +++++++++-- .../signal/registration/StorageController.kt | 107 +++---- .../src/main/protowire/Registration.proto | 68 +++++ 5 files changed, 391 insertions(+), 235 deletions(-) create mode 100644 feature/registration/src/main/protowire/Registration.proto diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index f02c9358e0..3defe85640 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -10,191 +10,159 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey -import org.signal.core.util.Base64 +import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.ServiceId.PNI import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.ecc.ECKeyPair -import org.signal.libsignal.protocol.kem.KEMKeyPair -import org.signal.libsignal.protocol.kem.KEMKeyType import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord -import org.signal.libsignal.zkgroup.profiles.ProfileKey -import org.signal.registration.KeyMaterial import org.signal.registration.NetworkController import org.signal.registration.NewRegistrationData import org.signal.registration.PreExistingRegistrationData import org.signal.registration.StorageController +import org.signal.registration.proto.ProvisioningData +import org.signal.registration.proto.RegistrationData import org.signal.registration.sample.storage.RegistrationDatabase import org.signal.registration.sample.storage.RegistrationPreferences -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec +import java.io.File /** * Implementation of [StorageController] that persists registration data using * SharedPreferences for simple key-value data and SQLite for prekeys. */ -class DemoStorageController(context: Context) : StorageController { +class DemoStorageController(private val context: Context) : StorageController { companion object { - private const val MAX_SVR_CREDENTIALS = 10 + private const val TEMP_PROTO_FILENAME = "registration_data.pb" } private val db = RegistrationDatabase(context) - override suspend fun generateAndStoreKeyMaterial( - existingAccountEntropyPool: AccountEntropyPool?, - existingAciIdentityKeyPair: IdentityKeyPair?, - existingPniIdentityKeyPair: IdentityKeyPair? - ): KeyMaterial = withContext(Dispatchers.IO) { - val accountEntropyPool = existingAccountEntropyPool ?: AccountEntropyPool.generate() - val aciIdentityKeyPair = existingAciIdentityKeyPair ?: IdentityKeyPair.generate() - val pniIdentityKeyPair = existingPniIdentityKeyPair ?: IdentityKeyPair.generate() - - val aciSignedPreKeyId = generatePreKeyId() - val pniSignedPreKeyId = generatePreKeyId() - val aciKyberPreKeyId = generatePreKeyId() - val pniKyberPreKeyId = generatePreKeyId() - - val timestamp = System.currentTimeMillis() - - val aciSignedPreKey = generateSignedPreKey(aciSignedPreKeyId, timestamp, aciIdentityKeyPair) - val pniSignedPreKey = generateSignedPreKey(pniSignedPreKeyId, timestamp, pniIdentityKeyPair) - val aciLastResortKyberPreKey = generateKyberPreKey(aciKyberPreKeyId, timestamp, aciIdentityKeyPair) - val pniLastResortKyberPreKey = generateKyberPreKey(pniKyberPreKeyId, timestamp, pniIdentityKeyPair) - - val aciRegistrationId = generateRegistrationId() - val pniRegistrationId = generateRegistrationId() - val profileKey = generateProfileKey() - val unidentifiedAccessKey = deriveUnidentifiedAccessKey(profileKey) - val password = generatePassword() - - val keyMaterial = KeyMaterial( - aciIdentityKeyPair = aciIdentityKeyPair, - aciSignedPreKey = aciSignedPreKey, - aciLastResortKyberPreKey = aciLastResortKyberPreKey, - pniIdentityKeyPair = pniIdentityKeyPair, - pniSignedPreKey = pniSignedPreKey, - pniLastResortKyberPreKey = pniLastResortKyberPreKey, - aciRegistrationId = aciRegistrationId, - pniRegistrationId = pniRegistrationId, - unidentifiedAccessKey = unidentifiedAccessKey, - servicePassword = password, - accountEntropyPool = accountEntropyPool - ) - - storeKeyMaterial(keyMaterial, profileKey) - - keyMaterial - } - - override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) { - RegistrationPreferences.saveRegistrationData(newRegistrationData) - } - override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) { RegistrationPreferences.getPreExistingRegistrationData() } override suspend fun clearAllData() = withContext(Dispatchers.IO) { + File(context.filesDir, TEMP_PROTO_FILENAME).takeIf { it.exists() }?.delete() RegistrationPreferences.clearAll() RegistrationPreferences.clearRestoredSvr2Credentials() db.clearAllPreKeys() } - override suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean) = withContext(Dispatchers.IO) { - RegistrationPreferences.pin = pin - RegistrationPreferences.pinAlphanumeric = isAlphanumeric - RegistrationPreferences.temporaryMasterKey = masterKey - RegistrationPreferences.registrationLockEnabled = registrationLockEnabled + override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) { + val file = File(context.filesDir, TEMP_PROTO_FILENAME) + if (file.exists()) { + RegistrationData.ADAPTER.decode(file.readBytes()) + } else { + RegistrationData() + } } - override suspend fun getRestoredSvrCredentials(): List = withContext(Dispatchers.IO) { - RegistrationPreferences.restoredSvr2Credentials + override suspend fun updateInProgressRegistrationData(updater: RegistrationData.Builder.() -> Unit) = withContext(Dispatchers.IO) { + val current = readInProgressRegistrationData() + val updated = current.newBuilder().apply(updater).build() + writeRegistrationData(updated) } - override suspend fun appendSvrCredentials(credentials: List) = withContext(Dispatchers.IO) { - val existing = RegistrationPreferences.restoredSvr2Credentials - val combined = (existing + credentials).distinctBy { it.username }.takeLast(MAX_SVR_CREDENTIALS) - RegistrationPreferences.restoredSvr2Credentials = combined + override suspend fun commitRegistrationData() = withContext(Dispatchers.IO) { + val file = File(context.filesDir, TEMP_PROTO_FILENAME) + val data = RegistrationData.ADAPTER.decode(file.readBytes()) + + // Key material + if (data.aciIdentityKeyPair.size > 0) { + RegistrationPreferences.aciIdentityKeyPair = IdentityKeyPair(data.aciIdentityKeyPair.toByteArray()) + } + if (data.pniIdentityKeyPair.size > 0) { + RegistrationPreferences.pniIdentityKeyPair = IdentityKeyPair(data.pniIdentityKeyPair.toByteArray()) + } + if (data.aciRegistrationId != 0) { + RegistrationPreferences.aciRegistrationId = data.aciRegistrationId + } + if (data.pniRegistrationId != 0) { + RegistrationPreferences.pniRegistrationId = data.pniRegistrationId + } + if (data.servicePassword.isNotEmpty()) { + RegistrationPreferences.servicePassword = data.servicePassword + } + if (data.accountEntropyPool.isNotEmpty()) { + RegistrationPreferences.aep = AccountEntropyPool(data.accountEntropyPool) + } + + // Pre-keys + if (data.aciSignedPreKey.size > 0) { + db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, SignedPreKeyRecord(data.aciSignedPreKey.toByteArray())) + } + if (data.pniSignedPreKey.size > 0) { + db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, SignedPreKeyRecord(data.pniSignedPreKey.toByteArray())) + } + if (data.aciLastResortKyberPreKey.size > 0) { + db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, KyberPreKeyRecord(data.aciLastResortKyberPreKey.toByteArray())) + } + if (data.pniLastResortKyberPreKey.size > 0) { + db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, KyberPreKeyRecord(data.pniLastResortKyberPreKey.toByteArray())) + } + + // Account identity + if (data.e164.isNotEmpty() && data.aci.isNotEmpty() && data.pni.isNotEmpty() && data.servicePassword.isNotEmpty() && data.accountEntropyPool.isNotEmpty()) { + RegistrationPreferences.saveRegistrationData( + NewRegistrationData( + e164 = data.e164, + aci = ACI.parseOrThrow(data.aci), + pni = PNI.parseOrThrow(data.pni), + servicePassword = data.servicePassword, + aep = AccountEntropyPool(data.accountEntropyPool) + ) + ) + } + + // PIN data + if (data.pin.isNotEmpty()) { + RegistrationPreferences.pin = data.pin + RegistrationPreferences.pinAlphanumeric = data.pinIsAlphanumeric + } + if (data.temporaryMasterKey.size > 0) { + RegistrationPreferences.temporaryMasterKey = MasterKey(data.temporaryMasterKey.toByteArray()) + } + RegistrationPreferences.registrationLockEnabled = data.registrationLockEnabled + + // SVR credentials + if (data.svrCredentials.isNotEmpty()) { + RegistrationPreferences.restoredSvr2Credentials = data.svrCredentials.map { + NetworkController.SvrCredentials(username = it.username, password = it.password) + } + } + + // Provisioning data + data.provisioningData?.let { prov -> + RegistrationPreferences.saveProvisioningData( + NetworkController.ProvisioningMessage( + accountEntropyPool = data.accountEntropyPool, + e164 = data.e164, + pin = data.pin.ifEmpty { null }, + aciIdentityKeyPair = IdentityKeyPair(data.aciIdentityKeyPair.toByteArray()), + pniIdentityKeyPair = IdentityKeyPair(data.pniIdentityKeyPair.toByteArray()), + platform = when (prov.platform) { + ProvisioningData.Platform.ANDROID -> NetworkController.ProvisioningMessage.Platform.ANDROID + ProvisioningData.Platform.IOS -> NetworkController.ProvisioningMessage.Platform.IOS + else -> NetworkController.ProvisioningMessage.Platform.ANDROID + }, + tier = when (prov.tier) { + ProvisioningData.Tier.FREE -> NetworkController.ProvisioningMessage.Tier.FREE + ProvisioningData.Tier.PAID -> NetworkController.ProvisioningMessage.Tier.PAID + else -> null + }, + backupTimestampMs = prov.backupTimestampMs, + backupSizeBytes = prov.backupSizeBytes, + restoreMethodToken = prov.restoreMethodToken, + backupVersion = prov.backupVersion + ) + ) + } + + Unit } - override suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) { - RegistrationPreferences.pin = pin - RegistrationPreferences.pinAlphanumeric = isAlphanumeric - } - - override suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage) = withContext(Dispatchers.IO) { - RegistrationPreferences.saveProvisioningData(provisioningMessage) - } - - private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) { - // Clear existing data - RegistrationPreferences.clearKeyMaterial() - db.clearAllPreKeys() - - // Store in SharedPreferences - RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair - RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair - RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId - RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId - RegistrationPreferences.profileKey = profileKey - - // Store prekeys in database - db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciSignedPreKey) - db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniSignedPreKey) - db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciLastResortKyberPreKey) - db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniLastResortKyberPreKey) - } - - private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord { - val keyPair = ECKeyPair.generate() - val signature = identityKeyPair.privateKey.calculateSignature(keyPair.publicKey.serialize()) - return SignedPreKeyRecord(id, timestamp, keyPair, signature) - } - - private fun generateKyberPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): KyberPreKeyRecord { - val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024) - val signature = identityKeyPair.privateKey.calculateSignature(kemKeyPair.publicKey.serialize()) - return KyberPreKeyRecord(id, timestamp, kemKeyPair, signature) - } - - private fun generatePreKeyId(): Int { - return SecureRandom().nextInt(Int.MAX_VALUE - 1) + 1 - } - - private fun generateRegistrationId(): Int { - return SecureRandom().nextInt(16380) + 1 - } - - private fun generateProfileKey(): ProfileKey { - val keyBytes = ByteArray(32) - SecureRandom().nextBytes(keyBytes) - return ProfileKey(keyBytes) - } - - /** - * Generates a password for basic auth during registration. - * 18 random bytes, base64 encoded with padding. - */ - private fun generatePassword(): String { - val passwordBytes = ByteArray(18) - SecureRandom().nextBytes(passwordBytes) - return Base64.encodeWithPadding(passwordBytes) - } - - /** - * Derives the unidentified access key from a profile key. - * This mirrors the logic in UnidentifiedAccess.deriveAccessKeyFrom(). - */ - private fun deriveUnidentifiedAccessKey(profileKey: ProfileKey): ByteArray { - val nonce = ByteArray(12) - val input = ByteArray(16) - - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(profileKey.serialize(), "AES"), GCMParameterSpec(128, nonce)) - - val ciphertext = cipher.doFinal(input) - return ciphertext.copyOf(16) + private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) { + val file = File(context.filesDir, TEMP_PROTO_FILENAME) + file.writeBytes(RegistrationData.ADAPTER.encode(data)) } } diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index 32c84ffb53..90bd3e2d0b 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("signal-library") id("kotlin-parcelize") + id("com.squareup.wire") alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlinx.serialization) } @@ -20,6 +21,16 @@ android { } } +wire { + kotlin { + javaInterop = true + } + + sourcePath { + srcDir("src/main/protowire") + } +} + dependencies { implementation(libs.androidx.ui.test.junit4) lintChecks(project(":lintchecks")) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index a513a82fd8..6057b1f4aa 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -10,12 +10,18 @@ import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString 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.Base64 import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.ECKeyPair +import org.signal.libsignal.protocol.kem.KEMKeyPair +import org.signal.libsignal.protocol.kem.KEMKeyType +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.CreateSessionError import org.signal.registration.NetworkController.MasterKeyResponse @@ -29,8 +35,14 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError import org.signal.registration.NetworkController.SessionMetadata import org.signal.registration.NetworkController.SvrCredentials import org.signal.registration.NetworkController.UpdateSessionError +import org.signal.registration.proto.ProvisioningData +import org.signal.registration.proto.SvrCredential import org.signal.registration.util.SensitiveLog +import java.security.SecureRandom import java.util.Locale +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController) { @@ -98,14 +110,17 @@ class RegistrationRepository(val context: Context, val networkController: Networ suspend fun getSvrCredentials(): RegistrationNetworkResult = withContext(Dispatchers.IO) { networkController.getSvrCredentials().also { if (it is RegistrationNetworkResult.Success) { - storageController.appendSvrCredentials(listOf(it.data)) + storageController.updateInProgressRegistrationData { + svrCredentials = svrCredentials + SvrCredential(username = it.data.username, password = it.data.password) + } BackupManager(context).dataChanged() } } } suspend fun getRestoredSvrCredentials(): List = withContext(Dispatchers.IO) { - storageController.getRestoredSvrCredentials() + val data = storageController.readInProgressRegistrationData() + data.svrCredentials.map { SvrCredentials(username = it.username, password = it.password) } } suspend fun checkSvrCredentials(e164: String, credentials: List): RegistrationNetworkResult = withContext(Dispatchers.IO) { @@ -123,9 +138,14 @@ class RegistrationRepository(val context: Context, val networkController: Networ pin = pin ).also { if (it is RegistrationNetworkResult.Success) { - // TODO consider whether we should save this now, or whether we should keep in app state and then hand it back to the library user at the end of the flow - storageController.saveValidatedPinAndTemporaryMasterKey(pin, isAlphanumeric, it.data.masterKey, forRegistrationLock) - storageController.appendSvrCredentials(listOf(svrCredentials)) + storageController.updateInProgressRegistrationData { + this.pin = pin + this.pinIsAlphanumeric = isAlphanumeric + this.temporaryMasterKey = it.data.masterKey.serialize().toByteString() + this.registrationLockEnabled = forRegistrationLock + this.svrCredentials += SvrCredential(username = svrCredentials.username, password = svrCredentials.password) + } + storageController.commitRegistrationData() } } } @@ -215,7 +235,23 @@ class RegistrationRepository(val context: Context, val networkController: Networ suspend fun registerAccountWithProvisioningData( provisioningMessage: NetworkController.ProvisioningMessage ): RegistrationNetworkResult, RegisterAccountError> = withContext(Dispatchers.IO) { - storageController.saveProvisioningData(provisioningMessage) + storageController.updateInProgressRegistrationData { + provisioningData = ProvisioningData( + restoreMethodToken = provisioningMessage.restoreMethodToken, + platform = when (provisioningMessage.platform) { + NetworkController.ProvisioningMessage.Platform.ANDROID -> ProvisioningData.Platform.ANDROID + NetworkController.ProvisioningMessage.Platform.IOS -> ProvisioningData.Platform.IOS + }, + tier = when (provisioningMessage.tier) { + NetworkController.ProvisioningMessage.Tier.FREE -> ProvisioningData.Tier.FREE + NetworkController.ProvisioningMessage.Tier.PAID -> ProvisioningData.Tier.PAID + null -> ProvisioningData.Tier.TIER_UNKNOWN + }, + backupTimestampMs = provisioningMessage.backupTimestampMs ?: 0, + backupSizeBytes = provisioningMessage.backupSizeBytes ?: 0, + backupVersion = provisioningMessage.backupVersion + ) + } val aep = AccountEntropyPool(provisioningMessage.accountEntropyPool) val recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword() @@ -263,11 +299,26 @@ class RegistrationRepository(val context: Context, val networkController: Networ Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, existingAep: ${existingAccountEntropyPool != null}") - val keyMaterial = storageController.generateAndStoreKeyMaterial( + val keyMaterial = generateKeyMaterial( existingAccountEntropyPool = existingAccountEntropyPool, existingAciIdentityKeyPair = existingAciIdentityKeyPair, existingPniIdentityKeyPair = existingPniIdentityKeyPair ) + + storageController.updateInProgressRegistrationData { + this.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair.serialize().toByteString() + this.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair.serialize().toByteString() + this.aciSignedPreKey = keyMaterial.aciSignedPreKey.serialize().toByteString() + this.pniSignedPreKey = keyMaterial.pniSignedPreKey.serialize().toByteString() + this.aciLastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey.serialize().toByteString() + this.pniLastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey.serialize().toByteString() + this.aciRegistrationId = keyMaterial.aciRegistrationId + this.pniRegistrationId = keyMaterial.pniRegistrationId + this.unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey.toByteString() + this.servicePassword = keyMaterial.servicePassword + this.accountEntropyPool = keyMaterial.accountEntropyPool.value + } + val fcmToken = networkController.getFcmToken() val newMasterKey = keyMaterial.accountEntropyPool.deriveMasterKey() @@ -321,15 +372,14 @@ class RegistrationRepository(val context: Context, val networkController: Networ ) if (result is RegistrationNetworkResult.Success) { - storageController.saveNewRegistrationData( - NewRegistrationData( - e164 = result.data.e164, - aci = ACI.parseOrThrow(result.data.aci), - pni = PNI.parseOrThrow(result.data.pni), - servicePassword = keyMaterial.servicePassword, - aep = keyMaterial.accountEntropyPool - ) - ) + storageController.updateInProgressRegistrationData { + this.e164 = result.data.e164 + this.aci = result.data.aci + this.pni = result.data.pni + this.servicePassword = keyMaterial.servicePassword + this.accountEntropyPool = keyMaterial.accountEntropyPool.value + } + storageController.commitRegistrationData() } result.mapSuccess { it to keyMaterial } @@ -343,10 +393,14 @@ class RegistrationRepository(val context: Context, val networkController: Networ val result = networkController.setPinAndMasterKeyOnSvr(pin, masterKey) if (result is RegistrationNetworkResult.Success) { - storageController.saveNewlyCreatedPin(pin, isAlphanumeric) - result.data?.let { credential -> - storageController.appendSvrCredentials(listOf(credential)) + storageController.updateInProgressRegistrationData { + this.pin = pin + this.pinIsAlphanumeric = isAlphanumeric + result.data?.let { credential -> + this.svrCredentials += SvrCredential(username = credential.username, password = credential.password) + } } + storageController.commitRegistrationData() } result @@ -356,6 +410,82 @@ class RegistrationRepository(val context: Context, val networkController: Networ return storageController.getPreExistingRegistrationData() } + private fun generateKeyMaterial( + existingAccountEntropyPool: AccountEntropyPool? = null, + existingAciIdentityKeyPair: IdentityKeyPair? = null, + existingPniIdentityKeyPair: IdentityKeyPair? = null + ): KeyMaterial { + val accountEntropyPool = existingAccountEntropyPool ?: AccountEntropyPool.generate() + val aciIdentityKeyPair = existingAciIdentityKeyPair ?: IdentityKeyPair.generate() + val pniIdentityKeyPair = existingPniIdentityKeyPair ?: IdentityKeyPair.generate() + + val timestamp = System.currentTimeMillis() + + val aciSignedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair) + val pniSignedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair) + val aciLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair) + val pniLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair) + + val profileKey = generateProfileKey() + + return KeyMaterial( + aciIdentityKeyPair = aciIdentityKeyPair, + aciSignedPreKey = aciSignedPreKey, + aciLastResortKyberPreKey = aciLastResortKyberPreKey, + pniIdentityKeyPair = pniIdentityKeyPair, + pniSignedPreKey = pniSignedPreKey, + pniLastResortKyberPreKey = pniLastResortKyberPreKey, + aciRegistrationId = generateRegistrationId(), + pniRegistrationId = generateRegistrationId(), + unidentifiedAccessKey = deriveUnidentifiedAccessKey(profileKey), + servicePassword = generatePassword(), + accountEntropyPool = accountEntropyPool + ) + } + + private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord { + val keyPair = ECKeyPair.generate() + val signature = identityKeyPair.privateKey.calculateSignature(keyPair.publicKey.serialize()) + return SignedPreKeyRecord(id, timestamp, keyPair, signature) + } + + private fun generateKyberPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): KyberPreKeyRecord { + val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024) + val signature = identityKeyPair.privateKey.calculateSignature(kemKeyPair.publicKey.serialize()) + return KyberPreKeyRecord(id, timestamp, kemKeyPair, signature) + } + + private fun generatePreKeyId(): Int { + return SecureRandom().nextInt(Int.MAX_VALUE - 1) + 1 + } + + private fun generateRegistrationId(): Int { + return SecureRandom().nextInt(16380) + 1 + } + + private fun generateProfileKey(): ProfileKey { + val keyBytes = ByteArray(32) + SecureRandom().nextBytes(keyBytes) + return ProfileKey(keyBytes) + } + + private fun generatePassword(): String { + val passwordBytes = ByteArray(18) + SecureRandom().nextBytes(passwordBytes) + return Base64.encodeWithPadding(passwordBytes) + } + + private fun deriveUnidentifiedAccessKey(profileKey: ProfileKey): ByteArray { + val nonce = ByteArray(12) + val input = ByteArray(16) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(profileKey.serialize(), "AES"), GCMParameterSpec(128, nonce)) + + val ciphertext = cipher.doFinal(input) + return ciphertext.copyOf(16) + } + companion object { private val TAG = Log.tag(RegistrationRepository::class) } diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index 852cfa2113..d97edb0dc9 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -9,12 +9,12 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler 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.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.registration.proto.RegistrationData import org.signal.registration.util.ACIParceler import org.signal.registration.util.AccountEntropyPoolParceler import org.signal.registration.util.IdentityKeyPairParceler @@ -22,31 +22,22 @@ import org.signal.registration.util.KyberPreKeyRecordParceler import org.signal.registration.util.PNIParceler import org.signal.registration.util.SignedPreKeyRecordParceler +/** + * The set of methods that the registration module needs to persist data to disk. + * + * Note that most data is stored via "in progress registration data", which gives the registration module + * a lot of control over what data is saved, with the app just needing to persist the blob. + * + * It's referred to as "in progress" because it represents state that the registration module wants to persist + * in case the process were to die, but it's not fully ready to be committed as permanent app state yet. + * + * For example, the module may create a bunch of keys, but until the user is registered and those keys are uploaded, + * they should not be considered the actual keys for the current account. + * + * When the data *is* ready to be committed, it will be done via [commitRegistrationData]. + */ interface StorageController { - /** - * Generates all key material required for account registration and stores it persistently. - * This includes ACI identity key, PNI identity key, and their respective pre-keys. - * - * If optional parameters are provided (e.g. from a pre-existing registration), those values - * will be re-used instead of generating new ones. - * - * @param existingAccountEntropyPool If non-null, re-use this AEP instead of generating a new one. - * @param existingAciIdentityKeyPair If non-null, re-use this ACI identity key pair instead of generating a new one. - * @param existingPniIdentityKeyPair If non-null, re-use this PNI identity key pair instead of generating a new one. - * @return [KeyMaterial] containing all generated cryptographic material needed for registration. - */ - suspend fun generateAndStoreKeyMaterial( - existingAccountEntropyPool: AccountEntropyPool? = null, - existingAciIdentityKeyPair: IdentityKeyPair? = null, - existingPniIdentityKeyPair: IdentityKeyPair? = null - ): KeyMaterial - - /** - * Called after a successful registration to store new registration data. - */ - suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) - /** * Retrieves previously stored registration data for registered installs, if any. * @@ -54,50 +45,38 @@ interface StorageController { */ suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? - /** - * Retrieves any SVR2 credentials that may have been restored via the OS-level backup/restore service. May be empty. - */ - suspend fun getRestoredSvrCredentials(): List - - // TODO [regV5] Can this just take a single item? - /** - * Appends known-working SVR credentials to the local store of credentials. - * Implementations should limit the number of stored credentials to some reasonable maximum. - */ - suspend fun appendSvrCredentials(credentials: List) - - /** - * Saves a validated PIN, temporary master key, and registration lock status. - * - * Called after successfully verifying a PIN against SVR, either during - * registration lock unlock or SVR restore flows. - * - * It's a "temporary master key" because at the end of the day, what we actually want is a master key derived from the AEP. - * We may need this master key to perform the initial storage service restore, but after that's done, it will be discarded after generating a new AEP. - * - * @param pin The validated PIN that was successfully verified. - * @param registrationLockEnabled Whether registration lock should be enabled for this account. - */ - suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean) - - /** - * Saves a newly-created PIN for the account. - */ - suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) - - /** - * Saves metadata from a provisioning message received during QR-based restore. - * - * This includes the restore method token, backup tier, backup timestamps, and - * platform information from the old device. Called before registering with - * the provisioned data. - */ - suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage) - /** * Clears all stored registration data, including key material and account information. */ suspend fun clearAllData() + + /** + * Reads the persisted [RegistrationData] proto that is currently in the process of being worked on. + * Returns a default empty [RegistrationData] if nothing has been written yet. + */ + suspend fun readInProgressRegistrationData(): RegistrationData + + /** + * Reads the persisted [RegistrationData] (that is currently in the process of being worked on), + * applies the [updater] to its builder, and writes the result back to persistent storage. + * + * Example usage: + * ``` + * storageController.updateRegistrationData { + * pin = "1234" + * pinIsAlphanumeric = false + * } + * ``` + */ + suspend fun updateInProgressRegistrationData(updater: RegistrationData.Builder.() -> Unit) + + /** + * Commits in-progress [RegistrationData] to permanent storage. Any data in the blob should be considered actual data + * for the currently-registered account. Commits can happen multiple times. For instance, we will commit data right after + * successfully registering, but then there may be more operations we perform after registration that need to be + * separately committed. + */ + suspend fun commitRegistrationData() } /** diff --git a/feature/registration/src/main/protowire/Registration.proto b/feature/registration/src/main/protowire/Registration.proto new file mode 100644 index 0000000000..7bc96390da --- /dev/null +++ b/feature/registration/src/main/protowire/Registration.proto @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +package signal; + +option java_package = "org.signal.registration.proto"; + +message RegistrationData { + // Key material (from generateAndStoreKeyMaterial) + bytes aciIdentityKeyPair = 1; + bytes pniIdentityKeyPair = 2; + bytes aciSignedPreKey = 3; + bytes pniSignedPreKey = 4; + bytes aciLastResortKyberPreKey = 5; + bytes pniLastResortKyberPreKey = 6; + int32 aciRegistrationId = 7; + int32 pniRegistrationId = 8; + bytes unidentifiedAccessKey = 9; + string servicePassword = 10; + string accountEntropyPool = 11; + + // Account identity (from saveNewRegistrationData / getPreExistingRegistrationData) + string e164 = 12; + string aci = 13; + string pni = 14; + + // PIN data (from saveValidatedPinAndTemporaryMasterKey / saveNewlyCreatedPin) + string pin = 15; + bool pinIsAlphanumeric = 16; + bytes temporaryMasterKey = 17; + bool registrationLockEnabled = 18; + + // SVR credentials (from appendSvrCredentials / getRestoredSvrCredentials) + repeated SvrCredential svrCredentials = 19; + + // Provisioning data (from saveProvisioningData) + ProvisioningData provisioningData = 20; +} + +message SvrCredential { + string username = 1; + string password = 2; +} + +message ProvisioningData { + enum Platform { + UNKNOWN = 0; + ANDROID = 1; + IOS = 2; + } + + enum Tier { + TIER_UNKNOWN = 0; + FREE = 1; + PAID = 2; + } + + string restoreMethodToken = 1; + Platform platform = 2; + Tier tier = 3; + int64 backupTimestampMs = 4; + int64 backupSizeBytes = 5; + int64 backupVersion = 6; +}