codex-handlers.ts

11.3 KB · raw

1/**2 * codex-handlers.ts — message handlers that back the Peach Codex flow.3 *4 * Three pure async functions that messages.ts dispatches to. Each takes its5 * already-parsed payload and returns the response object that messages.ts6 * forwards to the popup. No `case` syntax, no chrome.runtime.onMessage glue7 * here — that lives in messages.ts.8 *9 * Design references:10 *  - /Users/june/Downloads/peach_codex_v2.md ("Message handlers", "Pro gating")11 *  - src/shared/codex-types.ts for the wire contract12 *  - src/background/vault.ts for the storage invariants (Projects.md §3.2–3.6)13 */1415import { deflate } from "pako";1617import { isProUser } from "./license";18import * as vault from "./vault";19import { isNativePlatform, nativePlugin } from "../shared/native";20import type { VaultEntry } from "../shared/types";21import {22  type CodexRestorePayload,23  type CodexVaultSnapshot,24  CHUNK_SIZE,25  CODES_PER_PAGE,26  computeParityShards,27  STORAGE_KEY_LAST_RESTORED,28  STORAGE_KEY_VERIFY_CODE,29} from "../shared/codex-types";30import { IMPORT_LIMITS } from "../shared/import-limits";3132// ── Verify-code helpers ─────────────────────────────────────────────────────3334const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/13536// PCH-01-017: same pattern enforced popup-side in helpers.ts. Keep these in37// sync — ideally both should import one shared constant (follow-up refactor).38const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;3940/**41 * Format: `PX-XXXX-XXXX`. 8 random Crockford-ish characters split into two42 * groups of four. Stable identifier for a vault across Codex prints — once43 * minted it lives in storage and is reused for every future Codex from this44 * device, so the user can recognise "their" Codex visually.45 */46function mintVerifyCode(): string {47  const random = new Uint8Array(8);48  crypto.getRandomValues(random);49  let group = "";50  for (let i = 0; i < 8; i++) {51    group += VERIFY_CODE_ALPHABET[random[i] % VERIFY_CODE_ALPHABET.length];52  }53  return `PX-${group.slice(0, 4)}-${group.slice(4, 8)}`;54}5556async function getOrCreateVerifyCode(): Promise<string> {57  const raw = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);58  const existing = (raw as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];59  if (typeof existing === "string" && existing.length > 0) {60    return existing;61  }62  const fresh = mintVerifyCode();63  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: fresh });64  return fresh;65}6667// ── base64url decoding ──────────────────────────────────────────────────────6869/**70 * Inlined to keep this module decoupled from popup/passkey helpers. The Codex71 * vaultBlob is base64url-encoded (no padding) JSON; decode to a UTF-8 string.72 */73function base64urlToString(b64url: string): string {74  const pad = b64url.length % 4 === 0 ? "" : "=".repeat(4 - (b64url.length % 4));75  const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + pad;76  const binary = atob(b64);77  const bytes = new Uint8Array(binary.length);78  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);79  return new TextDecoder().decode(bytes);80}8182// ── Mobile autofill bridge — duplicated here intentionally ─────────────────83// messages.ts has its own copy; we don't import from messages.ts because84// that would create a cycle. The plugin shape is small and stable.8586async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {87  const plugin = nativePlugin<{88    setEntries?: (args: {89      entries: Array<{90        id: string;91        title: string;92        username: string;93        email: string;94        password: string;95        url: string;96        totp: string;97        additionalUrls: string[];98      }>;99    }) => Promise<void>;100  }>("InMemoryVaultPlugin");101  if (!plugin?.setEntries) return;102  await plugin.setEntries({103    entries: entries.map((e) => ({104      id: e.id,105      title: e.title ?? "",106      username: e.username ?? "",107      email: (e as VaultEntry & { email?: string }).email ?? "",108      password: e.password ?? "",109      url: e.url ?? "",110      totp: (e as VaultEntry & { totp?: string }).totp ?? "",111      additionalUrls:112        (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],113    })),114  });115}116117// ── Handlers ────────────────────────────────────────────────────────────────118119/**120 * Read everything the popup needs to build a CodexPayload. Pro-gated:121 * generating a Codex is a Pro feature (restoring one is not — see122 * `handleRestoreFromCodex`).123 */124export async function handleGetCodexVaultData(): Promise<{125  ok: boolean;126  error?: string;127  data?: CodexVaultSnapshot;128}> {129  if (!(await isProUser())) {130    return { ok: false, error: "pro_required" };131  }132  if (!vault.isUnlocked()) {133    return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." };134  }135136  const raw = await chrome.storage.local.get([137    "peach_vault_a",138    "peach_vault_b",139    "peach_last_modified_at",140  ]);141  const slotARaw = (raw as Record<string, unknown>)["peach_vault_a"];142  const slotBRaw = (raw as Record<string, unknown>)["peach_vault_b"];143  const lastModRaw = (raw as Record<string, unknown>)["peach_last_modified_at"];144145  if (!slotARaw) {146    return { ok: false, error: "VAULT_EMPTY: No vault to print." };147  }148149  // chrome.storage stores objects directly; serialise to JSON strings to150  // produce the slot blobs the Codex format expects.151  const slotA = JSON.stringify(slotARaw);152  const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;153  const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;154155  // Recovery words exist only on v2.12+ vaults that have completed migration.156  // A missing root key throws; treat that as "no words available" rather than157  // a hard error so the Codex can still print without them.158  let recoveryWords: string[] | null = null;159  try {160    recoveryWords = await vault.getRecoveryWords();161  } catch {162    recoveryWords = null;163  }164165  const verifyCode = await getOrCreateVerifyCode();166  const entryCount = vault.getEntries().length;167168  const snapshot: CodexVaultSnapshot = {169    slotA,170    slotB,171    lastModified,172    entryCount,173    recoveryWords,174    verifyCode,175    vaultUnlocked: true,176  };177  return { ok: true, data: snapshot };178}179180/**181 * Cheap pre-flight: estimate how many A4 pages the Codex will need so the182 * popup can warn the user before kicking off the full generation pipeline.183 * Pro-gated for the same reason as `handleGetCodexVaultData`.184 *185 * Falls back to a safe over-estimate (3 pages) on any error — better to186 * promise more pages than the user actually needs than to surprise them.187 */188export async function handleEstimateCodexPages(): Promise<{189  ok: boolean;190  error?: string;191  pages?: number;192  parityShards?: number;193}> {194  if (!(await isProUser())) {195    return { ok: false, error: "pro_required" };196  }197  try {198    const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);199    const slotA = (raw as Record<string, unknown>)["peach_vault_a"];200    const slotB = (raw as Record<string, unknown>)["peach_vault_b"];201    if (!slotA) return { ok: true, pages: 3, parityShards: 2 };202203    // Match the generator's payload shape: { a, b } JSON, deflated. The204    // popup's real generator does the same compression on the actual205    // CodexPayload, but slot ciphertext dominates the size and dwarfs the206    // envelope, so a deflate of the slot blobs is a faithful estimate.207    const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });208    const compressed = deflate(new TextEncoder().encode(blobJson));209    const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));210    const parityShards = computeParityShards(dataChunks);211    const totalChunks = dataChunks + parityShards;212    const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));213    return { ok: true, pages, parityShards };214  } catch {215    return { ok: true, pages: 3, parityShards: 2 };216  }217}218219/**220 * Restore the vault from a scanned Codex.221 *222 * NOT Pro-gated by design. The Codex was earned by a Pro user; we must never223 * block them from recovering their own vault, even from a free device. See224 * peach_codex_v2.md "Pro gating".225 */226export async function handleRestoreFromCodex(227  payload: CodexRestorePayload,228): Promise<{229  ok: boolean;230  error?: string;231  data?: { entryCount: number; verifyCode: string };232}> {233  // PCH-01-017: validate attacker-supplied metadata before it can reach storage234  // or sync-version comparisons. The trial-decrypt gate in unlockFromCodex235  // protects the vault bytes; these checks protect the metadata riding alongside.236  if (!VERIFY_CODE_PATTERN.test(payload.verifyCode)) {237    return { ok: false, error: "INVALID_VERIFY_CODE" };238  }239  if (!Number.isFinite(payload.timestamp)) {240    return { ok: false, error: "INVALID_VAULT_BLOB" };241  }242  if (payload.vaultBlob.length > IMPORT_LIMITS.MAX_JSON_BYTES) {243    return { ok: false, error: "INVALID_VAULT_BLOB" };244  }245246  // Parse the base64url-wrapped JSON { a, b }. Malformed input is a hard247  // refusal — we will not write garbage into slotA.248  let slotAJson: string;249  let slotBJson: string | null;250  try {251    const decoded = base64urlToString(payload.vaultBlob);252    const parsed = JSON.parse(decoded) as { a: unknown; b: unknown };253    if (!parsed || typeof parsed !== "object" || parsed.a == null) {254      return { ok: false, error: "INVALID_VAULT_BLOB" };255    }256    // The generator stores each slot as a JSON string payload, so restore257    // must pass that string through unchanged. Re-stringifying here would258    // wrap the already-encoded JSON in quotes and make unlockFromCodex259    // reject the blob as unreadable.260    slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a);261    slotBJson =262      parsed.b == null263        ? null264        : typeof parsed.b === "string"265          ? parsed.b266          : JSON.stringify(parsed.b);267  } catch {268    return { ok: false, error: "INVALID_VAULT_BLOB" };269  }270271  const result = await vault.unlockFromCodex({272    slotA: slotAJson,273    slotB: slotBJson,274    timestamp: payload.timestamp,275    masterPassphrase: payload.masterPassphrase,276  });277278  if (!result.ok) {279    if (result.reason === "wrong_passphrase") {280      return { ok: false, error: "wrong_passphrase" };281    }282    return { ok: false, error: "INVALID_VAULT_BLOB" };283  }284285  // Persist the Codex's verify code as this device's verify code so future286  // Codex prints from here keep the same identifier. Also record the restore287  // time so Settings can surface "restored from Codex at …".288  await chrome.storage.local.set({289    [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,290    [STORAGE_KEY_LAST_RESTORED]: Date.now(),291  });292293  // Mirror the UNLOCK handler's mobile-bridge step so the Android autofill294  // service sees the newly-restored entries without requiring a re-lock.295  if (isNativePlatform()) {296    try {297      await publishInMemoryVault(vault.getEntries());298    } catch {299      // Non-fatal — autofill will catch up on the next unlock.300    }301  }302303  return {304    ok: true,305    data: {306      entryCount: result.entryCount,307      verifyCode: payload.verifyCode,308    },309  };310}