codex-handlers.ts

9.7 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 */87export async function handleGetCodexVaultData(): Promise<{88  ok: boolean;89  error?: string;90  data?: CodexVaultSnapshot;91}> {92  if (!(await isProUser())) {93    return { ok: false, error: "pro_required" };94  }95  if (!vault.isUnlocked()) {96    return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." };97  }9899  const raw = await chrome.storage.local.get([100    "peach_vault_a",101    "peach_vault_b",102    "peach_last_modified_at",103  ]);104  const slotARaw = (raw as Record<string, unknown>)["peach_vault_a"];105  const slotBRaw = (raw as Record<string, unknown>)["peach_vault_b"];106  const lastModRaw = (raw as Record<string, unknown>)["peach_last_modified_at"];107108  if (!slotARaw) {109    return { ok: false, error: "VAULT_EMPTY: No vault to print." };110  }111112  // chrome.storage stores objects directly; serialise to JSON strings to113  // produce the slot blobs the Codex format expects.114  const slotA = JSON.stringify(slotARaw);115  const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;116  const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;117118  // Recovery words exist only on v2.12+ vaults that have completed migration.119  // A missing root key throws; treat that as "no words available" rather than120  // a hard error so the Codex can still print without them.121  let recoveryWords: string[] | null = null;122  try {123    recoveryWords = await vault.getRecoveryWords();124  } catch {125    recoveryWords = null;126  }127128  const verifyCode = await getOrCreateVerifyCode();129  const entryCount = vault.getEntries().length;130131  const snapshot: CodexVaultSnapshot = {132    slotA,133    slotB,134    lastModified,135    entryCount,136    recoveryWords,137    verifyCode,138    vaultUnlocked: true,139  };140  return { ok: true, data: snapshot };141}142143/**144 * Cheap pre-flight: estimate how many A4 pages the Codex will need so the145 * popup can warn the user before kicking off the full generation pipeline.146 * Pro-gated for the same reason as `handleGetCodexVaultData`.147 *148 * Falls back to a safe over-estimate (3 pages) on any error — better to149 * promise more pages than the user actually needs than to surprise them.150 */151export async function handleEstimateCodexPages(): Promise<{152  ok: boolean;153  error?: string;154  pages?: number;155  parityShards?: number;156}> {157  if (!(await isProUser())) {158    return { ok: false, error: "pro_required" };159  }160  try {161    const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);162    const slotA = (raw as Record<string, unknown>)["peach_vault_a"];163    const slotB = (raw as Record<string, unknown>)["peach_vault_b"];164    if (!slotA) return { ok: true, pages: 3, parityShards: 2 };165166    // Match the generator's payload shape: { a, b } JSON, deflated. The167    // popup's real generator does the same compression on the actual168    // CodexPayload, but slot ciphertext dominates the size and dwarfs the169    // envelope, so a deflate of the slot blobs is a faithful estimate.170    const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });171    const compressed = deflate(new TextEncoder().encode(blobJson));172    const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));173    const parityShards = computeParityShards(dataChunks);174    const totalChunks = dataChunks + parityShards;175    const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));176    return { ok: true, pages, parityShards };177  } catch {178    return { ok: true, pages: 3, parityShards: 2 };179  }180}181182/**183 * Restore the vault from a scanned Codex.184 *185 * NOT Pro-gated by design. The Codex was earned by a Pro user; we must never186 * block them from recovering their own vault, even from a free device. See187 * peach_codex_v2.md "Pro gating".188 */189export async function handleRestoreFromCodex(190  payload: CodexRestorePayload,191): Promise<{192  ok: boolean;193  error?: string;194  data?: { entryCount: number; verifyCode: string };195}> {196  // PCH-01-017: validate attacker-supplied metadata before it can reach storage197  // or sync-version comparisons. The trial-decrypt gate in unlockFromCodex198  // protects the vault bytes; these checks protect the metadata riding alongside.199  if (!VERIFY_CODE_PATTERN.test(payload.verifyCode)) {200    return { ok: false, error: "INVALID_VERIFY_CODE" };201  }202  if (!Number.isFinite(payload.timestamp)) {203    return { ok: false, error: "INVALID_VAULT_BLOB" };204  }205  if (payload.vaultBlob.length > IMPORT_LIMITS.MAX_JSON_BYTES) {206    return { ok: false, error: "INVALID_VAULT_BLOB" };207  }208209  // Parse the base64url-wrapped JSON { a, b }. Malformed input is a hard210  // refusal — we will not write garbage into slotA.211  let slotAJson: string;212  let slotBJson: string | null;213  try {214    const decoded = base64urlToString(payload.vaultBlob);215    const parsed = JSON.parse(decoded) as { a: unknown; b: unknown };216    if (!parsed || typeof parsed !== "object" || parsed.a == null) {217      return { ok: false, error: "INVALID_VAULT_BLOB" };218    }219    // The generator stores each slot as a JSON string payload, so restore220    // must pass that string through unchanged. Re-stringifying here would221    // wrap the already-encoded JSON in quotes and make unlockFromCodex222    // reject the blob as unreadable.223    slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a);224    slotBJson =225      parsed.b == null226        ? null227        : typeof parsed.b === "string"228          ? parsed.b229          : JSON.stringify(parsed.b);230  } catch {231    return { ok: false, error: "INVALID_VAULT_BLOB" };232  }233234  const result = await vault.unlockFromCodex({235    slotA: slotAJson,236    slotB: slotBJson,237    timestamp: payload.timestamp,238    masterPassphrase: payload.masterPassphrase,239  });240241  if (!result.ok) {242    if (result.reason === "wrong_passphrase") {243      return { ok: false, error: "wrong_passphrase" };244    }245    return { ok: false, error: "INVALID_VAULT_BLOB" };246  }247248  // Persist the Codex's verify code as this device's verify code so future249  // Codex prints from here keep the same identifier. Also record the restore250  // time so Settings can surface "restored from Codex at …".251  await chrome.storage.local.set({252    [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,253    [STORAGE_KEY_LAST_RESTORED]: Date.now(),254  });255256  return {257    ok: true,258    data: {259      entryCount: result.entryCount,260      verifyCode: payload.verifyCode,261    },262  };263}