210b78739ec1

pad sync frames to size buckets before encryption, matching the browser ladder byte for byte (v2.4.8)

time
2026-07-26 09:07 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync campaign remaining items
parent
3b1a8ee6b9fb
10 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+4 −4app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+6 −4app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+4 −2app/src/main/java/com/peachpasswords/sync/SyncPadding.kt+65 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+9 −5app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+3 −2app/src/test/java/com/peachpasswords/sync/SyncPaddingTest.kt+64 −0app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+6 −6
VERSION+1 −1
@@ -1 +1 @@1-2.4.71+2.4.8

view file

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

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+4 −4
@@ -217,20 +217,20 @@217217     )218218     try {219219         val theirIdentity = parsePairIdentityFrame(220-            session.decrypt(socket.receive()),220+            SyncPadding.unpad(session.decrypt(socket.receive())),221221         )222222         require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {223223             "peer identity does not match authenticated KK key"224224         }225-        socket.send(session.encrypt(encodePairIdentityFrame(localIdentity)))225+        socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))226226 227227         val vault = vaultFactory() ?: return228228         vault.bindAuthenticatedPeer(known)229229         val outcome = runChirpSyncResponder(230230             ChirpSyncSession(231231                 remoteStaticPublicKey = initiatorKey.copyOf(),232-                send = { plaintext -> socket.send(session.encrypt(plaintext)) },233-                receive = { session.decrypt(socket.receive()) },232+                send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },233+                receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },234234                 close = { socket.close() },235235             ),236236             vault,

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+6 −4
@@ -138,12 +138,14 @@138138 139139             // 2. PAIR_IDENTITY exchange (responder reads first, then answers) —140140             //    mirrors ChirpPairing.runPairingResponder ordering.141-            val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))141+            val theirIdentity = parsePairIdentityFrame(142+                SyncPadding.unpad(session.decrypt(transport.receive())),143+            )142144             require(theirIdentity.deviceId == known.deviceId) { "peer device id changed after KK" }143145             require(144146                 CryptoCompare.hex(theirIdentity.longTermPublicKeyHex, initiatorKeyHex),145147             ) { "peer identity does not match authenticated KK key" }146-            transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))148+            transport.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))147149 148150             val sas = SASDerivation.derive(session.handshakeHash)149151             // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.@@ -289,8 +291,8 @@289291         remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),290292         // ChirpSyncSession.send is non-suspending;291293         // SocketFrameIO.sendBlocking writes a frame synchronously.292-        send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },293-        receive = { session.decrypt(transport.receive()) },294+        send = { plaintext -> transport.sendBlocking(session.encrypt(SyncPadding.pad(plaintext))) },295+        receive = { SyncPadding.unpad(session.decrypt(transport.receive())) },294296         close = {295297             try {296298                 session.close()

view file

app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+4 −2
@@ -141,8 +141,10 @@141141 142142             // PAIR_IDENTITY: initiator writes first, then reads (mirrors143143             // ChirpPairing.runPairingInitiator).144-            transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))145-            val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))144+            transport.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))145+            val theirIdentity = parsePairIdentityFrame(146+                SyncPadding.unpad(session.decrypt(transport.receive())),147+            )146148             require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }147149             require(148150                 CryptoCompare.hex(

view file

