SyncFrameTransport.kt

5.2 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/**45 * Pad [plaintext], optionally chunk it across the Noise plaintext limit,46 * encrypt each chunk, and hand each ciphertext to [sendCiphertext].47 *48 * A padded frame that fits one Noise message goes out as one ciphertext,49 * no marker, no per-chunk overhead. Otherwise the padded frame is sliced50 * into 64 KiB chunks; the first N−1 carry the [FRAME_CONTINUATION_MARKER],51 * the last does not, and the receiver reassembles by stripping the byte.52 */53internal fun sendSyncFrame(54    plaintext: ByteArray,55    encrypt: (ByteArray) -> ByteArray,56    sendCiphertext: (ByteArray) -> Unit,57) {58    val padded = SyncPadding.pad(plaintext)59    if (padded.size <= MAX_NOISE_PLAINTEXT_BYTES) {60        sendCiphertext(encrypt(padded))61        return62    }63    val total = padded.size64    var offset = 065    while (offset < total) {66        val end = minOf(offset + MAX_NOISE_PLAINTEXT_BYTES, total)67        val chunkBytes = end - offset68        val chunk = padded.copyOfRange(offset, end)69        val isLast = end == total70        val pt: ByteArray = if (isLast) {71            chunk72        } else {73            // One continuation byte plus a sub-limit chunk is still under the74            // cap by 1, so this can only fail if a future change widens the75            // cap or drops the marker — defend in check, rather than rely on76            // the loop bound above.77            require(chunkBytes <= MAX_NOISE_PLAINTEXT_BYTES - 1) {78                "chunk leaves no room for the continuation byte: $chunkBytes"79            }80            ByteArray(chunkBytes + 1).also {81                it[0] = FRAME_CONTINUATION_MARKER82                chunk.copyInto(it, 1)83            }84        }85        sendCiphertext(encrypt(pt))86        offset += chunkBytes87    }88}8990/**91 * Inverse of [sendSyncFrame]: read Noise ciphertexts from92 * [receiveCiphertext], reassemble chunked padded frames, and return the93 * original payload. The first byte of every decrypted message tells the94 * loop whether more chunks are coming; the last chunk is unmarker and95 * triggers unpad.96 */97internal suspend fun receiveSyncFrame(98    receiveCiphertext: suspend () -> ByteArray,99    decrypt: (ByteArray) -> ByteArray,100): ByteArray {101    var buffered: ByteArray? = null102    while (true) {103        val pt = decrypt(receiveCiphertext())104        if (buffered == null) {105            // First chunk. Marked means "more chunks to come"; an unmarked106            // first chunk is the entire single-chunk frame, exactly as before107            // the chunking layer existed.108            if (pt.isNotEmpty() && pt[0] == FRAME_CONTINUATION_MARKER) {109                buffered = pt.copyOfRange(1, pt.size)110                continue111            }112            return SyncPadding.unpad(pt)113        }114        // Continuing the assembly. A marked chunk contributes bytes past115        // the marker; the final chunk is unmarker, so we concatenate it116        // whole and unpad.117        if (pt.isNotEmpty() && pt[0] == FRAME_CONTINUATION_MARKER) {118            val merged = ByteArray(buffered.size + pt.size - 1)119            buffered.copyInto(merged, 0)120            pt.copyInto(merged, buffered.size, 1)121            buffered = merged122            continue123        }124        val reassembled = ByteArray(buffered.size + pt.size)125        buffered.copyInto(reassembled, 0)126        pt.copyInto(reassembled, buffered.size)127        return SyncPadding.unpad(reassembled)128    }129}