4ebae050a135

mirror chunking on Android with the marker wire shape from peach-browser sync-transport.ts; route every encrypt-of-padded-frame callsite through sendSyncFrame/receiveSyncFrame, slice non-last chunks at MAX-1 to leave room for the marker, and pin the constants and the bucket ladder in cross-platform KAT (v2.5.12)

time
2026-07-26 21:32 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
E7b sync frame chunking
parent
7b389c585363
3 files app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+15 −10app/src/main/java/com/peachpasswords/sync/SyncSession.kt+1 −1app/src/test/java/com/peachpasswords/sync/SyncFrameTransportTest.kt+43 −70
app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+15 −10
@@ -50,10 +50,10 @@5050  * into 64 KiB chunks; the first N−1 carry the [FRAME_CONTINUATION_MARKER],5151  * the last does not, and the receiver reassembles by stripping the byte.5252  */53-internal fun sendSyncFrame(53+internal suspend fun sendSyncFrame(5454     plaintext: ByteArray,5555     encrypt: (ByteArray) -> ByteArray,56-    sendCiphertext: (ByteArray) -> Unit,56+    sendCiphertext: suspend (ByteArray) -> Unit,5757 ) {5858     val padded = SyncPadding.pad(plaintext)5959     if (padded.size <= MAX_NOISE_PLAINTEXT_BYTES) {@@ -63,20 +63,25 @@6363     val total = padded.size6464     var offset = 06565     while (offset < total) {66-        val end = minOf(offset + MAX_NOISE_PLAINTEXT_BYTES, total)66+        val remaining = total - offset67+        // A non-last chunk must leave room for the one-byte marker under the68+        // Noise plaintext cap; the last chunk carries no marker and can use69+        // the full cap. This slicing is one byte shy of the browser's loop,70+        // which slices at the cap and then prepends the marker — that path71+        // produces [marker][MAX bytes] = MAX+1 bytes, which the crypto core72+        // rejects. Wire shape ([0x01][data]) is unchanged across platforms.73+        val cap = if (remaining <= MAX_NOISE_PLAINTEXT_BYTES) {74+            MAX_NOISE_PLAINTEXT_BYTES75+        } else {76+            MAX_NOISE_PLAINTEXT_BYTES - 177+        }78+        val end = minOf(offset + cap, total)6779         val chunkBytes = end - offset6880         val chunk = padded.copyOfRange(offset, end)6981         val isLast = end == total7082         val pt: ByteArray = if (isLast) {7183             chunk7284         } else {73-            // One continuation byte plus a sub-limit chunk is still under the74-            // cap by 1, so this can only fail if a future change widens the75-            // cap or drops the marker — defend in check, rather than rely on76-            // the loop bound above.77-            require(chunkBytes <= MAX_NOISE_PLAINTEXT_BYTES - 1) {78-                "chunk leaves no room for the continuation byte: $chunkBytes"79-            }8085             ByteArray(chunkBytes + 1).also {8186                 it[0] = FRAME_CONTINUATION_MARKER8287                 chunk.copyInto(it, 1)

view file

app/src/main/java/com/peachpasswords/sync/SyncSession.kt+1 −1
@@ -8,7 +8,7 @@88  */99 data class SyncSession(1010     val remoteStaticPublicKey: ByteArray,11-    val send: (ByteArray) -> Unit,11+    val send: suspend (ByteArray) -> Unit,1212     val receive: suspend () -> ByteArray,1313     val close: suspend () -> Unit,1414 )

view file

app/src/test/java/com/peachpasswords/sync/SyncFrameTransportTest.kt+43 −70
@@ -21,10 +21,6 @@2121  * future Android↔iOS ports all depend on: 0x01 marker on every non-last2222  * chunk, no marker on the last, reassembled bytes equal original, and a2323  * single-chunk frame goes out as one message unchanged.24- *25- * Mirrors the invariants in peach-browser `sync-padding.test.ts` and26- * exercises the wire format end-to-end the browser's transport.test.ts does27- * not.2824  */2925 class SyncFrameTransportTest {3026     private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)@@ -35,22 +31,23 @@3531     }3632 3733     /**38-     * Build two sessions (initiator and responder) against each other, sharing39-     * an in-memory channel pair so the test owns every ciphertext on the wire40-     * — no socket, no real network.34+     * Build two Noise sessions against each other. The two halves share an35+     * in-memory channel pair so the test owns every ciphertext on the wire —36+     * no socket, no real network, and no state from one test leaking into37+     * the next.4138      */4239     private fun sessions(): Pair<NoiseNKSession, NoiseNKSession> = runBlocking {4340         val initiator = keys()4441         val responder = keys()45-        val aToB = Channel<ByteArray>(Channel.UNLIMITED)46-        val bToA = Channel<ByteArray>(Channel.UNLIMITED)42+        val initiatorToResponder = Channel<ByteArray>(Channel.UNLIMITED)43+        val responderToInitiator = Channel<ByteArray>(Channel.UNLIMITED)4744         val initiatorHandshake = async {4845             noiseKKInitiator(4946                 initiator.privateKey,5047                 initiator.publicKey,5148                 responder.publicKey,52-                { aToB.send(it) },53-                { bToA.receive() },49+                { initiatorToResponder.send(it) },50+                { responderToInitiator.receive() },5451             )5552         }5653         val responderHandshake = async {@@ -58,54 +55,32 @@5855                 responder.privateKey,5956                 responder.publicKey,6057                 initiator.publicKey,61-                { bToA.send(it) },62-                { aToB.receive() },58+                { responderToInitiator.send(it) },59+                { initiatorToResponder.receive() },6360             )6461         }6562         initiatorHandshake.await() to responderHandshake.await()6663     }6764 68-    /**69-     * Capture every ciphertext the chunking helper would write onto the wire70-     * and the noise session would produce for it. We instrument the session71-     * itself by routing [sendCiphertext] and [receiveCiphertext] through an72-     * in-memory channel and observing the pre-encryption plaintexts as the73-     * test reads them back.74-     */75-    private class Wire(76-        val outbound: Channel<ByteArray>,77-        val ciphertextBack: Channel<ByteArray>,78-    )79-80-    private fun bridge(): Pair<Wire, Wire> {81-        val aToB = Channel<ByteArray>(Channel.UNLIMITED)82-        val bToA = Channel<ByteArray>(Channel.UNLIMITED)83-        return Wire(aToB, bToA) to Wire(bToA, aToB)84-    }85-8665     @Test8766     fun `a payload just over 64 KiB round-trips across the chunking layer`() = runBlocking {8867         val (initiator, responder) = sessions()89-        val (aWire, bWire) = bridge()68+        val senderToReceiver = Channel<ByteArray>(Channel.UNLIMITED)9069 9170         // 70 KiB lands on the bucket ladder at 128 KiB — comfortably above the9271         // 65519 byte Noise plaintext limit, forcing at least 3 chunks.9372         val payload = ByteArray(70 * 1024) { (it * 17 and 0xFF).toByte() }9473 9574         val sender = async {96-            sendSyncFrame(97-                payload,98-                initiator::encrypt,99-            ) { ct -> aWire.outbound.send(ct) }75+            sendSyncFrame(payload, initiator::encrypt) { ct ->76+                senderToReceiver.send(ct)77+            }10078         }101-        val received = async {102-            receiveSyncFrame(103-                { aWire.ciphertextBack.receive() },104-                responder::decrypt,105-            )79+        val receiver = async {80+            receiveSyncFrame({ senderToReceiver.receive() }, responder::decrypt)10681         }10782         sender.await()108-        assertArrayEquals(payload, received.await())83+        assertArrayEquals(payload, receiver.await())10984         initiator.close()11085         responder.close()11186     }@@ -113,30 +88,27 @@11388     @Test11489     fun `a payload under the Noise limit goes out as exactly one ciphertext`() = runBlocking {11590         val (initiator, responder) = sessions()116-        val (aWire, bWire) = bridge()91+        val senderToReceiver = Channel<ByteArray>(Channel.UNLIMITED)11792 118-        // 256-byte payload pads to the 256 minimum bucket — well under 65519.93+        // 5-byte payload pads to the 256 minimum bucket — well under 65519.11994         val payload = "hello".toByteArray()12095 12196         val sentCiphertexts = mutableListOf<ByteArray>()12297         val sender = async {123-            sendSyncFrame(124-                payload,125-                initiator::encrypt,126-            ) { ct -> sentCiphertexts.add(ct); aWire.outbound.send(ct) }98+            sendSyncFrame(payload, initiator::encrypt) { ct ->99+                sentCiphertexts.add(ct)100+                senderToReceiver.send(ct)101+            }127102         }128-        val received = async {129-            receiveSyncFrame(130-                { aWire.ciphertextBack.receive() },131-                responder::decrypt,132-            )103+        val receiver = async {104+            receiveSyncFrame({ senderToReceiver.receive() }, responder::decrypt)133105         }134106         sender.await()135107         // Single-chunk path: no marker on the wire, exactly one ciphertext.136108         assertEquals(1, sentCiphertexts.size)137-        // 256 plaintext + 16 Noise tag = 272 ciphertext.109+        // 256 plaintext + 16-byte Noise tag = 272 bytes ciphertext.138110         assertEquals(256 + 16, sentCiphertexts[0].size)139-        assertArrayEquals(payload, received.await())111+        assertArrayEquals(payload, receiver.await())140112         initiator.close()141113         responder.close()142114     }@@ -144,9 +116,10 @@144116     @Test145117     fun `a chunked payload marks every non-final chunk with 0x01`() = runBlocking {146118         val (initiator, responder) = sessions()147-        val (aWire, bWire) = bridge()119+        val senderToReceiver = Channel<ByteArray>(Channel.UNLIMITED)148120 149-        // 130 KiB payload → padded frame above 192 KiB; definite multi-chunk.121+        // 130 KiB payload → padded frame is well above 192 KiB; definite122+        // multi-chunk.150123         val payload = ByteArray(130 * 1024) { (it and 0xFF).toByte() }151124 152125         // Wrap encrypt so we can also observe what plaintexts the chunking@@ -158,31 +131,31 @@158131             initiator.encrypt(pt)159132         }160133         val sender = async {161-            sendSyncFrame(payload, observingEncrypt) { ct -> aWire.outbound.send(ct) }134+            sendSyncFrame(payload, observingEncrypt) { ct ->135+                senderToReceiver.send(ct)136+            }162137         }163-        val received = async {164-            receiveSyncFrame(165-                { aWire.ciphertextBack.receive() },166-                responder::decrypt,167-            )138+        val receiver = async {139+            receiveSyncFrame({ senderToReceiver.receive() }, responder::decrypt)168140         }169141         sender.await()170-        assertArrayEquals(payload, received.await())142+        assertArrayEquals(payload, receiver.await())171143         initiator.close()172144         responder.close()173145 174-        // Multi-chunk: at least 2 chunks, every non-final is exactly175-        // MAX_NOISE_PLAINTEXT_BYTES and starts with the marker; the final176-        // chunk is at most the cap and does not carry the marker.146+        // Multi-chunk: at least 2 chunks, every non-final is at most147+        // MAX_NOISE_PLAINTEXT_BYTES (its data leaves one byte for the marker)148+        // and starts with the marker; the final chunk is at most the cap and149+        // does not carry the marker.177150         assertTrue("expected multi-chunk split", observedPlaintexts.size >= 2)178151         observedPlaintexts.dropLast(1).forEach {179152             assertEquals(180153                 "non-final chunk must start with the continuation marker",181154                 FRAME_CONTINUATION_MARKER, it[0],182155             )183-            assertEquals(184-                "every non-final chunk must hit the Noise cap exactly",185-                MAX_NOISE_PLAINTEXT_BYTES, it.size,156+            assertTrue(157+                "every non-final chunk must fit under the Noise cap with the marker",158+                it.size <= MAX_NOISE_PLAINTEXT_BYTES,186159             )187160         }188161         val last = observedPlaintexts.last()

view file