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
↓ peach-android-7b389c58.tar.gzatlas at this save
files changed10 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 −0app/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 {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,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,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+}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) {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 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 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+}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 }