943c244c435c
fix: Reserve frame for duress at chunk cap (v2.9.9 HIGH re-review fix)
- time
- 2026-07-30 22:09 UTC · 2 months ago
- machine
- koh2
- parent
- c0c413a880b4
↓ peach-android-943c244c.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+44 −13app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+55 −3app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+44 −13
@@ -481,14 +481,26 @@481481 * sizeable sealed duress vault can no longer push the entries frame raw482482 * bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB).483483 *484- * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries final485- * chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a duress-486- * only frame has empty `entries`/`tombstones` which merge as a no-op), so both487- * shapes decode and both new->old / old->new directions interoperate.484+ * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200):485+ * - When duress is configured, ONE of the 200 frames is reserved for the486+ * duress slot. The entries cap is [MAX_SNAPSHOT_CHUNKS] - 1 = 199 entries487+ * chunks; excess entries are deferred to the sync fallback pipeline (sync488+ * carries entries and tombstones on Android too, so this is a graceful489+ * truncation). This reservation is REQUIRED: ordinary sync does NOT490+ * carry duress on Android (VaultRepository.exportDuressSlotForAdoption491+ * is the only transfer path), so silently dropping a duress frame at492+ * the cap would leave the adopted device permanently without its duress493+ * vault. We prioritize duress delivery over completeness of entries.488494 *489- * If adding the duress frame would push `chunkCount` past [MAX_SNAPSHOT_CHUNKS]490- * (200), the duress frame is dropped and the fallback sync pipeline fills the491- * duress slot later (graceful degradation, never blocks adoption).495+ * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]496+ * is rejected with a thrown error BEFORE the duress frame is sent. Hard stop;497+ * the caller must split duress out of band. We do not silently truncate498+ * duress; sync cannot restore it.499+ *500+ * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries501+ * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a502+ * duress-only frame has empty `entries`/`tombstones` which merge as a no-op),503+ * so both shapes decode and both new->old / old->new directions interoperate.492504 */493505 suspend fun sendVaultSnapshot(494506 channel: PairingTransport,@@ -496,6 +508,14 @@496508 tombstones: Map<String, Long>,497509 duressSlot: String? = null,498510 ) {511+ val includeDuress = !duressSlot.isNullOrBlank()512+ // Reserve one frame for duress when configured so duress is never513+ // silently dropped at the cap; ordinary sync does NOT carry duress on514+ // Android (VaultRepository.exportDuressSlotForAdoption is the only515+ // transfer path), so dropping a duress frame would orphan the adopted516+ // device's duress vault.517+ val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS518+499519 val chunks = mutableListOf<Map<String, String>>()500520 var current = LinkedHashMap<String, String>()501521 var currentBytes = 2 // "{}"@@ -512,12 +532,8 @@512532 }513533 chunks.add(current)514534 515- val entriesChunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)516- val includeDuress = !duressSlot.isNullOrBlank()517- // ponytail: cap total chunks to MAX_SNAPSHOT_CHUNKS; if duress would push518- // us over, drop the duress frame and let sync fill the duress slot later.519- val duressFits = entriesChunkCount < MAX_SNAPSHOT_CHUNKS520- val sendDuress = includeDuress && duressFits535+ val entriesChunkCount = minOf(chunks.size, entryCap)536+ val sendDuress = includeDuress521537 val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)522538 523539 for (index in 0 until entriesChunkCount) {@@ -535,6 +551,21 @@535551 }536552 537553 if (sendDuress) {554+ // Pre-check: a duress frame that exceeds the wire ceiling cannot be555+ // sent and would fail the receiver's size check. Throw loudly so the556+ // caller can split duress out of band instead of triggering a silent557+ // "adoption succeeded but duress vanished" path. Sync cannot recover558+ // duress on Android either — the spec is a hard stop.559+ val duressEnvelope = (AdoptJson.encodeToString(AdoptionFrame.Snapshot(560+ chunkIndex = entriesChunkCount,561+ chunkCount = totalChunks,562+ entries = emptyMap(),563+ tombstones = tombstones,564+ duressSlot = duressSlot,565+ ))).encodeToByteArray()566+ if (duressEnvelope.size > SNAPSHOT_FRAME_WIRE_CEILING) {567+ throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")568+ }538569 sendFrame(channel, AdoptionFrame.Snapshot(539570 chunkIndex = entriesChunkCount,540571 chunkCount = totalChunks,app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+55 −3
@@ -211,9 +211,9 @@211211 assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)212212 }213213 214- // Mirrors browser v4.6.4 T-A33: a >768 KiB combined payload round-trips215- // because duress rides its own frame after the entries chunks, not the216- // entries final chunk. Pre-fix this throws ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.214+ // Mirrors browser v4.6.4 T-A33: post-fix regression. With duress on215+ // its own frame after the entries chunks, a >768 KiB combined payload216+ // round-trips intact; pre-fix this threw ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.217217 @Test218218 fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking {219219 val (source, receiver) = memoryPair()@@ -304,5 +304,57 @@304304 assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries)305305 assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones)306306 assertEquals(duressJson, snapshot.duressSlot)307+ }308+309+ // Mirrors browser T-A36: cap boundary inclusive. With 199 entries and a310+ // duress slot, the sender fits everything within MAX_SNAPSHOT_CHUNKS = 200311+ // (199 entries + 1 duress = 200 frames). Duress MUST arrive intact — sync312+ // does not carry duress. Sequential pattern matches the rest of the file313+ // (channels buffer via UNLIMITED capacity).314+ @Test315+ fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {316+ val (source, receiver) = memoryPair()317+ val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""318+ val big = "x".repeat(450 * 1024)319+ val entries = (0 until 199).associate { "id-$it" to big }320+ sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)321+ val snapshot = receiveVaultSnapshot(receiver)322+ assertEquals(entries, snapshot.entries)323+ assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)324+ assertEquals(duressJson, snapshot.duressSlot)325+ }326+327+ // Mirrors browser T-A37: cap boundary exceeded. With 200 entries + duress,328+ // the duress frame is reserved (entry cap = MAX_SNAPSHOT_CHUNKS - 1 = 199),329+ // so duress still arrives intact. Excess entries (one beyond 199) are330+ // deferred to sync. Critically: duress MUST NOT be silently dropped, else331+ // the adopted device lacks its duress vault permanently.332+ @Test333+ fun `200 entries plus duress does not silently drop duress at the chunk cap`() = runBlocking {334+ val (source, receiver) = memoryPair()335+ val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""336+ val big = "x".repeat(450 * 1024)337+ val entries = (0 until 200).associate { "id-$it" to big }338+ sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)339+ val snapshot = receiveVaultSnapshot(receiver)340+ // Duress MUST arrive intact.341+ assertEquals(duressJson, snapshot.duressSlot)342+ // The inline cap = MAX_SNAPSHOT_CHUNKS - 1 = 199 entries.343+ assertEquals(199, snapshot.entries.size)344+ assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)345+ }346+347+ // Cap-pre-check guardrail: a duress payload that itself exceeds the wire348+ // ceiling throws ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE before any frame is349+ // sent — adoption never silently loses duress.350+ @Test351+ fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {352+ val (source, _) = memoryPair()353+ val duressJson = "x".repeat(800 * 1024) // 800 KiB duress, exceeds 768 KiB ceiling354+ val error = runCatching {355+ sendVaultSnapshot(source, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)356+ }.exceptionOrNull()357+ assertTrue(error is IllegalStateException)358+ assertTrue(error!!.message!!.contains("DURESS_FRAME_TOO_LARGE"))307359 }308360 }