import { describe, expect, it } from "vitest"; import { padSyncFrame, syncFrameBucket, unpadSyncFrame, MAX_NOISE_PLAINTEXT_BYTES, FRAME_CONTINUATION_MARKER, chunkPaddedSyncFrame, } from "./sync-padding"; describe("sync frame padding (E7)", () => { it("round-trips payloads of every shape, including empty", () => { for (const size of [0, 1, 5, 251, 252, 253, 1000, 70_000]) { const payload = Uint8Array.from({ length: size }, (_, i) => (i * 31) & 0xff); expect(Array.from(unpadSyncFrame(padSyncFrame(payload)))).toEqual( Array.from(payload), ); } }); it("collapses every small control frame to one indistinguishable size", () => { // The point of the exercise: a relay must not be able to tell a HELLO from // a REQUEST from a DONE by looking at the frame it is forwarding. const hello = new TextEncoder().encode('{"type":"SYNC_HELLO","vaultId":"' + "a".repeat(64) + '"}'); const done = new TextEncoder().encode('{"type":"SYNC_DONE"}'); expect(padSyncFrame(hello).length).toBe(padSyncFrame(done).length); expect(padSyncFrame(done).length).toBe(256); }); it("pins the bucket ladder — cross-platform KAT, must match peach-android", () => { const cases: Array<[number, number]> = [ [0, 256], [252, 256], [253, 512], [1024, 2048], [65_532, 65_536], [65_533, 131_072], [200_000, 262_144], ]; for (const [payload, expected] of cases) { expect(syncFrameBucket(4 + payload)).toBe(expected); expect(padSyncFrame(new Uint8Array(payload)).length).toBe(expected); } }); it("keeps large-frame overhead bounded rather than doubling", () => { // Doubling a 12 MiB frame would exceed the 24 MiB transport cap for no // extra privacy, so buckets go linear above 64 KiB. const twelveMiB = 12 * 1024 * 1024; const padded = syncFrameBucket(twelveMiB); expect(padded).toBeGreaterThanOrEqual(twelveMiB); expect(padded - twelveMiB).toBeLessThan(64 * 1024); }); it("rejects a frame whose declared length exceeds what it carries", () => { const forged = new Uint8Array(256); new DataView(forged.buffer).setUint32(0, 9999, false); expect(() => unpadSyncFrame(forged)).toThrow(/more payload than it carries/); }); it("rejects a frame shorter than its length header", () => { expect(() => unpadSyncFrame(new Uint8Array(3))).toThrow(/length header/); }); }); /** * E7b — chunking invariants. The padded-frame ladder can produce frames up * to 24 MiB; Noise caps a single transport message at 65519 plaintext * bytes. SyncSession.send/receive chunks across that boundary transparently * to callers. These tests pin the invariants that the cross-platform * transport (and a future Android port) must respect. */ describe("sync frame chunking (E7b)", () => { it("MAX_NOISE_PLAINTEXT_BYTES matches what the crypto core allows", () => { // crypto-core/src/lib.rs: encrypt refuses > MAX_NOISE_MESSAGE - TAG_LEN, // which is 65535 - 16 = 65519. If either side drifts, the wire breaks // here first, before a real device. expect(MAX_NOISE_PLAINTEXT_BYTES).toBe(65519); }); it("the bucket ladder can produce frames above the Noise limit — the bug", () => { // A padded frame for a payload of 32 KiB rounds up to 64 KiB, which // exceeds the Noise transport limit (65535 bytes ciphertext, 65519 // plaintext). Before chunking, the crypto core rejected these with // "Noise plaintext exceeds transport limit" and the peer waited for a // frame that never arrived. This test pins that the condition is // reachable so anyone tightening the ladder in future notices. const payload = Uint8Array.from({ length: 32 * 1024 }, () => 0); const padded = padSyncFrame(payload); expect(padded.byteLength).toBeGreaterThan(MAX_NOISE_PLAINTEXT_BYTES); }); it("the continuation marker is the byte the chunking protocol uses", () => { // A byte that is never a plausible first byte of a real padded frame // (which begins with a 4-byte big-endian length, so byte 0 of a 3-byte // payload is 0). 0x01 is unambiguous and matches the KAT on Android. expect(FRAME_CONTINUATION_MARKER).toBe(0x01); }); it("production chunking reserves marker space at the Noise boundary", () => { const payload = Uint8Array.from( { length: 32 * 1024 }, (_, index) => index & 0xff, ); const padded = padSyncFrame(payload); const chunks = chunkPaddedSyncFrame(padded); expect(chunks).toHaveLength(2); expect(chunks[0]![0]).toBe(FRAME_CONTINUATION_MARKER); expect(chunks[0]!.byteLength).toBe(MAX_NOISE_PLAINTEXT_BYTES); expect(chunks[1]![0]).not.toBe(FRAME_CONTINUATION_MARKER); const reassembled = new Uint8Array( chunks[0]!.byteLength - 1 + chunks[1]!.byteLength, ); reassembled.set(chunks[0]!.subarray(1), 0); reassembled.set(chunks[1]!, chunks[0]!.byteLength - 1); expect(unpadSyncFrame(reassembled)).toEqual(payload); }); });