7b389c585363

add SyncFrameTransport chunking helper and cross-platform KAT constants; route every encrypt-of-padded-frame callsite through it

time
2026-07-26 21:03 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
E7b sync frame chunking
parent
dcb2093343f2
10 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+8 −4app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+8 −4app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+129 −0app/src/main/java/com/peachpasswords/sync/SyncPadding.kt+5 −0app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+6 −8app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+11 −3app/src/test/java/com/peachpasswords/sync/SyncFrameTransportTest.kt+195 −0app/src/test/java/com/peachpasswords/sync/SyncPaddingTest.kt+33 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.111+2.5.12

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24011120-        versionName "2.5.11"19+        versionCode 24011220+        versionName "2.5.12"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+8 −4
@@ -51,9 +51,13 @@5151     // point is a reportable outcome; everything before it is a dial into an5252     // empty room, which is the resting state of a phone, not a failure.5353     try {54-        socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))54+        sendSyncFrame(55+            encodePairIdentityFrame(localIdentity),56+            session::encrypt,57+            socket::send,58+        )5559         val theirIdentity = parsePairIdentityFrame(56-            SyncPadding.unpad(session.decrypt(socket.receive())),60+            receiveSyncFrame(socket::receive, session::decrypt),5761         )5862         require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {5963             "peer identity does not match authenticated KK key"@@ -67,8 +71,8 @@6771         val outcome = runSyncInitiator(6872             SyncSession(6973                 remoteStaticPublicKey = responderKey.copyOf(),70-                send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },71-                receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },74+                send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },75+                receive = { receiveSyncFrame(socket::receive, session::decrypt) },7276                 close = { socket.close() },7377             ),7478             vault,

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+8 −4
@@ -330,12 +330,16 @@330330         )331331         try {332332             val theirIdentity = parsePairIdentityFrame(333-                SyncPadding.unpad(session.decrypt(socket.receive())),333+                receiveSyncFrame(socket::receive, session::decrypt),334334             )335335             require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {336336                 "peer identity does not match authenticated KK key"337337             }338-            socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))338+            sendSyncFrame(339+                encodePairIdentityFrame(localIdentity),340+                session::encrypt,341+                socket::send,342+            )339343 340344             val vault = vaultFactory() ?: run {341345                 report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))@@ -345,8 +349,8 @@345349             val outcome = runSyncResponder(346350                 SyncSession(347351                     remoteStaticPublicKey = initiatorKey.copyOf(),348-                    send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },349-                    receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },352+                    send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },353+                    receive = { receiveSyncFrame(socket::receive, session::decrypt) },350354                     close = { socket.close() },351355                 ),352356                 vault,

view file

