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 buildEncryptedSyncSession,7 openRelaySyncResponderSession,8 openRelaySyncSession,9 openSyncSessionAtUrl,10 SyncFrameTooLargeError,11 type SyncPeerIdentity,12} from "./sync-transport";1314const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));15const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));16const hex = (bytes: Uint8Array) =>17 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");18const RENDEZVOUS_ID = "Q".repeat(43);1920function installPeer(peerIdentityKeyHex?: string) {21 const initiator = generateKeyPair();22 const responder = generateKeyPair();23 const clientFrames: Uint8Array[] = [];24 const clientReaders: Array<(frame: Uint8Array) => void> = [];25 let socket: {26 binaryType: string;27 onopen: ((event: Event) => void) | null;28 onmessage: ((event: MessageEvent) => void) | null;29 onerror: (() => void) | null;30 onclose: ((event: CloseEvent) => void) | null;31 send(data: Uint8Array | ArrayBuffer): void;32 close(): void;33 };34 let resolveUrl!: (url: string) => void;35 const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });36 const receiveClient = async () => {37 const queued = clientFrames.shift();38 if (queued) return queued;39 return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));40 };41 const sendServer = async (frame: Uint8Array) => {42 queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));43 };4445 const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {46 const WebSocketMock = vi.fn().mockImplementation(function(url: string) {47 resolveUrl(url);48 socket = {49 binaryType: "arraybuffer",50 onopen: null,51 onmessage: null,52 onerror: null,53 onclose: null,54 send(data) {55 const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));56 const reader = clientReaders.shift();57 if (reader) reader(frame);58 else clientFrames.push(frame);59 },60 close() {61 queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));62 },63 };64 queueMicrotask(() => socket.onopen?.(new Event("open")));65 return socket;66 });67 vi.stubGlobal("WebSocket", WebSocketMock);6869 void (async () => {70 try {71 const selector = decode(await receiveClient()) as Record<string, unknown>;72 resolve(selector);73 const session = await noiseKKResponder(74 responder.secretKey,75 responder.publicKey,76 initiator.publicKey,77 sendServer,78 receiveClient,79 );80 81 82 const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));83 if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {84 throw new Error("initiator identity was not bound to KK key");85 }86 await sendServer(session.encrypt(padSyncFrame(encode({87 type: "PAIR_IDENTITY",88 device_id: "phone",89 device_name: "Pixel",90 device_type: "android",91 long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),92 }))));93 } catch (error) {94 reject(error);95 }96 })();97 });9899 const identity: SyncPeerIdentity = {100 deviceId: "browser",101 deviceName: "Firefox",102 deviceType: "extension",103 longTermPublicKeyHex: hex(initiator.publicKey),104 longTermPrivateKey: initiator.secretKey,105 };106 return { identity, responder, selectorSeen, urlSeen };107}108109afterEach(() => vi.unstubAllGlobals());110111describe("KK WebSocket reconnect transport", () => {112 it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {113 const initiatorKeys = generateKeyPair();114 const responderKeys = generateKeyPair();115 type MockSocket = {116 peer?: MockSocket;117 binaryType: string;118 readyState: number;119 onopen: ((event: Event) => void) | null;120 onmessage: ((event: MessageEvent) => void) | null;121 onerror: (() => void) | null;122 onclose: ((event: CloseEvent) => void) | null;123 send(data: Uint8Array | ArrayBuffer): void;124 close(): void;125 };126 let waiting: MockSocket | undefined;127 const WebSocketMock = vi.fn().mockImplementation(function() {128 const socket: MockSocket = {129 binaryType: "arraybuffer",130 readyState: 1,131 onopen: null,132 onmessage: null,133 onerror: null,134 onclose: null,135 send(data) {136 const frame = data instanceof Uint8Array137 ? data.slice()138 : new Uint8Array(data.slice(0));139 if (frame.byteLength === 0) return;140 const target = socket.peer;141 if (!target) return;142 queueMicrotask(() => target.onmessage?.({143 data: frame.buffer,144 } as MessageEvent));145 },146 close() {147 socket.readyState = 3;148 queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));149 },150 };151 if (waiting) {152 socket.peer = waiting;153 waiting.peer = socket;154 waiting = undefined;155 } else {156 waiting = socket;157 }158 queueMicrotask(() => socket.onopen?.(new Event("open")));159 return socket;160 });161 Object.assign(WebSocketMock, { OPEN: 1 });162 vi.stubGlobal("WebSocket", WebSocketMock);163164 const initiatorIdentity: SyncPeerIdentity = {165 deviceId: "firefox-a",166 deviceName: "Firefox A",167 deviceType: "extension",168 longTermPublicKeyHex: hex(initiatorKeys.publicKey),169 longTermPrivateKey: initiatorKeys.secretKey,170 };171 const responderIdentity: SyncPeerIdentity = {172 deviceId: "firefox-c",173 deviceName: "Firefox C",174 deviceType: "extension",175 longTermPublicKeyHex: hex(responderKeys.publicKey),176 longTermPrivateKey: responderKeys.secretKey,177 };178179 const responderPromise = openRelaySyncResponderSession(180 "wss://relay.example.test",181 responderIdentity,182 initiatorIdentity.longTermPublicKeyHex,183 RENDEZVOUS_ID,184 { keepaliveMs: 1_000_000 },185 );186 await new Promise((resolve) => setTimeout(resolve, 0));187 const initiatorPromise = openRelaySyncSession(188 "wss://relay.example.test",189 initiatorIdentity,190 responderIdentity.longTermPublicKeyHex,191 RENDEZVOUS_ID,192 );193 const [initiatorSession, responderSession] = await Promise.all([194 initiatorPromise,195 responderPromise,196 ]);197198 await initiatorSession.send(new TextEncoder().encode("direct mesh"));199 await expect(responderSession.receive()).resolves.toEqual(200 new TextEncoder().encode("direct mesh"),201 );202 await Promise.all([initiatorSession.close(), responderSession.close()]);203 });204205 it("selects paired state then completes KK with both keys pinned", async () => {206 const peer = installPeer();207 const session = await openSyncSessionAtUrl(208 "ws://127.0.0.1:49142/",209 "LAN peer",210 peer.identity,211 hex(peer.responder.publicKey),212 RENDEZVOUS_ID,213 );214215 await expect(peer.selectorSeen).resolves.toEqual({216 type: "PEACH_RECONNECT",217 rendezvous_id: RENDEZVOUS_ID,218 });219 expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));220 await session.close();221 });222223 it("rejects a post-KK identity that claims a different responder key", async () => {224 const peer = installPeer("ff".repeat(32));225 await expect(226 openSyncSessionAtUrl(227 "ws://127.0.0.1:49142/",228 "LAN peer",229 peer.identity,230 hex(peer.responder.publicKey),231 RENDEZVOUS_ID,232 ),233 ).rejects.toThrow(/identity does not match authenticated KK key/);234 });235236 it("uses the same KK wire over a credential-free wss relay target", async () => {237 const peer = installPeer();238 const session = await openRelaySyncSession(239 "wss://relay.example.test/base/",240 peer.identity,241 hex(peer.responder.publicKey),242 RENDEZVOUS_ID,243 );244245 await expect(peer.urlSeen).resolves.toBe(246 `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,247 );248 await expect(peer.selectorSeen).resolves.toEqual({249 type: "PEACH_RECONNECT",250 rendezvous_id: RENDEZVOUS_ID,251 });252 await session.close();253 });254});255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285describe("E7b chunking — wire shape", () => {286 it("a padded frame above the Noise limit requires more than one chunk", () => {287 288 289 290 291 292 293 294 295 296 297 const MAX = 65519;298 const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));299 expect(padded.byteLength).toBeGreaterThan(MAX);300 const expectedChunks = Math.ceil(padded.byteLength / MAX);301 expect(expectedChunks).toBe(2);302 });303});304305306307308309310311312313314315316describe("E7b chunking — round trip through real Noise", () => {317 it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {318 319 320 321 322 323 const MAX = 65519;324 const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);325 const padded = padSyncFrame(payload);326 expect(padded.byteLength).toBeGreaterThan(MAX);327328 329 330 331 332 333 334 const FRAME_CONTINUATION_MARKER = 0x01;335 const sentChunks: Uint8Array[] = [];336 for (let offset = 0; offset < padded.byteLength; ) {337 const remaining = padded.byteLength - offset;338 const isLast = remaining <= MAX;339 const chunkLen = isLast ? remaining : MAX - 1;340 const chunkPayload = padded.subarray(offset, offset + chunkLen);341 const plaintext = isLast342 ? chunkPayload343 : (() => {344 const out = new Uint8Array(1 + chunkPayload.byteLength);345 out[0] = FRAME_CONTINUATION_MARKER;346 out.set(chunkPayload, 1);347 return out;348 })();349 350 351 352 353 354 if (!isLast) {355 expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);356 }357 expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);358 sentChunks.push(plaintext);359 offset += chunkLen;360 }361 expect(sentChunks.length).toBeGreaterThan(1);362363 364 365 366 const reassembled: Uint8Array[] = [];367 for (let i = 0; i < sentChunks.length; i++) {368 const chunk = sentChunks[i]!;369 if (i === 0) {370 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);371 reassembled.push(chunk.subarray(1));372 } else if (i < sentChunks.length - 1) {373 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);374 reassembled.push(chunk.subarray(1));375 } else {376 expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);377 reassembled.push(chunk);378 }379 }380 const joined = new Uint8Array(381 reassembled.reduce((n, b) => n + b.byteLength, 0),382 );383 let p = 0;384 for (const part of reassembled) {385 joined.set(part, p);386 p += part.byteLength;387 }388 expect(joined.byteLength).toBe(padded.byteLength);389 expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));390 });391});392393394395396397398399400describe("PB-26-002 reassembly bound", () => {401 it("fails the receive as soon as continuation chunks exceed MAX_FRAME_BYTES", async () => {402 const noopSession = {403 encrypt: (pt: Uint8Array) => pt,404 decrypt: (_ct: Uint8Array) => {405 406 const pt = new Uint8Array(65535);407 pt[0] = 0x01; 408 return pt;409 },410 close: () => undefined,411 };412 const session = buildEncryptedSyncSession(413 {} as WebSocket,414 noopSession as Parameters<typeof buildEncryptedSyncSession>[1],415 "00".repeat(32),416 async () => new Uint8Array(0),417 );418419 420 421 let received = 0;422 await expect(423 (async () => {424 for (;;) {425 await session.receive();426 received++;427 if (received > 1000) throw new Error("bound never enforced");428 }429 })(),430 ).rejects.toBeInstanceOf(SyncFrameTooLargeError);431 expect(received).toBeLessThan(400);432 });433});