// 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 "./chirp-handshake"; import { parsePairingCode } from "./pairing-code"; /** The pairing-only port a legacy helper code implies. */ 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 { /** * Legacy 6-digit codes carry no address, so a host has to be found. Omit it * where only pairing codes are accepted — a code without an address then * simply has nowhere to go. */ findHost?(port: number): Promise; 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; 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); // A pairing code already carries an endpoint; a legacy helper code does not, // so the /24 has to be probed for it. const port = pairing?.port ?? PAIRING_PORT; const host = pairing?.ip ?? (await deps.findHost?.(PAIRING_PORT)) ?? null; if (!host) return { ok: false, error: "no_host_found" }; let sock: PairingSocket; try { sock = await deps.openSocket(host, port); } catch { return { ok: false, error: "no_host_found" }; } try { // The legacy helper compares the code byte-for-byte against a [6]u8, so // that form is normalised to exactly 6 zero-padded ASCII digits (ground // truth §2); a pairing token is sent as the hex it decoded to. sock.send(encodeJson( pairing ? { type: "PEACH_HELLO", token: pairing.tokenHex } : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) }, )); // 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, 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; }