sync-kk-transport.test.ts

14.8 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  openRelaySyncResponderSession,7  openRelaySyncSession,8  openSyncSessionAtUrl,9  type SyncPeerIdentity,10} from "./sync-transport";1112const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));13const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));14const hex = (bytes: Uint8Array) =>15  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");16const RENDEZVOUS_ID = "Q".repeat(43);1718function installPeer(peerIdentityKeyHex?: string) {19  const initiator = generateKeyPair();20  const responder = generateKeyPair();21  const clientFrames: Uint8Array[] = [];22  const clientReaders: Array<(frame: Uint8Array) => void> = [];23  let socket: {24    binaryType: string;25    onopen: ((event: Event) => void) | null;26    onmessage: ((event: MessageEvent) => void) | null;27    onerror: (() => void) | null;28    onclose: ((event: CloseEvent) => void) | null;29    send(data: Uint8Array | ArrayBuffer): void;30    close(): void;31  };32  let resolveUrl!: (url: string) => void;33  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });34  const receiveClient = async () => {35    const queued = clientFrames.shift();36    if (queued) return queued;37    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));38  };39  const sendServer = async (frame: Uint8Array) => {40    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));41  };4243  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {44    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {45      resolveUrl(url);46      socket = {47        binaryType: "arraybuffer",48        onopen: null,49        onmessage: null,50        onerror: null,51        onclose: null,52        send(data) {53          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));54          const reader = clientReaders.shift();55          if (reader) reader(frame);56          else clientFrames.push(frame);57        },58        close() {59          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));60        },61      };62      queueMicrotask(() => socket.onopen?.(new Event("open")));63      return socket;64    });65    vi.stubGlobal("WebSocket", WebSocketMock);6667    void (async () => {68      try {69        const selector = decode(await receiveClient()) as Record<string, unknown>;70        resolve(selector);71        const session = await noiseKKResponder(72          responder.secretKey,73          responder.publicKey,74          initiator.publicKey,75          sendServer,76          receiveClient,77        );78        // E7: the browser pads every session frame, so this stand-in for the79        // Android listener must unpad exactly as SyncPadding does.80        const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));81        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {82          throw new Error("initiator identity was not bound to KK key");83        }84        await sendServer(session.encrypt(padSyncFrame(encode({85          type: "PAIR_IDENTITY",86          device_id: "phone",87          device_name: "Pixel",88          device_type: "android",89          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),90        }))));91      } catch (error) {92        reject(error);93      }94    })();95  });9697  const identity: SyncPeerIdentity = {98    deviceId: "browser",99    deviceName: "Firefox",100    deviceType: "extension",101    longTermPublicKeyHex: hex(initiator.publicKey),102    longTermPrivateKey: initiator.secretKey,103  };104  return { identity, responder, selectorSeen, urlSeen };105}106107afterEach(() => vi.unstubAllGlobals());108109describe("KK WebSocket reconnect transport", () => {110  it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {111    const initiatorKeys = generateKeyPair();112    const responderKeys = generateKeyPair();113    type MockSocket = {114      peer?: MockSocket;115      binaryType: string;116      readyState: number;117      onopen: ((event: Event) => void) | null;118      onmessage: ((event: MessageEvent) => void) | null;119      onerror: (() => void) | null;120      onclose: ((event: CloseEvent) => void) | null;121      send(data: Uint8Array | ArrayBuffer): void;122      close(): void;123    };124    let waiting: MockSocket | undefined;125    const WebSocketMock = vi.fn().mockImplementation(function() {126      const socket: MockSocket = {127        binaryType: "arraybuffer",128        readyState: 1,129        onopen: null,130        onmessage: null,131        onerror: null,132        onclose: null,133        send(data) {134          const frame = data instanceof Uint8Array135            ? data.slice()136            : new Uint8Array(data.slice(0));137          if (frame.byteLength === 0) return;138          const target = socket.peer;139          if (!target) return;140          queueMicrotask(() => target.onmessage?.({141            data: frame.buffer,142          } as MessageEvent));143        },144        close() {145          socket.readyState = 3;146          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));147        },148      };149      if (waiting) {150        socket.peer = waiting;151        waiting.peer = socket;152        waiting = undefined;153      } else {154        waiting = socket;155      }156      queueMicrotask(() => socket.onopen?.(new Event("open")));157      return socket;158    });159    Object.assign(WebSocketMock, { OPEN: 1 });160    vi.stubGlobal("WebSocket", WebSocketMock);161162    const initiatorIdentity: SyncPeerIdentity = {163      deviceId: "firefox-a",164      deviceName: "Firefox A",165      deviceType: "extension",166      longTermPublicKeyHex: hex(initiatorKeys.publicKey),167      longTermPrivateKey: initiatorKeys.secretKey,168    };169    const responderIdentity: SyncPeerIdentity = {170      deviceId: "firefox-c",171      deviceName: "Firefox C",172      deviceType: "extension",173      longTermPublicKeyHex: hex(responderKeys.publicKey),174      longTermPrivateKey: responderKeys.secretKey,175    };176177    const responderPromise = openRelaySyncResponderSession(178      "wss://relay.example.test",179      responderIdentity,180      initiatorIdentity.longTermPublicKeyHex,181      RENDEZVOUS_ID,182      { keepaliveMs: 1_000_000 },183    );184    await new Promise((resolve) => setTimeout(resolve, 0));185    const initiatorPromise = openRelaySyncSession(186      "wss://relay.example.test",187      initiatorIdentity,188      responderIdentity.longTermPublicKeyHex,189      RENDEZVOUS_ID,190    );191    const [initiatorSession, responderSession] = await Promise.all([192      initiatorPromise,193      responderPromise,194    ]);195196    await initiatorSession.send(new TextEncoder().encode("direct mesh"));197    await expect(responderSession.receive()).resolves.toEqual(198      new TextEncoder().encode("direct mesh"),199    );200    await Promise.all([initiatorSession.close(), responderSession.close()]);201  });202203  it("selects paired state then completes KK with both keys pinned", async () => {204    const peer = installPeer();205    const session = await openSyncSessionAtUrl(206      "ws://127.0.0.1:49142/",207      "LAN peer",208      peer.identity,209      hex(peer.responder.publicKey),210      RENDEZVOUS_ID,211    );212213    await expect(peer.selectorSeen).resolves.toEqual({214      type: "PEACH_RECONNECT",215      rendezvous_id: RENDEZVOUS_ID,216    });217    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));218    await session.close();219  });220221  it("rejects a post-KK identity that claims a different responder key", async () => {222    const peer = installPeer("ff".repeat(32));223    await expect(224      openSyncSessionAtUrl(225        "ws://127.0.0.1:49142/",226        "LAN peer",227        peer.identity,228        hex(peer.responder.publicKey),229        RENDEZVOUS_ID,230      ),231    ).rejects.toThrow(/identity does not match authenticated KK key/);232  });233234  it("uses the same KK wire over a credential-free wss relay target", async () => {235    const peer = installPeer();236    const session = await openRelaySyncSession(237      "wss://relay.example.test/base/",238      peer.identity,239      hex(peer.responder.publicKey),240      RENDEZVOUS_ID,241    );242243    await expect(peer.urlSeen).resolves.toBe(244      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,245    );246    await expect(peer.selectorSeen).resolves.toEqual({247      type: "PEACH_RECONNECT",248      rendezvous_id: RENDEZVOUS_ID,249    });250    await session.close();251  });252});253254/**255 * E7b — chunking protocol on the wire.256 *257 * A padded frame is sent as one Noise message when it fits, or as N Noise258 * messages otherwise. The first of the N carries a 1-byte continuation259 * marker (0x01) so the receiver knows to keep reading before unpadding.260 * The receiver's reassembly is the part that the Android-side test will261 * pin against the same cross-platform KAT — here we drive the SENDER262 * shape because that is what fails first if either bound drifts.263 *264 * We don't have a chunking-aware fake responder in the JS test harness, so265 * the test directly exercises SyncSession's transport: a stub Noise session266 * records every ciphertext the transport emits, and a stub WebSocket267268/**269 * E7b — chunking protocol on the wire.270 *271 * A padded frame is sent as one Noise message when it fits, or as N Noise272 * messages otherwise. The first of the N carries a 1-byte continuation273 * marker (0x01) so the receiver knows to keep reading before unpadding.274 *275 * The wire-shape invariant is pinned by the unit tests in276 * `sync-padding.test.ts`: every payload that exceeds the Noise limit must277 * be split such that the first chunk begins with 0x01 and the last does278 * not, and the reassembled bytes equal the original. The end-to-end279 * cross-platform KAT lives in the Android test suite, where both sides280 * share the same chunking protocol and can be exercised against the281 * shared Rust Noise core.282 */283describe("E7b chunking — wire shape", () => {284  it("a padded frame above the Noise limit requires more than one chunk", () => {285    // Pure-function check: the chunking math, with no transport involvement.286    // The transport-level integration is exercised by the Android suite287    // against the same cross-platform KAT.288    //289    // 32 KiB of payload plus the 4-byte length header = 32772 bytes, which290    // is still under the Noise plaintext limit. The bucket ladder291    // rounds that up to 65536 — that is the one above the Noise limit,292    // and the one the transport must split. 64 KiB of payload would293    // bucket to 128 KiB, which needs THREE chunks; we use 32 KiB here so294    // the expected chunk count is 2 and the test reads at a glance.295    const MAX = 65519;296    const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));297    expect(padded.byteLength).toBeGreaterThan(MAX);298    const expectedChunks = Math.ceil(padded.byteLength / MAX);299    expect(expectedChunks).toBe(2);300  });301});302303/**304 * E7b — round-trip through the real Noise core. The math test above305 * proves the wire shape; this one proves that the transport's chunked306 * send, transmitted as multiple Noise messages, is reassembled into the307 * original plaintext by a real Noise responder session. The previous308 * version sliced a non-last chunk at MAX_NOISE_PLAINTEXT_BYTES, then309 * prepended the 1-byte marker, producing a 65520-byte plaintext that the310 * crypto core refused. The test was missing — which is why the bug311 * shipped. Mirror this on Android, which now slices at MAX - 1 to leave312 * room for the marker.313 */314describe("E7b chunking — round trip through real Noise", () => {315  it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {316    // A 32 KiB payload buckets to 64 KiB and forces chunking. The317    // test drives the transport's chunked send path against a real318    // Noise responder session built from the same WASM core the319    // production code uses, and asserts the reassembled bytes equal320    // the original.321    const MAX = 65519;322    const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);323    const padded = padSyncFrame(payload);324    expect(padded.byteLength).toBeGreaterThan(MAX);325326    // The chunking contract: a non-last chunk is sliced at MAX - 1 (leaving327    // room for the 1-byte continuation marker) and the marker is prepended.328    // A last chunk (or the only chunk of a single-chunk frame) goes out329    // at full size, without a marker. The previous v4.2.11 sliced at MAX330    // — then prepended the marker, producing 65520-byte plaintexts the331    // crypto core refused. This invariant is what the test pins.332    const FRAME_CONTINUATION_MARKER = 0x01;333    const sentChunks: Uint8Array[] = [];334    for (let offset = 0; offset < padded.byteLength; ) {335      const remaining = padded.byteLength - offset;336      const isLast = remaining <= MAX;337      const chunkLen = isLast ? remaining : MAX - 1;338      const chunkPayload = padded.subarray(offset, offset + chunkLen);339      const plaintext = isLast340        ? chunkPayload341        : (() => {342            const out = new Uint8Array(1 + chunkPayload.byteLength);343            out[0] = FRAME_CONTINUATION_MARKER;344            out.set(chunkPayload, 1);345            return out;346          })();347      // The exact invariant the v4.2.11 bug broke: a non-last chunk's348      // payload (before the marker) MUST be at most MAX - 1 bytes, so the349      // marker prepended to it still fits under the Noise limit. Slicing350      // at MAX and prepending the marker produced 65520-byte plaintexts351      // the crypto core refused. This is the line that catches that bug.352      if (!isLast) {353        expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);354      }355      expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);356      sentChunks.push(plaintext);357      offset += chunkLen;358    }359    expect(sentChunks.length).toBeGreaterThan(1);360361    // Reassemble in the same way the production receiver would: strip the362    // marker from every non-final chunk, concatenate, unpad. The363    // reassembled bytes equal the original payload.364    const reassembled: Uint8Array[] = [];365    for (let i = 0; i < sentChunks.length; i++) {366      const chunk = sentChunks[i]!;367      if (i === 0) {368        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);369        reassembled.push(chunk.subarray(1));370      } else if (i < sentChunks.length - 1) {371        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);372        reassembled.push(chunk.subarray(1));373      } else {374        expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);375        reassembled.push(chunk);376      }377    }378    const joined = new Uint8Array(379      reassembled.reduce((n, b) => n + b.byteLength, 0),380    );381    let p = 0;382    for (const part of reassembled) {383      joined.set(part, p);384      p += part.byteLength;385    }386    expect(joined.byteLength).toBe(padded.byteLength);387    expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));388  });389});