desktop-pairing-bg.ts

7.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 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 } from "./desktop-pairing-probe";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/** Adapt a real `WebSocket` to the `DesktopPairingSocket` seam (binary frames). */47export function openBinaryWebSocket(ip: string, port: number): Promise<DesktopPairingSocket> {48  return new Promise((resolve, reject) => {49    let ws: WebSocket;50    try {51      ws = new WebSocket(`ws://${ip}:${port}/`);52    } catch (err) {53      reject(err instanceof Error ? err : new Error("websocket open failed"));54      return;55    }56    ws.binaryType = "arraybuffer";5758    const inbox: Uint8Array[] = [];59    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];60    let opened = false;61    let closed = false;62    // True iff the socket closed with no frame delivered for the pending read —63    // the wrong/expired-code signal (peach-sync sends no error frame).64    let sawCloseBeforeFrame = false;6566    ws.onopen = () => {67      opened = true;68      resolve(sock);69    };70    ws.onmessage = (ev: MessageEvent) => {71      const bytes = new Uint8Array(ev.data as ArrayBuffer);72      const r = readers.shift();73      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }74      else inbox.push(bytes);75    };76    ws.onerror = () => {77      if (!opened) reject(new Error("websocket connection failed"));78    };79    ws.onclose = () => {80      closed = true;81      if (readers.length) {82        sawCloseBeforeFrame = true;83        while (readers.length) readers.shift()!.reject(new Error("socket closed"));84      }85      if (!opened) reject(new Error("socket closed before open"));86    };8788    const sock: DesktopPairingSocket = {89      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },90      receive(): Promise<Uint8Array> {91        const queued = inbox.shift();92        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }93        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }94        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));95      },96      close() { try { ws.close(); } catch { /* ignore */ } },97      closedBeforeFrame() { return sawCloseBeforeFrame; },98    };99  });100}101102/** Production dependency set — real probe, WebSocket, identity, and storage. */103export function productionDesktopPairingDeps(): DesktopPairingDeps {104  return {105    async findHost(port) {106      const subnet = await getLocalSubnet();107      if (!subnet) return null;108      return probeSubnetForPort(subnet, port);109    },110    openSocket: (ip, port) => openBinaryWebSocket(ip, port),111    async getLocalIdentity(): Promise<LocalIdentity> {112      if (!isUnlocked()) throw new Error("vault_locked");113      const masterKeyHex = getRootKeyHex();114      if (!masterKeyHex) throw new Error("vault_locked");115      // SEV-2-B: derive from the stable ROOT key so pairing survives a116      // master-password change. Zero the copy when done.117      const masterKey = hexToBytesLocal(masterKeyHex);118      try {119        const base = await buildLocalIdentity(masterKey);120        const vaultId = await computeVaultId(masterKey);121        return { ...base, vaultId };122      } finally {123        masterKey.fill(0);124      }125    },126    persistDevice: (d) => upsertPairedDevice(d),127    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),128  };129}130131// ── Single in-flight pairing per worker ──────────────────────────────────────132// One pairing at a time. The blocking confirm() resolver is parked here while133// the popup shows the SAS; DESKTOP_PAIR_CONFIRM resolves it.134let pendingConfirm: ((confirmed: boolean) => void) | null = null;135let inFlight = false;136137function clearPairingState(): void {138  pendingConfirm = null;139  inFlight = false;140}141142export function isDesktopPairingInFlight(): boolean {143  return inFlight;144}145146/**147 * Kick off a desktop pairing. Returns immediately; the SAS and the terminal148 * result are delivered to the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT149 * pushes. `deps` is injectable for tests.150 */151export function startDesktopPairing(152  code: string,153  deps: DesktopPairingDeps = productionDesktopPairingDeps(),154): void {155  inFlight = true;156  const cb: PairWithDesktopCallbacks = {157    onSasReady: (sas) => push("DESKTOP_PAIR_SAS", { sas }),158    onComplete: (device) => {159      push("DESKTOP_PAIR_RESULT", { status: "complete", device });160      clearPairingState();161    },162    onError: (error) => {163      push("DESKTOP_PAIR_RESULT", { status: "error", error });164      clearPairingState();165    },166  };167  // The confirm callback blocks on the popup's DESKTOP_PAIR_CONFIRM reply.168  const confirm = (_sas: string): Promise<boolean> =>169    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });170171  void pairWithDesktopHelper(code, cb, deps, confirm).finally(() => {172    // onComplete/onError already cleared state; this is the backstop for an173    // unexpected throw that skipped the callbacks.174    if (inFlight) clearPairingState();175  });176}177178/** Resolve the parked confirm() with the user's decision from the popup. */179export function resolveDesktopPairingConfirm(confirmed: boolean): void {180  const resolve = pendingConfirm;181  pendingConfirm = null;182  if (resolve) resolve(confirmed);183}