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
↓ peach-android-5066252a.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+8 −2app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+40 −15app/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 }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),