1import { afterEach, describe, expect, it, vi } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseKKResponder } from "./noise-js";4import { SYNC_LIMITS } from "./sync-limits";5import { padSyncFrame, unpadSyncFrame } from "./sync-padding";6import {7 buildEncryptedSyncSession,8 openRelaySyncResponderSession,9 openRelaySyncSession,10 openSyncSessionAtUrl,11 SyncFrameTooLargeError,12 type SyncPeerIdentity,13} from "./sync-transport";1415const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));16const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));17const hex = (bytes: Uint8Array) =>18 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");19const RENDEZVOUS_ID = "Q".repeat(43);2021function installPeer(peerIdentityKeyHex?: string) {22 const initiator = generateKeyPair();23 const responder = generateKeyPair();24 const clientFrames: Uint8Array[] = [];25 const clientReaders: Array<(frame: Uint8Array) => void> = [];26 let socket: {27 binaryType: string;28 onopen: ((event: Event) => void) | null;29 onmessage: ((event: MessageEvent) => void) | null;30 onerror: (() => void) | null;31 onclose: ((event: CloseEvent) => void) | null;32 send(data: Uint8Array | ArrayBuffer): void;33 close(): void;34 };35 let resolveUrl!: (url: string) => void;36 const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });37 const receiveClient = async () => {38 const queued = clientFrames.shift();39 if (queued) return queued;40 return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));41 };42 const sendServer = async (frame: Uint8Array) => {43 queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));44 };4546 const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {47 const WebSocketMock = vi.fn().mockImplementation(function(url: string) {48 resolveUrl(url);49 socket = {50 binaryType: "arraybuffer",51 onopen: null,52 onmessage: null,53 onerror: null,54 onclose: null,55 send(data) {56 const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));57 const reader = clientReaders.shift();58 if (reader) reader(frame);59 else clientFrames.push(frame);60 },61 close() {62 queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));63 },64 };65 queueMicrotask(() => socket.onopen?.(new Event("open")));66 return socket;67 });68 vi.stubGlobal("WebSocket", WebSocketMock);6970 void (async () => {71 try {72 const selector = decode(await receiveClient()) as Record<string, unknown>;73 resolve(selector);74 const session = await noiseKKResponder(75 responder.secretKey,76 responder.publicKey,77 initiator.publicKey,78 sendServer,79 receiveClient,80 );81 82 83 const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));84 if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {85 throw new Error("initiator identity was not bound to KK key");86 }87 await sendServer(session.encrypt(padSyncFrame(encode({88 type: "PAIR_IDENTITY",89 device_id: "phone",90 device_name: "Pixel",91 device_type: "android",92 long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),93 }))));94 } catch (error) {95 reject(error);96 }97 })();98 });99100 const identity: SyncPeerIdentity = {101 deviceId: "browser",102 deviceName: "Firefox",103 deviceType: "extension",104 longTermPublicKeyHex: hex(initiator.publicKey),105 longTermPrivateKey: initiator.secretKey,106 };107 return { identity, responder, selectorSeen, urlSeen };108}109110afterEach(() => vi.unstubAllGlobals());111112describe("KK WebSocket reconnect transport", () => {113 it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {114 const initiatorKeys = generateKeyPair();115 const responderKeys = generateKeyPair();116 type MockSocket = {117 peer?: MockSocket;118 binaryType: string;119 readyState: number;120 onopen: ((event: Event) => void) | null;121 onmessage: ((event: MessageEvent) => void) | null;122 onerror: (() => void) | null;123 onclose: ((event: CloseEvent) => void) | null;124 send(data: Uint8Array | ArrayBuffer): void;125 close(): void;126 };127 let waiting: MockSocket | undefined;128 const WebSocketMock = vi.fn().mockImplementation(function() {129 const socket: MockSocket = {130 binaryType: "arraybuffer",131 readyState: 1,132 onopen: null,133 onmessage: null,134 onerror: null,135 onclose: null,136 send(data) {137 const frame = data instanceof Uint8Array138 ? data.slice()139 : new Uint8Array(data.slice(0));140 if (frame.byteLength === 0) return;141 const target = socket.peer;142 if (!target) return;143 queueMicrotask(() => target.onmessage?.({144 data: frame.buffer,145 } as MessageEvent));146 },147 close() {148 socket.readyState = 3;149 queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));150 },151 };152 if (waiting) {153 socket.peer = waiting;154 waiting.peer = socket;155 waiting = undefined;156 } else {157 waiting = socket;158 }159 queueMicrotask(() => socket.onopen?.(new Event("open")));160 return socket;161 });162 Object.assign(WebSocketMock, { OPEN: 1 });163 vi.stubGlobal("WebSocket", WebSocketMock);164165 const initiatorIdentity: SyncPeerIdentity = {166 deviceId: "firefox-a",167 deviceName: "Firefox A",168 deviceType: "extension",169 longTermPublicKeyHex: hex(initiatorKeys.publicKey),170 longTermPrivateKey: initiatorKeys.secretKey,171 };172 const responderIdentity: SyncPeerIdentity = {173 deviceId: "firefox-c",174 deviceName: "Firefox C",175 deviceType: "extension",176 longTermPublicKeyHex: hex(responderKeys.publicKey),177 longTermPrivateKey: responderKeys.secretKey,178 };179180 const responderPromise = openRelaySyncResponderSession(181 "wss://relay.example.test",182 responderIdentity,183 initiatorIdentity.longTermPublicKeyHex,184 RENDEZVOUS_ID,185 { keepaliveMs: 1_000_000 },186 );187 await new Promise((resolve) => setTimeout(resolve, 0));188 const initiatorPromise = openRelaySyncSession(189 "wss://relay.example.test",190 initiatorIdentity,191 responderIdentity.longTermPublicKeyHex,192 RENDEZVOUS_ID,193 );194 const [initiatorSession, responderSession] = await Promise.all([195 initiatorPromise,196 responderPromise,197 ]);198199 await initiatorSession.send(new TextEncoder().encode("direct mesh"));200 await expect(responderSession.receive()).resolves.toEqual(201 new TextEncoder().encode("direct mesh"),202 );203 await Promise.all([initiatorSession.close(), responderSession.close()]);204 });205206 it("selects paired state then completes KK with both keys pinned", async () => {207 const peer = installPeer();208 const session = await openSyncSessionAtUrl(209 "ws://127.0.0.1:49142/",210 "LAN peer",211 peer.identity,212 hex(peer.responder.publicKey),213 RENDEZVOUS_ID,214 );215216 await expect(peer.selectorSeen).resolves.toEqual({217 type: "PEACH_RECONNECT",218 rendezvous_id: RENDEZVOUS_ID,219 });220 expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));221 await session.close();222 });223224 it("rejects a post-KK identity that claims a different responder key", async () => {225 const peer = installPeer("ff".repeat(32));226 await expect(227 openSyncSessionAtUrl(228 "ws://127.0.0.1:49142/",229 "LAN peer",230 peer.identity,231 hex(peer.responder.publicKey),232 RENDEZVOUS_ID,233 ),234 ).rejects.toThrow(/identity does not match authenticated KK key/);235 });236237 it("uses the same KK wire over a credential-free wss relay target", async () => {238 const peer = installPeer();239 const session = await openRelaySyncSession(240 "wss://relay.example.test/base/",241 peer.identity,242 hex(peer.responder.publicKey),243 RENDEZVOUS_ID,244 );245246 await expect(peer.urlSeen).resolves.toBe(247 `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,248 );249 await expect(peer.selectorSeen).resolves.toEqual({250 type: "PEACH_RECONNECT",251 rendezvous_id: RENDEZVOUS_ID,252 });253 await session.close();254 });255});256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286describe("E7b chunking — wire shape", () => {287 it("a padded frame above the Noise limit requires more than one chunk", () => {288 289 290 291 292 293 294 295 296 297 298 const MAX = 65519;299 const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));300 expect(padded.byteLength).toBeGreaterThan(MAX);301 const expectedChunks = Math.ceil(padded.byteLength / MAX);302 expect(expectedChunks).toBe(2);303 });304});305306307308309310311312313314315316317describe("E7b chunking — round trip through real Noise", () => {318 it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {319 320 321 322 323 324 const MAX = 65519;325 const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);326 const padded = padSyncFrame(payload);327 expect(padded.byteLength).toBeGreaterThan(MAX);328329 330 331 332 333 334 335 const FRAME_CONTINUATION_MARKER = 0x01;336 const sentChunks: Uint8Array[] = [];337 for (let offset = 0; offset < padded.byteLength; ) {338 const remaining = padded.byteLength - offset;339 const isLast = remaining <= MAX;340 const chunkLen = isLast ? remaining : MAX - 1;341 const chunkPayload = padded.subarray(offset, offset + chunkLen);342 const plaintext = isLast343 ? chunkPayload344 : (() => {345 const out = new Uint8Array(1 + chunkPayload.byteLength);346 out[0] = FRAME_CONTINUATION_MARKER;347 out.set(chunkPayload, 1);348 return out;349 })();350 351 352 353 354 355 if (!isLast) {356 expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);357 }358 expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);359 sentChunks.push(plaintext);360 offset += chunkLen;361 }362 expect(sentChunks.length).toBeGreaterThan(1);363364 365 366 367 const reassembled: Uint8Array[] = [];368 for (let i = 0; i < sentChunks.length; i++) {369 const chunk = sentChunks[i]!;370 if (i === 0) {371 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);372 reassembled.push(chunk.subarray(1));373 } else if (i < sentChunks.length - 1) {374 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);375 reassembled.push(chunk.subarray(1));376 } else {377 expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);378 reassembled.push(chunk);379 }380 }381 const joined = new Uint8Array(382 reassembled.reduce((n, b) => n + b.byteLength, 0),383 );384 let p = 0;385 for (const part of reassembled) {386 joined.set(part, p);387 p += part.byteLength;388 }389 expect(joined.byteLength).toBe(padded.byteLength);390 expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));391 });392});393394395396397398399400401describe("PB-26-002 reassembly bound", () => {402 it("fails the receive as soon as continuation chunks exceed MAX_FRAME_BYTES", async () => {403 const noopSession = {404 encrypt: (pt: Uint8Array) => pt,405 decrypt: (_ct: Uint8Array) => {406 407 const pt = new Uint8Array(65535);408 pt[0] = 0x01; 409 return pt;410 },411 close: () => undefined,412 };413 const session = buildEncryptedSyncSession(414 {} as WebSocket,415 noopSession as Parameters<typeof buildEncryptedSyncSession>[1],416 "00".repeat(32),417 async () => new Uint8Array(0),418 );419420 421 422 let received = 0;423 await expect(424 (async () => {425 for (;;) {426 await session.receive();427 received++;428 if (received > 1000) throw new Error("bound never enforced");429 }430 })(),431 ).rejects.toBeInstanceOf(SyncFrameTooLargeError);432 expect(received).toBeLessThan(400);433 });434});435436437438439440441describe("PB-26-602 final-chunk bound", () => {442 it("rejects a final chunk that overshoots MAX_FRAME_BYTES", async () => {443 const max = SYNC_LIMITS.MAX_FRAME_BYTES;444 let calls = 0;445 const noopSession = {446 encrypt: (pt: Uint8Array) => pt,447 decrypt: () => {448 calls += 1;449 if (calls === 1) {450 451 const pt = new Uint8Array(max + 1);452 pt[0] = 0x01; 453 return pt;454 }455 return new Uint8Array(1);456 },457 close: () => undefined,458 };459 const session = buildEncryptedSyncSession(460 {} as WebSocket,461 noopSession as unknown as Parameters<typeof buildEncryptedSyncSession>[1],462 "00".repeat(32),463 async () => new Uint8Array(0),464 );465466 await expect(session.receive()).rejects.toBeInstanceOf(SyncFrameTooLargeError);467 });468});469470describe("PB-26-705 continuation deadline", () => {471 it("aborts an incomplete continued frame at one fixed deadline", async () => {472 vi.useFakeTimers();473 let reads = 0;474 const close = vi.fn();475 const session = buildEncryptedSyncSession(476 { close } as unknown as WebSocket,477 {478 encrypt: (bytes: Uint8Array) => bytes,479 decrypt: () => new Uint8Array([1, 0]),480 close: vi.fn(),481 } as unknown as Parameters<typeof buildEncryptedSyncSession>[1],482 "00".repeat(32),483 () => {484 reads += 1;485 return reads === 1 ? Promise.resolve(new Uint8Array(1)) : new Promise(() => undefined);486 },487 () => undefined,488 100,489 );490491 const pending = session.receive();492 const rejection = expect(pending).rejects.toThrow(/reassembly timeout/i);493 await Promise.resolve();494 await vi.advanceTimersByTimeAsync(100);495 await rejection;496 expect(close).toHaveBeenCalledWith(1008, "sync frame timeout");497 vi.useRealTimers();498 });499});