code-pairing-bg.ts

5.5 KB · raw

1// Background wiring for code pairing.2//3// Keeps `code-pairing.ts` a PURE, dependency-injected client (so its unit tests4// run without chrome / vault / WebSocket). This module supplies the production5// `CodePairingDeps` and the single-flight SAS/confirm round-trip that bridges6// the blocking `confirm(sas)` callback to the popup via runtime messages:7//8//   PAIR_WITH_CODE    → startCodePairing()            (popup → bg, messages.ts)9//   CODE_PAIR_SAS     ← push when onSasReady fires    (bg → popup)10//   CODE_PAIR_CONFIRM → resolveCodePairingConfirm()   (popup → bg, messages.ts)11//   CODE_PAIR_RESULT  ← push on complete/error        (bg → popup)1213import {14  pairWithCode,15  type CodePairingDeps,16  type CodePairingCallbacks,17} from "./code-pairing";18import { openPairingSocket } from "../shared/pairing-dial";19import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";20import { buildLocalIdentity } from "./chirp-identity";21import { computeVaultId } from "../shared/sync-identity";22import { isUnlocked, getRootKeyHex } from "./vault";23import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";24import { discoverPeachSync } from "./desktop-pairing-mdns";25import { getLocalSubnet } from "../shared/local-network";26import type { LocalIdentity } from "../shared/chirp-handshake";2728function hexToBytesLocal(hex: string): Uint8Array {29  const out = new Uint8Array(hex.length / 2);30  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);31  return out;32}3334/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */35function push(type: string, payload: unknown): void {36  try {37    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;38    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {39      (maybe as Promise<unknown>).catch(() => undefined);40    }41  } catch {42    // Popup closed / no receiver — fine.43  }44}4546/** Production dependency set — real probe, WebSocket, identity, and storage. */47export function productionCodePairingDeps(): CodePairingDeps {48  return {49    async findHost(port) {50      // Localhost first: peach-sync runs on the same machine as the extension.51      // This works in every browser without mDNS or WebRTC, and is the fastest52      // path. The daemon binds 0.0.0.0 so 127.0.0.1 is always reachable.53      if (await wsPortOpen("127.0.0.1", port, 800)) return "127.0.0.1";54      // mDNS next — instant when chrome.mdns is available (Chrome/Chromium).55      const discovered = await discoverPeachSync();56      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;57      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).58      const subnet = await getLocalSubnet();59      if (!subnet) return null;60      return probeSubnetForPort(subnet, port);61    },62    openSocket: (ip, port) => openPairingSocket(ip, port),63    async getLocalIdentity(): Promise<LocalIdentity> {64      if (!isUnlocked()) throw new Error("vault_locked");65      const masterKeyHex = getRootKeyHex();66      if (!masterKeyHex) throw new Error("vault_locked");67      // SEV-2-B: derive from the stable ROOT key so pairing survives a68      // master-password change. Zero the copy when done.69      const masterKey = hexToBytesLocal(masterKeyHex);70      try {71        const base = await buildLocalIdentity(masterKey);72        const vaultId = await computeVaultId(masterKey);73        return { ...base, vaultId };74      } finally {75        masterKey.fill(0);76      }77    },78    persistDevice: (d) => upsertPairedDevice(d),79    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),80  };81}8283// ── Single in-flight pairing per worker ──────────────────────────────────────84// One pairing at a time. The blocking confirm() resolver is parked here while85// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.86let pendingConfirm: ((confirmed: boolean) => void) | null = null;87let inFlight = false;8889function clearPairingState(): void {90  pendingConfirm = null;91  inFlight = false;92}9394export function isCodePairingInFlight(): boolean {95  return inFlight;96}9798/**99 * Kick off a code pairing. Returns immediately; the SAS and the terminal100 * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT101 * pushes. `deps` is injectable for tests.102 */103export function startCodePairing(104  code: string,105  deps: CodePairingDeps = productionCodePairingDeps(),106): void {107  inFlight = true;108  const cb: CodePairingCallbacks = {109    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),110    onComplete: (device) => {111      push("CODE_PAIR_RESULT", { status: "complete", device });112      clearPairingState();113    },114    onError: (error) => {115      push("CODE_PAIR_RESULT", { status: "error", error });116      clearPairingState();117    },118  };119  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.120  const confirm = (_sas: string): Promise<boolean> =>121    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });122123  void pairWithCode(code, cb, deps, confirm).finally(() => {124    // onComplete/onError already cleared state; this is the backstop for an125    // unexpected throw that skipped the callbacks.126    if (inFlight) clearPairingState();127  });128}129130/** Resolve the parked confirm() with the user's decision from the popup. */131export function resolveCodePairingConfirm(confirmed: boolean): void {132  const resolve = pendingConfirm;133  pendingConfirm = null;134  if (resolve) resolve(confirmed);135}