codex-handlers.ts

9.9 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  PARITY_SHARDS,27  STORAGE_KEY_LAST_RESTORED,28  STORAGE_KEY_VERIFY_CODE,29} from "../shared/codex-types";3031// ── Verify-code helpers ─────────────────────────────────────────────────────3233const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/13435/**36 * Format: `PX-XXXX-XXXX`. 8 random Crockford-ish characters split into two37 * groups of four. Stable identifier for a vault across Codex prints — once38 * minted it lives in storage and is reused for every future Codex from this39 * device, so the user can recognise "their" Codex visually.40 */41function mintVerifyCode(): string {42  const random = new Uint8Array(8);43  crypto.getRandomValues(random);44  let group = "";45  for (let i = 0; i < 8; i++) {46    group += VERIFY_CODE_ALPHABET[random[i] % VERIFY_CODE_ALPHABET.length];47  }48  return `PX-${group.slice(0, 4)}-${group.slice(4, 8)}`;49}5051async function getOrCreateVerifyCode(): Promise<string> {52  const raw = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);53  const existing = (raw as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];54  if (typeof existing === "string" && existing.length > 0) {55    return existing;56  }57  const fresh = mintVerifyCode();58  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: fresh });59  return fresh;60}6162// ── base64url decoding ──────────────────────────────────────────────────────6364/**65 * Inlined to keep this module decoupled from popup/passkey helpers. The Codex66 * vaultBlob is base64url-encoded (no padding) JSON; decode to a UTF-8 string.67 */68function base64urlToString(b64url: string): string {69  const pad = b64url.length % 4 === 0 ? "" : "=".repeat(4 - (b64url.length % 4));70  const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + pad;71  const binary = atob(b64);72  const bytes = new Uint8Array(binary.length);73  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);74  return new TextDecoder().decode(bytes);75}7677// ── Mobile autofill bridge — duplicated here intentionally ─────────────────78// messages.ts has its own copy; we don't import from messages.ts because79// that would create a cycle. The plugin shape is small and stable.8081async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {82  const plugin = nativePlugin<{83    setEntries?: (args: {84      entries: Array<{85        id: string;86        title: string;87        username: string;88        email: string;89        password: string;90        url: string;91        totp: string;92        additionalUrls: string[];93      }>;94    }) => Promise<void>;95  }>("InMemoryVaultPlugin");96  if (!plugin?.setEntries) return;97  await plugin.setEntries({98    entries: entries.map((e) => ({99      id: e.id,100      title: e.title ?? "",101      username: e.username ?? "",102      email: (e as VaultEntry & { email?: string }).email ?? "",103      password: e.password ?? "",104      url: e.url ?? "",105      totp: (e as VaultEntry & { totp?: string }).totp ?? "",106      additionalUrls:107        (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],108    })),109  });110}111112// ── Handlers ────────────────────────────────────────────────────────────────113114/**115 * Read everything the popup needs to build a CodexPayload. Pro-gated:116 * generating a Codex is a Pro feature (restoring one is not — see117 * `handleRestoreFromCodex`).118 */119export async function handleGetCodexVaultData(): Promise<{120  ok: boolean;121  error?: string;122  data?: CodexVaultSnapshot;123}> {124  if (!(await isProUser())) {125    return { ok: false, error: "pro_required" };126  }127  if (!vault.isUnlocked()) {128    return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." };129  }130131  const raw = await chrome.storage.local.get([132    "peach_vault_a",133    "peach_vault_b",134    "peach_last_modified_at",135  ]);136  const slotARaw = (raw as Record<string, unknown>)["peach_vault_a"];137  const slotBRaw = (raw as Record<string, unknown>)["peach_vault_b"];138  const lastModRaw = (raw as Record<string, unknown>)["peach_last_modified_at"];139140  if (!slotARaw) {141    return { ok: false, error: "VAULT_EMPTY: No vault to print." };142  }143144  // chrome.storage stores objects directly; serialise to JSON strings to145  // produce the slot blobs the Codex format expects.146  const slotA = JSON.stringify(slotARaw);147  const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;148  const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;149150  // Recovery words exist only on v2.12+ vaults that have completed migration.151  // A missing root key throws; treat that as "no words available" rather than152  // a hard error so the Codex can still print without them.153  let recoveryWords: string[] | null = null;154  try {155    recoveryWords = await vault.getRecoveryWords();156  } catch {157    recoveryWords = null;158  }159160  const verifyCode = await getOrCreateVerifyCode();161  const entryCount = vault.getEntries().length;162163  const snapshot: CodexVaultSnapshot = {164    slotA,165    slotB,166    lastModified,167    entryCount,168    recoveryWords,169    verifyCode,170    vaultUnlocked: true,171  };172  return { ok: true, data: snapshot };173}174175/**176 * Cheap pre-flight: estimate how many A4 pages the Codex will need so the177 * popup can warn the user before kicking off the full generation pipeline.178 * Pro-gated for the same reason as `handleGetCodexVaultData`.179 *180 * Falls back to a safe over-estimate (3 pages) on any error — better to181 * promise more pages than the user actually needs than to surprise them.182 */183export async function handleEstimateCodexPages(): Promise<{184  ok: boolean;185  error?: string;186  pages?: number;187}> {188  if (!(await isProUser())) {189    return { ok: false, error: "pro_required" };190  }191  try {192    const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);193    const slotA = (raw as Record<string, unknown>)["peach_vault_a"];194    const slotB = (raw as Record<string, unknown>)["peach_vault_b"];195    if (!slotA) return { ok: true, pages: 3 };196197    // Match the generator's payload shape: { a, b } JSON, deflated. The198    // popup's real generator does the same compression on the actual199    // CodexPayload, but slot ciphertext dominates the size and dwarfs the200    // envelope, so a deflate of the slot blobs is a faithful estimate.201    const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });202    const compressed = deflate(new TextEncoder().encode(blobJson));203    const dataChunks = Math.ceil(compressed.length / CHUNK_SIZE);204    const totalChunks = dataChunks + PARITY_SHARDS;205    const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));206    return { ok: true, pages };207  } catch {208    return { ok: true, pages: 3 };209  }210}211212/**213 * Restore the vault from a scanned Codex.214 *215 * NOT Pro-gated by design. The Codex was earned by a Pro user; we must never216 * block them from recovering their own vault, even from a free device. See217 * peach_codex_v2.md "Pro gating".218 */219export async function handleRestoreFromCodex(220  payload: CodexRestorePayload,221): Promise<{222  ok: boolean;223  error?: string;224  data?: { entryCount: number; verifyCode: string };225}> {226  // Parse the base64url-wrapped JSON { a, b }. Malformed input is a hard227  // refusal — we will not write garbage into slotA.228  let slotAJson: string;229  let slotBJson: string | null;230  try {231    const decoded = base64urlToString(payload.vaultBlob);232    const parsed = JSON.parse(decoded) as { a: unknown; b: unknown };233    if (!parsed || typeof parsed !== "object" || parsed.a == null) {234      return { ok: false, error: "INVALID_VAULT_BLOB" };235    }236    slotAJson = JSON.stringify(parsed.a);237    slotBJson = parsed.b == null ? null : JSON.stringify(parsed.b);238  } catch {239    return { ok: false, error: "INVALID_VAULT_BLOB" };240  }241242  const result = await vault.unlockFromCodex({243    slotA: slotAJson,244    slotB: slotBJson,245    timestamp: payload.timestamp,246    masterPassphrase: payload.masterPassphrase,247  });248249  if (!result.ok) {250    if (result.reason === "wrong_passphrase") {251      return { ok: false, error: "wrong_passphrase" };252    }253    return { ok: false, error: "INVALID_VAULT_BLOB" };254  }255256  // Persist the Codex's verify code as this device's verify code so future257  // Codex prints from here keep the same identifier. Also record the restore258  // time so Settings can surface "restored from Codex at …".259  await chrome.storage.local.set({260    [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,261    [STORAGE_KEY_LAST_RESTORED]: Date.now(),262  });263264  // Mirror the UNLOCK handler's mobile-bridge step so the Android autofill265  // service sees the newly-restored entries without requiring a re-lock.266  if (isNativePlatform()) {267    try {268      await publishInMemoryVault(vault.getEntries());269    } catch {270      // Non-fatal — autofill will catch up on the next unlock.271    }272  }273274  return {275    ok: true,276    data: {277      entryCount: result.entryCount,278      verifyCode: payload.verifyCode,279    },280  };281}