sync-kk-transport.test.ts

5.1 KB · raw

1import { afterEach, describe, expect, it, vi } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseKKResponder } from "./noise-js";4import {5  openRelaySyncSession,6  openSyncSession,7  type SyncPeerIdentity,8} from "./sync-transport";910const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));11const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));12const hex = (bytes: Uint8Array) =>13  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");14const RENDEZVOUS_ID = "Q".repeat(43);1516function installPeer(peerIdentityKeyHex?: string) {17  const initiator = generateKeyPair();18  const responder = generateKeyPair();19  const clientFrames: Uint8Array[] = [];20  const clientReaders: Array<(frame: Uint8Array) => void> = [];21  let socket: {22    binaryType: string;23    onopen: ((event: Event) => void) | null;24    onmessage: ((event: MessageEvent) => void) | null;25    onerror: (() => void) | null;26    onclose: ((event: CloseEvent) => void) | null;27    send(data: Uint8Array | ArrayBuffer): void;28    close(): void;29  };30  let resolveUrl!: (url: string) => void;31  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });32  const receiveClient = async () => {33    const queued = clientFrames.shift();34    if (queued) return queued;35    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));36  };37  const sendServer = async (frame: Uint8Array) => {38    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));39  };4041  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {42    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {43      resolveUrl(url);44      socket = {45        binaryType: "arraybuffer",46        onopen: null,47        onmessage: null,48        onerror: null,49        onclose: null,50        send(data) {51          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));52          const reader = clientReaders.shift();53          if (reader) reader(frame);54          else clientFrames.push(frame);55        },56        close() {57          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));58        },59      };60      queueMicrotask(() => socket.onopen?.(new Event("open")));61      return socket;62    });63    vi.stubGlobal("WebSocket", WebSocketMock);6465    void (async () => {66      try {67        const selector = decode(await receiveClient()) as Record<string, unknown>;68        resolve(selector);69        const session = await noiseKKResponder(70          responder.secretKey,71          responder.publicKey,72          initiator.publicKey,73          sendServer,74          receiveClient,75        );76        const theirIdentity = decode(session.decrypt(await receiveClient()));77        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {78          throw new Error("initiator identity was not bound to KK key");79        }80        await sendServer(session.encrypt(encode({81          type: "PAIR_IDENTITY",82          device_id: "phone",83          device_name: "Pixel",84          device_type: "android",85          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),86        })));87      } catch (error) {88        reject(error);89      }90    })();91  });9293  const identity: SyncPeerIdentity = {94    deviceId: "browser",95    deviceName: "Firefox",96    deviceType: "extension",97    longTermPublicKeyHex: hex(initiator.publicKey),98    longTermPrivateKey: initiator.secretKey,99  };100  return { identity, responder, selectorSeen, urlSeen };101}102103afterEach(() => vi.unstubAllGlobals());104105describe("KK WebSocket reconnect transport", () => {106  it("selects paired state then completes KK with both keys pinned", async () => {107    const peer = installPeer();108    const session = await openSyncSession(109      { ip: "127.0.0.1", port: 49142 },110      peer.identity,111      hex(peer.responder.publicKey),112      RENDEZVOUS_ID,113    );114115    await expect(peer.selectorSeen).resolves.toEqual({116      type: "PEACH_RECONNECT",117      rendezvous_id: RENDEZVOUS_ID,118    });119    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));120    await session.close();121  });122123  it("rejects a post-KK identity that claims a different responder key", async () => {124    const peer = installPeer("ff".repeat(32));125    await expect(126      openSyncSession(127        { ip: "127.0.0.1", port: 49142 },128        peer.identity,129        hex(peer.responder.publicKey),130        RENDEZVOUS_ID,131      ),132    ).rejects.toThrow(/identity does not match authenticated KK key/);133  });134135  it("uses the same KK wire over a credential-free wss relay target", async () => {136    const peer = installPeer();137    const session = await openRelaySyncSession(138      "wss://relay.example.test/base/",139      peer.identity,140      hex(peer.responder.publicKey),141      RENDEZVOUS_ID,142    );143144    await expect(peer.urlSeen).resolves.toBe(145      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,146    );147    await expect(peer.selectorSeen).resolves.toEqual({148      type: "PEACH_RECONNECT",149      rendezvous_id: RENDEZVOUS_ID,150    });151    await session.close();152  });153});