Add basic reglock support to regV5.

This commit is contained in:
Greyson Parrelli
2025-12-08 09:11:14 -05:00
committed by Michelle Tang
parent 4b06e14df6
commit 7969df4e4c
33 changed files with 1961 additions and 329 deletions

View File

@@ -6,6 +6,7 @@
package org.signal.core.models package org.signal.core.models
import org.signal.core.models.backup.MessageBackupKey import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.logging.Log
private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messagebackup.AccountEntropyPool private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messagebackup.AccountEntropyPool

View File

@@ -69,6 +69,8 @@ dependencies {
// AndroidX // AndroidX
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.framework)
// Lifecycle // Lifecycle
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)

View File

@@ -10,12 +10,25 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels 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.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavBackStack 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.compose.theme.SignalTheme
import org.signal.core.ui.navigation.ResultEffect import org.signal.core.ui.navigation.ResultEffect
import org.signal.core.ui.navigation.ResultEventBus import org.signal.core.ui.navigation.ResultEventBus
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationActivity 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.MainActivity.Companion.REGISTRATION_RESULT
import org.signal.registration.sample.screens.RegistrationCompleteScreen import org.signal.registration.sample.screens.RegistrationCompleteScreen
import org.signal.registration.sample.screens.main.MainScreen import org.signal.registration.sample.screens.main.MainScreen
import org.signal.registration.sample.screens.main.MainScreenViewModel 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. * Navigation routes for the sample app.
@@ -45,6 +91,9 @@ sealed interface SampleRoute : NavKey {
@Serializable @Serializable
data object RegistrationComplete : SampleRoute data object RegistrationComplete : SampleRoute
@Serializable
data object PinSettings : SampleRoute
} }
/** /**
@@ -76,6 +125,8 @@ class MainActivity : ComponentActivity() {
onLaunchRegistration = { registrationLauncher.launch(Unit) }, onLaunchRegistration = { registrationLauncher.launch(Unit) },
backStack = backStack, backStack = backStack,
resultEventBus = viewModel.resultEventBus, resultEventBus = viewModel.resultEventBus,
storageController = RegistrationDependencies.get().storageController,
networkController = RegistrationDependencies.get().networkController,
onStartOver = { onStartOver = {
backStack.clear() backStack.clear()
backStack.add(SampleRoute.Main) backStack.add(SampleRoute.Main)
@@ -93,17 +144,29 @@ private fun SampleNavHost(
onStartOver: () -> Unit, onStartOver: () -> Unit,
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
resultEventBus: ResultEventBus, resultEventBus: ResultEventBus,
storageController: StorageController,
networkController: NetworkController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider { val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
entry<SampleRoute.Main> { entry<SampleRoute.Main> {
val viewModel: MainScreenViewModel = viewModel( 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() val state by viewModel.state.collectAsStateWithLifecycle()
LifecycleResumeEffect(Unit) {
viewModel.refreshData()
onPauseOrDispose { }
}
ResultEffect<Boolean>(resultEventBus, REGISTRATION_RESULT) { success -> ResultEffect<Boolean>(resultEventBus, REGISTRATION_RESULT) { success ->
if (success) { if (success) {
viewModel.refreshData()
backStack.add(SampleRoute.RegistrationComplete) backStack.add(SampleRoute.RegistrationComplete)
} }
} }
@@ -117,6 +180,23 @@ private fun SampleNavHost(
entry<SampleRoute.RegistrationComplete> { entry<SampleRoute.RegistrationComplete> {
RegistrationCompleteScreen(onStartOver = onStartOver) RegistrationCompleteScreen(onStartOver = onStartOver)
} }
entry<SampleRoute.PinSettings>(
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( val decorators = listOf(
@@ -131,7 +211,51 @@ private fun SampleNavHost(
NavDisplay( NavDisplay(
entries = entries, entries = entries,
onBack = {}, onBack = { backStack.removeLastOrNull() },
modifier = modifier 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))
)
}
) )
} }

View File

@@ -15,6 +15,7 @@ import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationDependencies import org.signal.registration.RegistrationDependencies
import org.signal.registration.sample.dependencies.RealNetworkController import org.signal.registration.sample.dependencies.RealNetworkController
import org.signal.registration.sample.dependencies.RealStorageController 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.push.TrustStore
import org.whispersystems.signalservice.api.util.CredentialsProvider import org.whispersystems.signalservice.api.util.CredentialsProvider
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
@@ -29,13 +30,22 @@ import java.util.Optional
class RegistrationApplication : Application() { class RegistrationApplication : Application() {
companion object {
// Staging SVR2 mrEnclave value
private const val SVR2_MRENCLAVE = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036"
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.initialize(AndroidLogger) Log.initialize(AndroidLogger)
val pushServiceSocket = createPushServiceSocket() RegistrationPreferences.init(this)
val networkController = RealNetworkController(this, pushServiceSocket)
val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore)
val pushServiceSocket = createPushServiceSocket(configuration)
val networkController = RealNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
val storageController = RealStorageController(this) val storageController = RealStorageController(this)
RegistrationDependencies.provide( RegistrationDependencies.provide(
@@ -46,9 +56,7 @@ class RegistrationApplication : Application() {
) )
} }
private fun createPushServiceSocket(): PushServiceSocket { private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {
val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore)
val credentialsProvider = NoopCredentialsProvider() val credentialsProvider = NoopCredentialsProvider()
val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}" val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}"

View File

@@ -8,8 +8,12 @@ package org.signal.registration.sample.dependencies
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.libsignal.net.Network
import org.signal.registration.NetworkController import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError 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.NetworkController.VerificationCodeTransport
import org.signal.registration.sample.fcm.FcmUtil import org.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver 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.push.PushServiceSocket
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection
import java.io.IOException import java.io.IOException
import java.util.Locale import java.util.Locale
import kotlin.time.Duration import kotlin.time.Duration
@@ -37,7 +53,9 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection as ServiceP
class RealNetworkController( class RealNetworkController(
private val context: android.content.Context, private val context: android.content.Context,
private val pushServiceSocket: PushServiceSocket private val pushServiceSocket: PushServiceSocket,
private val serviceConfiguration: SignalServiceConfiguration,
private val svr2MrEnclave: String
) : NetworkController { ) : NetworkController {
companion object { companion object {
@@ -46,6 +64,24 @@ class RealNetworkController(
private val json = Json { ignoreUnknownKeys = true } 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( override suspend fun createSession(
e164: String, e164: String,
fcmToken: String?, fcmToken: String?,
@@ -273,6 +309,9 @@ class RealNetworkController(
val result = json.decodeFromString<RegisterAccountResponse>(response.body.string()) val result = json.decodeFromString<RegisterAccountResponse>(response.body.string())
RegistrationNetworkResult.Success(result) RegistrationNetworkResult.Success(result)
} }
401 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string()))
}
403 -> { 403 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string())) RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string()))
} }
@@ -323,6 +362,238 @@ class RealNetworkController(
return "https://signalcaptchas.org/staging/registration/generate.html" return "https://signalcaptchas.org/staging/registration/generate.html"
} }
override suspend fun restoreMasterKeyFromSvr(
svr2Credentials: NetworkController.SvrCredentials,
pin: String
): RegistrationNetworkResult<NetworkController.MasterKeyResponse, NetworkController.RestoreMasterKeyError> = 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<Unit, NetworkController.BackupMasterKeyError> = 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<Unit, NetworkController.SetRegistrationLockError> = 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<Unit, NetworkController.SetRegistrationLockError> = 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 { private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes( return ServiceAccountAttributes(
signalingKey, signalingKey,

View File

@@ -5,16 +5,12 @@
package org.signal.registration.sample.dependencies package org.signal.registration.sample.dependencies
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.Base64 import org.signal.core.util.Base64
import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ServiceId
import org.signal.libsignal.protocol.ecc.ECKeyPair import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyPair import org.signal.libsignal.protocol.kem.KEMKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyType 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.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController import org.signal.registration.StorageController
import org.signal.registration.sample.storage.RegistrationDatabase
import org.signal.registration.sample.storage.RegistrationPreferences
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec 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 { class RealStorageController(context: Context) : StorageController {
private val db: RegistrationDatabase = RegistrationDatabase(context) private val db = RegistrationDatabase(context)
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) { override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) {
val accountEntropyPool = AccountEntropyPool.generate()
val aciIdentityKeyPair = IdentityKeyPair.generate() val aciIdentityKeyPair = IdentityKeyPair.generate()
val pniIdentityKeyPair = IdentityKeyPair.generate() val pniIdentityKeyPair = IdentityKeyPair.generate()
@@ -69,7 +69,8 @@ class RealStorageController(context: Context) : StorageController {
aciRegistrationId = aciRegistrationId, aciRegistrationId = aciRegistrationId,
pniRegistrationId = pniRegistrationId, pniRegistrationId = pniRegistrationId,
unidentifiedAccessKey = unidentifiedAccessKey, unidentifiedAccessKey = unidentifiedAccessKey,
servicePassword = password servicePassword = password,
accountEntropyPool = accountEntropyPool
) )
storeKeyMaterial(keyMaterial, profileKey) storeKeyMaterial(keyMaterial, profileKey)
@@ -78,176 +79,35 @@ class RealStorageController(context: Context) : StorageController {
} }
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) { override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) {
val database = db.writableDatabase RegistrationPreferences.saveRegistrationData(newRegistrationData)
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()
}
} }
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) { override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
val database = db.readableDatabase RegistrationPreferences.getPreExistingRegistrationData()
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
)
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) { private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
val database = db.writableDatabase // Clear existing data
database.beginTransaction() RegistrationPreferences.clearKeyMaterial()
try { db.clearAllPreKeys()
// 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)
// Store ACI identity key // Store in SharedPreferences
database.insert( RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair
RegistrationDatabase.TABLE_IDENTITY_KEYS, RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair
null, RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId
ContentValues().apply { RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) RegistrationPreferences.profileKey = profileKey
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciIdentityKeyPair.serialize())
}
)
// Store PNI identity key // Store prekeys in database
database.insert( db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciSignedPreKey)
RegistrationDatabase.TABLE_IDENTITY_KEYS, db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniSignedPreKey)
null, db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciLastResortKyberPreKey)
ContentValues().apply { db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniLastResortKyberPreKey)
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()
}
} }
private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord { private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord {
@@ -300,109 +160,4 @@ class RealStorageController(context: Context) : StorageController {
val ciphertext = cipher.doFinal(input) val ciphertext = cipher.doFinal(input)
return ciphertext.copyOf(16) 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
}
}
} }

View File

@@ -7,15 +7,29 @@ package org.signal.registration.sample.screens.main
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button 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.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
@@ -26,6 +40,34 @@ fun MainScreen(
onEvent: (MainScreenEvents) -> Unit, onEvent: (MainScreenEvents) -> Unit,
modifier: Modifier = Modifier 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( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -33,6 +75,8 @@ fun MainScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Spacer(modifier = Modifier.height(32.dp))
Text( Text(
text = "Registration Sample App", text = "Registration Sample App",
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium
@@ -44,23 +88,92 @@ fun MainScreen(
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
Button( Spacer(modifier = Modifier.height(32.dp))
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
) {
Text("Start Registration")
}
if (state.existingRegistrationState != null) { if (state.existingRegistrationState != null) {
RegistrationInfo(state.existingRegistrationState) 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 @Composable
private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) { 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) @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 = {}
)
}
}

View File

@@ -7,4 +7,6 @@ package org.signal.registration.sample.screens.main
sealed interface MainScreenEvents { sealed interface MainScreenEvents {
data object LaunchRegistration : MainScreenEvents data object LaunchRegistration : MainScreenEvents
data object OpenPinSettings : MainScreenEvents
data object ClearAllData : MainScreenEvents
} }

View File

@@ -8,5 +8,12 @@ package org.signal.registration.sample.screens.main
data class MainScreenState( data class MainScreenState(
val existingRegistrationState: ExistingRegistrationState? = null 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
)
} }

View File

@@ -12,25 +12,66 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.signal.registration.StorageController
import org.signal.registration.sample.storage.RegistrationPreferences
class MainScreenViewModel( class MainScreenViewModel(
private val onLaunchRegistration: () -> Unit private val storageController: StorageController,
private val onLaunchRegistration: () -> Unit,
private val onOpenPinSettings: () -> Unit
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MainScreenState()) private val _state = MutableStateFlow(MainScreenState())
val state: StateFlow<MainScreenState> = _state.asStateFlow() val state: StateFlow<MainScreenState> = _state.asStateFlow()
init {
loadRegistrationData()
}
fun refreshData() {
loadRegistrationData()
}
fun onEvent(event: MainScreenEvents) { fun onEvent(event: MainScreenEvents) {
viewModelScope.launch { viewModelScope.launch {
when (event) { when (event) {
MainScreenEvents.LaunchRegistration -> onLaunchRegistration() 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 <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MainScreenViewModel(onLaunchRegistration) as T return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T
} }
} }
} }

View File

@@ -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
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
)

View File

@@ -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<PinSettingsState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return PinSettingsViewModel(networkController, onBack) as T
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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() }
}
}

View File

@@ -9,6 +9,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.signal.core.models.MasterKey
import org.signal.core.util.serialization.ByteArrayToBase64Serializer import org.signal.core.util.serialization.ByteArrayToBase64Serializer
import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.KyberPreKeyRecord
@@ -101,6 +102,48 @@ interface NetworkController {
*/ */
fun getCaptchaUrl(): String 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<MasterKeyResponse, RestoreMasterKeyError>
/**
* 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<Unit, BackupMasterKeyError>
/**
* 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<Unit, SetRegistrationLockError>
/**
* Disables registration lock on the account.
*
* @return Success or an appropriate error.
*/
suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError>
// TODO // TODO
// /** // /**
// * Validates the provided SVR2 auth credentials, returning information on their usability. // * Validates the provided SVR2 auth credentials, returning information on their usability.
@@ -176,6 +219,7 @@ interface NetworkController {
} }
sealed class RegisterAccountError() { sealed class RegisterAccountError() {
data class SessionNotFoundOrNotVerified(val message: String) : RegisterAccountError()
data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError() data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError()
data object DeviceTransferPossible : RegisterAccountError() data object DeviceTransferPossible : RegisterAccountError()
data class InvalidRequest(val message: String) : RegisterAccountError() data class InvalidRequest(val message: String) : RegisterAccountError()
@@ -183,6 +227,27 @@ interface NetworkController {
data class RateLimited(val retryAfter: Duration) : RegisterAccountError() 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 @Serializable
@Parcelize @Parcelize
data class SessionMetadata( data class SessionMetadata(
@@ -261,14 +326,14 @@ interface NetworkController {
data class RegistrationLockResponse( data class RegistrationLockResponse(
val timeRemaining: Long, val timeRemaining: Long,
val svr2Credentials: SvrCredentials val svr2Credentials: SvrCredentials
) { )
@Serializable @Serializable
@Parcelize
data class SvrCredentials( data class SvrCredentials(
val username: String, val username: String,
val password: String val password: String
) ) : Parcelable
}
@Serializable @Serializable
data class ThirdPartyServiceErrorResponse( data class ThirdPartyServiceErrorResponse(

View File

@@ -5,10 +5,13 @@
package org.signal.registration package org.signal.registration
import org.signal.core.models.MasterKey
sealed interface RegistrationFlowEvent { sealed interface RegistrationFlowEvent {
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
data object NavigateBack : RegistrationFlowEvent data object NavigateBack : RegistrationFlowEvent
data object ResetState : RegistrationFlowEvent data object ResetState : RegistrationFlowEvent
data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent
data class E164Chosen(val e164: String) : RegistrationFlowEvent data class E164Chosen(val e164: String) : RegistrationFlowEvent
data class MasterKeyRestoredForRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent
} }

View File

@@ -5,12 +5,30 @@
package org.signal.registration package org.signal.registration
import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.signal.core.models.MasterKey
@Parcelize @Parcelize
@TypeParceler<MasterKey?, MasterKeyParceler>
data class RegistrationFlowState( data class RegistrationFlowState(
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome), val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
val sessionMetadata: NetworkController.SessionMetadata? = null, val sessionMetadata: NetworkController.SessionMetadata? = null,
val sessionE164: String? = null val sessionE164: String? = null,
val masterKey: MasterKey? = null,
val registrationLockProof: String? = null
) : Parcelable ) : Parcelable
object MasterKeyParceler : Parceler<MasterKey?> {
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())
}
}

View File

@@ -30,6 +30,9 @@ import com.google.accompanist.permissions.MultiplePermissionsState
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.signal.core.ui.navigation.ResultEffect 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.CaptchaScreen
import org.signal.registration.screens.captcha.CaptchaScreenEvents import org.signal.registration.screens.captcha.CaptchaScreenEvents
import org.signal.registration.screens.captcha.CaptchaState 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.PinCreationScreen
import org.signal.registration.screens.pincreation.PinCreationScreenEvents import org.signal.registration.screens.pincreation.PinCreationScreenEvents
import org.signal.registration.screens.pincreation.PinCreationState 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.RestoreViaQrScreen
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
import org.signal.registration.screens.restore.RestoreViaQrState import org.signal.registration.screens.restore.RestoreViaQrState
@@ -72,6 +77,18 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable @Serializable
data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute 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 @Serializable
data object Profile : RegistrationRoute data object Profile : RegistrationRoute
@@ -317,6 +334,44 @@ private fun EntryProviderScope<NavKey>.registrationEntries(
) )
} }
// -- Registration Lock PIN Entry Screen
entry<RegistrationRoute.RegistrationLockPinEntry> { 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<RegistrationRoute.AccountLocked> { 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<RegistrationRoute.Restore> { entry<RegistrationRoute.Restore> {
// TODO: Implement RestoreScreen // TODO: Implement RestoreScreen
} }

View File

@@ -7,14 +7,19 @@ package org.signal.registration
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.MasterKeyResponse
import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationNetworkResult import org.signal.registration.NetworkController.RegistrationNetworkResult
import org.signal.registration.NetworkController.RequestVerificationCodeError import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.UpdateSessionError import org.signal.registration.NetworkController.UpdateSessionError
import java.util.Locale import java.util.Locale
@@ -81,6 +86,16 @@ class RegistrationRepository(val networkController: NetworkController, val stora
) )
} }
suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
pin: String
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
networkController.restoreMasterKeyFromSvr(
svr2Credentials = svr2Credentials,
pin = pin
)
}
/** /**
* Registers a new account after successful phone number verification. * 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 * 1. Generates and stores all required cryptographic key material
* 2. Creates account attributes with registration IDs and capabilities * 2. Creates account attributes with registration IDs and capabilities
* 3. Calls the network controller to register the account * 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 e164 The phone number in E.164 format (used for basic auth)
* @param sessionId The verified session ID from phone number verification * @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 * @param skipDeviceTransfer Whether to skip device transfer flow
* @return The registration result containing account information or an error * @return The registration result containing account information or an error
*/ */
suspend fun registerAccount( suspend fun registerAccount(
e164: String, e164: String,
sessionId: String, sessionId: String,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true skipDeviceTransfer: Boolean = true
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) { ): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
val keyMaterial = storageController.generateAndStoreKeyMaterial() val keyMaterial = storageController.generateAndStoreKeyMaterial()
val fcmToken = networkController.getFcmToken() 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( val accountAttributes = AccountAttributes(
signalingKey = null, signalingKey = null,
registrationId = keyMaterial.aciRegistrationId, registrationId = keyMaterial.aciRegistrationId,
voice = true, voice = true,
video = true, video = true,
fetchesMessages = fcmToken == null, fetchesMessages = fcmToken == null,
registrationLock = null, registrationLock = registrationLock,
unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey, unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = false, 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 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 lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey
) )
networkController.registerAccount( val result = networkController.registerAccount(
e164 = e164, e164 = e164,
password = keyMaterial.servicePassword, password = keyMaterial.servicePassword,
sessionId = sessionId, sessionId = sessionId,
@@ -147,5 +164,19 @@ class RegistrationRepository(val networkController: NetworkController, val stora
fcmToken = fcmToken, fcmToken = fcmToken,
skipDeviceTransfer = skipDeviceTransfer 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
} }
} }

View File

@@ -43,6 +43,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
is RegistrationFlowEvent.ResetState -> RegistrationFlowState() is RegistrationFlowEvent.ResetState -> RegistrationFlowState()
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session) is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164) 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.NavigateToScreen -> applyNavigationToScreenEvent(state, event)
is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1)) is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1))
} }

View File

@@ -6,8 +6,9 @@
package org.signal.registration package org.signal.registration
import org.signal.core.models.AccountEntropyPool 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.IdentityKeyPair
import org.signal.libsignal.protocol.ServiceId
import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord
@@ -32,6 +33,11 @@ interface StorageController {
* @return Data for the existing registration if registered, otherwise null. * @return Data for the existing registration if registered, otherwise null.
*/ */
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? 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. */ /** Unidentified access key (derived from profile key) for sealed sender. */
val unidentifiedAccessKey: ByteArray, val unidentifiedAccessKey: ByteArray,
/** Password for basic auth during registration (18 random bytes, base64 encoded). */ /** 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( data class NewRegistrationData(
val e164: String, val e164: String,
val aci: ServiceId.Aci, val aci: ACI,
val pni: ServiceId.Pni, val pni: PNI,
val servicePassword: String, val servicePassword: String,
val aep: AccountEntropyPool val aep: AccountEntropyPool
) )
data class PreExistingRegistrationData( data class PreExistingRegistrationData(
val e164: String, val e164: String,
val aci: ServiceId.Aci, val aci: ACI,
val pni: ServiceId.Pni, val pni: PNI,
val servicePassword: String, val servicePassword: String,
val aep: AccountEntropyPool val aep: AccountEntropyPool
) )

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -54,18 +54,28 @@ fun PhoneNumberScreen(
onEvent: (PhoneNumberEntryScreenEvents) -> Unit, onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
LaunchedEffect(state.oneTimeEvent) { LaunchedEffect(state.oneTimeEvent) {
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent) onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
when (state.oneTimeEvent) { when (state.oneTimeEvent) {
OneTimeEvent.NetworkError -> TODO() OneTimeEvent.NetworkError -> simpleErrorMessage = "Network error"
is OneTimeEvent.RateLimited -> TODO() is OneTimeEvent.RateLimited -> simpleErrorMessage = "Rate limited"
OneTimeEvent.UnknownError -> TODO() OneTimeEvent.UnknownError -> simpleErrorMessage = "Unknown error"
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO() OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = "Could not request code with selected transport"
OneTimeEvent.ThirdPartyError -> TODO() OneTimeEvent.ThirdPartyError -> simpleErrorMessage = "Third party error"
null -> Unit null -> Unit
} }
} }
simpleErrorMessage?.let { message ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = "Ok",
onDismiss = { simpleErrorMessage = null }
)
}
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
ScreenContent(state, onEvent) ScreenContent(state, onEvent)

View File

@@ -48,10 +48,10 @@ class PhoneNumberEntryViewModel(
fun onEvent(event: PhoneNumberEntryScreenEvents) { fun onEvent(event: PhoneNumberEntryScreenEvents) {
viewModelScope.launch { viewModelScope.launch {
val stateEMitter: (PhoneNumberEntryState) -> Unit = { state -> val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
_state.value = 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)) stateEmitter(applyPhoneNumberChanged(state, event.value))
} }
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> { is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
stateEmitter(state.copy(showFullScreenSpinner = true)) var localState = state.copy(showFullScreenSpinner = true)
val resultState = applyPhoneNumberSubmitted(state, parentEventEmitter) stateEmitter(localState)
stateEmitter(resultState.copy(showFullScreenSpinner = false)) localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
stateEmitter(localState.copy(showFullScreenSpinner = false))
} }
is PhoneNumberEntryScreenEvents.CountryPicker -> { is PhoneNumberEntryScreenEvents.CountryPicker -> {
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) } state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }

View File

@@ -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)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = state.errorMessage, text = "Incorrect PIN. ${state.triesRemaining} attempts remaining.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -205,7 +205,7 @@ private fun PinEntryScreenWithErrorPreview() {
Previews.Preview { Previews.Preview {
PinEntryScreen( PinEntryScreen(
state = PinEntryState( state = PinEntryState(
errorMessage = "Incorrect PIN. Try again.", triesRemaining = 3,
showNeedHelp = true showNeedHelp = true
), ),
onEvent = {} onEvent = {}

View File

@@ -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!")
}
}
}

View File

@@ -5,8 +5,18 @@
package org.signal.registration.screens.pinentry package org.signal.registration.screens.pinentry
import kotlin.time.Duration
data class PinEntryState( data class PinEntryState(
val errorMessage: String? = null,
val showNeedHelp: Boolean = false, 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
}
}

View File

@@ -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<RegistrationFlowState>,
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<PinEntryState> = _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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val timeRemaining: Long,
private val svrCredentials: NetworkController.SvrCredentials
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RegistrationLockPinEntryViewModel(
repository,
parentState,
parentEventEmitter,
timeRemaining,
svrCredentials
) as T
}
}
}

View File

@@ -136,6 +136,9 @@ class VerificationCodeViewModel(
} }
is NetworkController.RegistrationNetworkResult.Failure -> { is NetworkController.RegistrationNetworkResult.Failure -> {
when (registerResult.error) { when (registerResult.error) {
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
TODO()
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> { is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.") Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState) parentEventEmitter(RegistrationFlowEvent.ResetState)
@@ -143,7 +146,13 @@ class VerificationCodeViewModel(
} }
is NetworkController.RegisterAccountError.RegistrationLock -> { is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Register] Reglocked.") 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 -> { is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited.") Log.w(TAG, "[Register] Rate limited.")