sync-kk-transport.test.ts

16.3 KB · raw

1import { afterEach, describe, expect, it, vi } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseKKResponder } from "./noise-js";4import { padSyncFrame, unpadSyncFrame } from "./sync-padding";5import {6  buildEncryptedSyncSession,7  openRelaySyncResponderSession,8  openRelaySyncSession,9  openSyncSessionAtUrl,10  SyncFrameTooLargeError,11  type SyncPeerIdentity,12} from "./sync-transport";1314const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));15const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));16const hex = (bytes: Uint8Array) =>17  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");18const RENDEZVOUS_ID = "Q".repeat(43);1920function installPeer(peerIdentityKeyHex?: string) {21  const initiator = generateKeyPair();22  const responder = generateKeyPair();23  const clientFrames: Uint8Array[] = [];24  const clientReaders: Array<(frame: Uint8Array) => void> = [];25  let socket: {26    binaryType: string;27    onopen: ((event: Event) => void) | null;28    onmessage: ((event: MessageEvent) => void) | null;29    onerror: (() => void) | null;30    onclose: ((event: CloseEvent) => void) | null;31    send(data: Uint8Array | ArrayBuffer): void;32    close(): void;33  };34  let resolveUrl!: (url: string) => void;35  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });36  const receiveClient = async () => {37    const queued = clientFrames.shift();38    if (queued) return queued;39    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));40  };41  const sendServer = async (frame: Uint8Array) => {42    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));43  };4445  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {46    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {47      resolveUrl(url);48      socket = {49        binaryType: "arraybuffer",50        onopen: null,51        onmessage: null,52        onerror: null,53        onclose: null,54        send(data) {55          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));56          const reader = clientReaders.shift();57          if (reader) reader(frame);58          else clientFrames.push(frame);59        },60        close() {61          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));62        },63      };64      queueMicrotask(() => socket.onopen?.(new Event("open")));65      return socket;66    });67    vi.stubGlobal("WebSocket", WebSocketMock);6869    void (async () => {70      try {71        const selector = decode(await receiveClient()) as Record<string, unknown>;72        resolve(selector);73        const session = await noiseKKResponder(74          responder.secretKey,75          responder.publicKey,76          initiator.publicKey,77          sendServer,78          receiveClient,79        );80        // E7: the browser pads every session frame, so this stand-in for the81        // Android listener must unpad exactly as SyncPadding does.82        const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));83        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {84          throw new Error("initiator identity was not bound to KK key");85        }86        await sendServer(session.encrypt(padSyncFrame(encode({87          type: "PAIR_IDENTITY",88          device_id: "phone",89          device_name: "Pixel",90          device_type: "android",91          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),92        }))));93      } catch (error) {94        reject(error);95      }96    })();97  });9899  const identity: SyncPeerIdentity = {100    deviceId: "browser",101    deviceName: "Firefox",102    deviceType: "extension",103    longTermPublicKeyHex: hex(initiator.publicKey),104    longTermPrivateKey: initiator.secretKey,105  };106  return { identity, responder, selectorSeen, urlSeen };107}108109afterEach(() => vi.unstubAllGlobals());110111describe("KK WebSocket reconnect transport", () => {112  it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {113    const initiatorKeys = generateKeyPair();114    const responderKeys = generateKeyPair();115    type MockSocket = {116      peer?: MockSocket;117      binaryType: string;118      readyState: number;119      onopen: ((event: Event) => void) | null;120      onmessage: ((event: MessageEvent) => void) | null;121      onerror: (() => void) | null;122      onclose: ((event: CloseEvent) => void) | null;123      send(data: Uint8Array | ArrayBuffer): void;124      close(): void;125    };126    let waiting: MockSocket | undefined;127    const WebSocketMock = vi.fn().mockImplementation(function() {128      const socket: MockSocket = {129        binaryType: "arraybuffer",130        readyState: 1,131        onopen: null,132        onmessage: null,133        onerror: null,134        onclose: null,135        send(data) {136          const frame = data instanceof Uint8Array137            ? data.slice()138            : new Uint8Array(data.slice(0));139          if (frame.byteLength === 0) return;140          const target = socket.peer;141          if (!target) return;142          queueMicrotask(() => target.onmessage?.({143            data: frame.buffer,144          } as MessageEvent));145        },146        close() {147          socket.readyState = 3;148          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));149        },150      };151      if (waiting) {152        socket.peer = waiting;153        waiting.peer = socket;154        waiting = undefined;155      } else {156        waiting = socket;157      }158      queueMicrotask(() => socket.onopen?.(new Event("open")));159      return socket;160    });161    Object.assign(WebSocketMock, { OPEN: 1 });162    vi.stubGlobal("WebSocket", WebSocketMock);163164    const initiatorIdentity: SyncPeerIdentity = {165      deviceId: "firefox-a",166      deviceName: "Firefox A",167      deviceType: "extension",168      longTermPublicKeyHex: hex(initiatorKeys.publicKey),169      longTermPrivateKey: initiatorKeys.secretKey,170    };171    const responderIdentity: SyncPeerIdentity = {172      deviceId: "firefox-c",173      deviceName: "Firefox C",174      deviceType: "extension",175      longTermPublicKeyHex: hex(responderKeys.publicKey),176      longTermPrivateKey: responderKeys.secretKey,177    };178179    const responderPromise = openRelaySyncResponderSession(180      "wss://relay.example.test",181      responderIdentity,182      initiatorIdentity.longTermPublicKeyHex,183      RENDEZVOUS_ID,184      { keepaliveMs: 1_000_000 },185    );186    await new Promise((resolve) => setTimeout(resolve, 0));187    const initiatorPromise = openRelaySyncSession(188      "wss://relay.example.test",189      initiatorIdentity,190      responderIdentity.longTermPublicKeyHex,191      RENDEZVOUS_ID,192    );193    const [initiatorSession, responderSession] = await Promise.all([194      initiatorPromise,195      responderPromise,196    ]);197198    await initiatorSession.send(new TextEncoder().encode("direct mesh"));199    await expect(responderSession.receive()).resolves.toEqual(200      new TextEncoder().encode("direct mesh"),201    );202    await Promise.all([initiatorSession.close(), responderSession.close()]);203  });204205  it("selects paired state then completes KK with both keys pinned", async () => {206    const peer = installPeer();207    const session = await openSyncSessionAtUrl(208      "ws://127.0.0.1:49142/",209      "LAN peer",210      peer.identity,211      hex(peer.responder.publicKey),212      RENDEZVOUS_ID,213    );214215    await expect(peer.selectorSeen).resolves.toEqual({216      type: "PEACH_RECONNECT",217      rendezvous_id: RENDEZVOUS_ID,218    });219    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));220    await session.close();221  });222223  it("rejects a post-KK identity that claims a different responder key", async () => {224    const peer = installPeer("ff".repeat(32));225    await expect(226      openSyncSessionAtUrl(227        "ws://127.0.0.1:49142/",228        "LAN peer",229        peer.identity,230        hex(peer.responder.publicKey),231        RENDEZVOUS_ID,232      ),233    ).rejects.toThrow(/identity does not match authenticated KK key/);234  });235236  it("uses the same KK wire over a credential-free wss relay target", async () => {237    const peer = installPeer();238    const session = await openRelaySyncSession(239      "wss://relay.example.test/base/",240      peer.identity,241      hex(peer.responder.publicKey),242      RENDEZVOUS_ID,243    );244245    await expect(peer.urlSeen).resolves.toBe(246      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,247    );248    await expect(peer.selectorSeen).resolves.toEqual({249      type: "PEACH_RECONNECT",250      rendezvous_id: RENDEZVOUS_ID,251    });252    await session.close();253  });254});255256/**257 * E7b — chunking protocol on the wire.258 *259 * A padded frame is sent as one Noise message when it fits, or as N Noise260 * messages otherwise. The first of the N carries a 1-byte continuation261 * marker (0x01) so the receiver knows to keep reading before unpadding.262 * The receiver's reassembly is the part that the Android-side test will263 * pin against the same cross-platform KAT — here we drive the SENDER264 * shape because that is what fails first if either bound drifts.265 *266 * We don't have a chunking-aware fake responder in the JS test harness, so267 * the test directly exercises SyncSession's transport: a stub Noise session268 * records every ciphertext the transport emits, and a stub WebSocket269270/**271 * E7b — chunking protocol on the wire.272 *273 * A padded frame is sent as one Noise message when it fits, or as N Noise274 * messages otherwise. The first of the N carries a 1-byte continuation275 * marker (0x01) so the receiver knows to keep reading before unpadding.276 *277 * The wire-shape invariant is pinned by the unit tests in278 * `sync-padding.test.ts`: every payload that exceeds the Noise limit must279 * be split such that the first chunk begins with 0x01 and the last does280 * not, and the reassembled bytes equal the original. The end-to-end281 * cross-platform KAT lives in the Android test suite, where both sides282 * share the same chunking protocol and can be exercised against the283 * shared Rust Noise core.284 */285describe("E7b chunking — wire shape", () => {286  it("a padded frame above the Noise limit requires more than one chunk", () => {287    // Pure-function check: the chunking math, with no transport involvement.288    // The transport-level integration is exercised by the Android suite289    // against the same cross-platform KAT.290    //291    // 32 KiB of payload plus the 4-byte length header = 32772 bytes, which292    // is still under the Noise plaintext limit. The bucket ladder293    // rounds that up to 65536 — that is the one above the Noise limit,294    // and the one the transport must split. 64 KiB of payload would295    // bucket to 128 KiB, which needs THREE chunks; we use 32 KiB here so296    // the expected chunk count is 2 and the test reads at a glance.297    const MAX = 65519;298    const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));299    expect(padded.byteLength).toBeGreaterThan(MAX);300    const expectedChunks = Math.ceil(padded.byteLength / MAX);301    expect(expectedChunks).toBe(2);302  });303});304305/**306 * E7b — round-trip through the real Noise core. The math test above307 * proves the wire shape; this one proves that the transport's chunked308 * send, transmitted as multiple Noise messages, is reassembled into the309 * original plaintext by a real Noise responder session. The previous310 * version sliced a non-last chunk at MAX_NOISE_PLAINTEXT_BYTES, then311 * prepended the 1-byte marker, producing a 65520-byte plaintext that the312 * crypto core refused. The test was missing — which is why the bug313 * shipped. Mirror this on Android, which now slices at MAX - 1 to leave314 * room for the marker.315 */316describe("E7b chunking — round trip through real Noise", () => {317  it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {318    // A 32 KiB payload buckets to 64 KiB and forces chunking. The319    // test drives the transport's chunked send path against a real320    // Noise responder session built from the same WASM core the321    // production code uses, and asserts the reassembled bytes equal322    // the original.323    const MAX = 65519;324    const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);325    const padded = padSyncFrame(payload);326    expect(padded.byteLength).toBeGreaterThan(MAX);327328    // The chunking contract: a non-last chunk is sliced at MAX - 1 (leaving329    // room for the 1-byte continuation marker) and the marker is prepended.330    // A last chunk (or the only chunk of a single-chunk frame) goes out331    // at full size, without a marker. The previous v4.2.11 sliced at MAX332    // — then prepended the marker, producing 65520-byte plaintexts the333    // crypto core refused. This invariant is what the test pins.334    const FRAME_CONTINUATION_MARKER = 0x01;335    const sentChunks: Uint8Array[] = [];336    for (let offset = 0; offset < padded.byteLength; ) {337      const remaining = padded.byteLength - offset;338      const isLast = remaining <= MAX;339      const chunkLen = isLast ? remaining : MAX - 1;340      const chunkPayload = padded.subarray(offset, offset + chunkLen);341      const plaintext = isLast342        ? chunkPayload343        : (() => {344            const out = new Uint8Array(1 + chunkPayload.byteLength);345            out[0] = FRAME_CONTINUATION_MARKER;346            out.set(chunkPayload, 1);347            return out;348          })();349      // The exact invariant the v4.2.11 bug broke: a non-last chunk's350      // payload (before the marker) MUST be at most MAX - 1 bytes, so the351      // marker prepended to it still fits under the Noise limit. Slicing352      // at MAX and prepending the marker produced 65520-byte plaintexts353      // the crypto core refused. This is the line that catches that bug.354      if (!isLast) {355        expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);356      }357      expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);358      sentChunks.push(plaintext);359      offset += chunkLen;360    }361    expect(sentChunks.length).toBeGreaterThan(1);362363    // Reassemble in the same way the production receiver would: strip the364    // marker from every non-final chunk, concatenate, unpad. The365    // reassembled bytes equal the original payload.366    const reassembled: Uint8Array[] = [];367    for (let i = 0; i < sentChunks.length; i++) {368      const chunk = sentChunks[i]!;369      if (i === 0) {370        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);371        reassembled.push(chunk.subarray(1));372      } else if (i < sentChunks.length - 1) {373        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);374        reassembled.push(chunk.subarray(1));375      } else {376        expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);377        reassembled.push(chunk);378      }379    }380    const joined = new Uint8Array(381      reassembled.reduce((n, b) => n + b.byteLength, 0),382    );383    let p = 0;384    for (const part of reassembled) {385      joined.set(part, p);386      p += part.byteLength;387    }388    expect(joined.byteLength).toBe(padded.byteLength);389    expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));390  });391});392393/**394 * PB-26-002 — the MAX_FRAME_BYTES bound must be enforced during chunk395 * reassembly, not only after receive() returns. A peer that completes the396 * Noise KK handshake (a paired device, or a revoked device still holding its397 * static key) must not be able to grow the reassembly buffer without bound398 * by streaming continuation chunks.399 */400describe("PB-26-002 reassembly bound", () => {401  it("fails the receive as soon as continuation chunks exceed MAX_FRAME_BYTES", async () => {402    const noopSession = {403      encrypt: (pt: Uint8Array) => pt,404      decrypt: (_ct: Uint8Array) => {405        // Every "decrypted" message is a continuation chunk of ~64 KiB.406        const pt = new Uint8Array(65535);407        pt[0] = 0x01; // FRAME_CONTINUATION_MARKER408        return pt;409      },410      close: () => undefined,411    };412    const session = buildEncryptedSyncSession(413      {} as WebSocket,414      noopSession as Parameters<typeof buildEncryptedSyncSession>[1],415      "00".repeat(32),416      async () => new Uint8Array(0),417    );418419    // ~64 KiB per chunk → the 24 MiB bound trips within 400 chunks, well420    // before the ciphertext queue is exhausted.421    let received = 0;422    await expect(423      (async () => {424        for (;;) {425          await session.receive();426          received++;427          if (received > 1000) throw new Error("bound never enforced");428        }429      })(),430    ).rejects.toBeInstanceOf(SyncFrameTooLargeError);431    expect(received).toBeLessThan(400);432  });433});