1import { afterEach, describe, expect, it, vi } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseKKResponder } from "./noise-js";4import { openSyncSession, type SyncPeerIdentity } from "./sync-transport";56const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));7const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));8const hex = (bytes: Uint8Array) =>9 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");10const RENDEZVOUS_ID = "Q".repeat(43);1112function installPeer(peerIdentityKeyHex?: string) {13 const initiator = generateKeyPair();14 const responder = generateKeyPair();15 const clientFrames: Uint8Array[] = [];16 const clientReaders: Array<(frame: Uint8Array) => void> = [];17 let socket: {18 binaryType: string;19 onopen: ((event: Event) => void) | null;20 onmessage: ((event: MessageEvent) => void) | null;21 onerror: (() => void) | null;22 onclose: ((event: CloseEvent) => void) | null;23 send(data: Uint8Array | ArrayBuffer): void;24 close(): void;25 };26 const receiveClient = async () => {27 const queued = clientFrames.shift();28 if (queued) return queued;29 return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));30 };31 const sendServer = async (frame: Uint8Array) => {32 queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));33 };3435 const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {36 const WebSocketMock = vi.fn().mockImplementation(function() {37 socket = {38 binaryType: "arraybuffer",39 onopen: null,40 onmessage: null,41 onerror: null,42 onclose: null,43 send(data) {44 const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));45 const reader = clientReaders.shift();46 if (reader) reader(frame);47 else clientFrames.push(frame);48 },49 close() {50 queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));51 },52 };53 queueMicrotask(() => socket.onopen?.(new Event("open")));54 return socket;55 });56 vi.stubGlobal("WebSocket", WebSocketMock);5758 void (async () => {59 try {60 const selector = decode(await receiveClient()) as Record<string, unknown>;61 resolve(selector);62 const session = await noiseKKResponder(63 responder.secretKey,64 responder.publicKey,65 initiator.publicKey,66 sendServer,67 receiveClient,68 );69 const theirIdentity = decode(session.decrypt(await receiveClient()));70 if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {71 throw new Error("initiator identity was not bound to KK key");72 }73 await sendServer(session.encrypt(encode({74 type: "PAIR_IDENTITY",75 device_id: "phone",76 device_name: "Pixel",77 device_type: "android",78 long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),79 })));80 } catch (error) {81 reject(error);82 }83 })();84 });8586 const identity: SyncPeerIdentity = {87 deviceId: "browser",88 deviceName: "Firefox",89 deviceType: "extension",90 longTermPublicKeyHex: hex(initiator.publicKey),91 longTermPrivateKey: initiator.secretKey,92 };93 return { identity, responder, selectorSeen };94}9596afterEach(() => vi.unstubAllGlobals());9798describe("KK WebSocket reconnect transport", () => {99 it("selects paired state then completes KK with both keys pinned", async () => {100 const peer = installPeer();101 const session = await openSyncSession(102 { ip: "127.0.0.1", port: 49142 },103 peer.identity,104 hex(peer.responder.publicKey),105 RENDEZVOUS_ID,106 );107108 await expect(peer.selectorSeen).resolves.toEqual({109 type: "PEACH_RECONNECT",110 rendezvous_id: RENDEZVOUS_ID,111 });112 expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));113 await session.close();114 });115116 it("rejects a post-KK identity that claims a different responder key", async () => {117 const peer = installPeer("ff".repeat(32));118 await expect(119 openSyncSession(120 { ip: "127.0.0.1", port: 49142 },121 peer.identity,122 hex(peer.responder.publicKey),123 RENDEZVOUS_ID,124 ),125 ).rejects.toThrow(/identity does not match authenticated KK key/);126 });127});