codex-handlers.ts

10.4 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";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  parityShards?: number;188}> {189  if (!(await isProUser())) {190    return { ok: false, error: "pro_required" };191  }192  try {193    const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);194    const slotA = (raw as Record<string, unknown>)["peach_vault_a"];195    const slotB = (raw as Record<string, unknown>)["peach_vault_b"];196    if (!slotA) return { ok: true, pages: 3, parityShards: 2 };197198    // Match the generator's payload shape: { a, b } JSON, deflated. The199    // popup's real generator does the same compression on the actual200    // CodexPayload, but slot ciphertext dominates the size and dwarfs the201    // envelope, so a deflate of the slot blobs is a faithful estimate.202    const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });203    const compressed = deflate(new TextEncoder().encode(blobJson));204    const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));205    const parityShards = computeParityShards(dataChunks);206    const totalChunks = dataChunks + parityShards;207    const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));208    return { ok: true, pages, parityShards };209  } catch {210    return { ok: true, pages: 3, parityShards: 2 };211  }212}213214/**215 * Restore the vault from a scanned Codex.216 *217 * NOT Pro-gated by design. The Codex was earned by a Pro user; we must never218 * block them from recovering their own vault, even from a free device. See219 * peach_codex_v2.md "Pro gating".220 */221export async function handleRestoreFromCodex(222  payload: CodexRestorePayload,223): Promise<{224  ok: boolean;225  error?: string;226  data?: { entryCount: number; verifyCode: string };227}> {228  // Parse the base64url-wrapped JSON { a, b }. Malformed input is a hard229  // refusal — we will not write garbage into slotA.230  let slotAJson: string;231  let slotBJson: string | null;232  try {233    const decoded = base64urlToString(payload.vaultBlob);234    const parsed = JSON.parse(decoded) as { a: unknown; b: unknown };235    if (!parsed || typeof parsed !== "object" || parsed.a == null) {236      return { ok: false, error: "INVALID_VAULT_BLOB" };237    }238    // The generator stores each slot as a JSON string payload, so restore239    // must pass that string through unchanged. Re-stringifying here would240    // wrap the already-encoded JSON in quotes and make unlockFromCodex241    // reject the blob as unreadable.242    slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a);243    slotBJson =244      parsed.b == null245        ? null246        : typeof parsed.b === "string"247          ? parsed.b248          : JSON.stringify(parsed.b);249  } catch {250    return { ok: false, error: "INVALID_VAULT_BLOB" };251  }252253  const result = await vault.unlockFromCodex({254    slotA: slotAJson,255    slotB: slotBJson,256    timestamp: payload.timestamp,257    masterPassphrase: payload.masterPassphrase,258  });259260  if (!result.ok) {261    if (result.reason === "wrong_passphrase") {262      return { ok: false, error: "wrong_passphrase" };263    }264    return { ok: false, error: "INVALID_VAULT_BLOB" };265  }266267  // Persist the Codex's verify code as this device's verify code so future268  // Codex prints from here keep the same identifier. Also record the restore269  // time so Settings can surface "restored from Codex at …".270  await chrome.storage.local.set({271    [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,272    [STORAGE_KEY_LAST_RESTORED]: Date.now(),273  });274275  // Mirror the UNLOCK handler's mobile-bridge step so the Android autofill276  // service sees the newly-restored entries without requiring a re-lock.277  if (isNativePlatform()) {278    try {279      await publishInMemoryVault(vault.getEntries());280    } catch {281      // Non-fatal — autofill will catch up on the next unlock.282    }283  }284285  return {286    ok: true,287    data: {288      entryCount: result.entryCount,289      verifyCode: payload.verifyCode,290    },291  };292}