app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+129 −0
@@ -0,0 +1,129 @@1+package com.peachpasswords.sync2+3+/**4+ * E7b: split a too-large padded sync frame across multiple Noise transport5+ * messages and reassemble it on the other side. Mirror of peach-browser6+ * `src/shared/sync-transport.ts`. Constants and the chunking protocol MUST7+ * match the browser byte-for-byte.8+ *9+ * Why this exists: [SyncPadding.pad] can produce frames up to 24 MiB, but10+ * Noise caps a single transport message at [MAX_NOISE_PLAINTEXT_BYTES]11+ * plaintext (65519 — see crypto-core/src/lib.rs: 65535 ciphertext minus the12+ * 16-byte Poly1305 auth tag). Until this layer existed, a payload above ~64 KiB13+ * tripped the core's "Noise plaintext exceeds transport limit" on the sender14+ * while the receiver sat waiting for a frame that never arrived.15+ *16+ * Wire shape for a multi-chunk frame:17+ *18+ *   Noise msg 1: [0x01][chunk 1 bytes ...]   <- 0x01 = [FRAME_CONTINUATION_MARKER]19+ *   Noise msg 2: [0x01][chunk 2 bytes ...]20+ *   ...21+ *   Noise msg N: [chunk N bytes ...]         <- no marker on the last chunk22+ *23+ * Single-chunk frames go out as one Noise message with no marker, so the24+ * common small-frame case is unchanged on the wire. The receiver recognises25+ * the difference by the first byte of the decrypted plaintext: a marker26+ * means "more chunks coming, do not unpad yet".27+ *28+ * The bucket ladder itself lives in [SyncPadding]. If [SyncPadding.bucket] or29+ * this limit ever drifts, both sides change in lockstep — see the30+ * cross-reference comment in [SyncPadding].31+ */32+33+/** Noise plaintext cap: 65535 ciphertext − 16-byte auth tag = 65519. */34+const val MAX_NOISE_PLAINTEXT_BYTES: Int = 6551935+36+/**37+ * First byte of every non-last chunk of a chunked padded frame. The byte38+ * `0x01` is unambiguous against a real padded frame: a 4-byte big-endian39+ * length header whose payload is < 4 bytes can only start at byte 0 with a40+ * zero, never `0x01`.41+ */42+const val FRAME_CONTINUATION_MARKER: Byte = 0x0143+44+/**45+ * Pad [plaintext], optionally chunk it across the Noise plaintext limit,46+ * encrypt each chunk, and hand each ciphertext to [sendCiphertext].47+ *48+ * A padded frame that fits one Noise message goes out as one ciphertext,49+ * no marker, no per-chunk overhead. Otherwise the padded frame is sliced50+ * into 64 KiB chunks; the first N−1 carry the [FRAME_CONTINUATION_MARKER],51+ * the last does not, and the receiver reassembles by stripping the byte.52+ */53+internal fun sendSyncFrame(54+    plaintext: ByteArray,55+    encrypt: (ByteArray) -> ByteArray,56+    sendCiphertext: (ByteArray) -> Unit,57+) {58+    val padded = SyncPadding.pad(plaintext)59+    if (padded.size <= MAX_NOISE_PLAINTEXT_BYTES) {60+        sendCiphertext(encrypt(padded))61+        return62+    }63+    val total = padded.size64+    var offset = 065+    while (offset < total) {66+        val end = minOf(offset + MAX_NOISE_PLAINTEXT_BYTES, total)67+        val chunkBytes = end - offset68+        val chunk = padded.copyOfRange(offset, end)69+        val isLast = end == total70+        val pt: ByteArray = if (isLast) {71+            chunk72+        } 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+            }80+            ByteArray(chunkBytes + 1).also {81+                it[0] = FRAME_CONTINUATION_MARKER82+                chunk.copyInto(it, 1)83+            }84+        }85+        sendCiphertext(encrypt(pt))86+        offset += chunkBytes87+    }88+}89+90+/**91+ * Inverse of [sendSyncFrame]: read Noise ciphertexts from92+ * [receiveCiphertext], reassemble chunked padded frames, and return the93+ * original payload. The first byte of every decrypted message tells the94+ * loop whether more chunks are coming; the last chunk is unmarker and95+ * triggers unpad.96+ */97+internal suspend fun receiveSyncFrame(98+    receiveCiphertext: suspend () -> ByteArray,99+    decrypt: (ByteArray) -> ByteArray,100+): ByteArray {101+    var buffered: ByteArray? = null102+    while (true) {103+        val pt = decrypt(receiveCiphertext())104+        if (buffered == null) {105+            // First chunk. Marked means "more chunks to come"; an unmarked106+            // first chunk is the entire single-chunk frame, exactly as before107+            // the chunking layer existed.108+            if (pt.isNotEmpty() && pt[0] == FRAME_CONTINUATION_MARKER) {109+                buffered = pt.copyOfRange(1, pt.size)110+                continue111+            }112+            return SyncPadding.unpad(pt)113+        }114+        // Continuing the assembly. A marked chunk contributes bytes past115+        // the marker; the final chunk is unmarker, so we concatenate it116+        // whole and unpad.117+        if (pt.isNotEmpty() && pt[0] == FRAME_CONTINUATION_MARKER) {118+            val merged = ByteArray(buffered.size + pt.size - 1)119+            buffered.copyInto(merged, 0)120+            pt.copyInto(merged, buffered.size, 1)121+            buffered = merged122+            continue123+        }124+        val reassembled = ByteArray(buffered.size + pt.size)125+        buffered.copyInto(reassembled, 0)126+        pt.copyInto(reassembled, buffered.size)127+        return SyncPadding.unpad(reassembled)128+    }129+}

view file

app/src/main/java/com/peachpasswords/sync/SyncPadding.kt+5 −0
@@ -28,6 +28,11 @@2828      */2929     private const val LINEAR_BUCKET_STEP = 64 * 10243030 31+    // Cross-platform wire invariant: this ladder and the chunking layer in32+    // [SyncFrameTransport] (MAX_NOISE_PLAINTEXT_BYTES, FRAME_CONTINUATION_MARKER)33+    // must match peach-browser sync-padding.ts / sync-transport.ts exactly.34+    // If either side changes, both change in lockstep.35+3136     fun bucket(totalBytes: Int): Int {3237         if (totalBytes <= MIN_BUCKET) return MIN_BUCKET3338         if (totalBytes <= LINEAR_BUCKET_STEP) {

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+6 −8
@@ -144,15 +144,13 @@144144                 waiterSocket::send,145145                 waiterSocket::receive,146146             )147-            SyncPadding.unpad(session.decrypt(waiterSocket.receive()))148-            waiterSocket.send(149-                session.encrypt(150-                    SyncPadding.pad(151-                        encodePairIdentityFrame(152-                            identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),153-                        ),154-                    ),147+            receiveSyncFrame(waiterSocket::receive, session::decrypt)148+            sendSyncFrame(149+                encodePairIdentityFrame(150+                    identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),155151                 ),152+                session::encrypt,153+                waiterSocket::send,156154             )157155         }158156 

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+11 −3
@@ -113,9 +113,13 @@113113             browserSocket::receive,114114         )115115         // E7: the real browser pads every session frame; this stand-in must too.116-        browserSocket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(browserIdentity))))116+        sendSyncFrame(117+            encodePairIdentityFrame(browserIdentity),118+            session::encrypt,119+            browserSocket::send,120+        )117121         val theirIdentity = parsePairIdentityFrame(118-            SyncPadding.unpad(session.decrypt(browserSocket.receive())),122+            receiveSyncFrame(browserSocket::receive, session::decrypt),119123         )120124 121125         responder.await()@@ -178,7 +182,11 @@178182                 peerSocket::receive,179183             )180184             session.decrypt(peerSocket.receive())181-            peerSocket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(peerIdentity))))185+            sendSyncFrame(186+                encodePairIdentityFrame(peerIdentity),187+                session::encrypt,188+                peerSocket::send,189+            )182190             awaitCancellation()183191         }184192 

