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
↓ peach-android-4ebae050.tar.gzatlas at this save
files changed3 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 −70app/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)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 )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()