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).
This commit is contained in:
Kayos 2026-03-28 17:23:42 -07:00
parent 86d6686aee
commit 0388cd7d06
5 changed files with 299 additions and 15 deletions

View file

@ -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<String>): Result<Unit>
/**
* 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<List<String>?>
/**
* 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<Boolean>
}

View file

@ -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<String>): Result<Unit> {
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<List<String>?> {
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<Boolean> {
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")
}

View file

@ -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<WalletSetupState> {
companion object {
@ -47,6 +43,8 @@ class WalletSetupPresenter @Inject constructor(
var isGenerating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(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,
)
}

View file

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

View file

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