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:
Paul Hitt
2026-08-28 20:29:22 -04:00
co-authored by Claude Opus 4.8
parent f1122777c1
commit 6a0f642e0d
6 changed files with 599 additions and 0 deletions
@@ -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 {