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
↓ peach-android-210b7873.tar.gzatlas at this save
files changed10 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 −6app/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 {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,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()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(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+}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 policyapp/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()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+}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"})")