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:
parent
86d6686aee
commit
0388cd7d06
5 changed files with 299 additions and 15 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue