// Desktop helper code-pairing client (Change 3). // // Pairs the extension with the `peach-sync` desktop helper: // 1. probe the /24 for a host accepting WebSocket on the pairing port // 2. open ws://:49142 // 3. send PEACH_HELLO { code } in PLAINTEXT // 4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close // (socket close before ACK == wrong/expired code — peach-sync sends // NO error frame; it silently closes and rotates the code) // 5. run Noise NK as INITIATOR against the helper's ephemeral key // 6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs) // 7. derive the SAS, surface it for user confirmation // 8. on confirm -> persist the PairedDevice (deviceType "desktop"); 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 helper's `ephemeralPublicKeyHex` is the NK responder static `s`. import { noiseNKInitiator } from "../shared/noise-js"; 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 { // The helper compares the code byte-for-byte against a [6]u8. Normalise to // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2). const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6); let identity: LocalIdentity; try { identity = await deps.getLocalIdentity(); } catch { cb.onError("vault_locked"); return; } // 1) Find a host on the pairing port. const host = 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, DESKTOP_PAIRING_PORT); } catch { cb.onError("no_host_found"); return; } try { // 3) Send PEACH_HELLO (PLAINTEXT binary frame). sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode })); // 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. deviceType "desktop"; ws_port from the helper frame, // defaulting to 8765 (the helper always announces 8765, but guard it). const device: PairedDevice = { deviceId: theirFrame.device_id, deviceName: theirFrame.device_name, deviceType: (theirFrame.device_type as DeviceType) || "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(); } }