SyncFrameTransport.kt

7.4 KB · raw

1package com.peachpasswords.sync23/**4 * E7b: split a too-large padded sync frame across multiple Noise transport5 * messages and reassemble it on the other side. Mirror of peach-browser6 * `src/shared/sync-transport.ts`. Constants and the chunking protocol MUST7 * match the browser byte-for-byte.8 *9 * Why this exists: [SyncPadding.pad] can produce frames up to 24 MiB, but10 * Noise caps a single transport message at [MAX_NOISE_PLAINTEXT_BYTES]11 * plaintext (65519 — see crypto-core/src/lib.rs: 65535 ciphertext minus the12 * 16-byte Poly1305 auth tag). Until this layer existed, a payload above ~64 KiB13 * tripped the core's "Noise plaintext exceeds transport limit" on the sender14 * while the receiver sat waiting for a frame that never arrived.15 *16 * Wire shape for a multi-chunk frame:17 *18 *   Noise msg 1: [0x01][chunk 1 bytes ...]   <- 0x01 = [FRAME_CONTINUATION_MARKER]19 *   Noise msg 2: [0x01][chunk 2 bytes ...]20 *   ...21 *   Noise msg N: [chunk N bytes ...]         <- no marker on the last chunk22 *23 * Single-chunk frames go out as one Noise message with no marker, so the24 * common small-frame case is unchanged on the wire. The receiver recognises25 * the difference by the first byte of the decrypted plaintext: a marker26 * means "more chunks coming, do not unpad yet".27 *28 * The bucket ladder itself lives in [SyncPadding]. If [SyncPadding.bucket] or29 * this limit ever drifts, both sides change in lockstep — see the30 * cross-reference comment in [SyncPadding].31 */3233/** Noise plaintext cap: 65535 ciphertext − 16-byte auth tag = 65519. */34const val MAX_NOISE_PLAINTEXT_BYTES: Int = 655193536/**37 * First byte of every non-last chunk of a chunked padded frame. The byte38 * `0x01` is unambiguous against a real padded frame: a 4-byte big-endian39 * length header whose payload is < 4 bytes can only start at byte 0 with a40 * zero, never `0x01`.41 */42const val FRAME_CONTINUATION_MARKER: Byte = 0x014344/** One Noise plaintext, rounded up — every control frame fits in a single chunk. */45private const val INITIAL_FRAME_BUFFER_BYTES: Int = 1 shl 164647/**48 * Pad [plaintext], optionally chunk it across the Noise plaintext limit,49 * encrypt each chunk, and hand each ciphertext to [sendCiphertext].50 *51 * A padded frame that fits one Noise message goes out as one ciphertext,52 * no marker, no per-chunk overhead. Otherwise the padded frame is sliced53 * into 64 KiB chunks; the first N−1 carry the [FRAME_CONTINUATION_MARKER],54 * the last does not, and the receiver reassembles by stripping the byte.55 */56internal suspend fun sendSyncFrame(57    plaintext: ByteArray,58    encrypt: (ByteArray) -> ByteArray,59    sendCiphertext: suspend (ByteArray) -> Unit,60) {61    val padded = SyncPadding.pad(plaintext)62    if (padded.size <= MAX_NOISE_PLAINTEXT_BYTES) {63        sendCiphertext(encrypt(padded))64        return65    }66    val total = padded.size67    var offset = 068    while (offset < total) {69        val remaining = total - offset70        // A non-last chunk must leave room for the one-byte marker under the71        // Noise plaintext cap; the last chunk carries no marker and can use72        // the full cap. This slicing is one byte shy of the browser's loop,73        // which slices at the cap and then prepends the marker — that path74        // produces [marker][MAX bytes] = MAX+1 bytes, which the crypto core75        // rejects. Wire shape ([0x01][data]) is unchanged across platforms.76        val cap = if (remaining <= MAX_NOISE_PLAINTEXT_BYTES) {77            MAX_NOISE_PLAINTEXT_BYTES78        } else {79            MAX_NOISE_PLAINTEXT_BYTES - 180        }81        val end = minOf(offset + cap, total)82        val chunkBytes = end - offset83        val chunk = padded.copyOfRange(offset, end)84        val isLast = end == total85        val pt: ByteArray = if (isLast) {86            chunk87        } else {88            ByteArray(chunkBytes + 1).also {89                it[0] = FRAME_CONTINUATION_MARKER90                chunk.copyInto(it, 1)91            }92        }93        sendCiphertext(encrypt(pt))94        offset += chunkBytes95    }96}9798/**99 * Inverse of [sendSyncFrame]: read Noise ciphertexts from100 * [receiveCiphertext], reassemble chunked padded frames, and return the101 * original payload. The first byte of every decrypted message tells the102 * loop whether more chunks are coming; the last chunk is unmarker and103 * triggers unpad.104 */105internal suspend fun receiveSyncFrame(106    receiveCiphertext: suspend () -> ByteArray,107    decrypt: (ByteArray) -> ByteArray,108): ByteArray {109    // The buffer doubles on demand up to MAX_SYNC_FRAME_BYTES rather than110    // reserving that cap for every frame: most frames are small control111    // messages, and a fixed 24 MiB allocation per frame is itself the112    // availability problem (PAI-2026-003). Doubling keeps the old O(n²)113    // copy cost away — at most ~18 copies to reach the cap. Cumulative size114    // and chunk count are still checked BEFORE writing, so an authenticated115    // peer can't grow memory unboundedly or pin the loop on tiny marked116    // chunks.117    var buffer = ByteArray(INITIAL_FRAME_BUFFER_BYTES)118    var size = 0119    var chunkCount = 0120    var chunkedFrame = false121    while (true) {122        chunkCount++123        val pt = decrypt(receiveCiphertext())124        val marked = pt.isNotEmpty() && pt[0] == FRAME_CONTINUATION_MARKER125        val dataStart = if (marked) 1 else 0126        val dataSize = pt.size - dataStart127        // MAX_SYNC_FRAME_BYTES doubles as the chunk-count ceiling: even a128        // maximally-fragmented adversarial stream (one byte per chunk) hits a129        // finite loop bound instead of running until the size cap.130        if (size + dataSize > MAX_SYNC_FRAME_BYTES || chunkCount > MAX_SYNC_FRAME_BYTES) {131            buffer.fill(0, 0, size)132            pt.fill(0)133            if (size + dataSize > MAX_SYNC_FRAME_BYTES) {134                requireSyncFrameSize(size + dataSize)135            }136            throw SyncFrameTooLargeException(137                "sync frame chunk count exceeded $MAX_SYNC_FRAME_BYTES",138            )139        }140        if (size + dataSize > buffer.size) {141            val grown = (buffer.size * 2).coerceAtMost(MAX_SYNC_FRAME_BYTES)142            buffer = buffer.copyOf(maxOf(grown, size + dataSize))143        }144        if (!chunkedFrame) {145            // First chunk. Marker ⇒ chunked frame; no marker ⇒ the entire146            // single-chunk frame, unchanged from before the chunking layer147            // existed. The chunkedFrame latch then enforces the rest of the148            // protocol: every subsequent chunk must be marked until one isn't.149            chunkedFrame = true150            if (!marked) {151                return SyncPadding.unpad(pt)152            }153        } else if (!marked) {154            // Final chunk of a chunked frame. Unpad and zero the buffer so155            // partial plaintext doesn't linger.156            pt.copyInto(buffer, size, dataStart, pt.size)157            val written = size + dataSize158            val out = SyncPadding.unpad(buffer.copyOfRange(0, written))159            buffer.fill(0, 0, written)160            return out161        }162        if (dataSize == 0) {163            // PB-26-007: a marker-only continuation chunk carries no data and164            // only burns iterations. Require positive progress per chunk.165            buffer.fill(0, 0, size)166            pt.fill(0)167            throw SyncFrameTooLargeException("sync frame continuation chunk made no progress")168        }169        pt.copyInto(buffer, size, dataStart, pt.size)170        size += dataSize171    }172}