D4: history the user can carry — encrypted export and import
Same design as iOS: the vault's history re-sealed under a passphrase- derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged back by content identity so the same backup twice adds nothing. Expired stories stay expired — a backup is not a way back in. The passphrase is stored nowhere, deliberately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f1122777c1
commit
6a0f642e0d
@@ -660,6 +660,9 @@ fun ColumnScope.AccountScreen(
|
||||
SectionBand()
|
||||
SettingsSection { RegisteredDevicesSection(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { HistoryBackupSection(model) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.HistoryExport
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Export and import of encrypted history backups (D4).
|
||||
*
|
||||
* The relay keeps no history, so a new phone starts empty — this is the
|
||||
* remedy, and the passphrase is deliberately the only key: the app stores
|
||||
* nothing that could open the file, so neither can anyone who takes the file.
|
||||
*/
|
||||
@Composable
|
||||
fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var pendingExport by remember { mutableStateOf<ByteArray?>(null) }
|
||||
var pendingImport by remember { mutableStateOf<Uri?>(null) }
|
||||
var working by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val bytes = pendingExport
|
||||
pendingExport = null
|
||||
if (uri == null || bytes == null) return@rememberLauncherForActivityResult
|
||||
status = runCatching {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||
?: error("nothing to write to")
|
||||
}.fold(
|
||||
onSuccess = { "Backup saved. Keep the passphrase — it is the only key." },
|
||||
onFailure = { "Couldn't write the file: ${it.message}" },
|
||||
)
|
||||
}
|
||||
|
||||
val openLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri -> if (uri != null) pendingImport = uri }
|
||||
|
||||
Text("History backup", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Your history lives only on this phone — the server keeps none, so a "
|
||||
+ "new phone starts empty. An export is sealed with a passphrase you "
|
||||
+ "choose and can be imported on another Android phone signed into "
|
||||
+ "this account. Lose the passphrase and the backup cannot be "
|
||||
+ "opened, by anyone.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Row {
|
||||
OutlinedButton(
|
||||
onClick = { showExportDialog = true },
|
||||
enabled = !working,
|
||||
modifier = Modifier.testTag("export-history"),
|
||||
) { Text("Export…") }
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
OutlinedButton(
|
||||
// SAF filters on MIME, and a custom extension has none — offer
|
||||
// everything and let the crypto refuse what isn't a backup.
|
||||
onClick = { openLauncher.launch(arrayOf("*/*")) },
|
||||
enabled = !working,
|
||||
modifier = Modifier.testTag("import-history"),
|
||||
) { Text("Import…") }
|
||||
}
|
||||
status?.let {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(it, fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("backup-status"))
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
PassphraseDialog(
|
||||
title = "Export history",
|
||||
explainer = "This passphrase is the only key to the backup. It isn't "
|
||||
+ "stored anywhere — a forgotten passphrase means the backup can "
|
||||
+ "never be opened.",
|
||||
requireRepeat = true,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) showExportDialog = false },
|
||||
) { phrase ->
|
||||
working = true
|
||||
scope.launch {
|
||||
val bytes = model.exportHistory(phrase.toCharArray())
|
||||
working = false
|
||||
showExportDialog = false
|
||||
if (bytes == null) {
|
||||
status = "Sign in first."
|
||||
} else {
|
||||
pendingExport = bytes
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||
saveLauncher.launch("flamenet-history-$stamp.${HistoryExport.FILE_EXTENSION}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingImport?.let { uri ->
|
||||
PassphraseDialog(
|
||||
title = "Import history",
|
||||
explainer = "Anything already on this phone stays — the backup is "
|
||||
+ "merged in, never a replacement.",
|
||||
requireRepeat = false,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) pendingImport = null },
|
||||
) { phrase ->
|
||||
working = true
|
||||
scope.launch {
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes == null) {
|
||||
working = false
|
||||
pendingImport = null
|
||||
status = "Couldn't read that file."
|
||||
return@launch
|
||||
}
|
||||
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
||||
working = false
|
||||
pendingImport = null
|
||||
status = outcome.fold(
|
||||
onSuccess = { counts ->
|
||||
if (counts.messages + counts.stories == 0)
|
||||
"Backup opened — everything in it was already here."
|
||||
else
|
||||
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
||||
" and ${counts.stories} " +
|
||||
if (counts.stories == 1) "story." else "stories."
|
||||
},
|
||||
onFailure = { it.message ?: "That backup couldn't be opened." },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassphraseDialog(
|
||||
title: String,
|
||||
explainer: String,
|
||||
requireRepeat: Boolean,
|
||||
busy: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var phrase by remember { mutableStateOf("") }
|
||||
var repeat by remember { mutableStateOf("") }
|
||||
val mismatch = requireRepeat && repeat.isNotEmpty() && repeat != phrase
|
||||
val ready = phrase.length >= 6 && (!requireRepeat || repeat == phrase)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
androidx.compose.foundation.layout.Column {
|
||||
Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = phrase,
|
||||
onValueChange = { phrase = it },
|
||||
placeholder = { Text("Passphrase", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
|
||||
)
|
||||
if (requireRepeat) {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
OutlinedTextField(
|
||||
value = repeat,
|
||||
onValueChange = { repeat = it },
|
||||
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = mismatch,
|
||||
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"),
|
||||
)
|
||||
if (mismatch) {
|
||||
Text("The two passphrases don't match yet.",
|
||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
if (phrase.isNotEmpty() && phrase.length < 6) {
|
||||
Text("Use at least 6 characters.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(phrase) },
|
||||
enabled = ready && !busy,
|
||||
modifier = Modifier.testTag("backup-confirm"),
|
||||
) { Text(if (busy) "Working…" else "Continue") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import io.flamenet.e2e.AccountKdf
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
import io.flamenet.e2e.HistoryExport
|
||||
import io.flamenet.e2e.Vault
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import io.flamenet.client.IdentityTrust
|
||||
@@ -1251,6 +1252,31 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
// -- History backup (D4) -------------------------------------------------
|
||||
|
||||
/**
|
||||
* The vault's history, sealed under a passphrase the user chose.
|
||||
*
|
||||
* Off the main thread on purpose: the KDF runs 600k rounds, which is the
|
||||
* point of it, and would freeze the UI for a visible beat.
|
||||
*/
|
||||
suspend fun exportHistory(passphrase: CharArray): ByteArray? =
|
||||
withContext(Dispatchers.Default) {
|
||||
vault?.let { HistoryExport.seal(it.exportPayload(), passphrase) }
|
||||
}
|
||||
|
||||
/** Merge a backup file in. Returns how much of it was actually new. */
|
||||
suspend fun importHistory(bytes: ByteArray, passphrase: CharArray): Result<Vault.ImportCounts> =
|
||||
withContext(Dispatchers.Default) {
|
||||
val v = vault
|
||||
?: return@withContext Result.failure(IllegalStateException("Sign in first."))
|
||||
runCatching {
|
||||
v.mergeImportedPayload(
|
||||
HistoryExport.open(bytes, passphrase),
|
||||
nowSeconds = System.currentTimeMillis() / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Stories (§6.6) ------------------------------------------------------
|
||||
|
||||
fun refreshStories() = viewModelScope.launch {
|
||||
|
||||
Reference in New Issue
Block a user