package com.peachpasswords.sync /** * E7: frame padding at the relay boundary. Port of peach-browser * `src/shared/sync-padding.ts` — the two MUST bucket identically. * * A relay forwards only Noise ciphertext it cannot read, so the one thing it * can still learn is traffic SHAPE. Sizes alone distinguish a SYNC_HELLO from a * SYNC_ENTRIES carrying a whole vault, and track how a vault grows over time. * * Wire format of a padded plaintext, BEFORE Noise encryption: * * [4-byte big-endian payload length][payload][zero padding to bucket size] * * The length header is what makes unpadding unambiguous. A bucketing mismatch * between platforms fails loudly, not silently: the receiver reads a length * that does not fit its frame and rejects it. */ object SyncPadding { private const val HEADER_BYTES = 4 /** Below this every frame looks the same — covers all small control frames. */ private const val MIN_BUCKET = 256 /** * Above this, buckets grow linearly instead of doubling: doubling a 12 MiB * frame would blow past [MAX_SYNC_FRAME_BYTES] for no extra privacy. */ private const val LINEAR_BUCKET_STEP = 64 * 1024 // Cross-platform wire invariant: this ladder and the chunking layer in // [SyncFrameTransport] (MAX_NOISE_PLAINTEXT_BYTES, FRAME_CONTINUATION_MARKER) // must match peach-browser sync-padding.ts / sync-transport.ts exactly. // If either side changes, both change in lockstep. fun bucket(totalBytes: Int): Int { if (totalBytes <= MIN_BUCKET) return MIN_BUCKET if (totalBytes <= LINEAR_BUCKET_STEP) { var b = MIN_BUCKET while (b < totalBytes) b *= 2 return b } return ((totalBytes + LINEAR_BUCKET_STEP - 1) / LINEAR_BUCKET_STEP) * LINEAR_BUCKET_STEP } fun pad(payload: ByteArray): ByteArray { val out = ByteArray(bucket(HEADER_BYTES + payload.size)) val len = payload.size out[0] = (len ushr 24).toByte() out[1] = (len ushr 16).toByte() out[2] = (len ushr 8).toByte() out[3] = len.toByte() payload.copyInto(out, HEADER_BYTES) return out } fun unpad(padded: ByteArray): ByteArray { require(padded.size >= HEADER_BYTES) { "padded sync frame is shorter than its length header" } val length = ((padded[0].toInt() and 0xFF) shl 24) or ((padded[1].toInt() and 0xFF) shl 16) or ((padded[2].toInt() and 0xFF) shl 8) or (padded[3].toInt() and 0xFF) require(length >= 0 && length <= padded.size - HEADER_BYTES) { "padded sync frame declares more payload than it carries" } return padded.copyOfRange(HEADER_BYTES, HEADER_BYTES + length) } }