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