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 {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* A history backup the platform cannot read.
|
||||
*
|
||||
* The relay keeps no history — that is the product's whole promise — which
|
||||
* means a new device genuinely starts empty. This is the designed remedy: the
|
||||
* vault's history, re-sealed under a key derived from a passphrase the user
|
||||
* chooses, so the file is safe to park anywhere and useless without the words.
|
||||
*
|
||||
* Same envelope as the iOS app (a JSON header naming the KDF around one
|
||||
* AES-256-GCM box), but the v1 payload is platform-shaped, so the envelope is
|
||||
* platform-tagged and a mismatched import is refused with an honest message
|
||||
* rather than half-parsed.
|
||||
*
|
||||
* ⚠️ The passphrase is the only key. It is not stored anywhere — not in the
|
||||
* Keystore, not in the file. A forgotten passphrase means the backup is gone,
|
||||
* and that is the design, not a gap.
|
||||
*/
|
||||
object HistoryExport {
|
||||
|
||||
const val FILE_EXTENSION = "fnhistory"
|
||||
|
||||
/** High on purpose: runs once per export, prices out billions of guesses. */
|
||||
private const val ITERATIONS = 600_000
|
||||
private const val SALT_LEN = 16
|
||||
private const val NONCE_LEN = 12
|
||||
private const val TAG_BITS = 128
|
||||
|
||||
class NotAnExport : Exception("That file is not a Flamenet history export.")
|
||||
class WrongPlatform(val platform: String) :
|
||||
Exception("This backup was made on ${if (platform == "ios") "iPhone" else platform} — import it on a device of the same kind.")
|
||||
class WrongPassphrase : Exception("That passphrase doesn't open this backup.")
|
||||
|
||||
fun seal(payload: JSONObject, passphrase: CharArray): ByteArray {
|
||||
val salt = ByteArray(SALT_LEN).also(SecureRandom()::nextBytes)
|
||||
val key = derive(passphrase, salt, ITERATIONS)
|
||||
val nonce = ByteArray(NONCE_LEN).also(SecureRandom()::nextBytes)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce))
|
||||
val sealed = cipher.doFinal(payload.toString().toByteArray(Charsets.UTF_8))
|
||||
val envelope = JSONObject()
|
||||
.put("magic", "fnhistory")
|
||||
.put("v", 1)
|
||||
.put("platform", "android")
|
||||
.put("kdf", JSONObject()
|
||||
.put("alg", "pbkdf2-sha256")
|
||||
.put("salt", Bytes.b64(salt))
|
||||
.put("iters", ITERATIONS))
|
||||
.put("box", Bytes.b64(nonce + sealed))
|
||||
return envelope.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun open(bytes: ByteArray, passphrase: CharArray): JSONObject {
|
||||
val envelope = runCatching { JSONObject(String(bytes, Charsets.UTF_8)) }
|
||||
.getOrNull() ?: throw NotAnExport()
|
||||
val kdf = envelope.optJSONObject("kdf")
|
||||
if (envelope.optString("magic") != "fnhistory" || envelope.optInt("v") != 1 ||
|
||||
kdf == null || kdf.optString("alg") != "pbkdf2-sha256"
|
||||
) throw NotAnExport()
|
||||
val platform = envelope.optString("platform")
|
||||
if (platform != "android") throw WrongPlatform(platform)
|
||||
val salt = runCatching { Bytes.unb64(kdf.getString("salt")) }.getOrNull()
|
||||
val combined = runCatching { Bytes.unb64(envelope.getString("box")) }.getOrNull()
|
||||
if (salt == null || combined == null || combined.size <= NONCE_LEN) throw NotAnExport()
|
||||
// The count comes from the file so an old export still opens after the
|
||||
// default rises — clamped, because a hostile file naming ten billion
|
||||
// rounds would wedge the import forever.
|
||||
val iters = kdf.optInt("iters", ITERATIONS).coerceIn(100_000, 2_000_000)
|
||||
val key = derive(passphrase, salt, iters)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"),
|
||||
GCMParameterSpec(TAG_BITS, combined.copyOfRange(0, NONCE_LEN)))
|
||||
val plain = runCatching {
|
||||
cipher.doFinal(combined.copyOfRange(NONCE_LEN, combined.size))
|
||||
}.getOrNull() ?: throw WrongPassphrase()
|
||||
return runCatching { JSONObject(String(plain, Charsets.UTF_8)) }
|
||||
.getOrNull() ?: throw NotAnExport()
|
||||
}
|
||||
|
||||
private fun derive(passphrase: CharArray, salt: ByteArray, iterations: Int): ByteArray =
|
||||
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
.generateSecret(PBEKeySpec(passphrase, salt, iterations, 256)).encoded
|
||||
}
|
||||
@@ -576,6 +576,112 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
fun replaceHistory(threadId: String, messages: JSONArray) =
|
||||
writeJson(historyName(threadId), JSONObject().put("messages", messages))
|
||||
|
||||
// -- History backup (D4) -------------------------------------------------
|
||||
|
||||
/**
|
||||
* Everything a history backup carries, as one JSON value.
|
||||
*
|
||||
* The outbox is deliberately left out: a pending send belongs to the
|
||||
* device that queued it, and importing one elsewhere would either send a
|
||||
* duplicate or strand a ghost that can never be flushed. Sessions, trust
|
||||
* and keys are left out too — a backup restores what was said, never who
|
||||
* this device is.
|
||||
*/
|
||||
fun exportPayload(): JSONObject {
|
||||
val threadsObj = JSONObject()
|
||||
threads().forEach { tid -> threadsObj.put(tid, history(tid)) }
|
||||
return JSONObject()
|
||||
.put("threads", threadsObj)
|
||||
.put("callLog", callLog())
|
||||
.also { p -> readJson(STORIES)?.let { p.put("stories", it) } }
|
||||
}
|
||||
|
||||
data class ImportCounts(val messages: Int, val stories: Int)
|
||||
|
||||
/**
|
||||
* Merge a backup into what this device already has. Returns how much of
|
||||
* it was actually new.
|
||||
*
|
||||
* Merge, never replace: the device has been receiving mail since it
|
||||
* signed in, and an import that overwrote those rows would trade old
|
||||
* history for new. Lines deduplicate by content (mine + timestamp + text
|
||||
* + attachment), stories by their real id, calls by peer + timestamp —
|
||||
* and the same backup imported twice adds nothing the second time.
|
||||
*/
|
||||
fun mergeImportedPayload(payload: JSONObject, nowSeconds: Double): ImportCounts {
|
||||
var newMessages = 0
|
||||
val threadsObj = payload.optJSONObject("threads") ?: JSONObject()
|
||||
for (tid in threadsObj.keys()) {
|
||||
val incoming = threadsObj.optJSONArray(tid) ?: continue
|
||||
val current = history(tid)
|
||||
val seen = HashSet<String>()
|
||||
for (i in 0 until current.length()) seen.add(lineKey(current.getJSONObject(i)))
|
||||
val fresh = ArrayList<JSONObject>()
|
||||
for (i in 0 until incoming.length()) {
|
||||
val m = incoming.optJSONObject(i) ?: continue
|
||||
if (seen.add(lineKey(m))) fresh.add(m)
|
||||
}
|
||||
if (fresh.isEmpty()) continue
|
||||
newMessages += fresh.size
|
||||
val merged = ArrayList<JSONObject>(current.length() + fresh.size)
|
||||
for (i in 0 until current.length()) merged.add(current.getJSONObject(i))
|
||||
merged.addAll(fresh)
|
||||
merged.sortBy { it.optLong("at") }
|
||||
replaceHistory(tid, JSONArray().also { arr -> merged.forEach(arr::put) })
|
||||
}
|
||||
|
||||
var newStories = 0
|
||||
val importedStories = payload.optJSONObject("stories")?.optJSONArray("items")
|
||||
if (importedStories != null && importedStories.length() > 0) {
|
||||
val current = readJson(STORIES)?.optJSONArray("items") ?: JSONArray()
|
||||
val known = HashSet<String>()
|
||||
for (i in 0 until current.length()) known.add(current.getJSONObject(i).optString("id"))
|
||||
for (i in 0 until importedStories.length()) {
|
||||
val s = importedStories.optJSONObject(i) ?: continue
|
||||
// An expired story must not sneak back in through a backup —
|
||||
// the expiry is a promise about the bytes leaving the device.
|
||||
val exp = s.optJSONObject("ref")?.optDouble("exp", 0.0) ?: 0.0
|
||||
val active = exp == 0.0 || nowSeconds < exp
|
||||
if (active && s.optString("id").isNotEmpty() && known.add(s.optString("id"))) {
|
||||
current.put(s)
|
||||
newStories++
|
||||
}
|
||||
}
|
||||
writeJson(STORIES, JSONObject().put("items", current))
|
||||
}
|
||||
|
||||
val importedCalls = payload.optJSONArray("callLog")
|
||||
if (importedCalls != null && importedCalls.length() > 0) {
|
||||
val current = callLog()
|
||||
val known = HashSet<String>()
|
||||
for (i in 0 until current.length()) {
|
||||
val c = current.getJSONObject(i)
|
||||
known.add("${c.optLong("peer")}|${c.optLong("at")}")
|
||||
}
|
||||
var changed = false
|
||||
for (i in 0 until importedCalls.length()) {
|
||||
val c = importedCalls.optJSONObject(i) ?: continue
|
||||
if (known.add("${c.optLong("peer")}|${c.optLong("at")}")) {
|
||||
current.put(c)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
val sorted = ArrayList<JSONObject>(current.length())
|
||||
for (i in 0 until current.length()) sorted.add(current.getJSONObject(i))
|
||||
sorted.sortBy { it.optLong("at") }
|
||||
saveCallLog(JSONArray().also { arr -> sorted.forEach(arr::put) })
|
||||
}
|
||||
}
|
||||
|
||||
return ImportCounts(newMessages, newStories)
|
||||
}
|
||||
|
||||
/** What makes a line *the same message* across two devices. */
|
||||
private fun lineKey(m: JSONObject): String =
|
||||
"${m.optBoolean("mine")}|${m.optLong("at")}|${m.optString("text")}|" +
|
||||
(m.optJSONObject("att")?.optString("id") ?: "")
|
||||
|
||||
/** Every thread this device holds, group or person. */
|
||||
fun threads(): List<String> =
|
||||
storage.names().filter { it.startsWith(HISTORY_PREFIX) }
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The history backup opens only with the right words, and merging never loses
|
||||
* what a device already has. The dedupe rules are the part worth guarding:
|
||||
* the same backup imported twice must add nothing the second time.
|
||||
*
|
||||
* ⚠️ These run on the JVM. The crypto here (PBKDF2, AES-GCM via the platform
|
||||
* `Cipher`) is deliberately the same JCA surface the vault already exercises
|
||||
* on Android — but per the Conscrypt rule, the on-device instrumentation
|
||||
* suite is what proves the Android side.
|
||||
*/
|
||||
class HistoryExportTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun vault() = Vault(MapStorage(), key())
|
||||
|
||||
private fun line(text: String, mine: Boolean, at: Long): JSONObject =
|
||||
JSONObject().put("author", 0).put("text", text).put("mine", mine).put("at", at)
|
||||
|
||||
private fun populated(): Vault {
|
||||
val v = vault()
|
||||
v.append("user:42", line("packing now", true, 1_756_400_000_000))
|
||||
v.append("user:42", line("bring the charger", false, 1_756_400_060_000))
|
||||
v.append("group:g1", line("meeting moved", false, 1_756_400_120_000))
|
||||
v.saveCallLog(JSONArray().put(JSONObject()
|
||||
.put("peer", 42).put("in", true).put("ok", true).put("at", 1_756_400_500_000)))
|
||||
return v
|
||||
}
|
||||
|
||||
// -- Sealing --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a backup round trips with the right passphrase`() {
|
||||
val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray())
|
||||
val back = HistoryExport.open(sealed, "correct horse".toCharArray())
|
||||
assertEquals(2, back.getJSONObject("threads").getJSONArray("user:42").length())
|
||||
assertEquals(1, back.getJSONObject("threads").getJSONArray("group:g1").length())
|
||||
assertEquals(1, back.getJSONArray("callLog").length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wrong passphrase is refused as exactly that`() {
|
||||
val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray())
|
||||
assertFailsWith<HistoryExport.WrongPassphrase> {
|
||||
HistoryExport.open(sealed, "wrong pony".toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage is not an export`() {
|
||||
assertFailsWith<HistoryExport.NotAnExport> {
|
||||
HistoryExport.open("not json".toByteArray(), "x".toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an iPhone backup is refused honestly`() {
|
||||
val sealed = HistoryExport.seal(populated().exportPayload(), "pw-123456".toCharArray())
|
||||
val doctored = JSONObject(String(sealed)).put("platform", "ios")
|
||||
assertFailsWith<HistoryExport.WrongPlatform> {
|
||||
HistoryExport.open(doctored.toString().toByteArray(), "pw-123456".toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the outbox never leaves the device`() {
|
||||
val v = populated()
|
||||
v.saveOutbox(JSONArray().put(JSONObject().put("to", 42).put("text", "pending")))
|
||||
assertTrue(!v.exportPayload().has("outbox"))
|
||||
}
|
||||
|
||||
// -- Merging --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `merging into an empty device takes everything`() {
|
||||
val fresh = vault()
|
||||
val counts = fresh.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
|
||||
assertEquals(3, counts.messages)
|
||||
assertEquals(2, fresh.history("user:42").length())
|
||||
assertEquals(1, fresh.callLog().length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merging keeps what the device already has`() {
|
||||
val device = vault()
|
||||
device.append("user:42", line("sent after the export", false, 1_756_500_000_000))
|
||||
device.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
|
||||
val texts = device.history("user:42").let { arr ->
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("text") }
|
||||
}
|
||||
assertEquals(listOf("packing now", "bring the charger", "sent after the export"), texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same backup twice adds nothing the second time`() {
|
||||
val device = vault()
|
||||
val payload = populated().exportPayload()
|
||||
device.mergeImportedPayload(payload, nowSeconds = 0.0)
|
||||
val second = device.mergeImportedPayload(payload, nowSeconds = 0.0)
|
||||
assertEquals(0, second.messages)
|
||||
assertEquals(0, second.stories)
|
||||
assertEquals(2, device.history("user:42").length())
|
||||
assertEquals(1, device.callLog().length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired story does not sneak back in through a backup`() {
|
||||
val source = vault()
|
||||
val items = JSONArray()
|
||||
.put(JSONObject().put("id", "story-live").put("author", 42).put("at", 5)
|
||||
.put("ref", JSONObject().put("exp", 0.0)))
|
||||
.put(JSONObject().put("id", "story-dead").put("author", 42).put("at", 6)
|
||||
.put("ref", JSONObject().put("exp", 100.0)))
|
||||
// Write through the raw record shape the vault itself uses.
|
||||
val payload = source.exportPayload().put("stories", JSONObject().put("items", items))
|
||||
|
||||
val device = vault()
|
||||
val counts = device.mergeImportedPayload(payload, nowSeconds = 999.0)
|
||||
assertEquals(1, counts.stories)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user