From 7969df4e4c38cc2b17db1e5d9f952a09cf0c3fb0 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 8 Dec 2025 09:11:14 -0500 Subject: [PATCH] Add basic reglock support to regV5. --- .../signal/core/models/AccountEntropyPool.kt | 1 + registration/app/build.gradle.kts | 2 + .../registration/sample/MainActivity.kt | 130 +++++++- .../sample/RegistrationApplication.kt | 18 +- .../dependencies/RealNetworkController.kt | 273 +++++++++++++++- .../dependencies/RealStorageController.kt | 301 ++---------------- .../sample/screens/main/MainScreen.kt | 149 ++++++++- .../sample/screens/main/MainScreenEvents.kt | 2 + .../sample/screens/main/MainScreenState.kt | 9 +- .../screens/main/MainScreenViewModel.kt | 47 ++- .../screens/pinsettings/PinSettingsEvents.kt | 13 + .../screens/pinsettings/PinSettingsScreen.kt | 264 +++++++++++++++ .../screens/pinsettings/PinSettingsState.kt | 13 + .../pinsettings/PinSettingsViewModel.kt | 166 ++++++++++ .../sample/storage/RegistrationDatabase.kt | 132 ++++++++ .../sample/storage/RegistrationPreferences.kt | 147 +++++++++ .../signal/registration/NetworkController.kt | 79 ++++- .../registration/RegistrationFlowEvent.kt | 3 + .../registration/RegistrationFlowState.kt | 20 +- .../registration/RegistrationNavigation.kt | 55 ++++ .../registration/RegistrationRepository.kt | 37 ++- .../registration/RegistrationViewModel.kt | 1 + .../signal/registration/StorageController.kt | 20 +- .../accountlocked/AccountLockedScreen.kt | 98 ++++++ .../AccountLockedScreenEvents.kt | 11 + .../accountlocked/AccountLockedState.kt | 10 + .../phonenumber/PhoneNumberEntryScreen.kt | 20 +- .../phonenumber/PhoneNumberEntryViewModel.kt | 11 +- .../screens/pinentry/PinEntryScreen.kt | 8 +- .../pinentry/PinEntryScreenEventHandler.kt | 16 + .../screens/pinentry/PinEntryState.kt | 16 +- .../RegistrationLockPinEntryViewModel.kt | 207 ++++++++++++ .../VerificationCodeViewModel.kt | 11 +- 33 files changed, 1961 insertions(+), 329 deletions(-) create mode 100644 registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsEvents.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsScreen.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsState.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationDatabase.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt create mode 100644 registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt create mode 100644 registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt create mode 100644 registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt create mode 100644 registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt create mode 100644 registration/lib/src/main/java/org/signal/registration/screens/registrationlock/RegistrationLockPinEntryViewModel.kt diff --git a/core-models/src/main/java/org/signal/core/models/AccountEntropyPool.kt b/core-models/src/main/java/org/signal/core/models/AccountEntropyPool.kt index d64411120b..796f282a5b 100644 --- a/core-models/src/main/java/org/signal/core/models/AccountEntropyPool.kt +++ b/core-models/src/main/java/org/signal/core/models/AccountEntropyPool.kt @@ -6,6 +6,7 @@ package org.signal.core.models import org.signal.core.models.backup.MessageBackupKey +import org.signal.core.util.logging.Log private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messagebackup.AccountEntropyPool diff --git a/registration/app/build.gradle.kts b/registration/app/build.gradle.kts index dfb4223fa8..796167f55d 100644 --- a/registration/app/build.gradle.kts +++ b/registration/app/build.gradle.kts @@ -69,6 +69,8 @@ dependencies { // AndroidX implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.sqlite) + implementation(libs.androidx.sqlite.framework) // Lifecycle implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt b/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt index cc1233bda7..de8b4f5e1c 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt @@ -10,12 +10,25 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavBackStack @@ -30,11 +43,44 @@ import kotlinx.serialization.Serializable import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.navigation.ResultEffect import org.signal.core.ui.navigation.ResultEventBus +import org.signal.registration.NetworkController import org.signal.registration.RegistrationActivity +import org.signal.registration.RegistrationDependencies +import org.signal.registration.StorageController import org.signal.registration.sample.MainActivity.Companion.REGISTRATION_RESULT import org.signal.registration.sample.screens.RegistrationCompleteScreen import org.signal.registration.sample.screens.main.MainScreen import org.signal.registration.sample.screens.main.MainScreenViewModel +import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen +import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel + +private const val ANIMATION_DURATION = 300 + +/** + * Transition spec for bottom sheet style screens that slide up from the bottom. + */ +private val BottomSheetTransitionSpec = NavDisplay.transitionSpec { + ( + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(ANIMATION_DURATION) + ) + fadeIn(animationSpec = tween(ANIMATION_DURATION)) + ) togetherWith ExitTransition.KeepUntilTransitionsFinished +} + NavDisplay.popTransitionSpec { + EnterTransition.None togetherWith ( + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(ANIMATION_DURATION) + ) + fadeOut(animationSpec = tween(ANIMATION_DURATION)) + ) +} + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith ( + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(ANIMATION_DURATION) + ) + fadeOut(animationSpec = tween(ANIMATION_DURATION)) + ) +} /** * Navigation routes for the sample app. @@ -45,6 +91,9 @@ sealed interface SampleRoute : NavKey { @Serializable data object RegistrationComplete : SampleRoute + + @Serializable + data object PinSettings : SampleRoute } /** @@ -76,6 +125,8 @@ class MainActivity : ComponentActivity() { onLaunchRegistration = { registrationLauncher.launch(Unit) }, backStack = backStack, resultEventBus = viewModel.resultEventBus, + storageController = RegistrationDependencies.get().storageController, + networkController = RegistrationDependencies.get().networkController, onStartOver = { backStack.clear() backStack.add(SampleRoute.Main) @@ -93,17 +144,29 @@ private fun SampleNavHost( onStartOver: () -> Unit, backStack: NavBackStack, resultEventBus: ResultEventBus, + storageController: StorageController, + networkController: NetworkController, modifier: Modifier = Modifier ) { val entryProvider: (NavKey) -> NavEntry = entryProvider { entry { val viewModel: MainScreenViewModel = viewModel( - factory = MainScreenViewModel.Factory(onLaunchRegistration) + factory = MainScreenViewModel.Factory( + storageController = storageController, + onLaunchRegistration = onLaunchRegistration, + onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) } + ) ) val state by viewModel.state.collectAsStateWithLifecycle() + LifecycleResumeEffect(Unit) { + viewModel.refreshData() + onPauseOrDispose { } + } + ResultEffect(resultEventBus, REGISTRATION_RESULT) { success -> if (success) { + viewModel.refreshData() backStack.add(SampleRoute.RegistrationComplete) } } @@ -117,6 +180,23 @@ private fun SampleNavHost( entry { RegistrationCompleteScreen(onStartOver = onStartOver) } + + entry( + metadata = BottomSheetTransitionSpec + ) { + val viewModel: PinSettingsViewModel = viewModel( + factory = PinSettingsViewModel.Factory( + networkController = networkController, + onBack = { backStack.removeLastOrNull() } + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + PinSettingsScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } } val decorators = listOf( @@ -131,7 +211,51 @@ private fun SampleNavHost( NavDisplay( entries = entries, - onBack = {}, - modifier = modifier + onBack = { backStack.removeLastOrNull() }, + modifier = modifier, + transitionSpec = { + // Default: slide in from right, previous screen shrinks back + ( + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(ANIMATION_DURATION) + ) + fadeIn(animationSpec = tween(ANIMATION_DURATION)) + ) togetherWith ( + fadeOut(animationSpec = tween(ANIMATION_DURATION)) + + scaleOut( + targetScale = 0.9f, + animationSpec = tween(ANIMATION_DURATION) + ) + ) + }, + popTransitionSpec = { + // Default pop: scale up from background, current slides out right + ( + fadeIn(animationSpec = tween(ANIMATION_DURATION)) + + scaleIn( + initialScale = 0.9f, + animationSpec = tween(ANIMATION_DURATION) + ) + ) togetherWith ( + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(ANIMATION_DURATION) + ) + fadeOut(animationSpec = tween(ANIMATION_DURATION)) + ) + }, + predictivePopTransitionSpec = { + ( + fadeIn(animationSpec = tween(ANIMATION_DURATION)) + + scaleIn( + initialScale = 0.9f, + animationSpec = tween(ANIMATION_DURATION) + ) + ) togetherWith ( + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(ANIMATION_DURATION) + ) + fadeOut(animationSpec = tween(ANIMATION_DURATION)) + ) + } ) } diff --git a/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt index 44bd88b6c6..df6a6e028b 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -15,6 +15,7 @@ import org.signal.core.util.logging.Log import org.signal.registration.RegistrationDependencies import org.signal.registration.sample.dependencies.RealNetworkController import org.signal.registration.sample.dependencies.RealStorageController +import org.signal.registration.sample.storage.RegistrationPreferences import org.whispersystems.signalservice.api.push.TrustStore import org.whispersystems.signalservice.api.util.CredentialsProvider import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl @@ -29,13 +30,22 @@ import java.util.Optional class RegistrationApplication : Application() { + companion object { + // Staging SVR2 mrEnclave value + private const val SVR2_MRENCLAVE = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036" + } + override fun onCreate() { super.onCreate() Log.initialize(AndroidLogger) - val pushServiceSocket = createPushServiceSocket() - val networkController = RealNetworkController(this, pushServiceSocket) + RegistrationPreferences.init(this) + + val trustStore = SampleTrustStore() + val configuration = createServiceConfiguration(trustStore) + val pushServiceSocket = createPushServiceSocket(configuration) + val networkController = RealNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE) val storageController = RealStorageController(this) RegistrationDependencies.provide( @@ -46,9 +56,7 @@ class RegistrationApplication : Application() { ) } - private fun createPushServiceSocket(): PushServiceSocket { - val trustStore = SampleTrustStore() - val configuration = createServiceConfiguration(trustStore) + private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket { val credentialsProvider = NoopCredentialsProvider() val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}" diff --git a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt index 54a2b1339f..528b1ffec3 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt @@ -8,8 +8,12 @@ package org.signal.registration.sample.dependencies import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.signal.core.models.MasterKey import org.signal.core.util.logging.Log +import org.signal.libsignal.net.Network import org.signal.registration.NetworkController import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.CreateSessionError @@ -27,7 +31,19 @@ import org.signal.registration.NetworkController.UpdateSessionError import org.signal.registration.NetworkController.VerificationCodeTransport import org.signal.registration.sample.fcm.FcmUtil import org.signal.registration.sample.fcm.PushChallengeReceiver +import org.signal.registration.sample.storage.RegistrationPreferences +import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse +import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse +import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2 +import org.whispersystems.signalservice.api.util.SleepTimer +import org.whispersystems.signalservice.api.websocket.HealthMonitor +import org.whispersystems.signalservice.api.websocket.SignalWebSocket +import org.whispersystems.signalservice.api.websocket.WebSocketFactory +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.push.AuthCredentials import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider +import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection import java.io.IOException import java.util.Locale import kotlin.time.Duration @@ -37,7 +53,9 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection as ServiceP class RealNetworkController( private val context: android.content.Context, - private val pushServiceSocket: PushServiceSocket + private val pushServiceSocket: PushServiceSocket, + private val serviceConfiguration: SignalServiceConfiguration, + private val svr2MrEnclave: String ) : NetworkController { companion object { @@ -46,6 +64,24 @@ class RealNetworkController( private val json = Json { ignoreUnknownKeys = true } + private val okHttpClient: okhttp3.OkHttpClient by lazy { + val trustStore = serviceConfiguration.signalServiceUrls[0].trustStore + val keyStore = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()) + keyStore.load(trustStore.keyStoreInputStream, trustStore.keyStorePassword.toCharArray()) + + val tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(keyStore) + + val sslContext = javax.net.ssl.SSLContext.getInstance("TLS") + sslContext.init(null, tmf.trustManagers, null) + + val trustManager = tmf.trustManagers[0] as javax.net.ssl.X509TrustManager + + okhttp3.OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + } + override suspend fun createSession( e164: String, fcmToken: String?, @@ -273,6 +309,9 @@ class RealNetworkController( val result = json.decodeFromString(response.body.string()) RegistrationNetworkResult.Success(result) } + 401 -> { + RegistrationNetworkResult.Failure(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string())) + } 403 -> { RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string())) } @@ -323,6 +362,238 @@ class RealNetworkController( return "https://signalcaptchas.org/staging/registration/generate.html" } + override suspend fun restoreMasterKeyFromSvr( + svr2Credentials: NetworkController.SvrCredentials, + pin: String + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + val authCredentials = AuthCredentials.create(svr2Credentials.username, svr2Credentials.password) + + // Create a stub websocket that will never be used for pre-registration restore + val stubWebSocketFactory = WebSocketFactory { throw UnsupportedOperationException("WebSocket not available during pre-registration") } + val stubWebSocket = SignalWebSocket.AuthenticatedWebSocket( + stubWebSocketFactory, + { false }, + object : SleepTimer { + override fun sleep(millis: Long) = Thread.sleep(millis) + }, + 0 + ) + + val svr2 = SecureValueRecoveryV2(serviceConfiguration, svr2MrEnclave, stubWebSocket) + + when (val response = svr2.restoreDataPreRegistration(authCredentials, null, pin)) { + is RestoreResponse.Success -> { + Log.i(TAG, "[restoreMasterKeyFromSvr] Successfully restored master key from SVR2") + RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(response.masterKey)) + } + is RestoreResponse.PinMismatch -> { + Log.w(TAG, "[restoreMasterKeyFromSvr] PIN mismatch. Tries remaining: ${response.triesRemaining}") + RegistrationNetworkResult.Failure(NetworkController.RestoreMasterKeyError.WrongPin(response.triesRemaining)) + } + is RestoreResponse.Missing -> { + Log.w(TAG, "[restoreMasterKeyFromSvr] No SVR data found for user") + RegistrationNetworkResult.Failure(NetworkController.RestoreMasterKeyError.NoDataFound) + } + is RestoreResponse.NetworkError -> { + Log.w(TAG, "[restoreMasterKeyFromSvr] Network error", response.exception) + RegistrationNetworkResult.NetworkError(response.exception) + } + is RestoreResponse.ApplicationError -> { + Log.w(TAG, "[restoreMasterKeyFromSvr] Application error", response.exception) + RegistrationNetworkResult.ApplicationError(response.exception) + } + is RestoreResponse.EnclaveNotFound -> { + Log.w(TAG, "[restoreMasterKeyFromSvr] Enclave not found") + RegistrationNetworkResult.ApplicationError(IllegalStateException("SVR2 enclave not found")) + } + } + } catch (e: IOException) { + Log.w(TAG, "[restoreMasterKeyFromSvr] IOException", e) + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + Log.w(TAG, "[restoreMasterKeyFromSvr] Exception", e) + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun setPinAndMasterKeyOnSvr( + pin: String, + masterKey: MasterKey + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + val aci = RegistrationPreferences.aci + val pni = RegistrationPreferences.pni + val e164 = RegistrationPreferences.e164 + val password = RegistrationPreferences.servicePassword + + if (aci == null || e164 == null || password == null) { + Log.w(TAG, "[backupMasterKeyToSvr] Credentials not available, cannot authenticate") + return@withContext RegistrationNetworkResult.Failure(NetworkController.BackupMasterKeyError.NotRegistered) + } + + val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION) + val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password) + val healthMonitor = object : HealthMonitor { + override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {} + override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {} + } + + val libSignalConnection = LibSignalChatConnection( + name = "SVR-Backup", + network = network, + credentialsProvider = credentialsProvider, + receiveStories = false, + healthMonitor = healthMonitor + ) + + val authWebSocket = SignalWebSocket.AuthenticatedWebSocket( + connectionFactory = { libSignalConnection }, + canConnect = { true }, + sleepTimer = { millis -> Thread.sleep(millis) }, + disconnectTimeoutMs = 60.seconds.inWholeMilliseconds + ) + + authWebSocket.connect() + + val svr2 = SecureValueRecoveryV2(serviceConfiguration, svr2MrEnclave, authWebSocket) + val session = svr2.setPin(pin, masterKey) + val response = session.execute() + + authWebSocket.disconnect() + + when (response) { + is BackupResponse.Success -> { + Log.i(TAG, "[backupMasterKeyToSvr] Successfully backed up master key to SVR2") + RegistrationNetworkResult.Success(Unit) + } + is BackupResponse.ApplicationError -> { + Log.w(TAG, "[backupMasterKeyToSvr] Application error", response.exception) + RegistrationNetworkResult.ApplicationError(response.exception) + } + is BackupResponse.NetworkError -> { + Log.w(TAG, "[backupMasterKeyToSvr] Network error", response.exception) + RegistrationNetworkResult.NetworkError(response.exception) + } + is BackupResponse.EnclaveNotFound -> { + Log.w(TAG, "[backupMasterKeyToSvr] Enclave not found") + RegistrationNetworkResult.Failure(NetworkController.BackupMasterKeyError.EnclaveNotFound) + } + is BackupResponse.ExposeFailure -> { + Log.w(TAG, "[backupMasterKeyToSvr] Expose failure -- per spec, treat as success.") + RegistrationNetworkResult.Success(Unit) + } + is BackupResponse.ServerRejected -> { + Log.w(TAG, "[backupMasterKeyToSvr] Server rejected") + RegistrationNetworkResult.NetworkError(IOException("Server rejected backup request")) + } + } + } catch (e: IOException) { + Log.w(TAG, "[backupMasterKeyToSvr] IOException", e) + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + Log.w(TAG, "[backupMasterKeyToSvr] Exception", e) + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun enableRegistrationLock(): RegistrationNetworkResult = withContext(Dispatchers.IO) { + val aci = RegistrationPreferences.aci + val password = RegistrationPreferences.servicePassword + val masterKey = RegistrationPreferences.masterKey + + if (aci == null || password == null) { + Log.w(TAG, "[enableRegistrationLock] Credentials not available") + return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NotRegistered) + } + + if (masterKey == null) { + Log.w(TAG, "[enableRegistrationLock] Master key not available") + return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NoPinSet) + } + + val registrationLockToken = masterKey.deriveRegistrationLock() + + try { + val credentials = okhttp3.Credentials.basic(aci.toString(), password) + val baseUrl = serviceConfiguration.signalServiceUrls[0].url + val requestBody = """{"registrationLock":"$registrationLockToken"}""" + .toRequestBody("application/json".toMediaType()) + + val request = okhttp3.Request.Builder() + .url("$baseUrl/v1/accounts/registration_lock") + .put(requestBody) + .header("Authorization", credentials) + .build() + + okHttpClient.newCall(request).execute().use { response -> + when (response.code) { + 200, 204 -> { + Log.i(TAG, "[enableRegistrationLock] Successfully enabled registration lock") + RegistrationNetworkResult.Success(Unit) + } + 401 -> { + RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.Unauthorized) + } + 422 -> { + RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.InvalidRequest(response.body?.string() ?: "")) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}")) + } + } + } + } catch (e: IOException) { + Log.w(TAG, "[enableRegistrationLock] IOException", e) + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + Log.w(TAG, "[enableRegistrationLock] Exception", e) + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun disableRegistrationLock(): RegistrationNetworkResult = withContext(Dispatchers.IO) { + val aci = RegistrationPreferences.aci + val password = RegistrationPreferences.servicePassword + + if (aci == null || password == null) { + Log.w(TAG, "[disableRegistrationLock] Credentials not available") + return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NotRegistered) + } + + try { + val credentials = okhttp3.Credentials.basic(aci.toString(), password) + val baseUrl = serviceConfiguration.signalServiceUrls[0].url + + val request = okhttp3.Request.Builder() + .url("$baseUrl/v1/accounts/registration_lock") + .delete() + .header("Authorization", credentials) + .build() + + okHttpClient.newCall(request).execute().use { response -> + when (response.code) { + 200, 204 -> { + Log.i(TAG, "[disableRegistrationLock] Successfully disabled registration lock") + RegistrationNetworkResult.Success(Unit) + } + 401 -> { + RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.Unauthorized) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}")) + } + } + } + } catch (e: IOException) { + Log.w(TAG, "[disableRegistrationLock] IOException", e) + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + Log.w(TAG, "[disableRegistrationLock] Exception", e) + RegistrationNetworkResult.ApplicationError(e) + } + } + private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes { return ServiceAccountAttributes( signalingKey, diff --git a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt index 46ead9e385..a473fdbec1 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt @@ -5,16 +5,12 @@ package org.signal.registration.sample.dependencies -import android.content.ContentValues import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.signal.core.models.AccountEntropyPool import org.signal.core.util.Base64 import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.ServiceId import org.signal.libsignal.protocol.ecc.ECKeyPair import org.signal.libsignal.protocol.kem.KEMKeyPair import org.signal.libsignal.protocol.kem.KEMKeyType @@ -25,19 +21,23 @@ import org.signal.registration.KeyMaterial import org.signal.registration.NewRegistrationData import org.signal.registration.PreExistingRegistrationData import org.signal.registration.StorageController +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 /** - * Implementation of [StorageController] that persists registration data to a SQLite database. + * Implementation of [StorageController] that persists registration data using + * SharedPreferences for simple key-value data and SQLite for prekeys. */ class RealStorageController(context: Context) : StorageController { - private val db: RegistrationDatabase = RegistrationDatabase(context) + private val db = RegistrationDatabase(context) override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) { + val accountEntropyPool = AccountEntropyPool.generate() val aciIdentityKeyPair = IdentityKeyPair.generate() val pniIdentityKeyPair = IdentityKeyPair.generate() @@ -69,7 +69,8 @@ class RealStorageController(context: Context) : StorageController { aciRegistrationId = aciRegistrationId, pniRegistrationId = pniRegistrationId, unidentifiedAccessKey = unidentifiedAccessKey, - servicePassword = password + servicePassword = password, + accountEntropyPool = accountEntropyPool ) storeKeyMaterial(keyMaterial, profileKey) @@ -78,176 +79,35 @@ class RealStorageController(context: Context) : StorageController { } override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) { - val database = db.writableDatabase - database.beginTransaction() - try { - database.delete(RegistrationDatabase.TABLE_ACCOUNT, null, null) - - database.insert( - RegistrationDatabase.TABLE_ACCOUNT, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_E164, newRegistrationData.e164) - put(RegistrationDatabase.COLUMN_ACI, newRegistrationData.aci.toString()) - put(RegistrationDatabase.COLUMN_PNI, newRegistrationData.pni.toString()) - put(RegistrationDatabase.COLUMN_SERVICE_PASSWORD, newRegistrationData.servicePassword) - put(RegistrationDatabase.COLUMN_AEP, newRegistrationData.aep.toString()) - } - ) - - database.setTransactionSuccessful() - } finally { - database.endTransaction() - } + RegistrationPreferences.saveRegistrationData(newRegistrationData) } override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) { - val database = db.readableDatabase - val cursor = database.query( - RegistrationDatabase.TABLE_ACCOUNT, - arrayOf( - RegistrationDatabase.COLUMN_E164, - RegistrationDatabase.COLUMN_ACI, - RegistrationDatabase.COLUMN_PNI, - RegistrationDatabase.COLUMN_SERVICE_PASSWORD, - RegistrationDatabase.COLUMN_AEP - ), - null, - null, - null, - null, - null - ) + RegistrationPreferences.getPreExistingRegistrationData() + } - cursor.use { - if (it.moveToFirst()) { - val e164 = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_E164)) - val aciString = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_ACI)) - val pniString = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_PNI)) - val servicePassword = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_SERVICE_PASSWORD)) - val aepValue = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_AEP)) - - PreExistingRegistrationData( - e164 = e164, - aci = ServiceId.Aci.parseFromString(aciString), - pni = ServiceId.Pni.parseFromString(pniString), - servicePassword = servicePassword, - aep = AccountEntropyPool(aepValue) - ) - } else { - null - } - } + override suspend fun clearAllData() = withContext(Dispatchers.IO) { + RegistrationPreferences.clearAll() + db.clearAllPreKeys() } private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) { - val database = db.writableDatabase - database.beginTransaction() - try { - // Clear any existing data - database.delete(RegistrationDatabase.TABLE_IDENTITY_KEYS, null, null) - database.delete(RegistrationDatabase.TABLE_SIGNED_PREKEYS, null, null) - database.delete(RegistrationDatabase.TABLE_KYBER_PREKEYS, null, null) - database.delete(RegistrationDatabase.TABLE_REGISTRATION_IDS, null, null) - database.delete(RegistrationDatabase.TABLE_PROFILE_KEY, null, null) + // Clear existing data + RegistrationPreferences.clearKeyMaterial() + db.clearAllPreKeys() - // Store ACI identity key - database.insert( - RegistrationDatabase.TABLE_IDENTITY_KEYS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) - put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciIdentityKeyPair.serialize()) - } - ) + // Store in SharedPreferences + RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair + RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair + RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId + RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId + RegistrationPreferences.profileKey = profileKey - // Store PNI identity key - database.insert( - RegistrationDatabase.TABLE_IDENTITY_KEYS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) - put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniIdentityKeyPair.serialize()) - } - ) - - // Store ACI signed pre-key - database.insert( - RegistrationDatabase.TABLE_SIGNED_PREKEYS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) - put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciSignedPreKey.id) - put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciSignedPreKey.serialize()) - } - ) - - // Store PNI signed pre-key - database.insert( - RegistrationDatabase.TABLE_SIGNED_PREKEYS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) - put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniSignedPreKey.id) - put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniSignedPreKey.serialize()) - } - ) - - // Store ACI Kyber pre-key - database.insert( - RegistrationDatabase.TABLE_KYBER_PREKEYS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) - put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciLastResortKyberPreKey.id) - put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciLastResortKyberPreKey.serialize()) - } - ) - - // Store PNI Kyber pre-key - database.insert( - RegistrationDatabase.TABLE_KYBER_PREKEYS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) - put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniLastResortKyberPreKey.id) - put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniLastResortKyberPreKey.serialize()) - } - ) - - // Store ACI registration ID - database.insert( - RegistrationDatabase.TABLE_REGISTRATION_IDS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) - put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.aciRegistrationId) - } - ) - - // Store PNI registration ID - database.insert( - RegistrationDatabase.TABLE_REGISTRATION_IDS, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) - put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.pniRegistrationId) - } - ) - - // Store profile key - database.insert( - RegistrationDatabase.TABLE_PROFILE_KEY, - null, - ContentValues().apply { - put(RegistrationDatabase.COLUMN_KEY_DATA, profileKey.serialize()) - } - ) - - database.setTransactionSuccessful() - } finally { - database.endTransaction() - } + // 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 { @@ -300,109 +160,4 @@ class RealStorageController(context: Context) : StorageController { val ciphertext = cipher.doFinal(input) return ciphertext.copyOf(16) } - - companion object { - private const val ACCOUNT_TYPE_ACI = "aci" - private const val ACCOUNT_TYPE_PNI = "pni" - } - - private class RegistrationDatabase(context: Context) : SQLiteOpenHelper( - context, - DATABASE_NAME, - null, - DATABASE_VERSION - ) { - companion object { - const val DATABASE_NAME = "registration.db" - const val DATABASE_VERSION = 1 - - const val TABLE_IDENTITY_KEYS = "identity_keys" - const val TABLE_SIGNED_PREKEYS = "signed_prekeys" - const val TABLE_KYBER_PREKEYS = "kyber_prekeys" - const val TABLE_REGISTRATION_IDS = "registration_ids" - const val TABLE_PROFILE_KEY = "profile_key" - const val TABLE_ACCOUNT = "account" - - const val COLUMN_ID = "_id" - const val COLUMN_ACCOUNT_TYPE = "account_type" - const val COLUMN_KEY_ID = "key_id" - const val COLUMN_KEY_DATA = "key_data" - const val COLUMN_REGISTRATION_ID = "registration_id" - const val COLUMN_E164 = "e164" - const val COLUMN_ACI = "aci" - const val COLUMN_PNI = "pni" - const val COLUMN_SERVICE_PASSWORD = "service_password" - const val COLUMN_AEP = "aep" - } - - override fun onCreate(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE $TABLE_IDENTITY_KEYS ( - $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, - $COLUMN_ACCOUNT_TYPE TEXT NOT NULL UNIQUE, - $COLUMN_KEY_DATA BLOB NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE $TABLE_SIGNED_PREKEYS ( - $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, - $COLUMN_ACCOUNT_TYPE TEXT NOT NULL, - $COLUMN_KEY_ID INTEGER NOT NULL, - $COLUMN_KEY_DATA BLOB NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE $TABLE_KYBER_PREKEYS ( - $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, - $COLUMN_ACCOUNT_TYPE TEXT NOT NULL, - $COLUMN_KEY_ID INTEGER NOT NULL, - $COLUMN_KEY_DATA BLOB NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE $TABLE_REGISTRATION_IDS ( - $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, - $COLUMN_ACCOUNT_TYPE TEXT NOT NULL UNIQUE, - $COLUMN_REGISTRATION_ID INTEGER NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE $TABLE_PROFILE_KEY ( - $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, - $COLUMN_KEY_DATA BLOB NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE $TABLE_ACCOUNT ( - $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, - $COLUMN_E164 TEXT NOT NULL, - $COLUMN_ACI TEXT NOT NULL, - $COLUMN_PNI TEXT NOT NULL, - $COLUMN_SERVICE_PASSWORD TEXT NOT NULL, - $COLUMN_AEP TEXT NOT NULL - ) - """.trimIndent() - ) - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - // No migrations needed yet - } - } } diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt index 664b40a811..9a72589b2c 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt @@ -7,15 +7,29 @@ package org.signal.registration.sample.screens.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.signal.core.ui.compose.Previews @@ -26,6 +40,34 @@ fun MainScreen( onEvent: (MainScreenEvents) -> Unit, modifier: Modifier = Modifier ) { + var showClearDataDialog by remember { mutableStateOf(false) } + + if (showClearDataDialog) { + AlertDialog( + onDismissRequest = { showClearDataDialog = false }, + title = { Text("Clear All Data?") }, + text = { Text("This will delete all registration data including your account information, keys, and PIN. This cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + showClearDataDialog = false + onEvent(MainScreenEvents.ClearAllData) + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear") + } + }, + dismissButton = { + TextButton(onClick = { showClearDataDialog = false }) { + Text("Cancel") + } + } + ) + } + Column( modifier = modifier .fillMaxSize() @@ -33,6 +75,8 @@ fun MainScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + Spacer(modifier = Modifier.height(32.dp)) + Text( text = "Registration Sample App", style = MaterialTheme.typography.headlineMedium @@ -44,23 +88,92 @@ fun MainScreen( modifier = Modifier.padding(top = 8.dp) ) - Button( - onClick = { onEvent(MainScreenEvents.LaunchRegistration) }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 48.dp) - ) { - Text("Start Registration") - } + Spacer(modifier = Modifier.height(32.dp)) if (state.existingRegistrationState != null) { RegistrationInfo(state.existingRegistrationState) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { onEvent(MainScreenEvents.LaunchRegistration) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Re-register") + } + + OutlinedButton( + onClick = { onEvent(MainScreenEvents.OpenPinSettings) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("PIN & Registration Lock Settings") + } + + TextButton( + onClick = { showClearDataDialog = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear All Data") + } + } else { + Button( + onClick = { onEvent(MainScreenEvents.LaunchRegistration) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Start Registration") + } } + + Spacer(modifier = Modifier.height(8.dp)) } } @Composable private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Registered Account", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + RegistrationField(label = "Phone Number", value = data.phoneNumber) + RegistrationField(label = "ACI", value = data.aci) + RegistrationField(label = "PNI", value = data.pni) + RegistrationField(label = "AEP", value = data.aep) + RegistrationField(label = "PIN", value = data.pin ?: "(not set)") + RegistrationField(label = "Registration Lock", value = if (data.registrationLockEnabled) "Enabled" else "Disabled") + } + } +} + +@Composable +private fun RegistrationField(label: String, value: String) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface + ) + } } @Preview(showBackground = true) @@ -73,3 +186,23 @@ private fun MainScreenPreview() { ) } } + +@Preview(showBackground = true) +@Composable +private fun MainScreenWithRegistrationPreview() { + Previews.Preview { + MainScreen( + state = MainScreenState( + existingRegistrationState = MainScreenState.ExistingRegistrationState( + phoneNumber = "+15551234567", + aci = "12345678-1234-1234-1234-123456789abc", + pni = "abcdefab-abcd-abcd-abcd-abcdefabcdef", + aep = "aep1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd", + pin = "1234", + registrationLockEnabled = true + ) + ), + onEvent = {} + ) + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt index 665128a616..3bb7243ba1 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt @@ -7,4 +7,6 @@ package org.signal.registration.sample.screens.main sealed interface MainScreenEvents { data object LaunchRegistration : MainScreenEvents + data object OpenPinSettings : MainScreenEvents + data object ClearAllData : MainScreenEvents } diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt index 3e282f69cf..4489e3a1de 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt @@ -8,5 +8,12 @@ package org.signal.registration.sample.screens.main data class MainScreenState( val existingRegistrationState: ExistingRegistrationState? = null ) { - data class ExistingRegistrationState(val phoneNumber: String) + data class ExistingRegistrationState( + val phoneNumber: String, + val aci: String, + val pni: String, + val aep: String, + val pin: String?, + val registrationLockEnabled: Boolean + ) } diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt index bd175a317f..5f4239c942 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt @@ -12,25 +12,66 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.signal.registration.StorageController +import org.signal.registration.sample.storage.RegistrationPreferences class MainScreenViewModel( - private val onLaunchRegistration: () -> Unit + private val storageController: StorageController, + private val onLaunchRegistration: () -> Unit, + private val onOpenPinSettings: () -> Unit ) : ViewModel() { private val _state = MutableStateFlow(MainScreenState()) val state: StateFlow = _state.asStateFlow() + init { + loadRegistrationData() + } + + fun refreshData() { + loadRegistrationData() + } + fun onEvent(event: MainScreenEvents) { viewModelScope.launch { when (event) { MainScreenEvents.LaunchRegistration -> onLaunchRegistration() + MainScreenEvents.OpenPinSettings -> onOpenPinSettings() + MainScreenEvents.ClearAllData -> { + storageController.clearAllData() + refreshData() + } } } } - class Factory(private val onLaunchRegistration: () -> Unit) : ViewModelProvider.Factory { + private fun loadRegistrationData() { + viewModelScope.launch { + val existingData = storageController.getPreExistingRegistrationData() + _state.value = _state.value.copy( + existingRegistrationState = if (existingData != null) { + MainScreenState.ExistingRegistrationState( + phoneNumber = existingData.e164, + aci = existingData.aci.toString(), + pni = existingData.pni.toStringWithoutPrefix(), + aep = existingData.aep.value, + pin = RegistrationPreferences.pin, + registrationLockEnabled = RegistrationPreferences.registrationLockEnabled + ) + } else { + null + } + ) + } + } + + class Factory( + private val storageController: StorageController, + private val onLaunchRegistration: () -> Unit, + private val onOpenPinSettings: () -> Unit + ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return MainScreenViewModel(onLaunchRegistration) as T + return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T } } } diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsEvents.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsEvents.kt new file mode 100644 index 0000000000..786390ecdd --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.screens.pinsettings + +sealed interface PinSettingsEvents { + data class SetPin(val pin: String) : PinSettingsEvents + data object ToggleRegistrationLock : PinSettingsEvents + data object Back : PinSettingsEvents + data object DismissMessage : PinSettingsEvents +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsScreen.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsScreen.kt new file mode 100644 index 0000000000..c4f4f6bc9b --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsScreen.kt @@ -0,0 +1,264 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.screens.pinsettings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PinSettingsScreen( + state: PinSettingsState, + onEvent: (PinSettingsEvents) -> Unit, + modifier: Modifier = Modifier +) { + var pinInput by remember { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("PIN Settings") }, + navigationIcon = { + TextButton(onClick = { onEvent(PinSettingsEvents.Back) }) { + Text("Back") + } + } + ) + }, + snackbarHost = { + if (state.toastMessage != null) { + Snackbar( + action = { + TextButton(onClick = { onEvent(PinSettingsEvents.DismissMessage) }) { + Text("Dismiss") + } + } + ) { + Text(state.toastMessage) + } + } + }, + modifier = modifier + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // PIN Setup Section + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Set Your PIN", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Your PIN protects your account and allows you to restore your data if you need to re-register.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = pinInput, + onValueChange = { if (it.length <= 6) pinInput = it }, + label = { Text("Enter PIN (4-6 digits)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (pinInput.length >= 4) { + onEvent(PinSettingsEvents.SetPin(pinInput)) + } + } + ), + enabled = !state.loading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { onEvent(PinSettingsEvents.SetPin(pinInput)) }, + modifier = Modifier.fillMaxWidth(), + enabled = pinInput.length >= 4 && !state.loading + ) { + Text(if (state.hasPinSet) "Update PIN" else "Set PIN") + } + + if (state.hasPinSet) { + Text( + text = "PIN is currently set", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Registration Lock Section + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Registration Lock", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "When enabled, your PIN will be required when re-registering your phone number.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Switch( + checked = state.registrationLockEnabled, + onCheckedChange = { onEvent(PinSettingsEvents.ToggleRegistrationLock) }, + enabled = state.hasPinSet && !state.loading + ) + } + + if (!state.hasPinSet) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = "Set a PIN first to enable registration lock", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Info Section + Text( + text = "Note: This is a sample app. PIN changes here are simulated and won't persist to the server.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + + if (state.loading) { + Dialogs.IndeterminateProgressDialog() + } + } + } +} + +@DayNightPreviews +@Composable +private fun PinSettingsScreenPreview() { + Previews.Preview { + PinSettingsScreen( + state = PinSettingsState(), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PinSettingsScreenWithPinPreview() { + Previews.Preview { + PinSettingsScreen( + state = PinSettingsState( + hasPinSet = true, + registrationLockEnabled = true + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PinSettingsScreenLoadingPreview() { + Previews.Preview { + PinSettingsScreen( + state = PinSettingsState( + loading = true + ), + onEvent = {} + ) + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsState.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsState.kt new file mode 100644 index 0000000000..7cc3d5a4ed --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsState.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.screens.pinsettings + +data class PinSettingsState( + val hasPinSet: Boolean = false, + val registrationLockEnabled: Boolean = false, + val loading: Boolean = false, + val toastMessage: String? = null +) diff --git a/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt new file mode 100644 index 0000000000..c1005ebf0e --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.screens.pinsettings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.signal.registration.NetworkController +import org.signal.registration.NetworkController.RegistrationNetworkResult +import org.signal.registration.sample.storage.RegistrationPreferences + +/** + * ViewModel for the PIN settings screen. + * + * Handles setting PIN via SVR backup and enabling/disabling registration lock. + */ +class PinSettingsViewModel( + private val networkController: NetworkController, + private val onBack: () -> Unit +) : ViewModel() { + + companion object { + private val TAG = Log.tag(PinSettingsViewModel::class) + } + + private val _state = MutableStateFlow( + PinSettingsState( + hasPinSet = RegistrationPreferences.hasPin, + registrationLockEnabled = RegistrationPreferences.registrationLockEnabled + ) + ) + val state: StateFlow = _state.asStateFlow() + + fun onEvent(event: PinSettingsEvents) { + when (event) { + is PinSettingsEvents.SetPin -> { + _state.value = _state.value.copy(loading = true) + handleSetPin(event.pin) + _state.value = _state.value.copy(loading = true) + } + is PinSettingsEvents.ToggleRegistrationLock -> { + _state.value = _state.value.copy(loading = true) + handleToggleRegistrationLock() + _state.value = _state.value.copy(loading = false) + } + is PinSettingsEvents.Back -> onBack() + is PinSettingsEvents.DismissMessage -> dismissMessage() + } + } + + private fun handleSetPin(pin: String) { + if (pin.length < 4) { + _state.value = _state.value.copy(toastMessage = "PIN must be at least 4 digits") + return + } + + viewModelScope.launch { + // Generate or reuse existing master key + val masterKey = RegistrationPreferences.masterKey ?: run { + _state.value = _state.value.copy(toastMessage = "No master key found!") + return@launch + } + + when (val result = networkController.setPinAndMasterKeyOnSvr(pin, masterKey)) { + is RegistrationNetworkResult.Success -> { + Log.i(TAG, "Successfully backed up PIN to SVR") + RegistrationPreferences.pin = pin + _state.value = _state.value.copy( + loading = false, + hasPinSet = true, + toastMessage = "PIN has been set successfully" + ) + } + is RegistrationNetworkResult.Failure -> { + Log.w(TAG, "Failed to backup PIN: ${result.error}") + _state.value = _state.value.copy( + loading = false, + toastMessage = "Failed to set PIN: ${result.error::class.simpleName}" + ) + } + is RegistrationNetworkResult.NetworkError -> { + Log.w(TAG, "Network error while setting PIN", result.exception) + _state.value = _state.value.copy( + loading = false, + toastMessage = "Network error. Please check your connection." + ) + } + is RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Application error while setting PIN", result.exception) + _state.value = _state.value.copy( + loading = false, + toastMessage = "An error occurred: ${result.exception.message}" + ) + } + } + } + } + + private fun handleToggleRegistrationLock() { + val currentlyEnabled = _state.value.registrationLockEnabled + + viewModelScope.launch { + val result = if (currentlyEnabled) { + networkController.disableRegistrationLock() + } else { + networkController.enableRegistrationLock() + } + + when (result) { + is RegistrationNetworkResult.Success -> { + val newEnabled = !currentlyEnabled + RegistrationPreferences.registrationLockEnabled = newEnabled + Log.i(TAG, "Registration lock ${if (newEnabled) "enabled" else "disabled"}") + _state.value = _state.value.copy( + loading = false, + registrationLockEnabled = newEnabled, + toastMessage = if (newEnabled) "Registration lock enabled" else "Registration lock disabled" + ) + } + is RegistrationNetworkResult.Failure -> { + Log.w(TAG, "Failed to toggle registration lock: ${result.error}") + _state.value = _state.value.copy( + loading = false, + toastMessage = "Failed to update registration lock" + ) + } + is RegistrationNetworkResult.NetworkError -> { + Log.w(TAG, "Network error while toggling registration lock", result.exception) + _state.value = _state.value.copy( + loading = false, + toastMessage = "Network error. Please check your connection." + ) + } + is RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Application error while toggling registration lock", result.exception) + _state.value = _state.value.copy( + loading = false, + toastMessage = "An error occurred: ${result.exception.message}" + ) + } + } + } + } + + private fun dismissMessage() { + _state.value = _state.value.copy(toastMessage = null) + } + + class Factory( + private val networkController: NetworkController, + private val onBack: () -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return PinSettingsViewModel(networkController, onBack) as T + } + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationDatabase.kt b/registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationDatabase.kt new file mode 100644 index 0000000000..0910e98448 --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationDatabase.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.storage + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import org.signal.core.util.deleteAll +import org.signal.core.util.insertInto +import org.signal.core.util.withinTransaction +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord + +/** + * SQLite database for storing prekey data in the sample app. + * Only stores signed prekeys and kyber prekeys, which benefit from + * database storage due to their structure. + */ +class RegistrationDatabase(context: Context) { + + companion object { + private const val DATABASE_NAME = "registration.db" + private const val DATABASE_VERSION = 2 + + const val ACCOUNT_TYPE_ACI = "aci" + const val ACCOUNT_TYPE_PNI = "pni" + } + + private val openHelper: SupportSQLiteOpenHelper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration( + context = context, + name = DATABASE_NAME, + callback = Callback() + ) + ) + + val writableDatabase: SupportSQLiteDatabase get() = openHelper.writableDatabase + val readableDatabase: SupportSQLiteDatabase get() = openHelper.readableDatabase + + val signedPreKeys = SampleSignedPreKeyTable(this) + val kyberPreKeys = SampleKyberPreKeyTable(this) + + fun clearAllPreKeys() { + writableDatabase.withinTransaction { db -> + db.deleteAll(SampleSignedPreKeyTable.TABLE_NAME) + db.deleteAll(SampleKyberPreKeyTable.TABLE_NAME) + } + } + + private class Callback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SampleSignedPreKeyTable.CREATE_TABLE) + db.execSQL(SampleKyberPreKeyTable.CREATE_TABLE) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + } + + /** + * Table for storing signed pre-keys. + */ + class SampleSignedPreKeyTable(private val db: RegistrationDatabase) { + + companion object { + const val TABLE_NAME = "signed_prekeys" + + private const val ID = "_id" + private const val ACCOUNT_TYPE = "account_type" + private const val KEY_ID = "key_id" + private const val KEY_DATA = "key_data" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $ACCOUNT_TYPE TEXT NOT NULL, + $KEY_ID INTEGER NOT NULL, + $KEY_DATA BLOB NOT NULL + ) + """ + } + + fun insert(accountType: String, signedPreKey: SignedPreKeyRecord) { + db.writableDatabase + .insertInto(TABLE_NAME) + .values( + ACCOUNT_TYPE to accountType, + KEY_ID to signedPreKey.id, + KEY_DATA to signedPreKey.serialize() + ) + .run() + } + } + + /** + * Table for storing Kyber pre-keys. + */ + class SampleKyberPreKeyTable(private val db: RegistrationDatabase) { + + companion object { + const val TABLE_NAME = "kyber_prekeys" + + private const val ID = "_id" + private const val ACCOUNT_TYPE = "account_type" + private const val KEY_ID = "key_id" + private const val KEY_DATA = "key_data" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $ACCOUNT_TYPE TEXT NOT NULL, + $KEY_ID INTEGER NOT NULL, + $KEY_DATA BLOB NOT NULL + ) + """ + } + + fun insert(accountType: String, kyberPreKey: KyberPreKeyRecord) { + db.writableDatabase + .insertInto(TABLE_NAME) + .values( + ACCOUNT_TYPE to accountType, + KEY_ID to kyberPreKey.id, + KEY_DATA to kyberPreKey.serialize() + ) + .run() + } + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt b/registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt new file mode 100644 index 0000000000..f20c753c34 --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.storage + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +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.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.signal.registration.NewRegistrationData +import org.signal.registration.PreExistingRegistrationData + +/** + * SharedPreferences-based storage for registration data that doesn't need + * the complexity of a SQLite database. + */ +object RegistrationPreferences { + + private lateinit var context: Application + + private const val PREFS_NAME = "registration_prefs" + + private const val KEY_E164 = "e164" + private const val KEY_ACI = "aci" + private const val KEY_PNI = "pni" + private const val KEY_SERVICE_PASSWORD = "service_password" + private const val KEY_AEP = "aep" + private const val KEY_PROFILE_KEY = "profile_key" + private const val KEY_ACI_REGISTRATION_ID = "aci_registration_id" + private const val KEY_PNI_REGISTRATION_ID = "pni_registration_id" + private const val KEY_ACI_IDENTITY_KEY = "aci_identity_key" + private const val KEY_PNI_IDENTITY_KEY = "pni_identity_key" + private const val KEY_MASTER_KEY = "master_key" + private const val KEY_REGISTRATION_LOCK_ENABLED = "registration_lock_enabled" + private const val KEY_PIN = "has_pin" + + fun init(context: Application) { + this.context = context + } + + private val prefs: SharedPreferences by lazy { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + var e164: String? + get() = prefs.getString(KEY_E164, null) + set(value) = prefs.edit { putString(KEY_E164, value) } + + var aci: ACI? + get() = prefs.getString(KEY_ACI, null)?.let { ACI.parseOrNull(it) } + set(value) = prefs.edit { putString(KEY_ACI, value?.toString()) } + + var pni: PNI? + get() = prefs.getString(KEY_PNI, null)?.let { PNI.parseOrNull(it) } + set(value) = prefs.edit { putString(KEY_PNI, value?.toString()) } + + var servicePassword: String? + get() = prefs.getString(KEY_SERVICE_PASSWORD, null) + set(value) = prefs.edit { putString(KEY_SERVICE_PASSWORD, value) } + + var aep: AccountEntropyPool? + get() = prefs.getString(KEY_AEP, null)?.let { AccountEntropyPool(it) } + set(value) = prefs.edit { putString(KEY_AEP, value?.toString()) } + + var profileKey: ProfileKey? + get() = prefs.getString(KEY_PROFILE_KEY, null)?.let { ProfileKey(Base64.decode(it)) } + set(value) = prefs.edit { putString(KEY_PROFILE_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) } + + var aciRegistrationId: Int + get() = prefs.getInt(KEY_ACI_REGISTRATION_ID, -1) + set(value) = prefs.edit { putInt(KEY_ACI_REGISTRATION_ID, value) } + + var pniRegistrationId: Int + get() = prefs.getInt(KEY_PNI_REGISTRATION_ID, -1) + set(value) = prefs.edit { putInt(KEY_PNI_REGISTRATION_ID, value) } + + var aciIdentityKeyPair: IdentityKeyPair? + get() = prefs.getString(KEY_ACI_IDENTITY_KEY, null)?.let { IdentityKeyPair(Base64.decode(it)) } + set(value) = prefs.edit { putString(KEY_ACI_IDENTITY_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) } + + var pniIdentityKeyPair: IdentityKeyPair? + get() = prefs.getString(KEY_PNI_IDENTITY_KEY, null)?.let { IdentityKeyPair(Base64.decode(it)) } + set(value) = prefs.edit { putString(KEY_PNI_IDENTITY_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) } + + val masterKey: MasterKey? + get() = aep?.deriveMasterKey() + + var registrationLockEnabled: Boolean + get() = prefs.getBoolean(KEY_REGISTRATION_LOCK_ENABLED, false) + set(value) = prefs.edit { putBoolean(KEY_REGISTRATION_LOCK_ENABLED, value) } + + val hasPin: Boolean + get() = pin != null + + var pin: String? + get() = prefs.getString(KEY_PIN, null) + set(value) = prefs.edit { putString(KEY_PIN, value) } + + fun saveRegistrationData(data: NewRegistrationData) { + prefs.edit { + putString(KEY_E164, data.e164) + putString(KEY_ACI, data.aci.toString()) + putString(KEY_PNI, data.pni.toString()) + putString(KEY_SERVICE_PASSWORD, data.servicePassword) + putString(KEY_AEP, data.aep.value) + } + } + + fun getPreExistingRegistrationData(): PreExistingRegistrationData? { + val e164 = e164 ?: return null + val aci = aci ?: return null + val pni = pni ?: return null + val servicePassword = servicePassword ?: return null + val aep = aep ?: return null + + return PreExistingRegistrationData( + e164 = e164, + aci = aci, + pni = pni, + servicePassword = servicePassword, + aep = aep + ) + } + + fun clearKeyMaterial() { + prefs.edit { + remove(KEY_PROFILE_KEY) + remove(KEY_ACI_REGISTRATION_ID) + remove(KEY_PNI_REGISTRATION_ID) + remove(KEY_ACI_IDENTITY_KEY) + remove(KEY_PNI_IDENTITY_KEY) + } + } + + fun clearAll() { + prefs.edit { clear() } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/NetworkController.kt b/registration/lib/src/main/java/org/signal/registration/NetworkController.kt index 84af11d31a..6737212570 100644 --- a/registration/lib/src/main/java/org/signal/registration/NetworkController.kt +++ b/registration/lib/src/main/java/org/signal/registration/NetworkController.kt @@ -9,6 +9,7 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.signal.core.models.MasterKey import org.signal.core.util.serialization.ByteArrayToBase64Serializer import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.state.KyberPreKeyRecord @@ -101,6 +102,48 @@ interface NetworkController { */ fun getCaptchaUrl(): String + /** + * Attempts to restore the master key from SVR using the provided credentials and PIN. + * + * This is called when the user encounters a registration lock and needs to prove + * they know their PIN to proceed with registration. + * + * @param svr2Credentials The SVR2 credentials provided by the server during the registration lock response. + * @param pin The user-entered PIN. + * @return The restored master key on success, or an appropriate error. + */ + suspend fun restoreMasterKeyFromSvr( + svr2Credentials: SvrCredentials, + pin: String + ): RegistrationNetworkResult + + /** + * Backs up the master key to SVR, protected by the user's PIN. + * + * @param pin The user-chosen PIN to protect the backup. + * @param masterKey The master key to backup. + * @return Success or an appropriate error. + */ + suspend fun setPinAndMasterKeyOnSvr( + pin: String, + masterKey: MasterKey + ): RegistrationNetworkResult + + /** + * Enables registration lock on the account using the registration lock token + * derived from the master key. + * + * @return Success or an appropriate error. + */ + suspend fun enableRegistrationLock(): RegistrationNetworkResult + + /** + * Disables registration lock on the account. + * + * @return Success or an appropriate error. + */ + suspend fun disableRegistrationLock(): RegistrationNetworkResult + // TODO // /** // * Validates the provided SVR2 auth credentials, returning information on their usability. @@ -176,6 +219,7 @@ interface NetworkController { } sealed class RegisterAccountError() { + data class SessionNotFoundOrNotVerified(val message: String) : RegisterAccountError() data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError() data object DeviceTransferPossible : RegisterAccountError() data class InvalidRequest(val message: String) : RegisterAccountError() @@ -183,6 +227,27 @@ interface NetworkController { data class RateLimited(val retryAfter: Duration) : RegisterAccountError() } + sealed class RestoreMasterKeyError() { + data class WrongPin(val triesRemaining: Int) : RestoreMasterKeyError() + data object NoDataFound : RestoreMasterKeyError() + } + + sealed class BackupMasterKeyError() { + data object EnclaveNotFound : BackupMasterKeyError() + data object NotRegistered : BackupMasterKeyError() + } + + sealed class SetRegistrationLockError() { + data class InvalidRequest(val message: String) : SetRegistrationLockError() + data object Unauthorized : SetRegistrationLockError() + data object NotRegistered : SetRegistrationLockError() + data object NoPinSet : SetRegistrationLockError() + } + + data class MasterKeyResponse( + val masterKey: MasterKey + ) + @Serializable @Parcelize data class SessionMetadata( @@ -261,14 +326,14 @@ interface NetworkController { data class RegistrationLockResponse( val timeRemaining: Long, val svr2Credentials: SvrCredentials - ) { + ) - @Serializable - data class SvrCredentials( - val username: String, - val password: String - ) - } + @Serializable + @Parcelize + data class SvrCredentials( + val username: String, + val password: String + ) : Parcelable @Serializable data class ThirdPartyServiceErrorResponse( diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt index 5fc9378b7f..89f9ca77e2 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -5,10 +5,13 @@ package org.signal.registration +import org.signal.core.models.MasterKey + sealed interface RegistrationFlowEvent { data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent data object NavigateBack : RegistrationFlowEvent data object ResetState : RegistrationFlowEvent data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent data class E164Chosen(val e164: String) : RegistrationFlowEvent + data class MasterKeyRestoredForRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent } diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt index a1a6614ebb..bbcae9ec5a 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -5,12 +5,30 @@ package org.signal.registration +import android.os.Parcel import android.os.Parcelable +import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import org.signal.core.models.MasterKey @Parcelize +@TypeParceler data class RegistrationFlowState( val backStack: List = listOf(RegistrationRoute.Welcome), val sessionMetadata: NetworkController.SessionMetadata? = null, - val sessionE164: String? = null + val sessionE164: String? = null, + val masterKey: MasterKey? = null, + val registrationLockProof: String? = null ) : Parcelable + +object MasterKeyParceler : Parceler { + override fun create(parcel: Parcel): MasterKey? { + val bytes = parcel.createByteArray() + return bytes?.let { MasterKey(it) } + } + + override fun MasterKey?.write(parcel: Parcel, flags: Int) { + parcel.writeByteArray(this?.serialize()) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt index 29533b4d7c..1269d04b5d 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -30,6 +30,9 @@ import com.google.accompanist.permissions.MultiplePermissionsState import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.signal.core.ui.navigation.ResultEffect +import org.signal.registration.screens.accountlocked.AccountLockedScreen +import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents +import org.signal.registration.screens.accountlocked.AccountLockedState import org.signal.registration.screens.captcha.CaptchaScreen import org.signal.registration.screens.captcha.CaptchaScreenEvents import org.signal.registration.screens.captcha.CaptchaState @@ -40,6 +43,8 @@ import org.signal.registration.screens.phonenumber.PhoneNumberScreen import org.signal.registration.screens.pincreation.PinCreationScreen import org.signal.registration.screens.pincreation.PinCreationScreenEvents import org.signal.registration.screens.pincreation.PinCreationState +import org.signal.registration.screens.pinentry.PinEntryScreen +import org.signal.registration.screens.registrationlock.RegistrationLockPinEntryViewModel import org.signal.registration.screens.restore.RestoreViaQrScreen import org.signal.registration.screens.restore.RestoreViaQrScreenEvents import org.signal.registration.screens.restore.RestoreViaQrState @@ -72,6 +77,18 @@ sealed interface RegistrationRoute : NavKey, Parcelable { @Serializable data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute + @Serializable + data object PinEntry : RegistrationRoute + + @Serializable + data class RegistrationLockPinEntry( + val timeRemaining: Long, + val svrCredentials: NetworkController.SvrCredentials + ) : RegistrationRoute + + @Serializable + data class AccountLocked(val timeRemainingMs: Long) : RegistrationRoute + @Serializable data object Profile : RegistrationRoute @@ -317,6 +334,44 @@ private fun EntryProviderScope.registrationEntries( ) } + // -- Registration Lock PIN Entry Screen + entry { key -> + val viewModel: RegistrationLockPinEntryViewModel = viewModel( + factory = RegistrationLockPinEntryViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent, + timeRemaining = key.timeRemaining, + svrCredentials = key.svrCredentials + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + PinEntryScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + + // -- Account Locked Screen + entry { key -> + val daysRemaining = (key.timeRemainingMs / (1000 * 60 * 60 * 24)).toInt() + AccountLockedScreen( + state = AccountLockedState(daysRemaining = daysRemaining), + onEvent = { event -> + when (event) { + AccountLockedScreenEvents.Next -> { + // TODO: Navigate to appropriate next screen (likely back to welcome or phone entry) + navigator.navigate(RegistrationRoute.Welcome) + } + AccountLockedScreenEvents.LearnMore -> { + // TODO: Open learn more URL + } + } + } + ) + } + entry { // TODO: Implement RestoreScreen } diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt index 4b93a0e63e..1dfbce42b5 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -7,14 +7,19 @@ package org.signal.registration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.ServiceId.PNI import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.MasterKeyResponse import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegistrationNetworkResult import org.signal.registration.NetworkController.RequestVerificationCodeError +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 java.util.Locale @@ -81,6 +86,16 @@ class RegistrationRepository(val networkController: NetworkController, val stora ) } + suspend fun restoreMasterKeyFromSvr( + svr2Credentials: SvrCredentials, + pin: String + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + networkController.restoreMasterKeyFromSvr( + svr2Credentials = svr2Credentials, + pin = pin + ) + } + /** * Registers a new account after successful phone number verification. * @@ -88,28 +103,30 @@ class RegistrationRepository(val networkController: NetworkController, val stora * 1. Generates and stores all required cryptographic key material * 2. Creates account attributes with registration IDs and capabilities * 3. Calls the network controller to register the account + * 4. On success, saves the registration data to persistent storage * * @param e164 The phone number in E.164 format (used for basic auth) * @param sessionId The verified session ID from phone number verification + * @param registrationLock The registration lock token derived from the master key (if unlocking a reglocked account) * @param skipDeviceTransfer Whether to skip device transfer flow * @return The registration result containing account information or an error */ suspend fun registerAccount( e164: String, sessionId: String, + registrationLock: String? = null, skipDeviceTransfer: Boolean = true ): RegistrationNetworkResult = withContext(Dispatchers.IO) { val keyMaterial = storageController.generateAndStoreKeyMaterial() val fcmToken = networkController.getFcmToken() - // TODO this will need to be re-usable for reglocked accounts too (i.e. can't assume no reglock) val accountAttributes = AccountAttributes( signalingKey = null, registrationId = keyMaterial.aciRegistrationId, voice = true, video = true, fetchesMessages = fcmToken == null, - registrationLock = null, + registrationLock = registrationLock, unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey, unrestrictedUnidentifiedAccess = false, discoverableByPhoneNumber = false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later @@ -136,7 +153,7 @@ class RegistrationRepository(val networkController: NetworkController, val stora lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey ) - networkController.registerAccount( + val result = networkController.registerAccount( e164 = e164, password = keyMaterial.servicePassword, sessionId = sessionId, @@ -147,5 +164,19 @@ class RegistrationRepository(val networkController: NetworkController, val stora fcmToken = fcmToken, skipDeviceTransfer = skipDeviceTransfer ) + + 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 + ) + ) + } + + result } } diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt index 75458802c8..0d52fd9632 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -43,6 +43,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save is RegistrationFlowEvent.ResetState -> RegistrationFlowState() is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session) is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164) + is RegistrationFlowEvent.MasterKeyRestoredForRegistrationLock -> state.copy(masterKey = event.masterKey, registrationLockProof = event.masterKey.deriveRegistrationLock()) is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event) is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1)) } diff --git a/registration/lib/src/main/java/org/signal/registration/StorageController.kt b/registration/lib/src/main/java/org/signal/registration/StorageController.kt index 2bd613851f..c1da0d7f6b 100644 --- a/registration/lib/src/main/java/org/signal/registration/StorageController.kt +++ b/registration/lib/src/main/java/org/signal/registration/StorageController.kt @@ -6,8 +6,9 @@ package org.signal.registration import org.signal.core.models.AccountEntropyPool +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.ServiceId import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord @@ -32,6 +33,11 @@ interface StorageController { * @return Data for the existing registration if registered, otherwise null. */ suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? + + /** + * Clears all stored registration data, including key material and account information. + */ + suspend fun clearAllData() } /** @@ -57,21 +63,23 @@ data class KeyMaterial( /** Unidentified access key (derived from profile key) for sealed sender. */ val unidentifiedAccessKey: ByteArray, /** Password for basic auth during registration (18 random bytes, base64 encoded). */ - val servicePassword: String + val servicePassword: String, + /** Account entropy pool for key derivation. */ + val accountEntropyPool: AccountEntropyPool ) data class NewRegistrationData( val e164: String, - val aci: ServiceId.Aci, - val pni: ServiceId.Pni, + val aci: ACI, + val pni: PNI, val servicePassword: String, val aep: AccountEntropyPool ) data class PreExistingRegistrationData( val e164: String, - val aci: ServiceId.Aci, - val pni: ServiceId.Pni, + val aci: ACI, + val pni: PNI, val servicePassword: String, val aep: AccountEntropyPool ) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt new file mode 100644 index 0000000000..12b5bc44ed --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.accountlocked + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews + +/** + * Screen shown when the user's account is locked due to too many failed PIN attempts + * and there's no SVR data available to recover. + */ +@Composable +fun AccountLockedScreen( + state: AccountLockedState, + onEvent: (AccountLockedScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(49.dp)) + + Text( + text = "Account locked", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Your account has been locked to protect your privacy and security. After ${state.daysRemaining} days of inactivity in your account you'll be able to re-register this phone number without needing your PIN. All content will be deleted.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { onEvent(AccountLockedScreenEvents.Next) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Next") + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = { onEvent(AccountLockedScreenEvents.LearnMore) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Learn More") + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@DayNightPreviews +@Composable +private fun AccountLockedScreenPreview() { + Previews.Preview { + AccountLockedScreen( + state = AccountLockedState(daysRemaining = 7), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt new file mode 100644 index 0000000000..3be0b6b690 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.accountlocked + +sealed class AccountLockedScreenEvents { + data object Next : AccountLockedScreenEvents() + data object LearnMore : AccountLockedScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt b/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt new file mode 100644 index 0000000000..adfcd66d7b --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.accountlocked + +data class AccountLockedState( + val daysRemaining: Int = 10 +) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index 5ca81d97df..2902f840be 100644 --- a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -54,18 +54,28 @@ fun PhoneNumberScreen( onEvent: (PhoneNumberEntryScreenEvents) -> Unit, modifier: Modifier = Modifier ) { + var simpleErrorMessage: String? by remember { mutableStateOf(null) } + LaunchedEffect(state.oneTimeEvent) { onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent) when (state.oneTimeEvent) { - OneTimeEvent.NetworkError -> TODO() - is OneTimeEvent.RateLimited -> TODO() - OneTimeEvent.UnknownError -> TODO() - OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO() - OneTimeEvent.ThirdPartyError -> TODO() + OneTimeEvent.NetworkError -> simpleErrorMessage = "Network error" + is OneTimeEvent.RateLimited -> simpleErrorMessage = "Rate limited" + OneTimeEvent.UnknownError -> simpleErrorMessage = "Unknown error" + OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = "Could not request code with selected transport" + OneTimeEvent.ThirdPartyError -> simpleErrorMessage = "Third party error" null -> Unit } } + simpleErrorMessage?.let { message -> + Dialogs.SimpleMessageDialog( + message = message, + dismiss = "Ok", + onDismiss = { simpleErrorMessage = null } + ) + } + Box(modifier = modifier.fillMaxSize()) { ScreenContent(state, onEvent) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 80750ff8c7..d753b4aa8d 100644 --- a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -48,10 +48,10 @@ class PhoneNumberEntryViewModel( fun onEvent(event: PhoneNumberEntryScreenEvents) { viewModelScope.launch { - val stateEMitter: (PhoneNumberEntryState) -> Unit = { state -> + val stateEmitter: (PhoneNumberEntryState) -> Unit = { state -> _state.value = state } - applyEvent(_state.value, event, stateEMitter, parentEventEmitter) + applyEvent(_state.value, event, stateEmitter, parentEventEmitter) } } @@ -64,9 +64,10 @@ class PhoneNumberEntryViewModel( stateEmitter(applyPhoneNumberChanged(state, event.value)) } is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> { - stateEmitter(state.copy(showFullScreenSpinner = true)) - val resultState = applyPhoneNumberSubmitted(state, parentEventEmitter) - stateEmitter(resultState.copy(showFullScreenSpinner = false)) + var localState = state.copy(showFullScreenSpinner = true) + stateEmitter(localState) + localState = applyPhoneNumberSubmitted(localState, parentEventEmitter) + stateEmitter(localState.copy(showFullScreenSpinner = false)) } is PhoneNumberEntryScreenEvents.CountryPicker -> { state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) } diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt index f07b4a4e8f..84072cb4e0 100644 --- a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt +++ b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt @@ -107,13 +107,13 @@ fun PinEntryScreen( } } ), - isError = state.errorMessage != null + isError = state.triesRemaining != null ) - if (state.errorMessage != null) { + if (state.triesRemaining != null) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = state.errorMessage, + text = "Incorrect PIN. ${state.triesRemaining} attempts remaining.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, @@ -205,7 +205,7 @@ private fun PinEntryScreenWithErrorPreview() { Previews.Preview { PinEntryScreen( state = PinEntryState( - errorMessage = "Incorrect PIN. Try again.", + triesRemaining = 3, showNeedHelp = true ), onEvent = {} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt new file mode 100644 index 0000000000..6be58e14cf --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pinentry + +object PinEntryScreenEventHandler { + + fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents): PinEntryState { + return when (event) { + PinEntryScreenEvents.ToggleKeyboard -> state.copy(isNumericKeyboard = !state.isNumericKeyboard) + else -> throw UnsupportedOperationException("This even is not handled generically!") + } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt index 05e0ec4bbe..8accfdc02a 100644 --- a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt +++ b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt @@ -5,8 +5,18 @@ package org.signal.registration.screens.pinentry +import kotlin.time.Duration + data class PinEntryState( - val errorMessage: String? = null, val showNeedHelp: Boolean = false, - val isNumericKeyboard: Boolean = true -) + val isNumericKeyboard: Boolean = true, + val loading: Boolean = false, + val triesRemaining: Int? = null, + val oneTimeEvent: OneTimeEvent? = null +) { + sealed interface OneTimeEvent { + data object NetworkError : OneTimeEvent + data class RateLimited(val retryAfter: Duration) : OneTimeEvent + data object UnknownError : OneTimeEvent + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/registrationlock/RegistrationLockPinEntryViewModel.kt b/registration/lib/src/main/java/org/signal/registration/screens/registrationlock/RegistrationLockPinEntryViewModel.kt new file mode 100644 index 0000000000..20e4dd6c21 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/registrationlock/RegistrationLockPinEntryViewModel.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.registrationlock + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.signal.core.models.MasterKey +import org.signal.core.util.logging.Log +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.pinentry.PinEntryScreenEventHandler +import org.signal.registration.screens.pinentry.PinEntryScreenEvents +import org.signal.registration.screens.pinentry.PinEntryState +import org.signal.registration.screens.util.navigateTo + +/** + * ViewModel for the registration lock PIN entry screen. + * + * This screen is shown when the user attempts to register and their account + * is protected by a registration lock (PIN). The user must enter their PIN + * to proceed with registration. + */ +class RegistrationLockPinEntryViewModel( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val timeRemaining: Long, + private val svrCredentials: NetworkController.SvrCredentials +) : ViewModel() { + + companion object { + private val TAG = Log.tag(RegistrationLockPinEntryViewModel::class) + } + + private val _state = MutableStateFlow( + PinEntryState( + showNeedHelp = true + ) + ) + + val state: StateFlow = _state + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true)) + + fun onEvent(event: PinEntryScreenEvents) { + viewModelScope.launch { + val stateEmitter: (PinEntryState) -> Unit = { state -> + _state.value = state + } + applyEvent(state.value, event, stateEmitter, parentEventEmitter) + } + } + + private suspend fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents, stateEmitter: (PinEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) { + when (event) { + is PinEntryScreenEvents.PinEntered -> { + var localState = state.copy(loading = true) + stateEmitter(localState) + localState = applyPinEntered(localState, event, parentEventEmitter) + stateEmitter(localState.copy(loading = false)) + } + is PinEntryScreenEvents.Skip -> { + handleSkip() + } + is PinEntryScreenEvents.ToggleKeyboard, + is PinEntryScreenEvents.NeedHelp -> { + stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event)) + } + } + } + + private suspend fun applyPinEntered(state: PinEntryState, event: PinEntryScreenEvents.PinEntered, parentEventEmitter: (RegistrationFlowEvent) -> Unit): PinEntryState { + Log.d(TAG, "[PinEntered] Attempting to restore master key from SVR...") + + val restoreResult = repository.restoreMasterKeyFromSvr(svrCredentials, event.pin) + + val masterKey: MasterKey = when (restoreResult) { + is NetworkController.RegistrationNetworkResult.Success -> { + Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.") + restoreResult.data.masterKey + } + is NetworkController.RegistrationNetworkResult.Failure -> { + return when (restoreResult.error) { + is NetworkController.RestoreMasterKeyError.WrongPin -> { + Log.w(TAG, "[PinEntered] Wrong PIN. Tries remaining: ${restoreResult.error.triesRemaining}") + state.copy(triesRemaining = restoreResult.error.triesRemaining) + } + is NetworkController.RestoreMasterKeyError.NoDataFound -> { + Log.w(TAG, "[PinEntered] No SVR data found. Account is locked.") + parentEventEmitter.navigateTo(RegistrationRoute.AccountLocked(timeRemainingMs = timeRemaining)) + state + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + Log.w(TAG, "[PinEntered] Network error when restoring master key.", restoreResult.exception) + return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "[PinEntered] Application error when restoring master key.", restoreResult.exception) + return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError) + } + } + + parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredForRegistrationLock(masterKey)) + + val registrationLockToken = masterKey.deriveRegistrationLock() + + val e164 = parentState.value.sessionE164 + val sessionId = parentState.value.sessionMetadata?.id + + if (e164 == null || sessionId == null) { + Log.w(TAG, "[PinEntered] Missing e164 or sessionId. Resetting state.") + parentEventEmitter(RegistrationFlowEvent.ResetState) + return state + } + + Log.d(TAG, "[PinEntered] Attempting to register with registration lock token...") + val registerResult = repository.registerAccount( + e164 = e164, + sessionId = sessionId, + registrationLock = registrationLockToken, + skipDeviceTransfer = true + ) + + return when (registerResult) { + is NetworkController.RegistrationNetworkResult.Success -> { + Log.i(TAG, "[PinEntered] Successfully registered!") + parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete(registerResult.data)) + state + } + is NetworkController.RegistrationNetworkResult.Failure -> { + when (registerResult.error) { + is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> { + Log.w(TAG, "[PinEntered] Session not found or verified: ${registerResult.error.message}") + TODO() + } + is NetworkController.RegisterAccountError.RegistrationLock -> { + Log.w(TAG, "[PinEntered] Still getting registration lock error after providing token. This shouldn't happen. Resetting state.") + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RegisterAccountError.RateLimited -> { + Log.w(TAG, "[PinEntered] Rate limited when registering. Retry After: ${registerResult.error.retryAfter}") + state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.RateLimited(registerResult.error.retryAfter)) + } + is NetworkController.RegisterAccountError.InvalidRequest -> { + Log.w(TAG, "[PinEntered] Invalid request when registering: ${registerResult.error.message}") + state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError) + } + is NetworkController.RegisterAccountError.DeviceTransferPossible -> { + Log.w(TAG, "[PinEntered] Device transfer possible. This shouldn't happen when skipDeviceTransfer is true.") + state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError) + } + is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> { + Log.w(TAG, "[PinEntered] Registration recovery password incorrect: ${registerResult.error.message}") + TODO() + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + Log.w(TAG, "[PinEntered] Network error when registering.", registerResult.exception) + state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "[PinEntered] Application error when registering.", registerResult.exception) + state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError) + } + } + } + + private fun handleSkip() { + Log.d(TAG, "Skip requested - this will result in account data loss after timeRemaining: $timeRemaining ms") + // TODO: Show confirmation dialog warning about data loss, then proceed without PIN + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val timeRemaining: Long, + private val svrCredentials: NetworkController.SvrCredentials + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return RegistrationLockPinEntryViewModel( + repository, + parentState, + parentEventEmitter, + timeRemaining, + svrCredentials + ) as T + } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index d9b07d3179..e7ca49f83f 100644 --- a/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -136,6 +136,9 @@ class VerificationCodeViewModel( } is NetworkController.RegistrationNetworkResult.Failure -> { when (registerResult.error) { + is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> { + TODO() + } is NetworkController.RegisterAccountError.DeviceTransferPossible -> { Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.") parentEventEmitter(RegistrationFlowEvent.ResetState) @@ -143,7 +146,13 @@ class VerificationCodeViewModel( } is NetworkController.RegisterAccountError.RegistrationLock -> { Log.w(TAG, "[Register] Reglocked.") - TODO("reglock") + parentEventEmitter.navigateTo( + RegistrationRoute.RegistrationLockPinEntry( + timeRemaining = registerResult.error.data.timeRemaining, + svrCredentials = registerResult.error.data.svr2Credentials + ) + ) + state } is NetworkController.RegisterAccountError.RateLimited -> { Log.w(TAG, "[Register] Rate limited.")