desktop-pairing-bg.ts

7.7 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 DesktopPairingSocket,18  type PairWithDesktopCallbacks,19} from "./desktop-pairing";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/** Adapt a real `WebSocket` to the `DesktopPairingSocket` seam (binary frames). */48export function openBinaryWebSocket(ip: string, port: number): Promise<DesktopPairingSocket> {49  return new Promise((resolve, reject) => {50    let ws: WebSocket;51    try {52      ws = new WebSocket(`ws://${ip}:${port}/`);53    } catch (err) {54      reject(err instanceof Error ? err : new Error("websocket open failed"));55      return;56    }57    ws.binaryType = "arraybuffer";5859    const inbox: Uint8Array[] = [];60    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];61    let opened = false;62    let closed = false;63    // True iff the socket closed with no frame delivered for the pending read —64    // the wrong/expired-code signal (peach-sync sends no error frame).65    let sawCloseBeforeFrame = false;6667    ws.onopen = () => {68      opened = true;69      resolve(sock);70    };71    ws.onmessage = (ev: MessageEvent) => {72      const bytes = new Uint8Array(ev.data as ArrayBuffer);73      const r = readers.shift();74      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }75      else inbox.push(bytes);76    };77    ws.onerror = () => {78      if (!opened) reject(new Error("websocket connection failed"));79    };80    ws.onclose = () => {81      closed = true;82      if (readers.length) {83        sawCloseBeforeFrame = true;84        while (readers.length) readers.shift()!.reject(new Error("socket closed"));85      }86      if (!opened) reject(new Error("socket closed before open"));87    };8889    const sock: DesktopPairingSocket = {90      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },91      receive(): Promise<Uint8Array> {92        const queued = inbox.shift();93        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }94        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }95        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));96      },97      close() { try { ws.close(); } catch { /* ignore */ } },98      closedBeforeFrame() { return sawCloseBeforeFrame; },99    };100  });101}102103/** Production dependency set — real probe, WebSocket, identity, and storage. */104export function productionDesktopPairingDeps(): DesktopPairingDeps {105  return {106    async findHost(port) {107      // mDNS first — instant and reliable when chrome.mdns is available. But108      // chrome.mdns can hand back stale/duplicate cache entries (e.g. a host109      // that's since gone, or one not running `peach-sync pair`), so only commit110      // to a discovered host that actually accepts a pairing connection right111      // now; otherwise fall through to the probe.112      const discovered = await discoverPeachSync();113      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;114      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).115      const subnet = await getLocalSubnet();116      if (!subnet) return null;117      return probeSubnetForPort(subnet, port);118    },119    openSocket: (ip, port) => openBinaryWebSocket(ip, port),120    async getLocalIdentity(): Promise<LocalIdentity> {121      if (!isUnlocked()) throw new Error("vault_locked");122      const masterKeyHex = getRootKeyHex();123      if (!masterKeyHex) throw new Error("vault_locked");124      // SEV-2-B: derive from the stable ROOT key so pairing survives a125      // master-password change. Zero the copy when done.126      const masterKey = hexToBytesLocal(masterKeyHex);127      try {128        const base = await buildLocalIdentity(masterKey);129        const vaultId = await computeVaultId(masterKey);130        return { ...base, vaultId };131      } finally {132        masterKey.fill(0);133      }134    },135    persistDevice: (d) => upsertPairedDevice(d),136    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),137  };138}139140// ── Single in-flight pairing per worker ──────────────────────────────────────141// One pairing at a time. The blocking confirm() resolver is parked here while142// the popup shows the SAS; DESKTOP_PAIR_CONFIRM resolves it.143let pendingConfirm: ((confirmed: boolean) => void) | null = null;144let inFlight = false;145146function clearPairingState(): void {147  pendingConfirm = null;148  inFlight = false;149}150151export function isDesktopPairingInFlight(): boolean {152  return inFlight;153}154155/**156 * Kick off a desktop pairing. Returns immediately; the SAS and the terminal157 * result are delivered to the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT158 * pushes. `deps` is injectable for tests.159 */160export function startDesktopPairing(161  code: string,162  deps: DesktopPairingDeps = productionDesktopPairingDeps(),163): void {164  inFlight = true;165  const cb: PairWithDesktopCallbacks = {166    onSasReady: (sas) => push("DESKTOP_PAIR_SAS", { sas }),167    onComplete: (device) => {168      push("DESKTOP_PAIR_RESULT", { status: "complete", device });169      clearPairingState();170    },171    onError: (error) => {172      push("DESKTOP_PAIR_RESULT", { status: "error", error });173      clearPairingState();174    },175  };176  // The confirm callback blocks on the popup's DESKTOP_PAIR_CONFIRM reply.177  const confirm = (_sas: string): Promise<boolean> =>178    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });179180  void pairWithDesktopHelper(code, cb, deps, confirm).finally(() => {181    // onComplete/onError already cleared state; this is the backstop for an182    // unexpected throw that skipped the callbacks.183    if (inFlight) clearPairingState();184  });185}186187/** Resolve the parked confirm() with the user's decision from the popup. */188export function resolveDesktopPairingConfirm(confirmed: boolean): void {189  const resolve = pendingConfirm;190  pendingConfirm = null;191  if (resolve) resolve(confirmed);192}