// Code-pairing client — the browser always dials out, never listens. // // Two code formats reach this module, and they differ ONLY in how the // endpoint is resolved and what the plaintext hello carries: // // - Pairing code (`A74H-48J3-KI32-OEP4`, pairing-code.ts) — the primary // path. The phone is the listener and the code carries its IP, port and // one-time token, so there is nothing to discover: dial it directly. // - Legacy 6-digit helper code — the speculative `peach-sync` desktop // helper, which has no address of its own, so the host is probed for. // // Everything after the hello is one protocol, shared by both: // 1. open ws://: // 2. send PEACH_HELLO { token } (pairing code) or { code } (legacy) in // PLAINTEXT // 3. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close // (socket close before ACK == wrong/expired code — the listener sends // NO error frame; it silently closes and rotates the code) // 4. run Noise NK as INITIATOR against the listener's ephemeral key // 5. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs) // 6. derive the SAS, surface it for user confirmation // 7. on confirm -> persist the PairedDevice; on reject -> abort without // persisting // // ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio // chirp flow (chirp-handshake.ts), where the extension is the responder. // The listener's `ephemeralPublicKeyHex` is the NK responder static `s`. // // The browser can never accept an incoming connection (MV3 has no listening // socket), which is why the dialler role is structural and not a choice. import { noiseNKInitiator } from "../shared/noise-js"; import { parsePairingCode } from "../shared/pairing-code"; import { derivePairingSAS } from "../shared/chirp-handshake"; import type { LocalIdentity } from "../shared/chirp-handshake"; import type { DeviceType, PairedDevice } from "../shared/sync-types"; /** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */ export const DESKTOP_PAIRING_PORT = 49142; /** Default sync port the helper announces; used when ws_port is absent. */ const DEFAULT_SYNC_PORT = 8765; export type DesktopPairingError = | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired) | "no_host_found" // no WebSocket server answered on the pairing port | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed | "sas_mismatch" // user rejected the SAS | "vault_locked"; // no master key available /** * Minimal binary WebSocket transport seam. Production wires the real * `WebSocket`; tests inject a fake. One binary message per frame. */ export interface DesktopPairingSocket { send(bytes: Uint8Array): void; /** Resolves with the next inbound binary frame, or rejects on close. */ receive(): Promise; close(): void; /** Resolves true if the socket closed before the next frame arrived. */ closedBeforeFrame(): boolean; } export interface DesktopPairingDeps { /** Change 5 — find a host answering WebSocket on the pairing port. */ findHost(port: number): Promise; /** Open a binary WebSocket to ws://:/ . */ openSocket(ip: string, port: number): Promise; /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */ getLocalIdentity(): Promise; /** Persist on user confirm. */ persistDevice(device: PairedDevice): Promise; /** Optional fast-path cache for the next sync round. */ cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise; } export interface PairWithDesktopCallbacks { /** Show the 6-digit SAS; the popup must compare against the helper's. */ onSasReady(sas: string): void; /** Pairing succeeded and the device was persisted. */ onComplete(device: PairedDevice): void; /** Pairing failed at some stage. */ onError(error: DesktopPairingError): void; } interface PairingFrame { type: "PAIR_IDENTITY"; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex?: string; ws_port?: number; vault_id?: string; // Change 1 — additive, optional } 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 hexToBytes(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; } function decodePairIdentity(bytes: Uint8Array): PairingFrame { const p = decodeJson>(bytes); if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY"); if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing"); if (typeof p.device_name !== "string") throw new Error("device_name missing"); if (typeof p.device_type !== "string") throw new Error("device_type missing"); if (typeof p.long_term_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) { throw new Error("long_term_public_key_hex is not 32-byte hex"); } return p as unknown as PairingFrame; } /** * Drive a complete desktop-helper code pairing. * * `confirm` is a function the caller resolves AFTER the user has compared * the SAS shown here against the SAS the helper displays. Resolving false * (or rejecting) aborts before any device is persisted — this is the * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts). */ export async function pairWithDesktopHelper( code: string, cb: PairWithDesktopCallbacks, deps: DesktopPairingDeps, confirm: (sas: string) => Promise, ): Promise { const pairing = parsePairingCode(code); let identity: LocalIdentity; try { identity = await deps.getLocalIdentity(); } catch { cb.onError("vault_locked"); return; } // 1) Resolve the endpoint. A pairing code already carries one; a legacy // helper code does not, so the /24 has to be probed for it. const port = pairing?.port ?? DESKTOP_PAIRING_PORT; const host = pairing?.ip ?? (await deps.findHost(DESKTOP_PAIRING_PORT)); if (!host) { cb.onError("no_host_found"); return; } // 2) Open the WebSocket. let sock: DesktopPairingSocket; try { sock = await deps.openSocket(host, port); } catch { cb.onError("no_host_found"); return; } try { // 3) Send PEACH_HELLO (PLAINTEXT binary frame). 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) }, )); // 4) Await PEACH_HELLO_ACK OR socket close. // peach-sync sends NO error frame; a wrong/expired code closes the // socket. So "receive() rejected / closed before a frame" == invalid_code. let ackBytes: Uint8Array; try { ackBytes = await sock.receive(); } catch { cb.onError("invalid_code"); return; } if (sock.closedBeforeFrame() || ackBytes.length === 0) { cb.onError("invalid_code"); return; } 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 the // missing ACK exactly like a wrong code. cb.onError("invalid_code"); return; } ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex; } catch { cb.onError("invalid_code"); return; } // 5) Noise NK as INITIATOR against the helper's ephemeral static key. const responderStatic = hexToBytes(ephemeralPublicKeyHex); const session = await noiseNKInitiator( responderStatic, async (msg) => sock.send(msg), () => sock.receive(), ); // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads. const ourFrame: PairingFrame = { type: "PAIR_IDENTITY", device_id: identity.deviceId, device_name: identity.deviceName, device_type: identity.deviceType, long_term_public_key_hex: identity.longTermPublicKeyHex, signing_public_key_hex: identity.signingPublicKeyHex, ws_port: identity.wsPort, vault_id: identity.vaultId, // Change 1 }; sock.send(session.encrypt(encodeJson(ourFrame))); const theirCipher = await sock.receive(); const theirFrame = decodePairIdentity(session.decrypt(theirCipher)); // 7) Derive the SAS (REUSED — same value the helper computes from the // shared handshake hash) and surface it for user confirmation. const sas = derivePairingSAS(session.handshakeHash); cb.onSasReady(sas); const confirmed = await confirm(sas); if (!confirmed) { cb.onError("sas_mismatch"); return; } // 8) Persist. ws_port comes from the peer frame, defaulting to 8765 (both // peers announce it, but guard it). The device-type fallback follows the // code form so a phone never lands in the registry labelled "desktop". const device: PairedDevice = { deviceId: theirFrame.device_id, deviceName: theirFrame.device_name, deviceType: (theirFrame.device_type as DeviceType) || (pairing ? "android" : "desktop"), longTermPublicKeyHex: theirFrame.long_term_public_key_hex, signingPublicKeyHex: theirFrame.signing_public_key_hex, trustSource: "direct", wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT, pairedAt: Date.now(), }; await deps.persistDevice(device); if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) { await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined); } cb.onComplete(device); } catch (err) { // The mapped error is intentionally coarse for the UI; log the real cause // so a failed pair is diagnosable from the service-worker console. if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err); cb.onError("handshake_failed"); } finally { sock.close(); } }