c0c413a880b4

fix: Move duress off the entries frame on Android (v2.9.9 frame-ceiling mirror)

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt:sendVaultSnapshot now mirrors the peach-browser v4.6.4 wire shape — duress rides its own ADOPT_SNAPSHOT frame after the entries chunks (chunk_index = entriesChunkCount, chunkCount += 1) instead of being attached to the entries final chunk. Tombstones migrate to the last wire frame (entries final if no duress, else duress frame). If the duress frame would push chunkCount past MAX_SNAPSHOT_CHUNKS (200), the duress frame is dropped and sync fills duress later (graceful, never blocks adoption). Cross-repo wire contract: identical to peach-browser save 8411b9a63ec4. The receiver is unchanged on both sides — receiveVaultSnapshot reads duress_slot off any chunk and merges per chunk, so: - new sender -> old receiver: duress-only frame on iteration N - old sender -> new receiver: duress on the entries final chunk - new sender -> new receiver: duress on its own frame app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt: 3 new tests mirroring browser T-A33/T-A34/T-A35 with backtick-space naming (VaultAdoptSnapshotTest uses backtick style, RelayAdoptionReceiverTest would also use it per the brief verification record): - frame ceiling 768KiB duress on separate frame round-trips (T-A33) - intermediate frame sizes stay under wire ceiling when duress rides its own frame (T-A34, structural probe observes raw sizes and assertions) - legacy duress on entries final chunk still decodes (T-A35, manual legacy sender shape) VERSION bumped 2.9.8 -> 2.9.9; versionCode 240154 -> 240155. Single-authority VERSION file unchanged in role (only value). Gate (JDK 21 from Android Studio bundle, required for Gradle 8.14.3): focused testDebugUnitTest --tests "*.VaultAdoptSnapshotTest" green after a fix on a Kotlin compile error in the first T-A33 attempt (used AdopJson inline form with mapOf which needs reified type; replaced with literal string-build matching the existing tests' style). Full gate: ./gradlew testDebugUnitTest lintDebug assembleRelease --rerun-tasks BUILD SUCCESSFUL 2m39s, 843 tests / 117 suites / 0 failures / 0 errors / 0 skips (was 840 at v2.9.8 baseline + 3 new duress tests). VaultAdoptSnapshot- Test: 16 tests / 0 / 0. Closes the deferred REVIEW_DEFERRED_ITEMS_BRIEF.md Item 1 (frame ceiling) on both ends of the wire.

time
2026-07-30 21:43 UTC · 2 months ago
machine
koh2
parent
af103f499122
4 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+44 −10app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+94 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.81+2.9.9

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24015435+        versionCode 2401553636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+44 −10
@@ -471,10 +471,24 @@471471 472472 /**473473  * Source side: chunk the vault snapshot and send it over the pairing channel.474- * Entries are split so each chunk's serialized entries map stays under475- * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault476- * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are477- * sent and the fallback sync pipeline fills the remainder.474+ *475+ * Wire shape (v2.9.9+, duress on its own frame; mirrors browser v4.6.4):476+ *   - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries.477+ *     Tombstones ride the last entries frame when no duress follows.478+ *   - If duressSlot is set, one extra frame after the entries carrying only479+ *     the duress slot (entries: empty, tombstones: tombstone map,480+ *     duress_slot: duressSlot). This moves duress off the entries frame so a481+ *     sizeable sealed duress vault can no longer push the entries frame raw482+ *     bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB).483+ *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.488+ *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).478492  */479493 suspend fun sendVaultSnapshot(480494     channel: PairingTransport,@@ -498,15 +512,35 @@498512     }499513     chunks.add(current)500514 501-    val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)502-    for (index in 0 until chunkCount) {503-        val isFinal = index == chunkCount - 1515+    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 && duressFits521+    val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)522+523+    for (index in 0 until entriesChunkCount) {524+        val isFinalEntries = index == entriesChunkCount - 1504525         sendFrame(channel, AdoptionFrame.Snapshot(505526             chunkIndex = index,506-            chunkCount = chunkCount,527+            chunkCount = totalChunks,507528             entries = chunks[index],508-            tombstones = if (isFinal) tombstones else emptyMap(),509-            duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,529+            // Tombstones ride the LAST wire frame: when duress follows the530+            // entries, it goes on the duress frame; otherwise on the last531+            // entries chunk.532+            tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),533+            duressSlot = null,534+        ))535+    }536+537+    if (sendDuress) {538+        sendFrame(channel, AdoptionFrame.Snapshot(539+            chunkIndex = entriesChunkCount,540+            chunkCount = totalChunks,541+            entries = emptyMap(),542+            tombstones = tombstones,543+            duressSlot = duressSlot,510544         ))511545     }512546 }

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+94 −0
@@ -211,4 +211,98 @@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.217+    @Test218+    fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking {219+        val (source, receiver) = memoryPair()220+        val entries = mapOf("id-0" to "x".repeat(450 * 1024))221+        val tombstones = mapOf("dead-0" to 1L)222+        // Build a sealed-duress JSON literal with a 320 KiB ciphertext field223+        // so combined raw payload (450 KiB entry + ~320 KiB duress + envelope)224+        // crosses the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING on the legacy wire.225+        val duressJson = ("""{"version":1,"salt":"${"a".repeat(16)}",""" +226+            """"nonce":"${"b".repeat(12)}",""" +227+            """"ciphertext":"${"c".repeat(320 * 1024)}",""" +228+            """"tag":"${"d".repeat(16)}"}""")229+        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)230+        val snapshot = receiveVaultSnapshot(receiver)231+        assertEquals(entries, snapshot.entries)232+        assertEquals(tombstones, snapshot.tombstones)233+        assertEquals(duressJson, snapshot.duressSlot)234+    }235+236+    // Mirrors browser v4.6.4 T-A34: structural proof that the duress wire237+    // change is real. With >768 KiB combined, intermediate frames each stay238+    // under the ceiling and exactly two frames are sent (entries + duress).239+    @Test240+    fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking {241+        val (source, _) = memoryPair()242+        val observedRawSizes = mutableListOf<Int>()243+        val observedHasDuress = mutableListOf<Boolean>()244+        val observedEntriesSize = mutableListOf<Int>()245+        val probe = object : PairingTransport {246+            override suspend fun send(msg: ByteArray) {247+                observedRawSizes.add(msg.size)248+                val parsed = runCatching {249+                    AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(msg, Charsets.UTF_8))250+                }.getOrNull()251+                observedHasDuress.add(parsed?.duressSlot != null)252+                observedEntriesSize.add(parsed?.entries?.size ?: 0)253+                source.send(msg)254+            }255+            override suspend fun receive(): ByteArray = source.receive()256+            override suspend fun close() {}257+        }258+        val entries = mapOf("id-0" to "x".repeat(450 * 1024))259+        val duressJson = "y".repeat(320 * 1024)260+        val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 // SNAPSHOT_FRAME_WIRE_CEILING261+262+        sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)263+264+        assertEquals(2, observedRawSizes.size)265+        // Entries frame carries entries, no duress, under ceiling.266+        assertEquals(false, observedHasDuress[0])267+        assertEquals(1, observedEntriesSize[0])268+        org.junit.Assert.assertTrue(269+            "entries frame ${observedRawSizes[0]} should be < ceiling $ceiling",270+            observedRawSizes[0] < ceiling271+        )272+        // Duress frame carries duress, no entries, under ceiling.273+        assertEquals(true, observedHasDuress[1])274+        assertEquals(0, observedEntriesSize[1])275+        org.junit.Assert.assertTrue(276+            "duress frame ${observedRawSizes[1]} should be < ceiling $ceiling",277+            observedRawSizes[1] < ceiling278+        )279+        // The point of the split: combined raw exceeds what a single frame280+        // was permitted to carry on the old wire.281+        org.junit.Assert.assertTrue(282+            "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",283+            observedRawSizes[0] + observedRawSizes[1] > ceiling284+        )285+    }286+287+    // Mirrors browser v4.6.4 T-A35: backward compat with pre-v2.9.9 senders288+    // (duress on entries final chunk). The receiver merges per chunk so both289+    // shapes decode.290+    @Test291+    fun `legacy duress on entries final chunk still decodes`() = runBlocking {292+        val (source, receiver) = memoryPair()293+        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""294+        // Manually construct the legacy v2.9.8 wire shape: a single chunk295+        // with entries, tombstones AND duress_slot all on it.296+        sendFrame(source, AdoptionFrame.Snapshot(297+            chunkIndex = 0,298+            chunkCount = 1,299+            entries = mapOf("id-0" to "ciphertext"),300+            tombstones = mapOf("dead-0" to 42L),301+            duressSlot = duressJson,302+        ))303+        val snapshot = receiveVaultSnapshot(receiver)304+        assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries)305+        assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones)306+        assertEquals(duressJson, snapshot.duressSlot)307+    }214308 }

view file