desktop-pairing-bg.ts

6.0 KB · raw

1// Background wiring for desktop helper code pairing (Change 3 §Wiring).2//3// Keeps `desktop-pairing.ts` a PURE, dependency-injected client (so its unit4// tests run without chrome / vault / WebSocket). This module supplies the5// production `DesktopPairingDeps`, the real `WebSocket` adapter, and the6// single-flight SAS/confirm round-trip that bridges the blocking7// `confirm(sas)` callback to the popup via runtime messages:8//9//   PAIR_WITH_DESKTOP  → startDesktopPairing()         (popup → bg, messages.ts)10//   DESKTOP_PAIR_SAS   ← push when onSasReady fires     (bg → popup)11//   DESKTOP_PAIR_CONFIRM → resolveDesktopPairingConfirm (popup → bg, messages.ts)12//   DESKTOP_PAIR_RESULT ← push on complete/error        (bg → popup)1314import {15  pairWithDesktopHelper,16  type DesktopPairingDeps,17  type PairWithDesktopCallbacks,18} from "./desktop-pairing";19import { openPairingSocket } from "../shared/pairing-dial";20import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";21import { buildLocalIdentity } from "./chirp-identity";22import { computeVaultId } from "../shared/sync-identity";23import { isUnlocked, getRootKeyHex } from "./vault";24import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";25import { discoverPeachSync } from "./desktop-pairing-mdns";26import { getLocalSubnet } from "../shared/local-network";27import type { LocalIdentity } from "../shared/chirp-handshake";2829function hexToBytesLocal(hex: string): Uint8Array {30  const out = new Uint8Array(hex.length / 2);31  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);32  return out;33}3435/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */36function push(type: string, payload: unknown): void {37  try {38    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;39    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {40      (maybe as Promise<unknown>).catch(() => undefined);41    }42  } catch {43    // Popup closed / no receiver — fine.44  }45}4647/**48 * The WebSocket adapter moved to `shared/pairing-dial.ts` alongside the seam it49 * implements, so vault adoption (which runs in the popup, before a vault50 * exists) dials through the same code. Re-exported under its original name51 * because that is what existing callers import.52 */53export { openPairingSocket as openBinaryWebSocket };5455/** Production dependency set — real probe, WebSocket, identity, and storage. */56export function productionDesktopPairingDeps(): DesktopPairingDeps {57  return {58    async findHost(port) {59      // Localhost first: peach-sync runs on the same machine as the extension.60      // This works in every browser without mDNS or WebRTC, and is the fastest61      // path. The daemon binds 0.0.0.0 so 127.0.0.1 is always reachable.62      if (await wsPortOpen("127.0.0.1", port, 800)) return "127.0.0.1";63      // mDNS next — instant when chrome.mdns is available (Chrome/Chromium).64      const discovered = await discoverPeachSync();65      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;66      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).67      const subnet = await getLocalSubnet();68      if (!subnet) return null;69      return probeSubnetForPort(subnet, port);70    },71    openSocket: (ip, port) => openPairingSocket(ip, port),72    async getLocalIdentity(): Promise<LocalIdentity> {73      if (!isUnlocked()) throw new Error("vault_locked");74      const masterKeyHex = getRootKeyHex();75      if (!masterKeyHex) throw new Error("vault_locked");76      // SEV-2-B: derive from the stable ROOT key so pairing survives a77      // master-password change. Zero the copy when done.78      const masterKey = hexToBytesLocal(masterKeyHex);79      try {80        const base = await buildLocalIdentity(masterKey);81        const vaultId = await computeVaultId(masterKey);82        return { ...base, vaultId };83      } finally {84        masterKey.fill(0);85      }86    },87    persistDevice: (d) => upsertPairedDevice(d),88    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),89  };90}9192// ── Single in-flight pairing per worker ──────────────────────────────────────93// One pairing at a time. The blocking confirm() resolver is parked here while94// the popup shows the SAS; DESKTOP_PAIR_CONFIRM resolves it.95let pendingConfirm: ((confirmed: boolean) => void) | null = null;96let inFlight = false;9798function clearPairingState(): void {99  pendingConfirm = null;100  inFlight = false;101}102103export function isDesktopPairingInFlight(): boolean {104  return inFlight;105}106107/**108 * Kick off a desktop pairing. Returns immediately; the SAS and the terminal109 * result are delivered to the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT110 * pushes. `deps` is injectable for tests.111 */112export function startDesktopPairing(113  code: string,114  deps: DesktopPairingDeps = productionDesktopPairingDeps(),115): void {116  inFlight = true;117  const cb: PairWithDesktopCallbacks = {118    onSasReady: (sas) => push("DESKTOP_PAIR_SAS", { sas }),119    onComplete: (device) => {120      push("DESKTOP_PAIR_RESULT", { status: "complete", device });121      clearPairingState();122    },123    onError: (error) => {124      push("DESKTOP_PAIR_RESULT", { status: "error", error });125      clearPairingState();126    },127  };128  // The confirm callback blocks on the popup's DESKTOP_PAIR_CONFIRM reply.129  const confirm = (_sas: string): Promise<boolean> =>130    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });131132  void pairWithDesktopHelper(code, cb, deps, confirm).finally(() => {133    // onComplete/onError already cleared state; this is the backstop for an134    // unexpected throw that skipped the callbacks.135    if (inFlight) clearPairingState();136  });137}138139/** Resolve the parked confirm() with the user's decision from the popup. */140export function resolveDesktopPairingConfirm(confirmed: boolean): void {141  const resolve = pendingConfirm;142  pendingConfirm = null;143  if (resolve) resolve(confirmed);144}