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