5066252aa149

fix: align adoption snapshot padding with browser (PB-26-706, v2.16.4)

time
2026-09-07 01:19 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
red team remediation
parent
edb312a8491e
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+8 −2app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+40 −15
VERSION+1 −1
@@ -1 +1 @@1-2.16.31+2.16.4

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+8 −2
@@ -170,6 +170,7 @@170170 // Headroom added to the per-chunk entries budget when validating an encoded171171 // frame on the wire: JSON envelope plus the final chunk's tombstone map.172172 internal const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024173+internal val MAX_PADDED_SNAPSHOT_FRAME_BYTES = SyncPadding.bucket(SNAPSHOT_FRAME_WIRE_CEILING + 4)173174 174175 /**175176  * PB-26-206 aggregate budget shared by sender and receiver — exact mirror of@@ -620,7 +621,7 @@620621         val frame = frames[i]621622         applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)622623     }623-    for (raw in encoded) channel.send(raw)624+    for (raw in encoded) channel.send(SyncPadding.pad(raw))624625 }625626 626627 /**@@ -716,7 +717,12 @@716717     var chunkCount = -1717718     var received = 0718719     while (chunkCount == -1 || received < chunkCount) {719-        val raw = channel.receive()720+        val wire = channel.receive()721+        if (wire.size > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {722+            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")723+        }724+        val raw = runCatching { SyncPadding.unpad(wire) }725+            .getOrElse { throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME", it) }720726         if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {721727             throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")722728         }

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+40 −15
@@ -14,6 +14,28 @@1414  */1515 class VaultAdoptSnapshotTest {1616 17+    private suspend fun sendSnapshotFrame(channel: PairingTransport, frame: AdoptionFrame.Snapshot) {18+        val raw = AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).encodeToByteArray()19+        channel.send(SyncPadding.pad(raw))20+    }21+22+    @Test23+    fun `PB-26-706 snapshot frames use the shared sync padding wire format`() = runBlocking {24+        val sent = mutableListOf<ByteArray>()25+        val channel = object : PairingTransport {26+            override suspend fun send(msg: ByteArray) { sent += msg }27+            override suspend fun receive(): ByteArray = error("unused")28+            override suspend fun close() {}29+        }30+31+        sendVaultSnapshot(channel, mapOf("id-0" to "ciphertext"), emptyMap())32+33+        val wire = sent.single()34+        assertEquals(SyncPadding.bucket(wire.size), wire.size)35+        assertTrue(String(wire, Charsets.UTF_8).first() != '{')36+        assertTrue(String(SyncPadding.unpad(wire), Charsets.UTF_8).startsWith("{\"type\":\"ADOPT_SNAPSHOT\""))37+    }38+1739     private fun memoryPair(): Pair<PairingTransport, PairingTransport> {1840         val a2b = Channel<ByteArray>(Channel.UNLIMITED)1941         val b2a = Channel<ByteArray>(Channel.UNLIMITED)@@ -72,7 +94,7 @@7294     @Test7395     fun `rejects out-of-order chunks`() = runBlocking {7496         val (source, receiver) = memoryPair()75-        sendFrame(source, AdoptionFrame.Snapshot(97+        sendSnapshotFrame(source, AdoptionFrame.Snapshot(7698             chunkIndex = 1,7799             chunkCount = 2,78100             entries = mapOf("a" to "x"),@@ -86,7 +108,7 @@86108     @Test87109     fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {88110         val (source, receiver) = memoryPair()89-        sendFrame(source, AdoptionFrame.Snapshot(111+        sendSnapshotFrame(source, AdoptionFrame.Snapshot(90112             chunkIndex = 0,91113             chunkCount = MAX_SNAPSHOT_CHUNKS + 1,92114             entries = emptyMap(),@@ -129,13 +151,12 @@129151     }130152 131153     @Test132-    fun `rejects raw frame exceeding wire ceiling`() = runBlocking {154+    fun `rejects padded frame exceeding wire ceiling`() = runBlocking {133155         // H-09: a hostile source could send an arbitrarily large frame to exhaust134-        // receiver memory before JSON parsing. receiveVaultSnapshot checks the raw135-        // byte length against SNAPSHOT_FRAME_WIRE_CEILING (512 KiB + 256 KiB = 768 KiB)136-        // and throws before attempting deserialization.137-        val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024138-        val oversized = ByteArray(ceiling + 1)156+        // receiver memory before unpadding or JSON parsing. receiveVaultSnapshot157+        // checks the padded wire length against the largest permitted sync bucket158+        // and throws before attempting either operation.159+        val oversized = ByteArray(MAX_PADDED_SNAPSHOT_FRAME_BYTES + 1)139160         val channel = object : PairingTransport {140161             override suspend fun send(msg: ByteArray) {}141162             override suspend fun receive(): ByteArray = oversized@@ -149,13 +170,13 @@149170     @Test150171     fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {151172         val (source, receiver) = memoryPair()152-        sendFrame(source, AdoptionFrame.Snapshot(173+        sendSnapshotFrame(source, AdoptionFrame.Snapshot(153174             chunkIndex = 0,154175             chunkCount = 2,155176             entries = mapOf("a" to "x"),156177             tombstones = emptyMap(),157178         ))158-        sendFrame(source, AdoptionFrame.Snapshot(179+        sendSnapshotFrame(source, AdoptionFrame.Snapshot(159180             chunkIndex = 1,160181             chunkCount = 3,161182             entries = mapOf("b" to "y"),@@ -240,13 +261,16 @@240261     fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking {241262         val (source, _) = memoryPair()242263         val observedRawSizes = mutableListOf<Int>()264+        val observedWireSizes = mutableListOf<Int>()243265         val observedHasDuress = mutableListOf<Boolean>()244266         val observedEntriesSize = mutableListOf<Int>()245267         val probe = object : PairingTransport {246268             override suspend fun send(msg: ByteArray) {247-                observedRawSizes.add(msg.size)269+                observedWireSizes.add(msg.size)270+                val raw = SyncPadding.unpad(msg)271+                observedRawSizes.add(raw.size)248272                 val parsed = runCatching {249-                    AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(msg, Charsets.UTF_8))273+                    AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(raw, Charsets.UTF_8))250274                 }.getOrNull()251275                 observedHasDuress.add(parsed?.duressSlot != null)252276                 observedEntriesSize.add(parsed?.entries?.size ?: 0)@@ -262,6 +286,7 @@262286         sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)263287 264288         assertEquals(2, observedRawSizes.size)289+        assertTrue(observedWireSizes.all { it == SyncPadding.bucket(it) })265290         // Entries frame carries entries, no duress, under ceiling.266291         assertEquals(false, observedHasDuress[0])267292         assertEquals(1, observedEntriesSize[0])@@ -293,7 +318,7 @@293318         val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""294319         // Manually construct the legacy v2.9.8 wire shape: a single chunk295320         // with entries, tombstones AND duress_slot all on it.296-        sendFrame(source, AdoptionFrame.Snapshot(321+        sendSnapshotFrame(source, AdoptionFrame.Snapshot(297322             chunkIndex = 0,298323             chunkCount = 1,299324             entries = mapOf("id-0" to "ciphertext"),@@ -484,7 +509,7 @@484509         for (hostileValue in listOf("1.5", "99999999999999999999999", "NaN")) {485510             val (source, receiver) = memoryPair()486511             val hostile = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{},"tombstones":{"dead":$hostileValue}}"""487-            source.send(hostile.encodeToByteArray())512+            source.send(SyncPadding.pad(hostile.encodeToByteArray()))488513             val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()489514             assertTrue("value $hostileValue must reject", error is IllegalStateException)490515             assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_INVALID_FRAME"))@@ -510,7 +535,7 @@510535         // Channels buffer UNLIMITED, so all frames can be queued before the511536         // receiver starts — sequential style matches the rest of this file.512537         for (i in 0 until chunks) {513-            sendFrame(source, AdoptionFrame.Snapshot(538+            sendSnapshotFrame(source, AdoptionFrame.Snapshot(514539                 chunkIndex = i,515540                 chunkCount = chunks,516541                 entries = mapOf("big-$i" to envelope),

view file