sync-kk-transport.test.ts

5.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  openRelaySyncSession,7  openSyncSession,8  type SyncPeerIdentity,9} from "./sync-transport";1011const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));12const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));13const hex = (bytes: Uint8Array) =>14  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");15const RENDEZVOUS_ID = "Q".repeat(43);1617function installPeer(peerIdentityKeyHex?: string) {18  const initiator = generateKeyPair();19  const responder = generateKeyPair();20  const clientFrames: Uint8Array[] = [];21  const clientReaders: Array<(frame: Uint8Array) => void> = [];22  let socket: {23    binaryType: string;24    onopen: ((event: Event) => void) | null;25    onmessage: ((event: MessageEvent) => void) | null;26    onerror: (() => void) | null;27    onclose: ((event: CloseEvent) => void) | null;28    send(data: Uint8Array | ArrayBuffer): void;29    close(): void;30  };31  let resolveUrl!: (url: string) => void;32  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });33  const receiveClient = async () => {34    const queued = clientFrames.shift();35    if (queued) return queued;36    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));37  };38  const sendServer = async (frame: Uint8Array) => {39    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));40  };4142  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {43    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {44      resolveUrl(url);45      socket = {46        binaryType: "arraybuffer",47        onopen: null,48        onmessage: null,49        onerror: null,50        onclose: null,51        send(data) {52          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));53          const reader = clientReaders.shift();54          if (reader) reader(frame);55          else clientFrames.push(frame);56        },57        close() {58          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));59        },60      };61      queueMicrotask(() => socket.onopen?.(new Event("open")));62      return socket;63    });64    vi.stubGlobal("WebSocket", WebSocketMock);6566    void (async () => {67      try {68        const selector = decode(await receiveClient()) as Record<string, unknown>;69        resolve(selector);70        const session = await noiseKKResponder(71          responder.secretKey,72          responder.publicKey,73          initiator.publicKey,74          sendServer,75          receiveClient,76        );77        // E7: the browser pads every session frame, so this stand-in for the78        // Android listener must unpad exactly as SyncPadding does.79        const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));80        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {81          throw new Error("initiator identity was not bound to KK key");82        }83        await sendServer(session.encrypt(padSyncFrame(encode({84          type: "PAIR_IDENTITY",85          device_id: "phone",86          device_name: "Pixel",87          device_type: "android",88          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),89        }))));90      } catch (error) {91        reject(error);92      }93    })();94  });9596  const identity: SyncPeerIdentity = {97    deviceId: "browser",98    deviceName: "Firefox",99    deviceType: "extension",100    longTermPublicKeyHex: hex(initiator.publicKey),101    longTermPrivateKey: initiator.secretKey,102  };103  return { identity, responder, selectorSeen, urlSeen };104}105106afterEach(() => vi.unstubAllGlobals());107108describe("KK WebSocket reconnect transport", () => {109  it("selects paired state then completes KK with both keys pinned", async () => {110    const peer = installPeer();111    const session = await openSyncSession(112      { ip: "127.0.0.1", port: 49142 },113      peer.identity,114      hex(peer.responder.publicKey),115      RENDEZVOUS_ID,116    );117118    await expect(peer.selectorSeen).resolves.toEqual({119      type: "PEACH_RECONNECT",120      rendezvous_id: RENDEZVOUS_ID,121    });122    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));123    await session.close();124  });125126  it("rejects a post-KK identity that claims a different responder key", async () => {127    const peer = installPeer("ff".repeat(32));128    await expect(129      openSyncSession(130        { ip: "127.0.0.1", port: 49142 },131        peer.identity,132        hex(peer.responder.publicKey),133        RENDEZVOUS_ID,134      ),135    ).rejects.toThrow(/identity does not match authenticated KK key/);136  });137138  it("uses the same KK wire over a credential-free wss relay target", async () => {139    const peer = installPeer();140    const session = await openRelaySyncSession(141      "wss://relay.example.test/base/",142      peer.identity,143      hex(peer.responder.publicKey),144      RENDEZVOUS_ID,145    );146147    await expect(peer.urlSeen).resolves.toBe(148      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,149    );150    await expect(peer.selectorSeen).resolves.toEqual({151      type: "PEACH_RECONNECT",152      rendezvous_id: RENDEZVOUS_ID,153    });154    await session.close();155  });156});