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 openSyncSession,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 79 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 openSyncSession(206 { ip: "127.0.0.1", port: 49142 },207 peer.identity,208 hex(peer.responder.publicKey),209 RENDEZVOUS_ID,210 );211212 await expect(peer.selectorSeen).resolves.toEqual({213 type: "PEACH_RECONNECT",214 rendezvous_id: RENDEZVOUS_ID,215 });216 expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));217 await session.close();218 });219220 it("rejects a post-KK identity that claims a different responder key", async () => {221 const peer = installPeer("ff".repeat(32));222 await expect(223 openSyncSession(224 { ip: "127.0.0.1", port: 49142 },225 peer.identity,226 hex(peer.responder.publicKey),227 RENDEZVOUS_ID,228 ),229 ).rejects.toThrow(/identity does not match authenticated KK key/);230 });231232 it("uses the same KK wire over a credential-free wss relay target", async () => {233 const peer = installPeer();234 const session = await openRelaySyncSession(235 "wss://relay.example.test/base/",236 peer.identity,237 hex(peer.responder.publicKey),238 RENDEZVOUS_ID,239 );240241 await expect(peer.urlSeen).resolves.toBe(242 `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,243 );244 await expect(peer.selectorSeen).resolves.toEqual({245 type: "PEACH_RECONNECT",246 rendezvous_id: RENDEZVOUS_ID,247 });248 await session.close();249 });250});251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281describe("E7b chunking — wire shape", () => {282 it("a padded frame above the Noise limit requires more than one chunk", () => {283 284 285 286 287 288 289 290 291 292 293 const MAX = 65519;294 const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));295 expect(padded.byteLength).toBeGreaterThan(MAX);296 const expectedChunks = Math.ceil(padded.byteLength / MAX);297 expect(expectedChunks).toBe(2);298 });299});300301302303304305306307308309310311312describe("E7b chunking — round trip through real Noise", () => {313 it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {314 315 316 317 318 319 const MAX = 65519;320 const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);321 const padded = padSyncFrame(payload);322 expect(padded.byteLength).toBeGreaterThan(MAX);323324 325 326 327 328 329 330 const FRAME_CONTINUATION_MARKER = 0x01;331 const sentChunks: Uint8Array[] = [];332 for (let offset = 0; offset < padded.byteLength; ) {333 const remaining = padded.byteLength - offset;334 const isLast = remaining <= MAX;335 const chunkLen = isLast ? remaining : MAX - 1;336 const chunkPayload = padded.subarray(offset, offset + chunkLen);337 const plaintext = isLast338 ? chunkPayload339 : (() => {340 const out = new Uint8Array(1 + chunkPayload.byteLength);341 out[0] = FRAME_CONTINUATION_MARKER;342 out.set(chunkPayload, 1);343 return out;344 })();345 346 347 348 349 350 if (!isLast) {351 expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);352 }353 expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);354 sentChunks.push(plaintext);355 offset += chunkLen;356 }357 expect(sentChunks.length).toBeGreaterThan(1);358359 360 361 362 const reassembled: Uint8Array[] = [];363 for (let i = 0; i < sentChunks.length; i++) {364 const chunk = sentChunks[i]!;365 if (i === 0) {366 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);367 reassembled.push(chunk.subarray(1));368 } else if (i < sentChunks.length - 1) {369 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);370 reassembled.push(chunk.subarray(1));371 } else {372 expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);373 reassembled.push(chunk);374 }375 }376 const joined = new Uint8Array(377 reassembled.reduce((n, b) => n + b.byteLength, 0),378 );379 let p = 0;380 for (const part of reassembled) {381 joined.set(part, p);382 p += part.byteLength;383 }384 expect(joined.byteLength).toBe(padded.byteLength);385 expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));386 });387});