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 openRelaySyncSession,7 openSyncSession,8 type SyncPeerIdentity,9} from "./sync-transport";1011const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));12const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));13const hex = (bytes: Uint8Array) =>14 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");15const RENDEZVOUS_ID = "Q".repeat(43);1617function installPeer(peerIdentityKeyHex?: string) {18 const initiator = generateKeyPair();19 const responder = generateKeyPair();20 const clientFrames: Uint8Array[] = [];21 const clientReaders: Array<(frame: Uint8Array) => void> = [];22 let socket: {23 binaryType: string;24 onopen: ((event: Event) => void) | null;25 onmessage: ((event: MessageEvent) => void) | null;26 onerror: (() => void) | null;27 onclose: ((event: CloseEvent) => void) | null;28 send(data: Uint8Array | ArrayBuffer): void;29 close(): void;30 };31 let resolveUrl!: (url: string) => void;32 const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });33 const receiveClient = async () => {34 const queued = clientFrames.shift();35 if (queued) return queued;36 return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));37 };38 const sendServer = async (frame: Uint8Array) => {39 queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));40 };4142 const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {43 const WebSocketMock = vi.fn().mockImplementation(function(url: string) {44 resolveUrl(url);45 socket = {46 binaryType: "arraybuffer",47 onopen: null,48 onmessage: null,49 onerror: null,50 onclose: null,51 send(data) {52 const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));53 const reader = clientReaders.shift();54 if (reader) reader(frame);55 else clientFrames.push(frame);56 },57 close() {58 queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));59 },60 };61 queueMicrotask(() => socket.onopen?.(new Event("open")));62 return socket;63 });64 vi.stubGlobal("WebSocket", WebSocketMock);6566 void (async () => {67 try {68 const selector = decode(await receiveClient()) as Record<string, unknown>;69 resolve(selector);70 const session = await noiseKKResponder(71 responder.secretKey,72 responder.publicKey,73 initiator.publicKey,74 sendServer,75 receiveClient,76 );77 78 79 const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));80 if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {81 throw new Error("initiator identity was not bound to KK key");82 }83 await sendServer(session.encrypt(padSyncFrame(encode({84 type: "PAIR_IDENTITY",85 device_id: "phone",86 device_name: "Pixel",87 device_type: "android",88 long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),89 }))));90 } catch (error) {91 reject(error);92 }93 })();94 });9596 const identity: SyncPeerIdentity = {97 deviceId: "browser",98 deviceName: "Firefox",99 deviceType: "extension",100 longTermPublicKeyHex: hex(initiator.publicKey),101 longTermPrivateKey: initiator.secretKey,102 };103 return { identity, responder, selectorSeen, urlSeen };104}105106afterEach(() => vi.unstubAllGlobals());107108describe("KK WebSocket reconnect transport", () => {109 it("selects paired state then completes KK with both keys pinned", async () => {110 const peer = installPeer();111 const session = await openSyncSession(112 { ip: "127.0.0.1", port: 49142 },113 peer.identity,114 hex(peer.responder.publicKey),115 RENDEZVOUS_ID,116 );117118 await expect(peer.selectorSeen).resolves.toEqual({119 type: "PEACH_RECONNECT",120 rendezvous_id: RENDEZVOUS_ID,121 });122 expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));123 await session.close();124 });125126 it("rejects a post-KK identity that claims a different responder key", async () => {127 const peer = installPeer("ff".repeat(32));128 await expect(129 openSyncSession(130 { ip: "127.0.0.1", port: 49142 },131 peer.identity,132 hex(peer.responder.publicKey),133 RENDEZVOUS_ID,134 ),135 ).rejects.toThrow(/identity does not match authenticated KK key/);136 });137138 it("uses the same KK wire over a credential-free wss relay target", async () => {139 const peer = installPeer();140 const session = await openRelaySyncSession(141 "wss://relay.example.test/base/",142 peer.identity,143 hex(peer.responder.publicKey),144 RENDEZVOUS_ID,145 );146147 await expect(peer.urlSeen).resolves.toBe(148 `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,149 );150 await expect(peer.selectorSeen).resolves.toEqual({151 type: "PEACH_RECONNECT",152 rendezvous_id: RENDEZVOUS_ID,153 });154 await session.close();155 });156});157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187describe("E7b chunking — wire shape", () => {188 it("a padded frame above the Noise limit requires more than one chunk", () => {189 190 191 192 193 194 195 196 197 198 199 const MAX = 65519;200 const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));201 expect(padded.byteLength).toBeGreaterThan(MAX);202 const expectedChunks = Math.ceil(padded.byteLength / MAX);203 expect(expectedChunks).toBe(2);204 });205});206207208209210211212213214215216217218describe("E7b chunking — round trip through real Noise", () => {219 it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {220 221 222 223 224 225 const MAX = 65519;226 const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);227 const padded = padSyncFrame(payload);228 expect(padded.byteLength).toBeGreaterThan(MAX);229230 231 232 233 234 235 236 const FRAME_CONTINUATION_MARKER = 0x01;237 const sentChunks: Uint8Array[] = [];238 for (let offset = 0; offset < padded.byteLength; ) {239 const remaining = padded.byteLength - offset;240 const isLast = remaining <= MAX;241 const chunkLen = isLast ? remaining : MAX - 1;242 const chunkPayload = padded.subarray(offset, offset + chunkLen);243 const plaintext = isLast244 ? chunkPayload245 : (() => {246 const out = new Uint8Array(1 + chunkPayload.byteLength);247 out[0] = FRAME_CONTINUATION_MARKER;248 out.set(chunkPayload, 1);249 return out;250 })();251 252 253 254 255 256 if (!isLast) {257 expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);258 }259 expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);260 sentChunks.push(plaintext);261 offset += chunkLen;262 }263 expect(sentChunks.length).toBeGreaterThan(1);264265 266 267 268 const reassembled: Uint8Array[] = [];269 for (let i = 0; i < sentChunks.length; i++) {270 const chunk = sentChunks[i]!;271 if (i === 0) {272 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);273 reassembled.push(chunk.subarray(1));274 } else if (i < sentChunks.length - 1) {275 expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);276 reassembled.push(chunk.subarray(1));277 } else {278 expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);279 reassembled.push(chunk);280 }281 }282 const joined = new Uint8Array(283 reassembled.reduce((n, b) => n + b.byteLength, 0),284 );285 let p = 0;286 for (const part of reassembled) {287 joined.set(part, p);288 p += part.byteLength;289 }290 expect(joined.byteLength).toBe(padded.byteLength);291 expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));292 });293});294