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
@@ -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)
}
}