sync-padding.ts
1// E7: frame padding at the relay boundary.2//3// A relay forwards only Noise ciphertext it cannot read, so the one thing it4// can still learn is traffic SHAPE — frame sizes and timing. Sizes alone5// distinguish a SYNC_HELLO from a SYNC_ENTRIES carrying a whole vault, and6// track how a vault grows over time. Padding to size buckets removes that7// resolution.8//9// Wire format of a padded plaintext, BEFORE Noise encryption:10//11// [4-byte big-endian payload length][payload][zero padding to bucket size]12//13// The length header is what makes unpadding unambiguous, so the padding may be14// any bytes; zeros are used because they compress and cost nothing.15//16// COORDINATED WIRE CHANGE — peach-android's SyncPadding must bucket17// identically. A mismatch is loud, not silent: the receiver reads a length18// that does not fit its frame and rejects it.1920const HEADER_BYTES = 4;21/** Below this, every frame looks the same — covers HELLO/REQUEST/DONE and any22 * other small control frame. */23const MIN_BUCKET = 256;24/** Above this, buckets grow linearly instead of doubling: doubling a 12 MiB25 * frame would blow past the 24 MiB cap for no extra privacy. */26const LINEAR_BUCKET_STEP = 64 * 1024;2728/**29 * Round a total frame size up to the next bucket: powers of two while small,30 * then multiples of LINEAR_BUCKET_STEP. Exported for the cross-platform KAT.31 */32export function syncFrameBucket(totalBytes: number): number {33 if (totalBytes <= MIN_BUCKET) return MIN_BUCKET;34 if (totalBytes <= LINEAR_BUCKET_STEP) {35 let bucket = MIN_BUCKET;36 while (bucket < totalBytes) bucket *= 2;37 return bucket;38 }39 return Math.ceil(totalBytes / LINEAR_BUCKET_STEP) * LINEAR_BUCKET_STEP;40}4142export function padSyncFrame(payload: Uint8Array): Uint8Array {43 const bucket = syncFrameBucket(HEADER_BYTES + payload.length);44 const out = new Uint8Array(bucket);45 new DataView(out.buffer).setUint32(0, payload.length, false);46 out.set(payload, HEADER_BYTES);47 return out;48}4950export function unpadSyncFrame(padded: Uint8Array): Uint8Array {51 if (padded.length < HEADER_BYTES) {52 throw new Error("padded sync frame is shorter than its length header");53 }54 const length = new DataView(55 padded.buffer,56 padded.byteOffset,57 padded.byteLength,58 ).getUint32(0, false);59 if (length > padded.length - HEADER_BYTES) {60 throw new Error("padded sync frame declares more payload than it carries");61 }62 return padded.slice(HEADER_BYTES, HEADER_BYTES + length);63}6465/**66 * Maximum plaintext bytes that fit in a single Noise transport message.67 * Noise caps a ciphertext at 65535 bytes, of which 16 are the Poly1305 auth68 * tag (see `crypto-core/src/lib.rs`), so a 65519-byte plaintext is the limit69 * before the core refuses with "Noise plaintext exceeds transport limit".70 *71 * The padded-frame ladder (above) can legitimately produce a frame up to the72 * 24 MiB relay cap. That is a multi-megabyte number of Noise messages; the73 * send/receive path chunks across this limit, not the frame.74 */75export const MAX_NOISE_PLAINTEXT_BYTES = 65519;7677/**78 * The first byte of a chunked padded frame. Identifies that what follows is79 * a fragment of a larger padded frame, not a complete one — receivers must80 * keep reading until the declared bucket has been reassembled before they81 * can hand the frame to the unpadder. Single-chunk frames never carry this82 * header: a frame that fits one Noise message is sent as one message, as83 * before, and the receiver uses its length header to know when it is done.84 */85export const FRAME_CONTINUATION_MARKER: number = 0x01;8687/**88 * Split one already-padded frame into Noise-sized plaintext chunks.89 *90 * Non-final chunks reserve one byte for the continuation marker. Keeping this91 * math here, rather than open-coding it in the WebSocket transport, lets the92 * production path and its boundary test share the exact implementation.93 */94export function chunkPaddedSyncFrame(padded: Uint8Array): Uint8Array[] {95 if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) return [padded];9697 const chunks: Uint8Array[] = [];98 for (let offset = 0; offset < padded.byteLength; ) {99 const remaining = padded.byteLength - offset;100 const isLast = remaining <= MAX_NOISE_PLAINTEXT_BYTES;101 const payloadLength = isLast102 ? remaining103 : MAX_NOISE_PLAINTEXT_BYTES - 1;104 const payload = padded.subarray(offset, offset + payloadLength);105106 if (isLast) {107 chunks.push(payload);108 } else {109 const marked = new Uint8Array(payload.byteLength + 1);110 marked[0] = FRAME_CONTINUATION_MARKER;111 marked.set(payload, 1);112 chunks.push(marked);113 }114 offset += payloadLength;115 }116 return chunks;117}