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}