app/src/main/java/com/peachpasswords/sync/SyncPadding.kt+65 −0
@@ -0,0 +1,65 @@1+package com.peachpasswords.sync2+3+/**4+ * E7: frame padding at the relay boundary. Port of peach-browser5+ * `src/shared/sync-padding.ts` — the two MUST bucket identically.6+ *7+ * A relay forwards only Noise ciphertext it cannot read, so the one thing it8+ * can still learn is traffic SHAPE. Sizes alone distinguish a SYNC_HELLO from a9+ * SYNC_ENTRIES carrying a whole vault, and track how a vault grows over time.10+ *11+ * Wire format of a padded plaintext, BEFORE Noise encryption:12+ *13+ *     [4-byte big-endian payload length][payload][zero padding to bucket size]14+ *15+ * The length header is what makes unpadding unambiguous. A bucketing mismatch16+ * between platforms fails loudly, not silently: the receiver reads a length17+ * that does not fit its frame and rejects it.18+ */19+object SyncPadding {20+    private const val HEADER_BYTES = 421+22+    /** Below this every frame looks the same — covers all small control frames. */23+    private const val MIN_BUCKET = 25624+25+    /**26+     * Above this, buckets grow linearly instead of doubling: doubling a 12 MiB27+     * frame would blow past [MAX_SYNC_FRAME_BYTES] for no extra privacy.28+     */29+    private const val LINEAR_BUCKET_STEP = 64 * 102430+31+    fun bucket(totalBytes: Int): Int {32+        if (totalBytes <= MIN_BUCKET) return MIN_BUCKET33+        if (totalBytes <= LINEAR_BUCKET_STEP) {34+            var b = MIN_BUCKET35+            while (b < totalBytes) b *= 236+            return b37+        }38+        return ((totalBytes + LINEAR_BUCKET_STEP - 1) / LINEAR_BUCKET_STEP) * LINEAR_BUCKET_STEP39+    }40+41+    fun pad(payload: ByteArray): ByteArray {42+        val out = ByteArray(bucket(HEADER_BYTES + payload.size))43+        val len = payload.size44+        out[0] = (len ushr 24).toByte()45+        out[1] = (len ushr 16).toByte()46+        out[2] = (len ushr 8).toByte()47+        out[3] = len.toByte()48+        payload.copyInto(out, HEADER_BYTES)49+        return out50+    }51+52+    fun unpad(padded: ByteArray): ByteArray {53+        require(padded.size >= HEADER_BYTES) {54+            "padded sync frame is shorter than its length header"55+        }56+        val length = ((padded[0].toInt() and 0xFF) shl 24) or57+            ((padded[1].toInt() and 0xFF) shl 16) or58+            ((padded[2].toInt() and 0xFF) shl 8) or59+            (padded[3].toInt() and 0xFF)60+        require(length >= 0 && length <= padded.size - HEADER_BYTES) {61+            "padded sync frame declares more payload than it carries"62+        }63+        return padded.copyOfRange(HEADER_BYTES, HEADER_BYTES + length)64+    }65+}

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+9 −5
@@ -127,8 +127,10 @@127127             noiseSession = session128128 129129             val theirIdentity = parsePairIdentityFrame(130-                session.decrypt(131-                    (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),130+                SyncPadding.unpad(131+                    session.decrypt(132+                        (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),133+                    ),132134                 ),133135             )134136 @@ -136,7 +138,7 @@136138                 return@withContext137139             }138140 139-            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))141+            ws.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))140142 141143             // Refresh the reconnect hint — phones and laptops change address142144             // across network joins, and a stale cache is the usual reason a@@ -151,7 +153,7 @@151153             // Registered by public key: that key is the only address a relay152154             // request can name (see RelayHub).153155             val link = relayHub.register(theirIdentity.longTermPublicKeyHex) { plaintext ->154-                ws.send(session.encrypt(plaintext))156+                ws.send(session.encrypt(SyncPadding.pad(plaintext)))155157             }156158             try {157159                 dispatch(link, session, ws, known, initiatorKey, vaultFactory)@@ -182,7 +184,9 @@182184         vaultFactory: () -> ChirpSyncVaultAdapter?,183185     ) {184186         while (true) {185-            val frame = session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: return)187+            val frame = SyncPadding.unpad(188+                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: return),189+            )186190             when (val relay = decodeRelayMessage(frame)) {187191                 null -> {188192                     // Ours to answer. Refusing while relaying is not a policy

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+3 −2
@@ -94,9 +94,10 @@9494             browserSocket::send,9595             browserSocket::receive,9696         )97-        browserSocket.send(session.encrypt(encodePairIdentityFrame(browserIdentity)))97+        // E7: the real browser pads every session frame; this stand-in must too.98+        browserSocket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(browserIdentity))))9899         val theirIdentity = parsePairIdentityFrame(99-            session.decrypt(browserSocket.receive()),100+            SyncPadding.unpad(session.decrypt(browserSocket.receive())),100101         )101102 102103         responder.await()

view file

