SyncPadding.kt

2.7 KB · raw

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}