// The dialling half of code pairing — shared by device pairing and vault // adoption. // // Both flows are identical up to the point where they diverge: decode a // pairing code, dial the address inside it, present the token in a plaintext // hello, and run Noise NK as the INITIATOR against the ephemeral key the // listener returns. What they then say over the encrypted channel differs // (PAIR_IDENTITY vs ADOPT_REQUEST); how they get there does not. // // The browser is always the dialler. It cannot listen, so it is always the NK // initiator and the listener's `ephemeralPublicKeyHex` is the responder static. // // Nothing here touches chrome, the vault, or a real WebSocket — the socket is // injected — because vault adoption runs before a vault exists at all. import { noiseNKInitiator } from "./noise-js"; import { derivePairingSAS, type PairingTransport } from "./pairing-handshake"; import { parsePairingCode } from "./pairing-code"; import { bytesToHex, deriveRendezvousSecret } from "./rendezvous"; /** Default pairing port used by native listeners. */ export const PAIRING_PORT = 49142; export type PairingDialError = | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired) | "no_host_found" // nothing answered at the endpoint | "handshake_failed"; // Noise NK failed or the ACK was malformed /** * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests * inject a fake. One binary message per frame. */ export interface PairingSocket { send(bytes: Uint8Array): void; /** Resolves with the next inbound binary frame, or rejects on close. */ receive(): Promise; close(): void; /** True if the socket closed before the next frame arrived. */ closedBeforeFrame(): boolean; } export interface PairingDialDeps { openSocket(ip: string, port: number): Promise; } export interface DialledPairingChannel { /** Encrypted channel — everything written to it is Noise-protected. */ channel: PairingTransport; /** The 6-digit code the user must confirm matches the listener's screen. */ sas: string; /** The address actually connected to, for the reconnect cache. */ host: string; /** Pair-only reconnect capability derived independently from the SAS. */ rendezvousSecretHex: string; socket: PairingSocket; } export type DialResult = | { ok: true; dialled: DialledPairingChannel } | { ok: false; error: PairingDialError }; /** * Dial a pairing code and bring up the encrypted channel behind it. * * On success the socket is left OPEN and owned by the caller, which must close * it. On failure it is closed here. */ export async function dialPairingChannel( code: string, deps: PairingDialDeps, ): Promise { const pairing = parsePairingCode(code); if (!pairing) return { ok: false, error: "invalid_code" }; const { ip: host, port } = pairing; let sock: PairingSocket; try { sock = await deps.openSocket(host, port); } catch { return { ok: false, error: "no_host_found" }; } try { sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex })); // A wrong or expired code gets NO error frame — the listener silently // closes and rotates. That silence IS the wrong-code signal. let ackBytes: Uint8Array; try { ackBytes = await sock.receive(); } catch { sock.close(); return { ok: false, error: "invalid_code" }; } if (sock.closedBeforeFrame() || ackBytes.length === 0) { sock.close(); return { ok: false, error: "invalid_code" }; } let ephemeralPublicKeyHex: string; try { const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes); if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") { // A non-ACK frame on a matching code should not happen; treat a missing // ACK exactly like a wrong code. sock.close(); return { ok: false, error: "invalid_code" }; } ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex; } catch { sock.close(); return { ok: false, error: "invalid_code" }; } const session = await noiseNKInitiator( hexToBytes32(ephemeralPublicKeyHex), async (msg) => sock.send(msg), () => sock.receive(), ); const channel: PairingTransport = { async send(plaintext) { sock.send(session.encrypt(plaintext)); }, async receive() { return session.decrypt(await sock.receive()); }, }; return { ok: true, dialled: { channel, sas: derivePairingSAS(session.handshakeHash), host, rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)), socket: sock, }, }; } catch (err) { // Coarse for the UI; log the real cause so a failed dial is diagnosable // from the service-worker console. if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err); sock.close(); return { ok: false, error: "handshake_failed" }; } } /** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */ export function openPairingSocket(ip: string, port: number): Promise { return new Promise((resolve, reject) => { let ws: WebSocket; try { ws = new WebSocket(`ws://${ip}:${port}/`); } catch (err) { reject(err instanceof Error ? err : new Error("websocket open failed")); return; } ws.binaryType = "arraybuffer"; const inbox: Uint8Array[] = []; const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = []; let opened = false; let closed = false; // True iff the socket closed with no frame delivered for the pending read — // the wrong/expired-code signal (the listener sends no error frame). let sawCloseBeforeFrame = false; ws.onopen = () => { opened = true; resolve(sock); }; ws.onmessage = (ev: MessageEvent) => { const bytes = new Uint8Array(ev.data as ArrayBuffer); const r = readers.shift(); if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); } else inbox.push(bytes); }; ws.onerror = () => { if (!opened) reject(new Error("websocket connection failed")); }; ws.onclose = () => { closed = true; if (readers.length) { sawCloseBeforeFrame = true; while (readers.length) readers.shift()!.reject(new Error("socket closed")); } if (!opened) reject(new Error("socket closed before open")); }; const sock: PairingSocket = { send(bytes: Uint8Array) { ws.send(bytes as Uint8Array); }, receive(): Promise { const queued = inbox.shift(); if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); } if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); } return new Promise((res, rej) => readers.push({ resolve: res, reject: rej })); }, close() { try { ws.close(); } catch { /* ignore */ } }, closedBeforeFrame() { return sawCloseBeforeFrame; }, }; }); } function encodeJson(obj: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(obj)); } function decodeJson(bytes: Uint8Array): T { return JSON.parse(new TextDecoder().decode(bytes)) as T; } function hexToBytes32(hex: string): Uint8Array { if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex"); const out = new Uint8Array(32); for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; }