feat: confirm-then-encrypt flow for the password-protected backup

The ncryptsec section was one blind password field feeding two buttons that
each re-ran scrypt and produced a different ciphertext. A single typo made the
backup permanently useless, and nothing showed what had been produced.

- The card is collapsed by default, so the paper backup stays the focus.
- The password must be typed twice. A mismatch is flagged inline and a
  check mark shows when the two match; IME Done encrypts.
- One "Encrypt key" button shows progress while scrypt runs off the main
  thread. It then shows the ncryptsec with Copy / QR buttons that reuse the
  same value, plus "Use a different password" to start over.
- The result and its QR are dropped when the app goes to the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP
This commit is contained in:
Claude
2026-09-26 20:22:26 +00:00
parent d73be2910a
commit 33ea20effc
2 changed files with 206 additions and 77 deletions
@@ -29,6 +29,7 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -45,16 +46,18 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
@@ -74,6 +77,7 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -91,9 +95,15 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypt
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypt_again
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_body
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_password
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_password_mismatch
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_repeat_password
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_saved_hint
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_title
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypting
import com.vitorpamplona.amethyst.commons.resources.account_backup_headline
import com.vitorpamplona.amethyst.commons.resources.account_backup_intro
import com.vitorpamplona.amethyst.commons.resources.account_backup_qr_code
@@ -374,108 +384,221 @@ private fun EncryptedKeyCard(
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") }
var repeated by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var working by remember { mutableStateOf(false) }
var qrCode by remember { mutableStateOf<String?>(null) }
var encrypted by remember { mutableStateOf<String?>(null) }
var showQr by remember { mutableStateOf(false) }
// NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread.
fun encryptThen(onEncrypted: suspend (String) -> Unit) {
val privKey = accountViewModel.account.settings.keyPair.privKey ?: return
val currentPassword = password
working = true
scope.launch {
val encrypted =
withContext(Dispatchers.Default) {
runCatching { Nip49().encrypt(privKey.toHexKey(), currentPassword) }.getOrNull()
// Never leave the encrypted key or the typed password around while in the background.
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
encrypted = null
showQr = false
}
// A typo in the password makes the backup permanently useless, so it must be typed twice.
val mismatch = repeated.isNotEmpty() && repeated != password
val canEncrypt = password.isNotBlank() && repeated == password && !working
fun encrypt() {
if (!canEncrypt) return
gate.withAccess {
val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess
val currentPassword = password
working = true
// NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread.
scope.launch {
val result =
withContext(Dispatchers.Default) {
runCatching { Nip49().encrypt(privKey.toHexKey(), currentPassword) }.getOrNull()
}
working = false
if (result != null) {
encrypted = result
} else {
Toast.makeText(context, loadStringRes(Res.string.failed_to_encrypt_key), Toast.LENGTH_SHORT).show()
}
working = false
if (encrypted != null) {
onEncrypted(encrypted)
} else {
Toast.makeText(context, loadStringRes(Res.string.failed_to_encrypt_key), Toast.LENGTH_SHORT).show()
}
}
}
val canEncrypt = password.isNotBlank() && !working
Surface(
modifier = Modifier.fillMaxWidth(),
shape = CardShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(Modifier.padding(16.dp)) {
CardTitle(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypted_title))
Spacer(Modifier.height(6.dp))
Text(
text = stringRes(Res.string.account_backup_encrypted_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.NewPassword },
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text(stringRes(Res.string.account_backup_encrypted_password)) },
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
symbol = if (showPassword) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
contentDescription = stringRes(if (showPassword) Res.string.hide_password else Res.string.show_password),
.clickable { expanded = !expanded }
.padding(start = 16.dp, end = 12.dp, top = 16.dp, bottom = if (expanded) 8.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
CardTitle(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypted_title))
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(Res.string.account_backup_encrypted_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp),
)
}
Spacer(Modifier.width(8.dp))
Icon(
symbol = if (expanded) MaterialSymbols.KeyboardArrowUp else MaterialSymbols.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded) {
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
val encryptedValue = encrypted
if (encryptedValue == null) {
val visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.NewPassword },
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text(stringRes(Res.string.account_backup_encrypted_password)) },
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next,
),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
symbol = if (showPassword) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
contentDescription = stringRes(if (showPassword) Res.string.hide_password else Res.string.show_password),
)
}
},
visualTransformation = visualTransformation,
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
modifier = Modifier.weight(1f),
enabled = canEncrypt,
onClick = {
gate.withAccess {
encryptThen { encrypted ->
clipboard.setText(encrypted)
Toast.makeText(context, loadStringRes(Res.string.secret_key_copied_to_clipboard), Toast.LENGTH_SHORT).show()
OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.NewPassword },
value = repeated,
onValueChange = { repeated = it },
singleLine = true,
label = { Text(stringRes(Res.string.account_backup_encrypted_repeat_password)) },
isError = mismatch,
supportingText =
if (mismatch) {
{ Text(stringRes(Res.string.account_backup_encrypted_password_mismatch)) }
} else {
null
},
trailingIcon =
if (canEncrypt) {
{ Icon(MaterialSymbols.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }
} else {
null
},
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { encrypt() }),
visualTransformation = visualTransformation,
)
Spacer(Modifier.height(12.dp))
FilledTonalButton(
modifier = Modifier.fillMaxWidth(),
enabled = canEncrypt,
onClick = ::encrypt,
) {
if (working) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(stringRes(Res.string.account_backup_encrypting))
} else {
ButtonContent(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypt))
}
}
},
) {
ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy))
}
OutlinedButton(
modifier = Modifier.weight(1f),
enabled = canEncrypt,
onClick = { gate.withAccess { encryptThen { qrCode = it } } },
) {
ButtonContent(MaterialSymbols.QrCode2, stringRes(Res.string.account_backup_qr_code))
} else {
Text(
text = encryptedValue,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(12.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringRes(Res.string.account_backup_encrypted_saved_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(
modifier = Modifier.weight(1f),
onClick = {
scope.launch {
clipboard.setText(encryptedValue)
Toast.makeText(context, loadStringRes(Res.string.secret_key_copied_to_clipboard), Toast.LENGTH_SHORT).show()
}
},
) {
ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy))
}
FilledTonalButton(
modifier = Modifier.weight(1f),
onClick = { showQr = true },
) {
ButtonContent(MaterialSymbols.QrCode2, stringRes(Res.string.account_backup_qr_code))
}
}
TextButton(
modifier = Modifier.align(Alignment.CenterHorizontally),
onClick = {
encrypted = null
password = ""
repeated = ""
},
) {
Text(stringRes(Res.string.account_backup_encrypt_again))
}
}
}
}
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { qrCode = null }
qrCode?.let {
ShowKeyQRDialog(it, onClose = { qrCode = null })
encrypted?.let {
if (showQr) {
ShowKeyQRDialog(it, onClose = { showQr = false })
}
}
}
@@ -666,7 +666,13 @@
<string name="account_backup_qr_code">QR code</string>
<string name="account_backup_encrypted_title">Password-protected copy</string>
<string name="account_backup_encrypted_password">Password</string>
<string name="account_backup_encrypted_body">Locks your key with a password into an ncryptsec1… that only works together with that password. Forget the password and this copy is useless.</string>
<string name="account_backup_encrypted_body">An ncryptsec1… that only works with your password.</string>
<string name="account_backup_encrypted_repeat_password">Repeat password</string>
<string name="account_backup_encrypted_password_mismatch">Passwords don't match</string>
<string name="account_backup_encrypt">Encrypt key</string>
<string name="account_backup_encrypting">Encrypting…</string>
<string name="account_backup_encrypted_saved_hint">Safe to keep in a password manager or cloud notes: it can't be opened without the password, and a forgotten password can't be recovered.</string>
<string name="account_backup_encrypt_again">Use a different password</string>
<string name="account_backup_tip_storage">Keep the paper somewhere only you can find it, or save the key in a password manager.</string>
<string name="account_backup_tip_trust">Never paste your key into websites or apps you don't trust.</string>
<string name="account_backup_tip_developers">Amethyst developers will never ask for your key.</string>