From 0388cd7d06d6d88fe87a35ccc906768118b508df Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 28 Mar 2026 17:23:42 -0700 Subject: [PATCH] feat(wallet): add SSSS backup for wallet seed phrase Adds ability to backup wallet seed phrase to Matrix SSSS: - WalletBackupService interface and implementation - New BACKUP_TO_MATRIX step in wallet setup flow - Recovery key input UI with FLAG_SECURE - Graceful handling of invalid keys and missing SSSS setup Users can now: 1. Write down seed phrase manually (existing) 2. Encrypt and store in Matrix account with recovery key The backup is encrypted with the same key used for cross-signing and message backup (SSSS). --- .../wallet/api/backup/WalletBackupService.kt | 49 +++++++ .../impl/backup/WalletBackupServiceImpl.kt | 64 +++++++++ .../wallet/impl/setup/WalletSetupPresenter.kt | 68 ++++++++-- .../wallet/impl/setup/WalletSetupState.kt | 9 +- .../wallet/impl/setup/WalletSetupView.kt | 124 +++++++++++++++++- 5 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt new file mode 100644 index 0000000000..dbab0c5ff2 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api.backup + +/** + * Service for backing up and restoring wallet seed phrases using Matrix SSSS. + * + * The backup is encrypted with the user's Matrix recovery key and stored + * in their account data, so it follows them across devices. + */ +interface WalletBackupService { + /** + * The secret name used to store the wallet seed in SSSS. + */ + companion object { + const val SECRET_NAME = "com.sulkta.cardano.wallet_seed" + } + + /** + * Backup the wallet seed phrase to Matrix SSSS. + * + * @param recoveryKey The Matrix recovery key (base58 encoded) + * @param mnemonic The wallet seed phrase to backup + * @return Success or error + */ + suspend fun backupSeed(recoveryKey: String, mnemonic: List): Result + + /** + * Restore a wallet seed phrase from Matrix SSSS. + * + * @param recoveryKey The Matrix recovery key + * @return The mnemonic words if found, null if no backup exists + */ + suspend fun restoreSeed(recoveryKey: String): Result?> + + /** + * Check if a wallet backup exists in SSSS. + * + * This can be called with the recovery key to verify a backup is present. + * + * @param recoveryKey The Matrix recovery key + * @return True if a backup exists, false otherwise + */ + suspend fun hasBackup(recoveryKey: String): Result +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt new file mode 100644 index 0000000000..cdd88d4600 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.backup + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.features.wallet.api.backup.WalletBackupService +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import timber.log.Timber + +/** + * Implementation of [WalletBackupService] that stores the wallet seed + * phrase in Matrix SSSS (Secure Secret Storage and Sharing). + */ +@ContributesBinding(AppScope::class) +class WalletBackupServiceImpl @Inject constructor( + private val matrixClientProvider: MatrixClientProvider, + private val activeSessionId: SessionId, +) : WalletBackupService { + + override suspend fun backupSeed(recoveryKey: String, mnemonic: List): Result { + return runCatching { + val client = matrixClientProvider.getOrRestore(activeSessionId).getOrThrow() + val secretStore = client.secretStorage.openSecretStore(recoveryKey) + ?: throw WalletBackupException.InvalidRecoveryKey() + + // Store mnemonic as space-separated string + val seedString = mnemonic.joinToString(" ") + secretStore.putSecret(WalletBackupService.SECRET_NAME, seedString).getOrThrow() + + Timber.d("Wallet seed backed up to SSSS") + } + } + + override suspend fun restoreSeed(recoveryKey: String): Result?> { + return runCatching { + val client = matrixClientProvider.getOrRestore(activeSessionId).getOrThrow() + val secretStore = client.secretStorage.openSecretStore(recoveryKey) + ?: throw WalletBackupException.InvalidRecoveryKey() + + val seedString = secretStore.getSecret(WalletBackupService.SECRET_NAME).getOrThrow() + + seedString?.split(" ")?.takeIf { it.size in listOf(12, 15, 18, 21, 24) } + } + } + + override suspend fun hasBackup(recoveryKey: String): Result { + return restoreSeed(recoveryKey).map { it != null } + } +} + +/** + * Exceptions for wallet backup operations. + */ +sealed class WalletBackupException(message: String) : Exception(message) { + class InvalidRecoveryKey : WalletBackupException("Recovery key is invalid or SSSS is not set up") + class NoBackupFound : WalletBackupException("No wallet backup found in SSSS") +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt index 6063e92324..f1c336db13 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.wallet.api.backup.WalletBackupService import io.element.android.features.wallet.api.storage.CardanoKeyStorage import io.element.android.features.wallet.impl.cardano.CardanoWalletManager import io.element.android.libraries.architecture.Presenter @@ -20,16 +21,11 @@ import io.element.android.libraries.matrix.api.MatrixClient import kotlinx.coroutines.launch import timber.log.Timber -// TODO: Phase 5 - Add optional SSSS backup -// When Matrix SDK exposes setAccountData, store encrypted mnemonic -// under m.cross_signing.user_signing_key or custom type. -// For alpha: wallet backup is LOCAL ONLY (device-bound). -// User must write down mnemonic manually. - class WalletSetupPresenter @Inject constructor( private val keyStorage: CardanoKeyStorage, private val walletManager: CardanoWalletManager, private val matrixClient: MatrixClient, + private val walletBackupService: WalletBackupService, ) : Presenter { companion object { @@ -47,6 +43,8 @@ class WalletSetupPresenter @Inject constructor( var isGenerating by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var hasConfirmedBackup by remember { mutableStateOf(false) } + var isBackingUp by remember { mutableStateOf(false) } + var recoveryKeyInput by remember { mutableStateOf("") } fun handleEvent(event: WalletSetupEvent) { when (event) { @@ -74,8 +72,7 @@ class WalletSetupPresenter @Inject constructor( } WalletSetupEvent.ImportExistingWallet -> { - // TODO: Navigate to import flow (out of scope for alpha) - // For now, just show an error + // TODO: Navigate to import flow error = "Import not yet supported. Please create a new wallet." } @@ -83,11 +80,59 @@ class WalletSetupPresenter @Inject constructor( step = SetupStep.BACKUP_PROMPT } + WalletSetupEvent.ProceedToMatrixBackup -> { + step = SetupStep.BACKUP_TO_MATRIX + recoveryKeyInput = "" + } + + WalletSetupEvent.SkipBackupToMatrix -> { + // User chose manual backup only - mark as confirmed + hasConfirmedBackup = true + step = SetupStep.COMPLETE + scope.launch { + walletManager.initialize(sessionId) + } + } + + is WalletSetupEvent.UpdateRecoveryKeyInput -> { + recoveryKeyInput = event.key + } + + WalletSetupEvent.ConfirmMatrixBackup -> { + if (recoveryKeyInput.isBlank()) { + error = "Please enter your Matrix recovery key" + return + } + + isBackingUp = true + error = null + + scope.launch { + walletBackupService.backupSeed(recoveryKeyInput, generatedMnemonic) + .onSuccess { + Timber.tag(TAG).i("Wallet backed up to SSSS") + isBackingUp = false + hasConfirmedBackup = true + step = SetupStep.COMPLETE + walletManager.initialize(sessionId) + } + .onFailure { e -> + Timber.tag(TAG).e(e, "Failed to backup wallet") + error = when { + e.message?.contains("invalid", ignoreCase = true) == true -> + "Invalid recovery key. Please check and try again." + e.message?.contains("not set up", ignoreCase = true) == true -> + "Matrix recovery is not set up. Please set up Security & Privacy first." + else -> e.message ?: "Backup failed" + } + isBackingUp = false + } + } + } + WalletSetupEvent.ConfirmBackup -> { hasConfirmedBackup = true step = SetupStep.COMPLETE - - // Reinitialize wallet manager so panel sees the new wallet scope.launch { walletManager.initialize(sessionId) } @@ -101,6 +146,7 @@ class WalletSetupPresenter @Inject constructor( when (step) { SetupStep.SHOW_ADDRESS -> step = SetupStep.WELCOME SetupStep.BACKUP_PROMPT -> step = SetupStep.SHOW_ADDRESS + SetupStep.BACKUP_TO_MATRIX -> step = SetupStep.BACKUP_PROMPT else -> { /* Let node handle close */ } } } @@ -118,6 +164,8 @@ class WalletSetupPresenter @Inject constructor( isGenerating = isGenerating, error = error, hasConfirmedBackup = hasConfirmedBackup, + isBackingUp = isBackingUp, + recoveryKeyInput = recoveryKeyInput, eventSink = ::handleEvent, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt index 770dda9549..10139d51c9 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt @@ -16,6 +16,8 @@ data class WalletSetupState( val isGenerating: Boolean, val error: String?, val hasConfirmedBackup: Boolean, + val isBackingUp: Boolean, + val recoveryKeyInput: String, val eventSink: (WalletSetupEvent) -> Unit, ) @@ -23,7 +25,8 @@ enum class SetupStep { WELCOME, // "Create New Wallet" or "Import Existing" GENERATING, // Spinning while generating keys SHOW_ADDRESS, // Display the derived address - BACKUP_PROMPT, // Show mnemonic with "I've backed it up" checkbox + BACKUP_PROMPT, // Show mnemonic with backup options + BACKUP_TO_MATRIX, // Enter recovery key for SSSS backup COMPLETE, // Done - ready to close } @@ -31,6 +34,10 @@ sealed interface WalletSetupEvent { data object CreateNewWallet : WalletSetupEvent data object ImportExistingWallet : WalletSetupEvent data object ProceedToBackup : WalletSetupEvent + data object SkipBackupToMatrix : WalletSetupEvent // User chooses manual backup only + data object ProceedToMatrixBackup : WalletSetupEvent // User wants SSSS backup + data class UpdateRecoveryKeyInput(val key: String) : WalletSetupEvent + data object ConfirmMatrixBackup : WalletSetupEvent // Submit the recovery key data object ConfirmBackup : WalletSetupEvent data object Complete : WalletSetupEvent data object Back : WalletSetupEvent diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt index a84d75c99e..0e3b9b36fb 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt @@ -21,11 +21,16 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Key import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox @@ -34,6 +39,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -48,6 +54,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.theme.components.Button @@ -62,10 +70,10 @@ fun WalletSetupView( onBack: () -> Unit, modifier: Modifier = Modifier, ) { - // FLAG_SECURE when showing mnemonic + // FLAG_SECURE when showing mnemonic or recovery key input val view = LocalView.current DisposableEffect(state.step) { - if (state.step == SetupStep.BACKUP_PROMPT) { + if (state.step in listOf(SetupStep.BACKUP_PROMPT, SetupStep.BACKUP_TO_MATRIX)) { val window = (view.context as? android.app.Activity)?.window window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } @@ -107,6 +115,7 @@ fun WalletSetupView( SetupStep.GENERATING -> GeneratingContent() SetupStep.SHOW_ADDRESS -> AddressContent(state) SetupStep.BACKUP_PROMPT -> BackupContent(state) + SetupStep.BACKUP_TO_MATRIX -> MatrixBackupContent(state) SetupStep.COMPLETE -> CompleteContent(onComplete) } } @@ -319,9 +328,20 @@ private fun ColumnScope.BackupContent(state: WalletSetupState) { Spacer(modifier = Modifier.height(16.dp)) + // Matrix SSSS backup option Button( - text = "Complete Setup", - onClick = { state.eventSink(WalletSetupEvent.ConfirmBackup) }, + text = "Backup to Matrix", + onClick = { state.eventSink(WalletSetupEvent.ProceedToMatrixBackup) }, + enabled = isChecked, + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(Icons.Default.Cloud), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + text = "Skip Cloud Backup", + onClick = { state.eventSink(WalletSetupEvent.SkipBackupToMatrix) }, enabled = isChecked, modifier = Modifier.fillMaxWidth(), ) @@ -329,6 +349,102 @@ private fun ColumnScope.BackupContent(state: WalletSetupState) { Spacer(modifier = Modifier.height(32.dp)) } +@Composable +private fun ColumnScope.MatrixBackupContent(state: WalletSetupState) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Backup to Matrix", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Your wallet seed will be encrypted and stored securely in your Matrix account.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Enter your Matrix recovery key (the 48-character key you saved when setting up Security).", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = state.recoveryKeyInput, + onValueChange = { state.eventSink(WalletSetupEvent.UpdateRecoveryKeyInput(it)) }, + label = { Text("Recovery Key") }, + placeholder = { Text("AAAA BBBB CCCC ...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + enabled = !state.isBackingUp, + ) + + state.error?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (state.isBackingUp) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Encrypting and uploading...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Button( + text = "Backup Now", + onClick = { state.eventSink(WalletSetupEvent.ConfirmMatrixBackup) }, + enabled = state.recoveryKeyInput.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + @Composable private fun ColumnScope.CompleteContent(onComplete: () -> Unit) { Spacer(modifier = Modifier.weight(1f))