/** * codex-handlers.ts — message handlers that back the Peach Codex flow. * * Three pure async functions that messages.ts dispatches to. Each takes its * already-parsed payload and returns the response object that messages.ts * forwards to the popup. No `case` syntax, no chrome.runtime.onMessage glue * here — that lives in messages.ts. * * Design references: * - /Users/june/Downloads/peach_codex_v2.md ("Message handlers", "Pro gating") * - src/shared/codex-types.ts for the wire contract * - src/background/vault.ts for the storage invariants (Projects.md §3.2–3.6) */ import { deflate } from "pako"; import { isProUser } from "./license"; import * as vault from "./vault"; import { type CodexRestorePayload, type CodexVaultSnapshot, CHUNK_SIZE, CODES_PER_PAGE, computeParityShards, STORAGE_KEY_LAST_RESTORED, STORAGE_KEY_VERIFY_CODE, } from "../shared/codex-types"; import { IMPORT_LIMITS } from "../shared/import-limits"; // ── Verify-code helpers ───────────────────────────────────────────────────── const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1 // PCH-01-017: same pattern enforced popup-side in helpers.ts. Keep these in // sync — ideally both should import one shared constant (follow-up refactor). const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/; /** * Format: `PX-XXXX-XXXX`. 8 random Crockford-ish characters split into two * groups of four. Stable identifier for a vault across Codex prints — once * minted it lives in storage and is reused for every future Codex from this * device, so the user can recognise "their" Codex visually. */ function mintVerifyCode(): string { const random = new Uint8Array(8); crypto.getRandomValues(random); let group = ""; for (let i = 0; i < 8; i++) { group += VERIFY_CODE_ALPHABET[random[i] % VERIFY_CODE_ALPHABET.length]; } return `PX-${group.slice(0, 4)}-${group.slice(4, 8)}`; } async function getOrCreateVerifyCode(): Promise { const raw = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE); const existing = (raw as Record)[STORAGE_KEY_VERIFY_CODE]; if (typeof existing === "string" && existing.length > 0) { return existing; } const fresh = mintVerifyCode(); await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: fresh }); return fresh; } // ── base64url decoding ────────────────────────────────────────────────────── /** * Inlined to keep this module decoupled from popup/passkey helpers. The Codex * vaultBlob is base64url-encoded (no padding) JSON; decode to a UTF-8 string. */ function base64urlToString(b64url: string): string { const pad = b64url.length % 4 === 0 ? "" : "=".repeat(4 - (b64url.length % 4)); const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + pad; const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new TextDecoder().decode(bytes); } // ── Handlers ──────────────────────────────────────────────────────────────── /** * Read everything the popup needs to build a CodexPayload. Pro-gated: * generating a Codex is a Pro feature (restoring one is not — see * `handleRestoreFromCodex`). * * The recovery words re-derive the ROOT KEY, so they are only returned when a * fresh master-password proof accompanies the request (PB-26-001) — the same * re-auth rule GET_RECOVERY_WORDS enforces. Without a proof the snapshot * still carries the encrypted slot blobs; only `recoveryWords` is withheld. */ export async function handleGetCodexVaultData(password?: string): Promise<{ ok: boolean; error?: string; data?: CodexVaultSnapshot; }> { if (!(await isProUser())) { return { ok: false, error: "pro_required" }; } if (!vault.isUnlocked()) { return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." }; } let passwordProof = false; if (typeof password === "string" && password) { if (!(await vault.verifyMainPassword(password))) { return { ok: false, error: "wrong_password" }; } passwordProof = true; } const raw = await chrome.storage.local.get([ "peach_vault_a", "peach_vault_b", "peach_last_modified_at", ]); const slotARaw = (raw as Record)["peach_vault_a"]; const slotBRaw = (raw as Record)["peach_vault_b"]; const lastModRaw = (raw as Record)["peach_last_modified_at"]; if (!slotARaw) { return { ok: false, error: "VAULT_EMPTY: No vault to print." }; } // chrome.storage stores objects directly; serialise to JSON strings to // produce the slot blobs the Codex format expects. const slotA = JSON.stringify(slotARaw); const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null; const lastModified = typeof lastModRaw === "string" ? lastModRaw : null; // Recovery words are present on every supported vault, but revealing them // requires the fresh master-password proof above (PB-26-001). let recoveryWords: string[] | null = null; if (passwordProof) { try { recoveryWords = await vault.getRecoveryWords(); } catch { recoveryWords = null; } } const verifyCode = await getOrCreateVerifyCode(); const entryCount = vault.getEntries().length; const snapshot: CodexVaultSnapshot = { slotA, slotB, lastModified, entryCount, recoveryWords, verifyCode, vaultUnlocked: true, }; return { ok: true, data: snapshot }; } /** * Cheap pre-flight: estimate how many A4 pages the Codex will need so the * popup can warn the user before kicking off the full generation pipeline. * Pro-gated for the same reason as `handleGetCodexVaultData`. * * Falls back to a safe over-estimate (3 pages) on any error — better to * promise more pages than the user actually needs than to surprise them. */ export async function handleEstimateCodexPages(): Promise<{ ok: boolean; error?: string; pages?: number; parityShards?: number; }> { if (!(await isProUser())) { return { ok: false, error: "pro_required" }; } try { const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]); const slotA = (raw as Record)["peach_vault_a"]; const slotB = (raw as Record)["peach_vault_b"]; if (!slotA) return { ok: true, pages: 3, parityShards: 2 }; // Match the generator's payload shape: { a, b } JSON, deflated. The // popup's real generator does the same compression on the actual // CodexPayload, but slot ciphertext dominates the size and dwarfs the // envelope, so a deflate of the slot blobs is a faithful estimate. const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null }); const compressed = deflate(new TextEncoder().encode(blobJson)); const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE)); const parityShards = computeParityShards(dataChunks); const totalChunks = dataChunks + parityShards; const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE)); return { ok: true, pages, parityShards }; } catch { return { ok: true, pages: 3, parityShards: 2 }; } } /** * Restore the vault from a scanned Codex. * * NOT Pro-gated by design. The Codex was earned by a Pro user; we must never * block them from recovering their own vault, even from a free device. See * peach_codex_v2.md "Pro gating". */ export async function handleRestoreFromCodex( payload: CodexRestorePayload, ): Promise<{ ok: boolean; error?: string; data?: { entryCount: number; verifyCode: string }; }> { // PCH-01-017: validate attacker-supplied metadata before it can reach storage // or sync-version comparisons. The trial-decrypt gate in unlockFromCodex // protects the vault bytes; these checks protect the metadata riding alongside. if (!VERIFY_CODE_PATTERN.test(payload.verifyCode)) { return { ok: false, error: "INVALID_VERIFY_CODE" }; } if (!Number.isFinite(payload.timestamp)) { return { ok: false, error: "INVALID_VAULT_BLOB" }; } if (payload.vaultBlob.length > IMPORT_LIMITS.MAX_JSON_BYTES) { return { ok: false, error: "INVALID_VAULT_BLOB" }; } // Parse the base64url-wrapped JSON { a, b }. Malformed input is a hard // refusal — we will not write garbage into slotA. let slotAJson: string; let slotBJson: string | null; try { const decoded = base64urlToString(payload.vaultBlob); const parsed = JSON.parse(decoded) as { a: unknown; b: unknown }; if (!parsed || typeof parsed !== "object" || parsed.a == null) { return { ok: false, error: "INVALID_VAULT_BLOB" }; } // The generator stores each slot as a JSON string payload, so restore // must pass that string through unchanged. Re-stringifying here would // wrap the already-encoded JSON in quotes and make unlockFromCodex // reject the blob as unreadable. slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a); slotBJson = parsed.b == null ? null : typeof parsed.b === "string" ? parsed.b : JSON.stringify(parsed.b); } catch { return { ok: false, error: "INVALID_VAULT_BLOB" }; } const result = await vault.unlockFromCodex({ slotA: slotAJson, slotB: slotBJson, timestamp: payload.timestamp, masterPassphrase: payload.masterPassphrase, }); if (!result.ok) { if (result.reason === "wrong_passphrase") { return { ok: false, error: "wrong_passphrase" }; } return { ok: false, error: "INVALID_VAULT_BLOB" }; } // Persist the Codex's verify code as this device's verify code so future // Codex prints from here keep the same identifier. Also record the restore // time so Settings can surface "restored from Codex at …". await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode, [STORAGE_KEY_LAST_RESTORED]: Date.now(), }); return { ok: true, data: { entryCount: result.entryCount, verifyCode: payload.verifyCode, }, }; }