SyncFrameTransport.kt
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 suspend fun sendSyncFrame(54 plaintext: ByteArray,55 encrypt: (ByteArray) -> ByteArray,56 sendCiphertext: suspend (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 remaining = total - offset67 // A non-last chunk must leave room for the one-byte marker under the68 // Noise plaintext cap; the last chunk carries no marker and can use69 // the full cap. This slicing is one byte shy of the browser's loop,70 // which slices at the cap and then prepends the marker — that path71 // produces [marker][MAX bytes] = MAX+1 bytes, which the crypto core72 // rejects. Wire shape ([0x01][data]) is unchanged across platforms.73 val cap = if (remaining <= MAX_NOISE_PLAINTEXT_BYTES) {74 MAX_NOISE_PLAINTEXT_BYTES75 } else {76 MAX_NOISE_PLAINTEXT_BYTES - 177 }78 val end = minOf(offset + cap, total)79 val chunkBytes = end - offset80 val chunk = padded.copyOfRange(offset, end)81 val isLast = end == total82 val pt: ByteArray = if (isLast) {83 chunk84 } else {85 ByteArray(chunkBytes + 1).also {86 it[0] = FRAME_CONTINUATION_MARKER87 chunk.copyInto(it, 1)88 }89 }90 sendCiphertext(encrypt(pt))91 offset += chunkBytes92 }93}9495/**96 * Inverse of [sendSyncFrame]: read Noise ciphertexts from97 * [receiveCiphertext], reassemble chunked padded frames, and return the98 * original payload. The first byte of every decrypted message tells the99 * loop whether more chunks are coming; the last chunk is unmarker and100 * triggers unpad.101 */102internal suspend fun receiveSyncFrame(103 receiveCiphertext: suspend () -> ByteArray,104 decrypt: (ByteArray) -> ByteArray,105): ByteArray {106 // Audit H-09: pre-allocated buffer (capped at MAX_SYNC_FRAME_BYTES) avoids107 // the O(n²) reallocation the old growing-array path suffered — one108 // allocation regardless of how many chunks the peer streams. Cumulative109 // size and chunk count are checked BEFORE writing so an authenticated peer110 // can't grow memory unboundedly or pin the loop on tiny marked chunks.111 val buffer = ByteArray(MAX_SYNC_FRAME_BYTES)112 var size = 0113 var chunkCount = 0114 var chunkedFrame = false115 while (true) {116 chunkCount++117 val pt = decrypt(receiveCiphertext())118 val marked = pt.isNotEmpty() && pt[0] == FRAME_CONTINUATION_MARKER119 val dataStart = if (marked) 1 else 0120 val dataSize = pt.size - dataStart121 // MAX_SYNC_FRAME_BYTES doubles as the chunk-count ceiling: even a122 // maximally-fragmented adversarial stream (one byte per chunk) hits a123 // finite loop bound instead of running until the size cap.124 if (size + dataSize > MAX_SYNC_FRAME_BYTES || chunkCount > MAX_SYNC_FRAME_BYTES) {125 buffer.fill(0, 0, size)126 pt.fill(0)127 if (size + dataSize > MAX_SYNC_FRAME_BYTES) {128 requireSyncFrameSize(size + dataSize)129 }130 throw SyncFrameTooLargeException(131 "sync frame chunk count exceeded $MAX_SYNC_FRAME_BYTES",132 )133 }134 if (!chunkedFrame) {135 // First chunk. Marker ⇒ chunked frame; no marker ⇒ the entire136 // single-chunk frame, unchanged from before the chunking layer137 // existed. The chunkedFrame latch then enforces the rest of the138 // protocol: every subsequent chunk must be marked until one isn't.139 chunkedFrame = true140 if (!marked) {141 return SyncPadding.unpad(pt)142 }143 } else if (!marked) {144 // Final chunk of a chunked frame. Unpad and zero the buffer so145 // partial plaintext doesn't linger.146 pt.copyInto(buffer, size, dataStart, pt.size)147 val written = size + dataSize148 val out = SyncPadding.unpad(buffer.copyOfRange(0, written))149 buffer.fill(0, 0, written)150 return out151 }152 pt.copyInto(buffer, size, dataStart, pt.size)153 size += dataSize154 }155}