view file

app/src/test/java/com/peachpasswords/sync/SyncFrameTransportTest.kt+195 −0
@@ -0,0 +1,195 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters7+import org.junit.Assert.assertArrayEquals8+import org.junit.Assert.assertEquals9+import org.junit.Assert.assertFalse10+import org.junit.Assert.assertTrue11+import org.junit.Test12+import java.security.SecureRandom13+14+/**15+ * E7b end-to-end chunking — the deliverable for the wire shape. Initiator and16+ * responder each build a real [noiseKKInitiator]/[noiseKKResponder] session,17+ * pipe ciphertexts through an in-memory channel, and run [sendSyncFrame] /18+ * [receiveSyncFrame] across a payload large enough that it MUST chunk.19+ *20+ * Pins the protocol that the browser, an Android phone-to-phone pair, and21+ * future Android↔iOS ports all depend on: 0x01 marker on every non-last22+ * chunk, no marker on the last, reassembled bytes equal original, and a23+ * 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.28+ */29+class SyncFrameTransportTest {30+    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)31+32+    private fun keys(): KeyPair {33+        val privateKey = X25519PrivateKeyParameters(SecureRandom())34+        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)35+    }36+37+    /**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.41+     */42+    private fun sessions(): Pair<NoiseNKSession, NoiseNKSession> = runBlocking {43+        val initiator = keys()44+        val responder = keys()45+        val aToB = Channel<ByteArray>(Channel.UNLIMITED)46+        val bToA = Channel<ByteArray>(Channel.UNLIMITED)47+        val initiatorHandshake = async {48+            noiseKKInitiator(49+                initiator.privateKey,50+                initiator.publicKey,51+                responder.publicKey,52+                { aToB.send(it) },53+                { bToA.receive() },54+            )55+        }56+        val responderHandshake = async {57+            noiseKKResponder(58+                responder.privateKey,59+                responder.publicKey,60+                initiator.publicKey,61+                { bToA.send(it) },62+                { aToB.receive() },63+            )64+        }65+        initiatorHandshake.await() to responderHandshake.await()66+    }67+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+86+    @Test87+    fun `a payload just over 64 KiB round-trips across the chunking layer`() = runBlocking {88+        val (initiator, responder) = sessions()89+        val (aWire, bWire) = bridge()90+91+        // 70 KiB lands on the bucket ladder at 128 KiB — comfortably above the92+        // 65519 byte Noise plaintext limit, forcing at least 3 chunks.93+        val payload = ByteArray(70 * 1024) { (it * 17 and 0xFF).toByte() }94+95+        val sender = async {96+            sendSyncFrame(97+                payload,98+                initiator::encrypt,99+            ) { ct -> aWire.outbound.send(ct) }100+        }101+        val received = async {102+            receiveSyncFrame(103+                { aWire.ciphertextBack.receive() },104+                responder::decrypt,105+            )106+        }107+        sender.await()108+        assertArrayEquals(payload, received.await())109+        initiator.close()110+        responder.close()111+    }112+113+    @Test114+    fun `a payload under the Noise limit goes out as exactly one ciphertext`() = runBlocking {115+        val (initiator, responder) = sessions()116+        val (aWire, bWire) = bridge()117+118+        // 256-byte payload pads to the 256 minimum bucket — well under 65519.119+        val payload = "hello".toByteArray()120+121+        val sentCiphertexts = mutableListOf<ByteArray>()122+        val sender = async {123+            sendSyncFrame(124+                payload,125+                initiator::encrypt,126+            ) { ct -> sentCiphertexts.add(ct); aWire.outbound.send(ct) }127+        }128+        val received = async {129+            receiveSyncFrame(130+                { aWire.ciphertextBack.receive() },131+                responder::decrypt,132+            )133+        }134+        sender.await()135+        // Single-chunk path: no marker on the wire, exactly one ciphertext.136+        assertEquals(1, sentCiphertexts.size)137+        // 256 plaintext + 16 Noise tag = 272 ciphertext.138+        assertEquals(256 + 16, sentCiphertexts[0].size)139+        assertArrayEquals(payload, received.await())140+        initiator.close()141+        responder.close()142+    }143+144+    @Test145+    fun `a chunked payload marks every non-final chunk with 0x01`() = runBlocking {146+        val (initiator, responder) = sessions()147+        val (aWire, bWire) = bridge()148+149+        // 130 KiB payload → padded frame above 192 KiB; definite multi-chunk.150+        val payload = ByteArray(130 * 1024) { (it and 0xFF).toByte() }151+152+        // Wrap encrypt so we can also observe what plaintexts the chunking153+        // layer hands to the Noise session. This is what `0x01` is being154+        // asserted against on the wire.155+        val observedPlaintexts = mutableListOf<ByteArray>()156+        val observingEncrypt: (ByteArray) -> ByteArray = { pt ->157+            observedPlaintexts.add(pt.copyOf())158+            initiator.encrypt(pt)159+        }160+        val sender = async {161+            sendSyncFrame(payload, observingEncrypt) { ct -> aWire.outbound.send(ct) }162+        }163+        val received = async {164+            receiveSyncFrame(165+                { aWire.ciphertextBack.receive() },166+                responder::decrypt,167+            )168+        }169+        sender.await()170+        assertArrayEquals(payload, received.await())171+        initiator.close()172+        responder.close()173+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.177+        assertTrue("expected multi-chunk split", observedPlaintexts.size >= 2)178+        observedPlaintexts.dropLast(1).forEach {179+            assertEquals(180+                "non-final chunk must start with the continuation marker",181+                FRAME_CONTINUATION_MARKER, it[0],182+            )183+            assertEquals(184+                "every non-final chunk must hit the Noise cap exactly",185+                MAX_NOISE_PLAINTEXT_BYTES, it.size,186+            )187+        }188+        val last = observedPlaintexts.last()189+        assertTrue("last chunk must be ≤ the Noise cap", last.size <= MAX_NOISE_PLAINTEXT_BYTES)190+        assertFalse(191+            "final chunk must NOT carry the continuation marker",192+            last.isNotEmpty() && last[0] == FRAME_CONTINUATION_MARKER,193+        )194+    }195+}

view file

app/src/test/java/com/peachpasswords/sync/SyncPaddingTest.kt+33 −0
@@ -61,4 +61,37 @@6161         try { SyncPadding.unpad(ByteArray(3)) } catch (_: IllegalArgumentException) { threw = true }6262         assertTrue(threw)6363     }64+65+    // ─────────────────────────────────────────────────────────────────────66+    // E7b cross-platform KAT — mirrors peach-browser sync-padding.test.ts67+    // `describe("sync frame chunking (E7b)")`. Pinned against the browser:68+    // either side drifting breaks the wire silently on real hardware.69+    // ─────────────────────────────────────────────────────────────────────70+71+    @Test fun max_noise_plaintext_bytes_matches_the_crypto_core_limit() {72+        // crypto-core caps a Noise ciphertext at 65535 bytes; minus the 16-byte73+        // Poly1305 tag that is 65519 plaintext. If this constant drifts, the74+        // chunking layer accepts chunks the crypto core will reject.75+        assertEquals(65519, MAX_NOISE_PLAINTEXT_BYTES)76+    }77+78+    @Test fun the_bucket_ladder_produces_frames_above_the_noise_limit_the_bug() {79+        // A 32 KiB payload rounds up to a 64 KiB padded frame, which exceeds80+        // the Noise transport limit. Without chunking, the crypto core81+        // refused these with "Noise plaintext exceeds transport limit" while82+        // the receiver waited for a frame that never arrived — the exact83+        // failure this layer exists to fix. Pinning the condition reachable84+        // means a future tightening of the ladder is caught here, not on a85+        // real device in someone's pocket.86+        val padded = SyncPadding.pad(ByteArray(32 * 1024))87+        assertTrue(padded.size > MAX_NOISE_PLAINTEXT_BYTES)88+    }89+90+    @Test fun the_continuation_marker_is_the_byte_the_chunking_protocol_uses() {91+        // 0x01 is unambiguous against a real padded frame: the first byte of92+        // the 4-byte big-endian length header for any payload under 64 KiB93+        // is 0x00, and the marker is itself the FIRST byte of the chunk —94+        // not a slot inside the padded frame. Must match the browser KAT.95+        assertEquals(0x01.toByte(), FRAME_CONTINUATION_MARKER)96+    }6497 }

view file