// E7: frame padding at the relay boundary. // // A relay forwards only Noise ciphertext it cannot read, so the one thing it // can still learn is traffic SHAPE — frame sizes and timing. Sizes alone // distinguish a SYNC_HELLO from a SYNC_ENTRIES carrying a whole vault, and // track how a vault grows over time. Padding to size buckets removes that // resolution. // // 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, so the padding may be // any bytes; zeros are used because they compress and cost nothing. // // COORDINATED WIRE CHANGE — peach-android's SyncPadding must bucket // identically. A mismatch is loud, not silent: the receiver reads a length // that does not fit its frame and rejects it. const HEADER_BYTES = 4; /** Below this, every frame looks the same — covers HELLO/REQUEST/DONE and any * other small control frame. */ const MIN_BUCKET = 256; /** Above this, buckets grow linearly instead of doubling: doubling a 12 MiB * frame would blow past the 24 MiB cap for no extra privacy. */ const LINEAR_BUCKET_STEP = 64 * 1024; /** * Round a total frame size up to the next bucket: powers of two while small, * then multiples of LINEAR_BUCKET_STEP. Exported for the cross-platform KAT. */ export function syncFrameBucket(totalBytes: number): number { if (totalBytes <= MIN_BUCKET) return MIN_BUCKET; if (totalBytes <= LINEAR_BUCKET_STEP) { let bucket = MIN_BUCKET; while (bucket < totalBytes) bucket *= 2; return bucket; } return Math.ceil(totalBytes / LINEAR_BUCKET_STEP) * LINEAR_BUCKET_STEP; } export function padSyncFrame(payload: Uint8Array): Uint8Array { const bucket = syncFrameBucket(HEADER_BYTES + payload.length); const out = new Uint8Array(bucket); new DataView(out.buffer).setUint32(0, payload.length, false); out.set(payload, HEADER_BYTES); return out; } export function unpadSyncFrame(padded: Uint8Array): Uint8Array { if (padded.length < HEADER_BYTES) { throw new Error("padded sync frame is shorter than its length header"); } const length = new DataView( padded.buffer, padded.byteOffset, padded.byteLength, ).getUint32(0, false); if (length > padded.length - HEADER_BYTES) { throw new Error("padded sync frame declares more payload than it carries"); } return padded.slice(HEADER_BYTES, HEADER_BYTES + length); } /** * Maximum plaintext bytes that fit in a single Noise transport message. * Noise caps a ciphertext at 65535 bytes, of which 16 are the Poly1305 auth * tag (see `crypto-core/src/lib.rs`), so a 65519-byte plaintext is the limit * before the core refuses with "Noise plaintext exceeds transport limit". * * The padded-frame ladder (above) can legitimately produce a frame up to the * 24 MiB relay cap. That is a multi-megabyte number of Noise messages; the * send/receive path chunks across this limit, not the frame. */ export const MAX_NOISE_PLAINTEXT_BYTES = 65519; /** * The first byte of a chunked padded frame. Identifies that what follows is * a fragment of a larger padded frame, not a complete one — receivers must * keep reading until the declared bucket has been reassembled before they * can hand the frame to the unpadder. Single-chunk frames never carry this * header: a frame that fits one Noise message is sent as one message, as * before, and the receiver uses its length header to know when it is done. */ export const FRAME_CONTINUATION_MARKER: number = 0x01; /** * Split one already-padded frame into Noise-sized plaintext chunks. * * Non-final chunks reserve one byte for the continuation marker. Keeping this * math here, rather than open-coding it in the WebSocket transport, lets the * production path and its boundary test share the exact implementation. */ export function chunkPaddedSyncFrame(padded: Uint8Array): Uint8Array[] { if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) return [padded]; const chunks: Uint8Array[] = []; for (let offset = 0; offset < padded.byteLength; ) { const remaining = padded.byteLength - offset; const isLast = remaining <= MAX_NOISE_PLAINTEXT_BYTES; const payloadLength = isLast ? remaining : MAX_NOISE_PLAINTEXT_BYTES - 1; const payload = padded.subarray(offset, offset + payloadLength); if (isLast) { chunks.push(payload); } else { const marked = new Uint8Array(payload.byteLength + 1); marked[0] = FRAME_CONTINUATION_MARKER; marked.set(payload, 1); chunks.push(marked); } offset += payloadLength; } return chunks; }