0dd507815d8b

enforce sync structure limits on every decoded frame and stop preallocating 24 MiB per frame (v2.14.82)

PAI-2026-003: the 24 MiB frame cap bounds a peer's bytes, not the shape inside them. A paired peer could send a small frame carrying millions of map keys, and the unlocked coordinator diffed and applied it with none of the entry/tombstone count gates the locked spool already enforced. SyncMessage.requireBounded now runs on every frame the moment it is decoded, in the one place both the initiator and the responder pass through, so Manifest, Request, Entries and Tombstones are all checked before any diff or persistence: counts against the declared 100,000 limits, identifier length against a new 128-char cap, and entry envelopes against MAX_ENTRY_ENVELOPE_BYTES. A breach is an IOException, so it lands in the same ERRORED outcome as a transport failure. receiveSyncFrame also stops reserving MAX_SYNC_FRAME_BYTES for every frame. Most frames are small control messages and an eager 24 MiB allocation per frame is itself the availability problem; the buffer now doubles from one Noise plaintext up to the same cap, keeping the O(n^2) copy cost the preallocation was added to avoid. Verification: five adversarial peer frames refused at N+1, oversized entry envelope, and oversized identifier, plus a real two-sided sync at exactly N entries proving the limit is not off by one; full JVM suite 1034 tests / 0 failures / 0 errors / 2 skipped, up from 1028; lintDebug 0 errors and 90 warnings, unchanged; assembleDebug and debug androidTest sources compile.

time
2026-09-01 00:19 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
resolve independent audit findings
parent
a9558300f7b0
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+1 −1app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+16 −6app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+12 −0app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+34 −0app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+81 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.81\ No newline at end of file1+2.14.82\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+1 −1
@@ -332,7 +332,7 @@332332     PeachSyncLog.i("sync-stage: role=$role wait=$expected")333333     val bytes = receive()334334     requireSyncFrameSize(bytes.size)335-    val msg = decodeSyncMessage(bytes)335+    val msg = decodeSyncMessage(bytes).also { it.requireBounded() }336336     return msg as? T ?: throw IllegalStateException("expected $expected, got ${msg::class.simpleName}")337337 }338338 

view file

