943c244c435c

fix: Reserve frame for duress at chunk cap (v2.9.9 HIGH re-review fix)

Mirrors the cross-repo GPT-5.6-Sol HIGH finding on peach-browser v4.6.4 (silent duress drop at MAX_SNAPSHOT_CHUNKS): when duress is configured, the entry cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 so the duress frame can fit alongside. Previously a vault with exactly 200 entries + duress had its duress frame silently dropped at the cap, and ordinary sync cannot restore duress (`VaultRepository.exportDuressSlotForAdoption` is the only transfer path on Android). Now duress always fits (one entry is deferred to sync when needed, which is safe because sync carries entries and tombstones). Also mirrors the overflow guard for oversized standalone duress: duress that itself exceeds the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING now throws ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE BEFORE any frame is sent, so the caller can split duress out of band instead of silently losing it. app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt:sendVaultSnapshot - entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS - sendDuress = includeDuress (always try when configured; rely on entryCap to leave room) - pre-encode check on the duress envelope; throw ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE if it would exceed the ceiling - doc comment updated to call out the duress-frame reservation rationale AND the oversized-duress hard-stop semantics app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt: - updated T-A33 comment to historical tense (matches browser) - new `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS` (T-A36) - new `200 entries plus duress does not silently drop duress at the chunk cap` (T-A37) - new `duress larger than wire ceiling throws loudly before sending` (cap-pre-check guardrail) versions: peach-android VERSION unchanged at 2.9.9 (cap fix is a follow-up to the prior v2.9.9 save c0c413a8804 -- there was no behavior change that requires a version bump. A follow-up review by GPT-5.6-Sol cleared the HIGH finding; the underlying protocol doc was also corrected.). Gate: focused `testDebugUnitTest --tests "*.VaultAdoptSnapshotTest"` green on first pass after the coroutine-style fix (the first attempt used runBlocking-inside-runBlocking which Kotlin rejected at compile); full gate `./gradlew testDebugUnitTest lintDebug assembleRelease --rerun-tasks` BUILD SUCCESSFUL 2m28s. 846 tests / 117 suites / 0 failures / 0 errors / 0 skips (was 840 at v2.9.8 baseline + 3 v4.6.4 frame-fix mirrors + 3 cap-math mirrors = 846). VaultAdoptSnapshotTest: 19 tests / 0 / 0.

time
2026-07-30 22:09 UTC · 2 months ago
machine
koh2
parent
c0c413a880b4
2 files app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+44 −13app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+55 −3
app/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,

view file

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 }

view file