SyncPadding.kt
1package com.peachpasswords.sync23/**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 */19object SyncPadding {20 private const val HEADER_BYTES = 42122 /** Below this every frame looks the same — covers all small control frames. */23 private const val MIN_BUCKET = 2562425 /**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 * 10243031 // 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.3536 fun bucket(totalBytes: Int): Int {37 if (totalBytes <= MIN_BUCKET) return MIN_BUCKET38 if (totalBytes <= LINEAR_BUCKET_STEP) {39 var b = MIN_BUCKET40 while (b < totalBytes) b *= 241 return b42 }43 return ((totalBytes + LINEAR_BUCKET_STEP - 1) / LINEAR_BUCKET_STEP) * LINEAR_BUCKET_STEP44 }4546 fun pad(payload: ByteArray): ByteArray {47 val out = ByteArray(bucket(HEADER_BYTES + payload.size))48 val len = payload.size49 out[0] = (len ushr 24).toByte()50 out[1] = (len ushr 16).toByte()51 out[2] = (len ushr 8).toByte()52 out[3] = len.toByte()53 payload.copyInto(out, HEADER_BYTES)54 return out55 }5657 fun unpad(padded: ByteArray): ByteArray {58 require(padded.size >= HEADER_BYTES) {59 "padded sync frame is shorter than its length header"60 }61 val length = ((padded[0].toInt() and 0xFF) shl 24) or62 ((padded[1].toInt() and 0xFF) shl 16) or63 ((padded[2].toInt() and 0xFF) shl 8) or64 (padded[3].toInt() and 0xFF)65 require(length >= 0 && length <= padded.size - HEADER_BYTES) {66 "padded sync frame declares more payload than it carries"67 }68 return padded.copyOfRange(HEADER_BYTES, HEADER_BYTES + length)69 }70}