sync-padding.test.ts

4.9 KB · raw

1import { describe, expect, it } from "vitest";2import {3  padSyncFrame,4  syncFrameBucket,5  unpadSyncFrame,6  MAX_NOISE_PLAINTEXT_BYTES,7  FRAME_CONTINUATION_MARKER,8  chunkPaddedSyncFrame,9} from "./sync-padding";1011describe("sync frame padding (E7)", () => {12  it("round-trips payloads of every shape, including empty", () => {13    for (const size of [0, 1, 5, 251, 252, 253, 1000, 70_000]) {14      const payload = Uint8Array.from({ length: size }, (_, i) => (i * 31) & 0xff);15      expect(Array.from(unpadSyncFrame(padSyncFrame(payload)))).toEqual(16        Array.from(payload),17      );18    }19  });2021  it("collapses every small control frame to one indistinguishable size", () => {22    // The point of the exercise: a relay must not be able to tell a HELLO from23    // a REQUEST from a DONE by looking at the frame it is forwarding.24    const hello = new TextEncoder().encode('{"type":"SYNC_HELLO","vaultId":"' + "a".repeat(64) + '"}');25    const done = new TextEncoder().encode('{"type":"SYNC_DONE"}');26    expect(padSyncFrame(hello).length).toBe(padSyncFrame(done).length);27    expect(padSyncFrame(done).length).toBe(256);28  });2930  it("pins the bucket ladder — cross-platform KAT, must match peach-android", () => {31    const cases: Array<[number, number]> = [32      [0, 256],33      [252, 256],34      [253, 512],35      [1024, 2048],36      [65_532, 65_536],37      [65_533, 131_072],38      [200_000, 262_144],39    ];40    for (const [payload, expected] of cases) {41      expect(syncFrameBucket(4 + payload)).toBe(expected);42      expect(padSyncFrame(new Uint8Array(payload)).length).toBe(expected);43    }44  });4546  it("keeps large-frame overhead bounded rather than doubling", () => {47    // Doubling a 12 MiB frame would exceed the 24 MiB transport cap for no48    // extra privacy, so buckets go linear above 64 KiB.49    const twelveMiB = 12 * 1024 * 1024;50    const padded = syncFrameBucket(twelveMiB);51    expect(padded).toBeGreaterThanOrEqual(twelveMiB);52    expect(padded - twelveMiB).toBeLessThan(64 * 1024);53  });5455  it("rejects a frame whose declared length exceeds what it carries", () => {56    const forged = new Uint8Array(256);57    new DataView(forged.buffer).setUint32(0, 9999, false);58    expect(() => unpadSyncFrame(forged)).toThrow(/more payload than it carries/);59  });6061  it("rejects a frame shorter than its length header", () => {62    expect(() => unpadSyncFrame(new Uint8Array(3))).toThrow(/length header/);63  });64});6566/**67 * E7b — chunking invariants. The padded-frame ladder can produce frames up68 * to 24 MiB; Noise caps a single transport message at 65519 plaintext69 * bytes. SyncSession.send/receive chunks across that boundary transparently70 * to callers. These tests pin the invariants that the cross-platform71 * transport (and a future Android port) must respect.72 */73describe("sync frame chunking (E7b)", () => {74  it("MAX_NOISE_PLAINTEXT_BYTES matches what the crypto core allows", () => {75    // crypto-core/src/lib.rs: encrypt refuses > MAX_NOISE_MESSAGE - TAG_LEN,76    // which is 65535 - 16 = 65519. If either side drifts, the wire breaks77    // here first, before a real device.78    expect(MAX_NOISE_PLAINTEXT_BYTES).toBe(65519);79  });8081  it("the bucket ladder can produce frames above the Noise limit — the bug", () => {82    // A padded frame for a payload of 32 KiB rounds up to 64 KiB, which83    // exceeds the Noise transport limit (65535 bytes ciphertext, 6551984    // plaintext). Before chunking, the crypto core rejected these with85    // "Noise plaintext exceeds transport limit" and the peer waited for a86    // frame that never arrived. This test pins that the condition is87    // reachable so anyone tightening the ladder in future notices.88    const payload = Uint8Array.from({ length: 32 * 1024 }, () => 0);89    const padded = padSyncFrame(payload);90    expect(padded.byteLength).toBeGreaterThan(MAX_NOISE_PLAINTEXT_BYTES);91  });9293  it("the continuation marker is the byte the chunking protocol uses", () => {94    // A byte that is never a plausible first byte of a real padded frame95    // (which begins with a 4-byte big-endian length, so byte 0 of a 3-byte96    // payload is 0). 0x01 is unambiguous and matches the KAT on Android.97    expect(FRAME_CONTINUATION_MARKER).toBe(0x01);98  });99100  it("production chunking reserves marker space at the Noise boundary", () => {101    const payload = Uint8Array.from(102      { length: 32 * 1024 },103      (_, index) => index & 0xff,104    );105    const padded = padSyncFrame(payload);106    const chunks = chunkPaddedSyncFrame(padded);107108    expect(chunks).toHaveLength(2);109    expect(chunks[0]![0]).toBe(FRAME_CONTINUATION_MARKER);110    expect(chunks[0]!.byteLength).toBe(MAX_NOISE_PLAINTEXT_BYTES);111    expect(chunks[1]![0]).not.toBe(FRAME_CONTINUATION_MARKER);112113    const reassembled = new Uint8Array(114      chunks[0]!.byteLength - 1 + chunks[1]!.byteLength,115    );116    reassembled.set(chunks[0]!.subarray(1), 0);117    reassembled.set(chunks[1]!, chunks[0]!.byteLength - 1);118    expect(unpadSyncFrame(reassembled)).toEqual(payload);119  });120});