code-pairing-bg.ts

4.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 "./device-identity";21import { computeVaultId } from "../shared/sync-identity";22import { isUnlocked, getRootKeyHex } from "./vault";23import type { LocalIdentity } from "../shared/pairing-handshake";2425function hexToBytesLocal(hex: string): Uint8Array {26  const out = new Uint8Array(hex.length / 2);27  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);28  return out;29}3031/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */32function push(type: string, payload: unknown): void {33  try {34    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;35    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {36      (maybe as Promise<unknown>).catch(() => undefined);37    }38  } catch {39    // Popup closed / no receiver — fine.40  }41}4243/** Production dependency set — real WebSocket, identity, and storage. */44export function productionCodePairingDeps(): CodePairingDeps {45  return {46    openSocket: (ip, port) => openPairingSocket(ip, port),47    async getLocalIdentity(): Promise<LocalIdentity> {48      if (!isUnlocked()) throw new Error("vault_locked");49      const masterKeyHex = getRootKeyHex();50      if (!masterKeyHex) throw new Error("vault_locked");51      // SEV-2-B: derive from the stable ROOT key so pairing survives a52      // master-password change. Zero the copy when done.53      const masterKey = hexToBytesLocal(masterKeyHex);54      try {55        const base = await buildLocalIdentity(masterKey);56        const vaultId = await computeVaultId(masterKey);57        return { ...base, vaultId };58      } finally {59        masterKey.fill(0);60      }61    },62    persistDevice: (d) => upsertPairedDevice(d),63    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),64  };65}6667// ── Single in-flight pairing per worker ──────────────────────────────────────68// One pairing at a time. The blocking confirm() resolver is parked here while69// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.70let pendingConfirm: ((confirmed: boolean) => void) | null = null;71let inFlight = false;7273function clearPairingState(): void {74  pendingConfirm = null;75  inFlight = false;76}7778export function isCodePairingInFlight(): boolean {79  return inFlight;80}8182/**83 * Kick off a code pairing. Returns immediately; the SAS and the terminal84 * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT85 * pushes. `deps` is injectable for tests.86 */87export function startCodePairing(88  code: string,89  deps: CodePairingDeps = productionCodePairingDeps(),90): void {91  inFlight = true;92  const cb: CodePairingCallbacks = {93    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),94    onComplete: (device) => {95      push("CODE_PAIR_RESULT", { status: "complete", device });96      clearPairingState();97    },98    onError: (error) => {99      push("CODE_PAIR_RESULT", { status: "error", error });100      clearPairingState();101    },102  };103  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.104  const confirm = (_sas: string): Promise<boolean> =>105    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });106107  void pairWithCode(code, cb, deps, confirm).finally(() => {108    // onComplete/onError already cleared state; this is the backstop for an109    // unexpected throw that skipped the callbacks.110    if (inFlight) clearPairingState();111  });112}113114/** Resolve the parked confirm() with the user's decision from the popup. */115export function resolveCodePairingConfirm(confirmed: boolean): void {116  const resolve = pendingConfirm;117  pendingConfirm = null;118  if (resolve) resolve(confirmed);119}