app/src/test/java/com/peachpasswords/sync/SyncPaddingTest.kt+64 −0
@@ -0,0 +1,64 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertArrayEquals4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class SyncPaddingTest {9+10+    @Test fun round_trips_payloads_of_every_shape() {11+        for (size in listOf(0, 1, 5, 251, 252, 253, 1000, 70_000)) {12+            val payload = ByteArray(size) { ((it * 31) and 0xFF).toByte() }13+            assertArrayEquals(payload, SyncPadding.unpad(SyncPadding.pad(payload)))14+        }15+    }16+17+    @Test fun small_control_frames_collapse_to_one_indistinguishable_size() {18+        // A relay must not be able to tell a HELLO from a DONE by frame size.19+        val hello = ("{\"type\":\"SYNC_HELLO\",\"vaultId\":\"" + "a".repeat(64) + "\"}")20+            .toByteArray(Charsets.UTF_8)21+        val done = "{\"type\":\"SYNC_DONE\"}".toByteArray(Charsets.UTF_8)22+        assertEquals(SyncPadding.pad(hello).size, SyncPadding.pad(done).size)23+        assertEquals(256, SyncPadding.pad(done).size)24+    }25+26+    @Test fun bucket_ladder_matches_the_browser_kat() {27+        // Pinned against peach-browser sync-padding.test.ts. A mismatch here28+        // means the two platforms cannot read each other's frames at all.29+        val cases = listOf(30+            0 to 256,31+            252 to 256,32+            253 to 512,33+            1024 to 2048,34+            65_532 to 65_536,35+            65_533 to 131_072,36+            200_000 to 262_144,37+        )38+        for ((payload, expected) in cases) {39+            assertEquals(expected, SyncPadding.bucket(4 + payload))40+            assertEquals(expected, SyncPadding.pad(ByteArray(payload)).size)41+        }42+    }43+44+    @Test fun large_frame_overhead_stays_bounded() {45+        val twelveMiB = 12 * 1024 * 102446+        val padded = SyncPadding.bucket(twelveMiB)47+        assertTrue(padded >= twelveMiB)48+        assertTrue(padded - twelveMiB < 64 * 1024)49+    }50+51+    @Test fun rejects_a_frame_declaring_more_payload_than_it_carries() {52+        val forged = ByteArray(256)53+        forged[0] = 0; forged[1] = 0; forged[2] = 0x27; forged[3] = 0x0F.toByte() // 999954+        var threw = false55+        try { SyncPadding.unpad(forged) } catch (_: IllegalArgumentException) { threw = true }56+        assertTrue(threw)57+    }58+59+    @Test fun rejects_a_frame_shorter_than_its_length_header() {60+        var threw = false61+        try { SyncPadding.unpad(ByteArray(3)) } catch (_: IllegalArgumentException) { threw = true }62+        assertTrue(threw)63+    }64+}

view file

app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+6 −6
@@ -97,18 +97,18 @@9797                 send = { ws.send(it.toByteString()) },9898                 receive = { take() },9999             )100-            ws.send(session.encrypt(encodePairIdentityFrame(identity)).toByteString())101-            session.decrypt(take()) // the phone's own PAIR_IDENTITY100+            ws.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(identity))).toByteString())101+            SyncPadding.unpad(session.decrypt(take())) // the phone's own PAIR_IDENTITY102102         }103103 104-        fun send(message: RelayMessage) = ws.send(session.encrypt(message.encodeToBytes()).toByteString())104+        fun send(message: RelayMessage) = ws.send(session.encrypt(SyncPadding.pad(message.encodeToBytes())).toByteString())105105 106106         /** Sends bytes the relay has no business parsing. */107-        fun sendRaw(plaintext: ByteArray) = ws.send(session.encrypt(plaintext).toByteString())107+        fun sendRaw(plaintext: ByteArray) = ws.send(session.encrypt(SyncPadding.pad(plaintext)).toByteString())108108 109-        fun next(): RelayMessage? = decodeRelayMessage(session.decrypt(take()))109+        fun next(): RelayMessage? = decodeRelayMessage(SyncPadding.unpad(session.decrypt(take())))110110 111-        fun nextRaw(): ByteArray = session.decrypt(take())111+        fun nextRaw(): ByteArray = SyncPadding.unpad(session.decrypt(take()))112112 113113         private fun take(): ByteArray =114114             inbox.poll(10, TimeUnit.SECONDS) ?: throw AssertionError("no frame from the phone (${trouble ?: "socket still open"})")

view file