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 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 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});253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283describe("E7b chunking — wire shape", () => {284 it("a padded frame above the Noise limit requires more than one chunk", () => {285 286 287 288 289 290 291 292 293 294 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});302303304305306307308309310311312313314describe("E7b chunking — round trip through real Noise", () => {315 it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {316 317 318 319 320 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 327 328 329 330 331 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 348 349 350 351 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 362 363 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});