code-pairing-bg.ts

5.3 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 {20  clearDeviceRevocation,21  upsertPairedDevice,22  setLastKnownIp,23} from "./paired-devices";24import { buildLocalIdentity } from "./device-identity";25import { computeVaultId, deriveSyncIdentityKeypair } from "../shared/sync-identity";26import { isUnlocked, getRootKeyHex } from "./vault";27import type { LocalIdentity } from "../shared/pairing-handshake";28import { getRelaySettings } from "./relay-settings";2930function hexToBytesLocal(hex: string): Uint8Array {31  const out = new Uint8Array(hex.length / 2);32  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);33  return out;34}3536/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */37function push(type: string, payload: unknown): void {38  try {39    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;40    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {41      (maybe as Promise<unknown>).catch(() => undefined);42    }43  } catch {44    // Popup closed / no receiver — fine.45  }46}4748/** Production dependency set — real WebSocket, identity, and storage. */49export function productionCodePairingDeps(): CodePairingDeps {50  return {51    openSocket: (ip, port) => openPairingSocket(ip, port),52    async getRelayUrl() {53      return (await getRelaySettings()).relayUrl;54    },55    async getLocalIdentity(): Promise<{56      identity: LocalIdentity;57      privateKey: Uint8Array;58      publicKey: Uint8Array;59    }> {60      if (!isUnlocked()) throw new Error("vault_locked");61      const masterKeyHex = getRootKeyHex();62      if (!masterKeyHex) throw new Error("vault_locked");63      // SEV-2-B: derive from the stable ROOT key so pairing survives a64      // master-password change. Zero the copy when done.65      const masterKey = hexToBytesLocal(masterKeyHex);66      try {67        const base = await buildLocalIdentity(masterKey);68        const vaultId = await computeVaultId(masterKey);69        const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);70        return {71          identity: { ...base, vaultId },72          privateKey: keys.secretKey,73          publicKey: keys.publicKey,74        };75      } finally {76        masterKey.fill(0);77      }78    },79    // A direct code pairing means a physically present user confirmed the SAS80    // on both screens — the strongest consent signal in the system, and the81    // only thing allowed to lift a terminal revocation (see revokeDeviceById).82    persistDevice: async (d) => {83      await clearDeviceRevocation(d.deviceId);84      await upsertPairedDevice(d);85    },86    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),87  };88}8990// ── Single in-flight pairing per worker ──────────────────────────────────────91// One pairing at a time. The blocking confirm() resolver is parked here while92// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.93let pendingConfirm: ((confirmed: boolean) => void) | null = null;94let inFlight = false;9596function clearPairingState(): void {97  pendingConfirm = null;98  inFlight = false;99}100101export function isCodePairingInFlight(): boolean {102  return inFlight;103}104105/**106 * Kick off a code pairing. Returns immediately; the SAS and the terminal107 * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT108 * pushes. `deps` is injectable for tests.109 */110export function startCodePairing(111  code: string,112  deps: CodePairingDeps = productionCodePairingDeps(),113): void {114  inFlight = true;115  const cb: CodePairingCallbacks = {116    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),117    onComplete: (device) => {118      push("CODE_PAIR_RESULT", { status: "complete", device });119      clearPairingState();120    },121    onError: (error) => {122      push("CODE_PAIR_RESULT", { status: "error", error });123      clearPairingState();124    },125  };126  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.127  const confirm = (_sas: string): Promise<boolean> =>128    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });129130  void pairWithCode(code, cb, deps, confirm).finally(() => {131    // onComplete/onError already cleared state; this is the backstop for an132    // unexpected throw that skipped the callbacks.133    if (inFlight) clearPairingState();134  });135}136137/** Resolve the parked confirm() with the user's decision from the popup. */138export function resolveCodePairingConfirm(confirmed: boolean): void {139  const resolve = pendingConfirm;140  pendingConfirm = null;141  if (resolve) resolve(confirmed);142}