Require passphrase confirmation for autobackup setting and encrypted export

A typo in a write-only passphrase field encrypts every backup with a
phrase the user cannot reproduce — unrecoverable by design. Both the
settings field and the manual-export dialog now demand a matching
repeat before accepting. Import is unchanged (wrong passphrase there
just fails loudly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 17:41:20 +02:00
commit 0dd48445e1

View file

@ -62,6 +62,7 @@ fun SettingsScreen(nav: Nav) {
var accessKey by remember { mutableStateOf(settings.s3AccessKey ?: "") }
var secretKey by remember { mutableStateOf(settings.s3SecretKey ?: "") }
var autoPassphrase by remember { mutableStateOf(settings.autoBackupPassphrase ?: "") }
var autoPassphraseConfirm by remember { mutableStateOf(settings.autoBackupPassphrase ?: "") }
var festUrl by remember { mutableStateOf(settings.festUrl ?: "") }
// Export/import passphrase dialogs hold the pending document URI.
@ -134,8 +135,24 @@ fun SettingsScreen(nav: Nav) {
},
modifier = Modifier.fillMaxWidth(),
)
// Confirmation field: a typo here would encrypt every backup with a
// passphrase the user can't reproduce — unrecoverable by design.
val passphraseMismatch = autoPassphrase != autoPassphraseConfirm
OutlinedTextField(
value = autoPassphraseConfirm, onValueChange = { autoPassphraseConfirm = it },
label = { Text("Gjenta passordfrase") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = passphraseMismatch,
supportingText = { if (passphraseMismatch) Text("Passordfrasene er ikke like") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
if (passphraseMismatch) {
toast("Passordfrasene er ikke like")
return@Button
}
settings.s3Endpoint = endpoint.trim().ifBlank { null }
settings.s3Region = region.trim().ifBlank { null }
settings.s3Bucket = bucket.trim().ifBlank { null }
@ -144,6 +161,7 @@ fun SettingsScreen(nav: Nav) {
settings.autoBackupPassphrase = autoPassphrase.ifBlank { null }
toast("Lagret")
},
enabled = !passphraseMismatch,
modifier = Modifier.fillMaxWidth(),
) { Text("Lagre backupinnstillinger") }
@ -214,6 +232,7 @@ fun SettingsScreen(nav: Nav) {
title = "Krypter eksport",
text = "Skriv en passordfrase (lagres ikke). La stå tom for ukryptert JSON.",
allowEmpty = true,
requireConfirmation = true, // a typo here = an unrecoverable export
onDismiss = { exportUri = null },
) { passphrase ->
exportUri = null
@ -306,10 +325,13 @@ private fun PassphraseDialog(
title: String,
text: String,
allowEmpty: Boolean,
requireConfirmation: Boolean = false,
onDismiss: () -> Unit,
onConfirm: (CharArray) -> Unit,
) {
var value by remember { mutableStateOf("") }
var repeated by remember { mutableStateOf("") }
val mismatch = requireConfirmation && value != repeated
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
@ -324,12 +346,24 @@ private fun PassphraseDialog(
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
if (requireConfirmation) {
OutlinedTextField(
value = repeated,
onValueChange = { repeated = it },
label = { Text("Gjenta passordfrase") },
singleLine = true,
isError = mismatch,
supportingText = { if (mismatch) Text("Passordfrasene er ikke like") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(value.toCharArray()) },
enabled = allowEmpty || value.isNotEmpty(),
enabled = (allowEmpty || value.isNotEmpty()) && !mismatch,
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },