code-pairing-bg.ts

5.0 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, deriveSyncIdentityKeypair } 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<{52      identity: LocalIdentity;53      privateKey: Uint8Array;54      publicKey: Uint8Array;55    }> {56      if (!isUnlocked()) throw new Error("vault_locked");57      const masterKeyHex = getRootKeyHex();58      if (!masterKeyHex) throw new Error("vault_locked");59      // SEV-2-B: derive from the stable ROOT key so pairing survives a60      // master-password change. Zero the copy when done.61      const masterKey = hexToBytesLocal(masterKeyHex);62      try {63        const base = await buildLocalIdentity(masterKey);64        const vaultId = await computeVaultId(masterKey);65        const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);66        return {67          identity: { ...base, vaultId },68          privateKey: keys.secretKey,69          publicKey: keys.publicKey,70        };71      } finally {72        masterKey.fill(0);73      }74    },75    persistDevice: (d) => upsertPairedDevice(d),76    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),77  };78}7980// ── Single in-flight pairing per worker ──────────────────────────────────────81// One pairing at a time. The blocking confirm() resolver is parked here while82// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.83let pendingConfirm: ((confirmed: boolean) => void) | null = null;84let inFlight = false;8586function clearPairingState(): void {87  pendingConfirm = null;88  inFlight = false;89}9091export function isCodePairingInFlight(): boolean {92  return inFlight;93}9495/**96 * Kick off a code pairing. Returns immediately; the SAS and the terminal97 * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT98 * pushes. `deps` is injectable for tests.99 */100export function startCodePairing(101  code: string,102  deps: CodePairingDeps = productionCodePairingDeps(),103): void {104  inFlight = true;105  const cb: CodePairingCallbacks = {106    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),107    onComplete: (device) => {108      push("CODE_PAIR_RESULT", { status: "complete", device });109      clearPairingState();110    },111    onError: (error) => {112      push("CODE_PAIR_RESULT", { status: "error", error });113      clearPairingState();114    },115  };116  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.117  const confirm = (_sas: string): Promise<boolean> =>118    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });119120  void pairWithCode(code, cb, deps, confirm).finally(() => {121    // onComplete/onError already cleared state; this is the backstop for an122    // unexpected throw that skipped the callbacks.123    if (inFlight) clearPairingState();124  });125}126127/** Resolve the parked confirm() with the user's decision from the popup. */128export function resolveCodePairingConfirm(confirmed: boolean): void {129  const resolve = pendingConfirm;130  pendingConfirm = null;131  if (resolve) resolve(confirmed);132}