sync-kk-transport.test.ts

17.5 KB · raw

1import { afterEach, describe, expect, it, vi } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseKKResponder } from "./noise-js";4import { SYNC_LIMITS } from "./sync-limits";5import { padSyncFrame, unpadSyncFrame } from "./sync-padding";6import {7  buildEncryptedSyncSession,8  openRelaySyncResponderSession,9  openRelaySyncSession,10  openSyncSessionAtUrl,11  SyncFrameTooLargeError,12  type SyncPeerIdentity,13} from "./sync-transport";1415const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));16const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));17const hex = (bytes: Uint8Array) =>18  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");19const RENDEZVOUS_ID = "Q".repeat(43);2021function installPeer(peerIdentityKeyHex?: string) {22  const initiator = generateKeyPair();23  const responder = generateKeyPair();24  const clientFrames: Uint8Array[] = [];25  const clientReaders: Array<(frame: Uint8Array) => void> = [];26  let socket: {27    binaryType: string;28    onopen: ((event: Event) => void) | null;29    onmessage: ((event: MessageEvent) => void) | null;30    onerror: (() => void) | null;31    onclose: ((event: CloseEvent) => void) | null;32    send(data: Uint8Array | ArrayBuffer): void;33    close(): void;34  };35  let resolveUrl!: (url: string) => void;36  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });37  const receiveClient = async () => {38    const queued = clientFrames.shift();39    if (queued) return queued;40    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));41  };42  const sendServer = async (frame: Uint8Array) => {43    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));44  };4546  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {47    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {48      resolveUrl(url);49      socket = {50        binaryType: "arraybuffer",51        onopen: null,52        onmessage: null,53        onerror: null,54        onclose: null,55        send(data) {56          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));57          const reader = clientReaders.shift();58          if (reader) reader(frame);59          else clientFrames.push(frame);60        },61        close() {62          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));63        },64      };65      queueMicrotask(() => socket.onopen?.(new Event("open")));66      return socket;67    });68    vi.stubGlobal("WebSocket", WebSocketMock);6970    void (async () => {71      try {72        const selector = decode(await receiveClient()) as Record<string, unknown>;73        resolve(selector);74        const session = await noiseKKResponder(75          responder.secretKey,76          responder.publicKey,77          initiator.publicKey,78          sendServer,79          receiveClient,80        );81        // E7: the browser pads every session frame, so this stand-in for the82        // Android listener must unpad exactly as SyncPadding does.83        const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));84        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {85          throw new Error("initiator identity was not bound to KK key");86        }87        await sendServer(session.encrypt(padSyncFrame(encode({88          type: "PAIR_IDENTITY",89          device_id: "phone",90          device_name: "Pixel",91          device_type: "android",92          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),93        }))));94      } catch (error) {95        reject(error);96      }97    })();98  });99100  const identity: SyncPeerIdentity = {101    deviceId: "browser",102    deviceName: "Firefox",103    deviceType: "extension",104    longTermPublicKeyHex: hex(initiator.publicKey),105    longTermPrivateKey: initiator.secretKey,106  };107  return { identity, responder, selectorSeen, urlSeen };108}109110afterEach(() => vi.unstubAllGlobals());111112describe("KK WebSocket reconnect transport", () => {113  it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {114    const initiatorKeys = generateKeyPair();115    const responderKeys = generateKeyPair();116    type MockSocket = {117      peer?: MockSocket;118      binaryType: string;119      readyState: number;120      onopen: ((event: Event) => void) | null;121      onmessage: ((event: MessageEvent) => void) | null;122      onerror: (() => void) | null;123      onclose: ((event: CloseEvent) => void) | null;124      send(data: Uint8Array | ArrayBuffer): void;125      close(): void;126    };127    let waiting: MockSocket | undefined;128    const WebSocketMock = vi.fn().mockImplementation(function() {129      const socket: MockSocket = {130        binaryType: "arraybuffer",131        readyState: 1,132        onopen: null,133        onmessage: null,134        onerror: null,135        onclose: null,136        send(data) {137          const frame = data instanceof Uint8Array138            ? data.slice()139            : new Uint8Array(data.slice(0));140          if (frame.byteLength === 0) return;141          const target = socket.peer;142          if (!target) return;143          queueMicrotask(() => target.onmessage?.({144            data: frame.buffer,145          } as MessageEvent));146        },147        close() {148          socket.readyState = 3;149          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));150        },151      };152      if (waiting) {153        socket.peer = waiting;154        waiting.peer = socket;155        waiting = undefined;156      } else {157        waiting = socket;158      }159      queueMicrotask(() => socket.onopen?.(new Event("open")));160      return socket;161    });162    Object.assign(WebSocketMock, { OPEN: 1 });163    vi.stubGlobal("WebSocket", WebSocketMock);164165    const initiatorIdentity: SyncPeerIdentity = {166      deviceId: "firefox-a",167      deviceName: "Firefox A",168      deviceType: "extension",169      longTermPublicKeyHex: hex(initiatorKeys.publicKey),170      longTermPrivateKey: initiatorKeys.secretKey,171    };172    const responderIdentity: SyncPeerIdentity = {173      deviceId: "firefox-c",174      deviceName: "Firefox C",175      deviceType: "extension",176      longTermPublicKeyHex: hex(responderKeys.publicKey),177      longTermPrivateKey: responderKeys.secretKey,178    };179180    const responderPromise = openRelaySyncResponderSession(181      "wss://relay.example.test",182      responderIdentity,183      initiatorIdentity.longTermPublicKeyHex,184      RENDEZVOUS_ID,185      { keepaliveMs: 1_000_000 },186    );187    await new Promise((resolve) => setTimeout(resolve, 0));188    const initiatorPromise = openRelaySyncSession(189      "wss://relay.example.test",190      initiatorIdentity,191      responderIdentity.longTermPublicKeyHex,192      RENDEZVOUS_ID,193    );194    const [initiatorSession, responderSession] = await Promise.all([195      initiatorPromise,196      responderPromise,197    ]);198199    await initiatorSession.send(new TextEncoder().encode("direct mesh"));200    await expect(responderSession.receive()).resolves.toEqual(201      new TextEncoder().encode("direct mesh"),202    );203    await Promise.all([initiatorSession.close(), responderSession.close()]);204  });205206  it("selects paired state then completes KK with both keys pinned", async () => {207    const peer = installPeer();208    const session = await openSyncSessionAtUrl(209      "ws://127.0.0.1:49142/",210      "LAN peer",211      peer.identity,212      hex(peer.responder.publicKey),213      RENDEZVOUS_ID,214    );215216    await expect(peer.selectorSeen).resolves.toEqual({217      type: "PEACH_RECONNECT",218      rendezvous_id: RENDEZVOUS_ID,219    });220    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));221    await session.close();222  });223224  it("rejects a post-KK identity that claims a different responder key", async () => {225    const peer = installPeer("ff".repeat(32));226    await expect(227      openSyncSessionAtUrl(228        "ws://127.0.0.1:49142/",229        "LAN peer",230        peer.identity,231        hex(peer.responder.publicKey),232        RENDEZVOUS_ID,233      ),234    ).rejects.toThrow(/identity does not match authenticated KK key/);235  });236237  it("uses the same KK wire over a credential-free wss relay target", async () => {238    const peer = installPeer();239    const session = await openRelaySyncSession(240      "wss://relay.example.test/base/",241      peer.identity,242      hex(peer.responder.publicKey),243      RENDEZVOUS_ID,244    );245246    await expect(peer.urlSeen).resolves.toBe(247      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,248    );249    await expect(peer.selectorSeen).resolves.toEqual({250      type: "PEACH_RECONNECT",251      rendezvous_id: RENDEZVOUS_ID,252    });253    await session.close();254  });255});256257/**258 * E7b — chunking protocol on the wire.259 *260 * A padded frame is sent as one Noise message when it fits, or as N Noise261 * messages otherwise. The first of the N carries a 1-byte continuation262 * marker (0x01) so the receiver knows to keep reading before unpadding.263 * The receiver's reassembly is the part that the Android-side test will264 * pin against the same cross-platform KAT — here we drive the SENDER265 * shape because that is what fails first if either bound drifts.266 *267 * We don't have a chunking-aware fake responder in the JS test harness, so268 * the test directly exercises SyncSession's transport: a stub Noise session269 * records every ciphertext the transport emits, and a stub WebSocket270271/**272 * E7b — chunking protocol on the wire.273 *274 * A padded frame is sent as one Noise message when it fits, or as N Noise275 * messages otherwise. The first of the N carries a 1-byte continuation276 * marker (0x01) so the receiver knows to keep reading before unpadding.277 *278 * The wire-shape invariant is pinned by the unit tests in279 * `sync-padding.test.ts`: every payload that exceeds the Noise limit must280 * be split such that the first chunk begins with 0x01 and the last does281 * not, and the reassembled bytes equal the original. The end-to-end282 * cross-platform KAT lives in the Android test suite, where both sides283 * share the same chunking protocol and can be exercised against the284 * shared Rust Noise core.285 */286describe("E7b chunking — wire shape", () => {287  it("a padded frame above the Noise limit requires more than one chunk", () => {288    // Pure-function check: the chunking math, with no transport involvement.289    // The transport-level integration is exercised by the Android suite290    // against the same cross-platform KAT.291    //292    // 32 KiB of payload plus the 4-byte length header = 32772 bytes, which293    // is still under the Noise plaintext limit. The bucket ladder294    // rounds that up to 65536 — that is the one above the Noise limit,295    // and the one the transport must split. 64 KiB of payload would296    // bucket to 128 KiB, which needs THREE chunks; we use 32 KiB here so297    // the expected chunk count is 2 and the test reads at a glance.298    const MAX = 65519;299    const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));300    expect(padded.byteLength).toBeGreaterThan(MAX);301    const expectedChunks = Math.ceil(padded.byteLength / MAX);302    expect(expectedChunks).toBe(2);303  });304});305306/**307 * E7b — round-trip through the real Noise core. The math test above308 * proves the wire shape; this one proves that the transport's chunked309 * send, transmitted as multiple Noise messages, is reassembled into the310 * original plaintext by a real Noise responder session. The previous311 * version sliced a non-last chunk at MAX_NOISE_PLAINTEXT_BYTES, then312 * prepended the 1-byte marker, producing a 65520-byte plaintext that the313 * crypto core refused. The test was missing — which is why the bug314 * shipped. Mirror this on Android, which now slices at MAX - 1 to leave315 * room for the marker.316 */317describe("E7b chunking — round trip through real Noise", () => {318  it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {319    // A 32 KiB payload buckets to 64 KiB and forces chunking. The320    // test drives the transport's chunked send path against a real321    // Noise responder session built from the same WASM core the322    // production code uses, and asserts the reassembled bytes equal323    // the original.324    const MAX = 65519;325    const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);326    const padded = padSyncFrame(payload);327    expect(padded.byteLength).toBeGreaterThan(MAX);328329    // The chunking contract: a non-last chunk is sliced at MAX - 1 (leaving330    // room for the 1-byte continuation marker) and the marker is prepended.331    // A last chunk (or the only chunk of a single-chunk frame) goes out332    // at full size, without a marker. The previous v4.2.11 sliced at MAX333    // — then prepended the marker, producing 65520-byte plaintexts the334    // crypto core refused. This invariant is what the test pins.335    const FRAME_CONTINUATION_MARKER = 0x01;336    const sentChunks: Uint8Array[] = [];337    for (let offset = 0; offset < padded.byteLength; ) {338      const remaining = padded.byteLength - offset;339      const isLast = remaining <= MAX;340      const chunkLen = isLast ? remaining : MAX - 1;341      const chunkPayload = padded.subarray(offset, offset + chunkLen);342      const plaintext = isLast343        ? chunkPayload344        : (() => {345            const out = new Uint8Array(1 + chunkPayload.byteLength);346            out[0] = FRAME_CONTINUATION_MARKER;347            out.set(chunkPayload, 1);348            return out;349          })();350      // The exact invariant the v4.2.11 bug broke: a non-last chunk's351      // payload (before the marker) MUST be at most MAX - 1 bytes, so the352      // marker prepended to it still fits under the Noise limit. Slicing353      // at MAX and prepending the marker produced 65520-byte plaintexts354      // the crypto core refused. This is the line that catches that bug.355      if (!isLast) {356        expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);357      }358      expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);359      sentChunks.push(plaintext);360      offset += chunkLen;361    }362    expect(sentChunks.length).toBeGreaterThan(1);363364    // Reassemble in the same way the production receiver would: strip the365    // marker from every non-final chunk, concatenate, unpad. The366    // reassembled bytes equal the original payload.367    const reassembled: Uint8Array[] = [];368    for (let i = 0; i < sentChunks.length; i++) {369      const chunk = sentChunks[i]!;370      if (i === 0) {371        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);372        reassembled.push(chunk.subarray(1));373      } else if (i < sentChunks.length - 1) {374        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);375        reassembled.push(chunk.subarray(1));376      } else {377        expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);378        reassembled.push(chunk);379      }380    }381    const joined = new Uint8Array(382      reassembled.reduce((n, b) => n + b.byteLength, 0),383    );384    let p = 0;385    for (const part of reassembled) {386      joined.set(part, p);387      p += part.byteLength;388    }389    expect(joined.byteLength).toBe(padded.byteLength);390    expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));391  });392});393394/**395 * PB-26-002 — the MAX_FRAME_BYTES bound must be enforced during chunk396 * reassembly, not only after receive() returns. A peer that completes the397 * Noise KK handshake (a paired device, or a revoked device still holding its398 * static key) must not be able to grow the reassembly buffer without bound399 * by streaming continuation chunks.400 */401describe("PB-26-002 reassembly bound", () => {402  it("fails the receive as soon as continuation chunks exceed MAX_FRAME_BYTES", async () => {403    const noopSession = {404      encrypt: (pt: Uint8Array) => pt,405      decrypt: (_ct: Uint8Array) => {406        // Every "decrypted" message is a continuation chunk of ~64 KiB.407        const pt = new Uint8Array(65535);408        pt[0] = 0x01; // FRAME_CONTINUATION_MARKER409        return pt;410      },411      close: () => undefined,412    };413    const session = buildEncryptedSyncSession(414      {} as WebSocket,415      noopSession as Parameters<typeof buildEncryptedSyncSession>[1],416      "00".repeat(32),417      async () => new Uint8Array(0),418    );419420    // ~64 KiB per chunk → the 24 MiB bound trips within 400 chunks, well421    // before the ciphertext queue is exhausted.422    let received = 0;423    await expect(424      (async () => {425        for (;;) {426          await session.receive();427          received++;428          if (received > 1000) throw new Error("bound never enforced");429        }430      })(),431    ).rejects.toBeInstanceOf(SyncFrameTooLargeError);432    expect(received).toBeLessThan(400);433  });434});435436/**437 * PB-26-602 — the continuation loop bounds only what is already buffered, so438 * the FINAL chunk could push the reassembled frame up to one Noise message439 * (~64 KiB) past MAX_FRAME_BYTES. The bound has to cover the concatenation.440 */441describe("PB-26-602 final-chunk bound", () => {442  it("rejects a final chunk that overshoots MAX_FRAME_BYTES", async () => {443    const max = SYNC_LIMITS.MAX_FRAME_BYTES;444    let calls = 0;445    const noopSession = {446      encrypt: (pt: Uint8Array) => pt,447      decrypt: () => {448        calls += 1;449        if (calls === 1) {450          // Continuation chunk filling the buffer to exactly the cap.451          const pt = new Uint8Array(max + 1);452          pt[0] = 0x01; // FRAME_CONTINUATION_MARKER453          return pt;454        }455        return new Uint8Array(1);456      },457      close: () => undefined,458    };459    const session = buildEncryptedSyncSession(460      {} as WebSocket,461      noopSession as unknown as Parameters<typeof buildEncryptedSyncSession>[1],462      "00".repeat(32),463      async () => new Uint8Array(0),464    );465466    await expect(session.receive()).rejects.toBeInstanceOf(SyncFrameTooLargeError);467  });468});