codex-handlers.ts

10.2 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 {20  type CodexRestorePayload,21  type CodexVaultSnapshot,22  CHUNK_SIZE,23  CODES_PER_PAGE,24  computeParityShards,25  STORAGE_KEY_LAST_RESTORED,26  STORAGE_KEY_VERIFY_CODE,27} from "../shared/codex-types";28import { IMPORT_LIMITS } from "../shared/import-limits";2930// ── Verify-code helpers ─────────────────────────────────────────────────────3132const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/13334// PCH-01-017: same pattern enforced popup-side in helpers.ts. Keep these in35// sync — ideally both should import one shared constant (follow-up refactor).36const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;3738/**39 * Format: `PX-XXXX-XXXX`. 8 random Crockford-ish characters split into two40 * groups of four. Stable identifier for a vault across Codex prints — once41 * minted it lives in storage and is reused for every future Codex from this42 * device, so the user can recognise "their" Codex visually.43 */44function mintVerifyCode(): string {45  const random = new Uint8Array(8);46  crypto.getRandomValues(random);47  let group = "";48  for (let i = 0; i < 8; i++) {49    group += VERIFY_CODE_ALPHABET[random[i] % VERIFY_CODE_ALPHABET.length];50  }51  return `PX-${group.slice(0, 4)}-${group.slice(4, 8)}`;52}5354async function getOrCreateVerifyCode(): Promise<string> {55  const raw = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);56  const existing = (raw as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];57  if (typeof existing === "string" && existing.length > 0) {58    return existing;59  }60  const fresh = mintVerifyCode();61  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: fresh });62  return fresh;63}6465// ── base64url decoding ──────────────────────────────────────────────────────6667/**68 * Inlined to keep this module decoupled from popup/passkey helpers. The Codex69 * vaultBlob is base64url-encoded (no padding) JSON; decode to a UTF-8 string.70 */71function base64urlToString(b64url: string): string {72  const pad = b64url.length % 4 === 0 ? "" : "=".repeat(4 - (b64url.length % 4));73  const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + pad;74  const binary = atob(b64);75  const bytes = new Uint8Array(binary.length);76  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);77  return new TextDecoder().decode(bytes);78}7980// ── Handlers ────────────────────────────────────────────────────────────────8182/**83 * Read everything the popup needs to build a CodexPayload. Pro-gated:84 * generating a Codex is a Pro feature (restoring one is not — see85 * `handleRestoreFromCodex`).86 *87 * The recovery words re-derive the ROOT KEY, so they are only returned when a88 * fresh master-password proof accompanies the request (PB-26-001) — the same89 * re-auth rule GET_RECOVERY_WORDS enforces. Without a proof the snapshot90 * still carries the encrypted slot blobs; only `recoveryWords` is withheld.91 */92export async function handleGetCodexVaultData(password?: string): Promise<{93  ok: boolean;94  error?: string;95  data?: CodexVaultSnapshot;96}> {97  if (!(await isProUser())) {98    return { ok: false, error: "pro_required" };99  }100  if (!vault.isUnlocked()) {101    return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." };102  }103104  let passwordProof = false;105  if (typeof password === "string" && password) {106    if (!(await vault.verifyMainPassword(password))) {107      return { ok: false, error: "wrong_password" };108    }109    passwordProof = true;110  }111112  const raw = await chrome.storage.local.get([113    "peach_vault_a",114    "peach_vault_b",115    "peach_last_modified_at",116  ]);117  const slotARaw = (raw as Record<string, unknown>)["peach_vault_a"];118  const slotBRaw = (raw as Record<string, unknown>)["peach_vault_b"];119  const lastModRaw = (raw as Record<string, unknown>)["peach_last_modified_at"];120121  if (!slotARaw) {122    return { ok: false, error: "VAULT_EMPTY: No vault to print." };123  }124125  // chrome.storage stores objects directly; serialise to JSON strings to126  // produce the slot blobs the Codex format expects.127  const slotA = JSON.stringify(slotARaw);128  const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;129  const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;130131  // Recovery words are present on every supported vault, but revealing them132  // requires the fresh master-password proof above (PB-26-001).133  let recoveryWords: string[] | null = null;134  if (passwordProof) {135    try {136      recoveryWords = await vault.getRecoveryWords();137    } catch {138      recoveryWords = null;139    }140  }141142  const verifyCode = await getOrCreateVerifyCode();143  const entryCount = vault.getEntries().length;144145  const snapshot: CodexVaultSnapshot = {146    slotA,147    slotB,148    lastModified,149    entryCount,150    recoveryWords,151    verifyCode,152    vaultUnlocked: true,153  };154  return { ok: true, data: snapshot };155}156157/**158 * Cheap pre-flight: estimate how many A4 pages the Codex will need so the159 * popup can warn the user before kicking off the full generation pipeline.160 * Pro-gated for the same reason as `handleGetCodexVaultData`.161 *162 * Falls back to a safe over-estimate (3 pages) on any error — better to163 * promise more pages than the user actually needs than to surprise them.164 */165export async function handleEstimateCodexPages(): Promise<{166  ok: boolean;167  error?: string;168  pages?: number;169  parityShards?: number;170}> {171  if (!(await isProUser())) {172    return { ok: false, error: "pro_required" };173  }174  try {175    const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);176    const slotA = (raw as Record<string, unknown>)["peach_vault_a"];177    const slotB = (raw as Record<string, unknown>)["peach_vault_b"];178    if (!slotA) return { ok: true, pages: 3, parityShards: 2 };179180    // Match the generator's payload shape: { a, b } JSON, deflated. The181    // popup's real generator does the same compression on the actual182    // CodexPayload, but slot ciphertext dominates the size and dwarfs the183    // envelope, so a deflate of the slot blobs is a faithful estimate.184    const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });185    const compressed = deflate(new TextEncoder().encode(blobJson));186    const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));187    const parityShards = computeParityShards(dataChunks);188    const totalChunks = dataChunks + parityShards;189    const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));190    return { ok: true, pages, parityShards };191  } catch {192    return { ok: true, pages: 3, parityShards: 2 };193  }194}195196/**197 * Restore the vault from a scanned Codex.198 *199 * NOT Pro-gated by design. The Codex was earned by a Pro user; we must never200 * block them from recovering their own vault, even from a free device. See201 * peach_codex_v2.md "Pro gating".202 */203export async function handleRestoreFromCodex(204  payload: CodexRestorePayload,205): Promise<{206  ok: boolean;207  error?: string;208  data?: { entryCount: number; verifyCode: string };209}> {210  // PCH-01-017: validate attacker-supplied metadata before it can reach storage211  // or sync-version comparisons. The trial-decrypt gate in unlockFromCodex212  // protects the vault bytes; these checks protect the metadata riding alongside.213  if (!VERIFY_CODE_PATTERN.test(payload.verifyCode)) {214    return { ok: false, error: "INVALID_VERIFY_CODE" };215  }216  if (!Number.isFinite(payload.timestamp)) {217    return { ok: false, error: "INVALID_VAULT_BLOB" };218  }219  if (payload.vaultBlob.length > IMPORT_LIMITS.MAX_JSON_BYTES) {220    return { ok: false, error: "INVALID_VAULT_BLOB" };221  }222223  // Parse the base64url-wrapped JSON { a, b }. Malformed input is a hard224  // refusal — we will not write garbage into slotA.225  let slotAJson: string;226  let slotBJson: string | null;227  try {228    const decoded = base64urlToString(payload.vaultBlob);229    const parsed = JSON.parse(decoded) as { a: unknown; b: unknown };230    if (!parsed || typeof parsed !== "object" || parsed.a == null) {231      return { ok: false, error: "INVALID_VAULT_BLOB" };232    }233    // The generator stores each slot as a JSON string payload, so restore234    // must pass that string through unchanged. Re-stringifying here would235    // wrap the already-encoded JSON in quotes and make unlockFromCodex236    // reject the blob as unreadable.237    slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a);238    slotBJson =239      parsed.b == null240        ? null241        : typeof parsed.b === "string"242          ? parsed.b243          : JSON.stringify(parsed.b);244  } catch {245    return { ok: false, error: "INVALID_VAULT_BLOB" };246  }247248  const result = await vault.unlockFromCodex({249    slotA: slotAJson,250    slotB: slotBJson,251    timestamp: payload.timestamp,252    masterPassphrase: payload.masterPassphrase,253  });254255  if (!result.ok) {256    if (result.reason === "wrong_passphrase") {257      return { ok: false, error: "wrong_passphrase" };258    }259    return { ok: false, error: "INVALID_VAULT_BLOB" };260  }261262  // Persist the Codex's verify code as this device's verify code so future263  // Codex prints from here keep the same identifier. Also record the restore264  // time so Settings can surface "restored from Codex at …".265  await chrome.storage.local.set({266    [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,267    [STORAGE_KEY_LAST_RESTORED]: Date.now(),268  });269270  return {271    ok: true,272    data: {273      entryCount: result.entryCount,274      verifyCode: payload.verifyCode,275    },276  };277}