sync-padding.ts

4.6 KB · raw

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}