code-pairing-bg.ts

4.7 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";24import { getRelaySettings } from "./relay-settings";2526function hexToBytesLocal(hex: string): Uint8Array {27  const out = new Uint8Array(hex.length / 2);28  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);29  return out;30}3132/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */33function push(type: string, payload: unknown): void {34  try {35    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;36    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {37      (maybe as Promise<unknown>).catch(() => undefined);38    }39  } catch {40    // Popup closed / no receiver — fine.41  }42}4344/** Production dependency set — real WebSocket, identity, and storage. */45export function productionCodePairingDeps(): CodePairingDeps {46  return {47    openSocket: (ip, port) => openPairingSocket(ip, port),48    async getRelayUrl() {49      return (await getRelaySettings()).relayUrl;50    },51    async getLocalIdentity(): Promise<LocalIdentity> {52      if (!isUnlocked()) throw new Error("vault_locked");53      const masterKeyHex = getRootKeyHex();54      if (!masterKeyHex) throw new Error("vault_locked");55      // SEV-2-B: derive from the stable ROOT key so pairing survives a56      // master-password change. Zero the copy when done.57      const masterKey = hexToBytesLocal(masterKeyHex);58      try {59        const base = await buildLocalIdentity(masterKey);60        const vaultId = await computeVaultId(masterKey);61        return { ...base, vaultId };62      } finally {63        masterKey.fill(0);64      }65    },66    persistDevice: (d) => upsertPairedDevice(d),67    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),68  };69}7071// ── Single in-flight pairing per worker ──────────────────────────────────────72// One pairing at a time. The blocking confirm() resolver is parked here while73// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.74let pendingConfirm: ((confirmed: boolean) => void) | null = null;75let inFlight = false;7677function clearPairingState(): void {78  pendingConfirm = null;79  inFlight = false;80}8182export function isCodePairingInFlight(): boolean {83  return inFlight;84}8586/**87 * Kick off a code pairing. Returns immediately; the SAS and the terminal88 * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT89 * pushes. `deps` is injectable for tests.90 */91export function startCodePairing(92  code: string,93  deps: CodePairingDeps = productionCodePairingDeps(),94): void {95  inFlight = true;96  const cb: CodePairingCallbacks = {97    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),98    onComplete: (device) => {99      push("CODE_PAIR_RESULT", { status: "complete", device });100      clearPairingState();101    },102    onError: (error) => {103      push("CODE_PAIR_RESULT", { status: "error", error });104      clearPairingState();105    },106  };107  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.108  const confirm = (_sas: string): Promise<boolean> =>109    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });110111  void pairWithCode(code, cb, deps, confirm).finally(() => {112    // onComplete/onError already cleared state; this is the backstop for an113    // unexpected throw that skipped the callbacks.114    if (inFlight) clearPairingState();115  });116}117118/** Resolve the parked confirm() with the user's decision from the popup. */119export function resolveCodePairingConfirm(confirmed: boolean): void {120  const resolve = pendingConfirm;121  pendingConfirm = null;122  if (resolve) resolve(confirmed);123}