c0c413a880b4
fix: Move duress off the entries frame on Android (v2.9.9 frame-ceiling mirror)
- time
- 2026-07-30 21:43 UTC · 2 months ago
- machine
- koh2
- parent
- af103f499122
↓ peach-android-c0c413a8.tar.gzatlas at this save
files changed4 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 −0app/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"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 }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 }