// Device pairing over a code — the PAIR_IDENTITY half. // // The dialling half (decode the code, connect, plaintext hello, Noise NK) lives // in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks // ADOPT_* frames over the same channel instead. This module is only what // happens once that channel is open: // // 1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs) // 2. derive the SAS, surface it for user confirmation // 3. on confirm -> persist the PairedDevice; on reject -> abort without // persisting // // Two code forms reach here. A pairing code (`A74H-48J3-KI32-OEP4`) is the // primary path: the phone listens and the code carries its address. A legacy // 6-digit helper code has no address, so the host is probed for. import { dialPairingChannel, type PairingDialDeps } from "../shared/pairing-dial"; import type { LocalIdentity } from "../shared/chirp-handshake"; import { parsePairingCode } from "../shared/pairing-code"; import type { DeviceType, PairedDevice } from "../shared/sync-types"; /** Default sync port a peer announces; used when ws_port is absent. */ const DEFAULT_SYNC_PORT = 8765; export type CodePairingError = | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired) | "no_host_found" // nothing answered at the endpoint | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed | "sas_mismatch" // user rejected the SAS | "vault_locked"; // no master key available export interface CodePairingDeps extends PairingDialDeps { /** 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 CodePairingCallbacks { /** Show the 6-digit SAS; the popup must compare it against the peer's. */ onSasReady(sas: string): void; /** Pairing succeeded and the device was persisted. */ onComplete(device: PairedDevice): void; /** Pairing failed at some stage. */ onError(error: CodePairingError): 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 decodePairIdentity(bytes: Uint8Array): PairingFrame { const p = JSON.parse(new TextDecoder().decode(bytes)) as Record; 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 code pairing. * * `confirm` is a function the caller resolves AFTER the user has compared the * SAS shown here against the SAS the peer displays. Resolving false (or * rejecting) aborts before any device is persisted — this is the out-of-band * MITM defence (see the SECURITY note in chirp-handshake.ts), and it, not the * pairing token, is what actually authenticates the peer. */ export async function pairWithCode( code: string, cb: CodePairingCallbacks, deps: CodePairingDeps, confirm: (sas: string) => Promise, ): Promise { let identity: LocalIdentity; try { identity = await deps.getLocalIdentity(); } catch { cb.onError("vault_locked"); return; } const dial = await dialPairingChannel(code, deps); if (!dial.ok) { cb.onError(dial.error); return; } const { channel, sas, host, socket } = dial.dialled; try { // Initiator (browser) sends its identity FIRST, then reads the peer's. 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 }; await channel.send(encodeJson(ourFrame)); const theirFrame = decodePairIdentity(await channel.receive()); cb.onSasReady(sas); const confirmed = await confirm(sas); if (!confirmed) { cb.onError("sas_mismatch"); return; } // 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) || (parsePairingCode(code) ? "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) { if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err); cb.onError("handshake_failed"); } finally { socket.close(); } }