app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+16 −6
@@ -41,6 +41,9 @@4141  */4242 const val FRAME_CONTINUATION_MARKER: Byte = 0x014343 44+/** One Noise plaintext, rounded up — every control frame fits in a single chunk. */45+private const val INITIAL_FRAME_BUFFER_BYTES: Int = 1 shl 1646+4447 /**4548  * Pad [plaintext], optionally chunk it across the Noise plaintext limit,4649  * encrypt each chunk, and hand each ciphertext to [sendCiphertext].@@ -103,12 +106,15 @@103106     receiveCiphertext: suspend () -> ByteArray,104107     decrypt: (ByteArray) -> ByteArray,105108 ): ByteArray {106-    // Audit H-09: pre-allocated buffer (capped at MAX_SYNC_FRAME_BYTES) avoids107-    // the O(n²) reallocation the old growing-array path suffered — one108-    // allocation regardless of how many chunks the peer streams. Cumulative109-    // size and chunk count are checked BEFORE writing so an authenticated peer110-    // can't grow memory unboundedly or pin the loop on tiny marked chunks.111-    val buffer = ByteArray(MAX_SYNC_FRAME_BYTES)109+    // The buffer doubles on demand up to MAX_SYNC_FRAME_BYTES rather than110+    // reserving that cap for every frame: most frames are small control111+    // messages, and a fixed 24 MiB allocation per frame is itself the112+    // availability problem (PAI-2026-003). Doubling keeps the old O(n²)113+    // copy cost away — at most ~18 copies to reach the cap. Cumulative size114+    // and chunk count are still checked BEFORE writing, so an authenticated115+    // peer can't grow memory unboundedly or pin the loop on tiny marked116+    // chunks.117+    var buffer = ByteArray(INITIAL_FRAME_BUFFER_BYTES)112118     var size = 0113119     var chunkCount = 0114120     var chunkedFrame = false@@ -130,6 +136,10 @@130136             throw SyncFrameTooLargeException(131137                 "sync frame chunk count exceeded $MAX_SYNC_FRAME_BYTES",132138             )139+        }140+        if (size + dataSize > buffer.size) {141+            val grown = (buffer.size * 2).coerceAtMost(MAX_SYNC_FRAME_BYTES)142+            buffer = buffer.copyOf(maxOf(grown, size + dataSize))133143         }134144         if (!chunkedFrame) {135145             // First chunk. Marker ⇒ chunked frame; no marker ⇒ the entire

view file

app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+12 −0
@@ -12,6 +12,12 @@1212     message: String = "sync frame too large (WebSocket close 1009)",1313 ) : IOException(message)1414 15+/**16+ * A decoded frame whose declared structure exceeds the protocol's limits. An17+ * IOException so it lands in the same ERRORED outcome as a transport failure.18+ */19+class SyncStructureTooLargeException(what: String) : IOException("sync structure exceeds limits: $what")20+1521 fun requireSyncFrameSize(size: Int) {1622     if (size > MAX_SYNC_FRAME_BYTES) {1723         throw SyncFrameTooLargeException("sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes")@@ -25,6 +31,12 @@2531 object SyncLimits {2632     const val MAX_ENTRY_COUNT = 100_0002733     const val MAX_TOMBSTONE_COUNT = 100_00034+    /**35+     * Entry and device identifiers are UUID strings (36 chars). The cap exists36+     * so a peer cannot pad a map with multi-kilobyte keys — the count limits37+     * alone leave the cost of any single key unbounded.38+     */39+    const val MAX_ID_LENGTH = 1282840     const val MAX_ENTRY_PLAINTEXT_BYTES = 512 * 10242941     const val MAX_ENTRY_ENVELOPE_BYTES = (MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 643042     /**

view file

app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+34 −0
@@ -132,3 +132,37 @@132132 133133 fun decodeSyncMessage(bytes: ByteArray): SyncMessage =134134     SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))135+136+/**137+ * Reject a decoded frame whose declared structure exceeds the protocol's138+ * limits, before any diff or persistence work touches it (PAI-2026-003).139+ *140+ * [requireSyncFrameSize] bounds a frame's bytes, not its shape: a 24 MiB frame141+ * of one-character keys carries millions of map entries, and the String and142+ * Map allocation for those — plus the diff and apply passes that follow —143+ * dwarfs the frame itself. LockedSyncSpoolStore already enforced these same144+ * bounds on the locked spool; the unlocked coordinator had none.145+ */146+fun SyncMessage.requireBounded() {147+    fun checkIds(ids: Collection<String>, cap: Int, what: String) {148+        if (ids.size > cap) throw SyncStructureTooLargeException(what)149+        if (ids.any { it.length > SyncLimits.MAX_ID_LENGTH }) {150+            throw SyncStructureTooLargeException("$what identifier length")151+        }152+    }153+    when (this) {154+        is SyncMessage.Manifest -> {155+            checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "manifest entries")156+            checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "manifest tombstones")157+        }158+        is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_ENTRY_COUNT, "request ids")159+        is SyncMessage.Entries -> {160+            checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "entries")161+            if (entries.values.any { it.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES }) {162+                throw SyncStructureTooLargeException("entry envelope")163+            }164+        }165+        is SyncMessage.Tombstones -> checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "tombstones")166+        else -> Unit167+    }168+}

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+81 −0
@@ -332,4 +332,85 @@332332         // The data still crossed: b got x.333333         assertTrue(a.blobs.containsKey("y"))334334     }335+336+    /**337+     * PAI-2026-003: the 24 MiB byte cap bounds a frame's size, not the shape338+     * inside it. Each case below is a well-formed, small frame that a paired339+     * peer can send, and each must be refused before it reaches the diff or340+     * the vault rather than being diffed and applied.341+     */342+    private fun scriptedResponder(vararg frames: SyncMessage): SyncOutcome = runBlocking {343+        val queue = ArrayDeque(frames.map { it.encodeToBytes() })344+        val session = SyncSession(345+            remoteStaticPublicKey = ByteArray(32),346+            send = { },347+            receive = { queue.removeFirstOrNull() ?: throw java.io.IOException("WebSocket closed") },348+            close = { },349+        )350+        runSyncResponder(session, FakeAdapter("B", version = 2), nowMs = 0L)351+    }352+353+    private fun hello() = SyncMessage.Hello("VAULT", "A", version = 1, deviceTime = 0L)354+355+    @Test fun oversized_manifest_is_refused() {356+        val over = (0..SyncLimits.MAX_ENTRY_COUNT).associate { "id-$it" to 1L }357+        val out = scriptedResponder(hello(), SyncMessage.Manifest(over, emptyMap()))358+        assertEquals(SyncOutcomeStatus.ERRORED, out.status)359+        assertTrue(out.error!!.contains("sync structure exceeds limits"))360+    }361+362+    @Test fun oversized_tombstone_map_is_refused() {363+        val over = (0..SyncLimits.MAX_TOMBSTONE_COUNT).associate { "id-$it" to 1L }364+        val out = scriptedResponder(365+            hello(),366+            SyncMessage.Manifest(emptyMap(), emptyMap()),367+            SyncMessage.Request(emptyList()),368+            SyncMessage.Entries(emptyMap()),369+            SyncMessage.Tombstones(over),370+        )371+        assertEquals(SyncOutcomeStatus.ERRORED, out.status)372+        assertTrue(out.error!!.contains("tombstones"))373+    }374+375+    @Test fun oversized_request_is_refused() {376+        val over = (0..SyncLimits.MAX_ENTRY_COUNT).map { "id-$it" }377+        val out = scriptedResponder(378+            hello(),379+            SyncMessage.Manifest(emptyMap(), emptyMap()),380+            SyncMessage.Request(over),381+        )382+        assertEquals(SyncOutcomeStatus.ERRORED, out.status)383+        assertTrue(out.error!!.contains("request ids"))384+    }385+386+    @Test fun oversized_entry_envelope_is_refused() {387+        val out = scriptedResponder(388+            hello(),389+            SyncMessage.Manifest(mapOf("x" to 1L), emptyMap()),390+            SyncMessage.Request(emptyList()),391+            SyncMessage.Entries(mapOf("x" to "A".repeat(SyncLimits.MAX_ENTRY_ENVELOPE_BYTES + 1))),392+        )393+        assertEquals(SyncOutcomeStatus.ERRORED, out.status)394+        assertTrue(out.error!!.contains("entry envelope"))395+    }396+397+    @Test fun oversized_identifier_is_refused() {398+        val long = "i".repeat(SyncLimits.MAX_ID_LENGTH + 1)399+        val out = scriptedResponder(hello(), SyncMessage.Manifest(mapOf(long to 1L), emptyMap()))400+        assertEquals(SyncOutcomeStatus.ERRORED, out.status)401+        assertTrue(out.error!!.contains("identifier length"))402+    }403+404+    @Test fun a_peer_at_the_count_limit_still_syncs() = runBlocking {405+        val (sa, sb) = duplex()406+        val a = FakeAdapter("A", version = 1)407+        val b = FakeAdapter("B", version = 2).apply {408+            repeat(SyncLimits.MAX_ENTRY_COUNT) { put("id-$it", 1L) }409+        }410+        val ra = async { runSyncInitiator(sa, a, nowMs = 0) }411+        val rb = async { runSyncResponder(sb, b, nowMs = 0) }412+        assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)413+        assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)414+        assertEquals(SyncLimits.MAX_ENTRY_COUNT, a.blobs.size)415+    }335416 }

view file