3c93c9cae9cc
add popup codex library: compression+RS erasure+Aztec roundtrip+PDF render+helpers+28 unit tests
- time
- 2026-05-15 17:38 UTC · 5 months ago
- machine
- koh2
- parent
- 53fdf206c18d
↓ peach-browser-3c93c9ca.tar.gzatlas at this save
files changed21 files
src/background/codex-handlers.test.ts+130 −0src/background/codex-handlers.ts+281 −0src/background/messages.ts+25 −0src/background/vault.ts+99 −1src/popup/App.tsx+29 −1src/popup/codex/aztec.ts+252 −0src/popup/codex/erasure.ts+196 −0src/popup/codex/generate.test.ts+259 −0src/popup/codex/generate.ts+371 −0src/popup/codex/helpers.ts+167 −0src/popup/codex/pdf.ts+314 −0src/popup/codex/restore.test.ts+322 −0src/popup/codex/restore.ts+221 −0src/popup/codex/scanner.ts+56 −0src/popup/index.html+1 −0src/popup/styles/codex.css+180 −0src/popup/views/CodexGenerateView.tsx+325 −0src/popup/views/CodexRestoreView.tsx+365 −0src/popup/views/Lock.tsx+12 −0src/popup/views/Settings.tsx+15 −0src/shared/types.ts+8 −2src/background/codex-handlers.test.ts+130 −0
@@ -0,0 +1,130 @@1+/**2+ * Tests for src/background/codex-handlers.ts.3+ *4+ * These exercise the gate logic and error paths without standing up a full5+ * vault — license and vault modules are mocked. The one happy-path that6+ * needs a real vault is exercised via the messages.test.ts harness; here we7+ * focus on the boundary behaviour the handler owns.8+ */9+10+import { beforeEach, describe, expect, it, vi } from "vitest";11+12+type StorageShape = Record<string, unknown>;13+14+describe("codex handlers", () => {15+ let storage: StorageShape;16+17+ beforeEach(() => {18+ vi.resetModules();19+ vi.doUnmock("./vault");20+ vi.doUnmock("./license");21+ storage = {};22+23+ const chromeMock = {24+ storage: {25+ local: {26+ get: vi.fn(async (key?: string | string[]) => {27+ if (!key) return storage;28+ if (typeof key === "string") return { [key]: storage[key] };29+ return key.reduce<Record<string, unknown>>((acc, k) => {30+ acc[k] = storage[k];31+ return acc;32+ }, {});33+ }),34+ set: vi.fn(async (next: Record<string, unknown>) => {35+ Object.assign(storage, next);36+ }),37+ remove: vi.fn(async (keys: string | string[]) => {38+ const list = Array.isArray(keys) ? keys : [keys];39+ for (const k of list) delete storage[k];40+ }),41+ },42+ },43+ } as unknown as typeof chrome;44+ (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;45+ });46+47+ it("handleGetCodexVaultData rejects free users with pro_required", async () => {48+ vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));49+ vi.doMock("./vault", () => ({50+ isUnlocked: () => true,51+ getEntries: () => [],52+ getRecoveryWords: async () => null,53+ }));54+ const { handleGetCodexVaultData } = await import("./codex-handlers");55+ const result = await handleGetCodexVaultData();56+ expect(result).toEqual({ ok: false, error: "pro_required" });57+ });58+59+ it("handleGetCodexVaultData returns VAULT_LOCKED for locked vault", async () => {60+ vi.doMock("./license", () => ({ isProUser: vi.fn(async () => true) }));61+ vi.doMock("./vault", () => ({62+ isUnlocked: () => false,63+ getEntries: () => {64+ throw new Error("vault locked");65+ },66+ getRecoveryWords: async () => {67+ throw new Error("vault locked");68+ },69+ }));70+ const { handleGetCodexVaultData } = await import("./codex-handlers");71+ const result = await handleGetCodexVaultData();72+ expect(result.ok).toBe(false);73+ expect(result.error).toMatch(/^VAULT_LOCKED/);74+ });75+76+ it("handleEstimateCodexPages returns >=1 page for a real slotA blob", async () => {77+ vi.doMock("./license", () => ({ isProUser: vi.fn(async () => true) }));78+ vi.doMock("./vault", () => ({})); // unused on this path79+ // A representative slotA-shaped record; field contents don't matter for80+ // the size estimate — only the JSON-serialised byte count does.81+ storage.peach_vault_a = {82+ version: 1,83+ salt: "00".repeat(16),84+ nonce: "00".repeat(12),85+ // ~2 KiB of hex so the deflated stream lands in a predictable range.86+ ciphertext: "ab".repeat(1024),87+ tag: "00".repeat(16),88+ };89+ storage.peach_vault_b = {90+ version: 1,91+ salt: "11".repeat(16),92+ nonce: "11".repeat(12),93+ ciphertext: "cd".repeat(1024),94+ tag: "11".repeat(16),95+ };96+ const { handleEstimateCodexPages } = await import("./codex-handlers");97+ const result = await handleEstimateCodexPages();98+ expect(result.ok).toBe(true);99+ expect(typeof result.pages).toBe("number");100+ expect(result.pages!).toBeGreaterThanOrEqual(1);101+ });102+103+ it("handleRestoreFromCodex rejects an invalid blob with INVALID_VAULT_BLOB", async () => {104+ vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));105+ const unlockFromCodex = vi.fn();106+ vi.doMock("./vault", () => ({107+ unlockFromCodex,108+ isUnlocked: () => false,109+ getEntries: () => [],110+ }));111+ vi.doMock("../shared/native", () => ({112+ isNativePlatform: () => false,113+ nativePlugin: () => null,114+ }));115+116+ const { handleRestoreFromCodex } = await import("./codex-handlers");117+ const result = await handleRestoreFromCodex({118+ vaultBlob: "this-is-not-valid-base64url-json!!!",119+ timestamp: 0,120+ verifyCode: "PX-AAAA-BBBB",121+ vaultFingerprint: "0".repeat(16),122+ entryCount: 0,123+ masterPassphrase: "ignored",124+ });125+ expect(result).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });126+ // Restoration must short-circuit before reaching the vault module on a127+ // malformed blob — we never want to touch storage on garbage input.128+ expect(unlockFromCodex).not.toHaveBeenCalled();129+ });130+});src/background/codex-handlers.ts+281 −0
@@ -0,0 +1,281 @@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+ */14+15+import { deflate } from "pako";16+17+import { isProUser } from "./license";18+import * as vault from "./vault";19+import { isNativePlatform, nativePlugin } from "../shared/native";20+import type { VaultEntry } from "../shared/types";21+import {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";30+31+// ── Verify-code helpers ─────────────────────────────────────────────────────32+33+const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/134+35+/**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+ */41+function 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+}50+51+async 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+}61+62+// ── base64url decoding ──────────────────────────────────────────────────────63+64+/**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+ */68+function 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+}76+77+// ── 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.80+81+async 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+}111+112+// ── Handlers ────────────────────────────────────────────────────────────────113+114+/**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+ */119+export 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+ }130+131+ 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"];139+140+ if (!slotARaw) {141+ return { ok: false, error: "VAULT_EMPTY: No vault to print." };142+ }143+144+ // 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;149+150+ // 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+ }159+160+ const verifyCode = await getOrCreateVerifyCode();161+ const entryCount = vault.getEntries().length;162+163+ 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+}174+175+/**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+ */183+export 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 };196+197+ // 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+}211+212+/**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+ */219+export 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+ }241+242+ const result = await vault.unlockFromCodex({243+ slotA: slotAJson,244+ slotB: slotBJson,245+ timestamp: payload.timestamp,246+ masterPassphrase: payload.masterPassphrase,247+ });248+249+ 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+ }255+256+ // 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+ });263+264+ // 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+ }273+274+ return {275+ ok: true,276+ data: {277+ entryCount: result.entryCount,278+ verifyCode: payload.verifyCode,279+ },280+ };281+}src/background/messages.ts+25 −0
@@ -13,6 +13,12 @@1313 import { setupAutoLock, touchAutoLock } from "./alarms";1414 import * as vault from "./vault";1515 import {16+ handleEstimateCodexPages,17+ handleGetCodexVaultData,18+ handleRestoreFromCodex,19+} from "./codex-handlers";20+import type { CodexRestorePayload } from "../shared/codex-types";21+import {1622 activateLicense,1723 deactivateLicense,1824 getLicenseInfo,@@ -753,6 +759,25 @@753759 case "MARK_CELEBRATION_PLAYED": {754760 await markCelebrationPlayed();755761 return { ok: true };762+ }763+ case "GET_CODEX_VAULT_DATA": {764+ // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the765+ // popup navigates to the License section without further coordination.766+ const gate = await requirePro("codex");767+ if (!gate.allowed) return { ok: false, error: "pro_required" };768+ return await handleGetCodexVaultData();769+ }770+ case "ESTIMATE_CODEX_PAGES": {771+ const gate = await requirePro("codex");772+ if (!gate.allowed) return { ok: false, error: "pro_required" };773+ return await handleEstimateCodexPages();774+ }775+ case "RESTORE_FROM_CODEX": {776+ // !! IMPORTANT: intentionally NOT Pro-gated. A free device must still777+ // be able to restore a vault that was printed by a Pro user.778+ // DO NOT add a Pro gate here.779+ const payload = msg.payload as CodexRestorePayload;780+ return await handleRestoreFromCodex(payload);756781 }757782 case "OPEN_PRO_GATE": {758783 // Content script requests: open popup and navigate to License section.src/background/vault.ts+99 −1
@@ -709,7 +709,12 @@709709 return { merged };710710 }711711 712-async function tryDecryptSlot(712+// Exported solely for `unlockFromCodex` below, which needs to verify a713+// supplied passphrase against a slotA blob *without* committing anything to714+// storage. Keep this internal otherwise — callers crossing the messages.ts715+// boundary should always go through `unlock` / `verifyMainPassword` so the716+// constant-time invariants in Projects.md §3.4 are not bypassed.717+export async function tryDecryptSlot(713718 vault: StoredVault,714719 key: Uint8Array715720 ): Promise<DecryptedPayload | null> {@@ -993,6 +998,99 @@993998 } catch {994999 return { ok: false, error: "invalid_key" };9951000 }1001+}1002+1003+/**1004+ * Restore the vault from a parsed Peach Codex payload.1005+ *1006+ * The supplied passphrase is verified against the Codex's slotA *before* any1007+ * storage mutation — wrong passphrases return without touching disk. On1008+ * success, the current vault state is snapshotted (reason: "restore") so the1009+ * user can revert via Settings -> Vault History, then slotA/slotB are written1010+ * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1011+ * regular `unlock()` follows so the rest of the SW sees the standard1012+ * post-unlock state.1013+ *1014+ * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1015+ * printed by a Pro user must always be restorable, even on a free device.1016+ *1017+ * Timing is not held constant here (in contrast to `unlock`): the Codex flow1018+ * is an explicit user action with a known passphrase, not a passive lock1019+ * screen, so the duress-vs-real timing side channel does not apply.1020+ */1021+export async function unlockFromCodex(params: {1022+ slotA: string; // JSON string of StoredVault for slot A1023+ slotB: string | null; // JSON string of StoredVault for slot B, or null1024+ timestamp: number;1025+ masterPassphrase: string;1026+}): Promise<1027+ | { ok: true; entryCount: number }1028+ | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1029+> {1030+ let parsedSlotA: StoredVault;1031+ let parsedSlotB: StoredVault | null = null;1032+ try {1033+ parsedSlotA = JSON.parse(params.slotA) as StoredVault;1034+ if (params.slotB !== null) {1035+ parsedSlotB = JSON.parse(params.slotB) as StoredVault;1036+ }1037+ // Minimal structural validation — the decrypt step below catches deeper1038+ // corruption, but bail early on obviously-bad shapes so we don't reach1039+ // storage with junk.1040+ if (1041+ typeof parsedSlotA?.salt !== "string" ||1042+ typeof parsedSlotA?.nonce !== "string" ||1043+ typeof parsedSlotA?.ciphertext !== "string" ||1044+ typeof parsedSlotA?.tag !== "string"1045+ ) {1046+ return { ok: false, reason: "invalid_slot_format" };1047+ }1048+ } catch {1049+ return { ok: false, reason: "invalid_slot_format" };1050+ }1051+1052+ // Verify the passphrase against the supplied slotA WITHOUT writing anything.1053+ // If this fails the on-disk vault is left completely untouched.1054+ const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1055+ const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1056+ if (!trialPayload) {1057+ zeroKey(trialKey);1058+ return { ok: false, reason: "wrong_passphrase" };1059+ }1060+ // Drop the trial key; `unlock()` below derives its own copy. Holding two1061+ // copies of the master key in memory at once would be a needless surface.1062+ zeroKey(trialKey);1063+1064+ // Snapshot the about-to-be-overwritten on-disk state so the user can revert1065+ // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1066+ await takeVaultSnapshot("restore");1067+1068+ await writeSlot(SLOT_A, parsedSlotA);1069+ if (parsedSlotB) {1070+ await writeSlot(SLOT_B, parsedSlotB);1071+ } else {1072+ // Slot B must always exist with ciphertext length matching slot A1073+ // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1074+ // configured); regenerate noise to satisfy the invariant.1075+ await writeSlot(1076+ SLOT_B,1077+ generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1078+ );1079+ }1080+ await chrome.storage.local.set({1081+ peach_last_modified_at: String(params.timestamp),1082+ });1083+1084+ // Standard unlock path so domain index, settings, auto-lock, and1085+ // recovery-key state all populate exactly as a normal unlock would.1086+ const ok = await unlock(params.masterPassphrase);1087+ if (!ok) {1088+ // Should be unreachable — we just verified the passphrase against the1089+ // same slotA we wrote. Treat as wrong_passphrase to keep the contract1090+ // simple if some race or storage anomaly intrudes.1091+ return { ok: false, reason: "wrong_passphrase" };1092+ }1093+ return { ok: true, entryCount: getEntries().length };9961094 }9971095 9981096 // Returns the current in-memory derived key as a hex string.src/popup/App.tsx+29 −1
@@ -39,6 +39,8 @@3939 const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));4040 const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));4141 const AddDeviceView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.AddDeviceView })));42+const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));43+const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));4244 4345 type View =4446 | "lock"@@ -53,7 +55,9 @@5355 | "export"5456 | "health"5557 | "trash"56- | "panic";58+ | "panic"59+ | "codex-generate"60+ | "codex-restore";5761 type BootState = "loading" | "ready";5862 5963 const canarySuppressMap = new Map<string, boolean>();@@ -1188,6 +1192,7 @@11881192 ? () => setOnboarded(false)11891193 : undefined11901194 }1195+ onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}11911196 />11921197 </Show>11931198 }@@ -1471,6 +1476,29 @@14711476 onOpenSyncPairing={() => setView("sync-pairing")}14721477 onOpenFileImport={handleOpenFileImport}14731478 onOpenPanic={() => setView("panic")}1479+ onOpenCodexGenerate={() => setView("codex-generate")}1480+ />1481+ </Suspense>1482+ </Show>1483+ <Show when={view() === "codex-generate"}>1484+ <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1485+ <CodexGenerateView1486+ isPro={proStatus()}1487+ onBack={() => setView("settings")}1488+ onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1489+ />1490+ </Suspense>1491+ </Show>1492+ <Show when={view() === "codex-restore"}>1493+ <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1494+ <CodexRestoreView1495+ onBack={() => setView("lock")}1496+ onRestoreSuccess={async () => {1497+ // After restore the vault is unlocked; refresh entries1498+ // and route to the main vault list. handleUnlock is the1499+ // canonical post-unlock setup path.1500+ await handleUnlock();1501+ }}14741502 />14751503 </Suspense>14761504 </Show>src/popup/codex/aztec.ts+252 −0
@@ -0,0 +1,252 @@1+/**2+ * aztec.ts — Aztec 2D barcode generation with round-trip verification.3+ *4+ * The library has two entry points:5+ * - browser: `bwip-js` — uses Canvas/OffscreenCanvas for rasterization6+ * - node: `bwip-js/node` — uses zlib-PNG, returns a Buffer directly7+ *8+ * We resolve which one to use at runtime so the popup (browser context) and9+ * vitest (node context) both work without any build-time conditional. The10+ * resolved module is cached because importing parses ~500KB of generated JS.11+ *12+ * Round-trip verification: every generated Aztec is decoded back via @zxing13+ * and the decoded text is compared byte-for-byte with the original envelope14+ * JSON. Without the round-trip we have no proof that what we printed is15+ * actually readable. In the popup the decode runs through OffscreenCanvas.16+ * In a pure node test environment OffscreenCanvas is unavailable, so we17+ * fall back to a no-op verification — the generation tests for that path18+ * cover the encoder via separate code paths.19+ */20+21+import {22+ BarcodeFormat,23+ BinaryBitmap,24+ DecodeHintType,25+ HybridBinarizer,26+ MultiFormatReader,27+ RGBLuminanceSource,28+} from "@zxing/library";29+import {30+ AZTEC_ECC_PERCENT,31+ CodexError,32+ type ChunkEnvelope,33+} from "../../shared/codex-types";34+35+export interface AztecImage {36+ pngBytes: Uint8Array;37+ width: number;38+ height: number;39+}40+41+// ── bwip-js loader ───────────────────────────────────────────────────────────42+43+/**44+ * In browser builds the popup bundler resolves `bwip-js` to the browser entry45+ * which exports `toCanvas`. In node (vitest) we want `bwip-js/node` which46+ * exports `toBuffer`. We can't import both unconditionally — node can't load47+ * the browser entry's canvas code, and the browser can't load `node:zlib`.48+ *49+ * The detection is "do we have a working OffscreenCanvas?" rather than50+ * "is `process` defined?" because the popup runs in an MV3 worker context51+ * where some Node globals leak via polyfills.52+ */53+interface BwipBrowserModule {54+ toCanvas: (55+ canvas: OffscreenCanvas | HTMLCanvasElement,56+ opts: Record<string, unknown>,57+ ) => OffscreenCanvas | HTMLCanvasElement;58+}59+60+interface BwipNodeModule {61+ toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;62+}63+64+let cachedBwip: BwipBrowserModule | BwipNodeModule | null = null;65+let cachedIsBrowser: boolean | null = null;66+67+async function loadBwip(): Promise<{68+ mod: BwipBrowserModule | BwipNodeModule;69+ isBrowser: boolean;70+}> {71+ if (cachedBwip && cachedIsBrowser !== null) {72+ return { mod: cachedBwip, isBrowser: cachedIsBrowser };73+ }74+ const isBrowser = typeof OffscreenCanvas !== "undefined";75+76+ // Pick the entrypoint explicitly. Vite (and therefore vitest) tends to77+ // resolve the bare `bwip-js` specifier against the package's browser export78+ // condition even when running under node — so we route node test runs79+ // through `bwip-js/node` directly, which gives us the toBuffer-shaped API.80+ const spec = isBrowser ? "bwip-js" : "bwip-js" + "/node";81+ const ns = (await import(/* @vite-ignore */ spec)) as Record<string, unknown>;82+ const defaultExport = ns["default"] as Record<string, unknown> | undefined;83+ // ESM dynamic import surface differs between the two builds: the browser84+ // bundle exports `toCanvas` as a named export, while the node bundle is85+ // CJS-shaped and exposes `toBuffer` on `default`. Merge both so callers86+ // can use either method without caring which path resolved.87+ const merged = { ...(defaultExport ?? {}), ...ns } as Record<string, unknown>;88+ const resolved = merged as unknown as BwipBrowserModule | BwipNodeModule;89+ cachedBwip = resolved;90+ cachedIsBrowser = isBrowser;91+ return { mod: resolved, isBrowser };92+}93+94+// ── Aztec PNG generation ────────────────────────────────────────────────────95+96+/**97+ * Render an Aztec barcode at 25% ECC and return its PNG bytes. The PNG is98+ * intentionally monochrome at scale 4 — that's the smallest scale that still99+ * looks crisp at print resolution while keeping per-code rendering well under100+ * 100ms on modest hardware.101+ */102+export async function generateAztecPng(payload: string): Promise<AztecImage> {103+ const { mod, isBrowser } = await loadBwip();104+ const opts = {105+ bcid: "azteccode",106+ text: payload,107+ eclevel: AZTEC_ECC_PERCENT.toString(),108+ scale: 4,109+ padding: 0,110+ monochrome: true,111+ } as const;112+113+ try {114+ if (isBrowser) {115+ const canvas = new OffscreenCanvas(1, 1);116+ const drawn = (mod as BwipBrowserModule).toCanvas(canvas, opts);117+ const out = drawn as OffscreenCanvas;118+ const blob = await out.convertToBlob({ type: "image/png" });119+ const pngBytes = new Uint8Array(await blob.arrayBuffer());120+ return { pngBytes, width: out.width, height: out.height };121+ }122+123+ const buf = await (mod as BwipNodeModule).toBuffer(opts);124+ const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();125+ const { width, height } = readPngDimensions(pngBytes);126+ return { pngBytes, width, height };127+ } catch (err) {128+ throw new CodexError(129+ "AZTEC_GENERATION_FAILED",130+ `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,131+ );132+ }133+}134+135+/** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */136+function readPngDimensions(png: Uint8Array): { width: number; height: number } {137+ // PNG signature: 8 bytes, then 4-byte chunk length, "IHDR", then width/height as big-endian uint32.138+ if (png.length < 24) {139+ throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");140+ }141+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);142+ const width = view.getUint32(16, false);143+ const height = view.getUint32(20, false);144+ return { width, height };145+}146+147+// ── Aztec PNG decoding (round-trip) ─────────────────────────────────────────148+149+/**150+ * Decode an Aztec PNG via @zxing. Returns the decoded UTF-8 text, or `null`151+ * if decoding fails for any reason (no DOM, malformed PNG, no match found).152+ * Never throws — the caller decides whether a `null` result is a verification153+ * failure or a deliberate skip.154+ */155+export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {156+ // The decoder needs raw pixel data, which means we need a canvas to157+ // rasterize the PNG into ImageData. Skip the round-trip in environments158+ // without OffscreenCanvas (vitest with environment: 'node').159+ if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {160+ return null;161+ }162+163+ try {164+ // Copy into a fresh ArrayBuffer-backed view so Blob's typing is satisfied165+ // (the input Uint8Array may be backed by a SharedArrayBuffer in some166+ // bundler typings, which Blob's BlobPart union rejects).167+ const owned = new Uint8Array(pngBytes.byteLength);168+ owned.set(pngBytes);169+ const blob = new Blob([owned], { type: "image/png" });170+ const bitmap = await createImageBitmap(blob);171+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);172+ const ctx = canvas.getContext("2d");173+ if (!ctx) return null;174+ ctx.drawImage(bitmap, 0, 0);175+ const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);176+177+ // RGBLuminanceSource wants packed 32-bit ARGB values, not raw Uint8 RGBA.178+ const argb = new Int32Array(bitmap.width * bitmap.height);179+ const raw = imageData.data;180+ for (let i = 0, j = 0; i < raw.length; i += 4, j++) {181+ argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;182+ }183+184+ const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);185+ const binarizer = new HybridBinarizer(source);186+ const bitmapBinary = new BinaryBitmap(binarizer);187+188+ const reader = new MultiFormatReader();189+ const hints = new Map();190+ hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);191+ hints.set(DecodeHintType.TRY_HARDER, true);192+ reader.setHints(hints);193+194+ const result = reader.decode(bitmapBinary);195+ return result.getText();196+ } catch {197+ return null;198+ }199+}200+201+// ── Verified Aztec generation ───────────────────────────────────────────────202+203+/**204+ * Generate the Aztec code for one envelope and confirm it round-trips. The205+ * envelope JSON is serialized once and reused as the decode target so any206+ * encoding mismatch (e.g. UTF-8 surrogate corruption) is caught immediately.207+ *208+ * In environments where round-trip decoding is unavailable (vitest node), the209+ * verification step is skipped — the generator still throws on encoder210+ * failure, which is the only condition this path can detect without a DOM.211+ */212+export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {213+ const envelopeJson = JSON.stringify(envelope);214+215+ let image: AztecImage;216+ try {217+ image = await generateAztecPng(envelopeJson);218+ } catch (err) {219+ if (err instanceof CodexError) throw err;220+ throw new CodexError(221+ "AZTEC_GENERATION_FAILED",222+ `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,223+ );224+ }225+226+ if (!image.pngBytes || image.pngBytes.length < 32) {227+ throw new CodexError(228+ "AZTEC_GENERATION_FAILED",229+ `Aztec PNG for chunk ${envelope.i} was empty`,230+ );231+ }232+233+ // Skip round-trip when we can't rasterize — see decodeAztecFromPng().234+ if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {235+ return image;236+ }237+238+ const decoded = await decodeAztecFromPng(image.pngBytes);239+ if (decoded === null) {240+ throw new CodexError(241+ "AZTEC_ROUNDTRIP_FAILED",242+ `Chunk ${envelope.i} generated but cannot be decoded back. This Codex cannot be trusted.`,243+ );244+ }245+ if (decoded !== envelopeJson) {246+ throw new CodexError(247+ "AZTEC_CONTENT_MISMATCH",248+ `Chunk ${envelope.i} decoded content does not match original envelope. Possible encoding corruption.`,249+ );250+ }251+ return image;252+}src/popup/codex/erasure.ts+196 −0
@@ -0,0 +1,196 @@1+/**2+ * erasure.ts — Reed-Solomon erasure coding around the WASM library.3+ *4+ * The library wants a single contiguous buffer of length5+ * (dataShards + parityShards) * shardSize6+ * with data shards laid out first, followed by space for parity. `encode()`7+ * fills the parity region in-place. `reconstruct()` rewrites missing shards8+ * in-place using a `shardsAvailable: boolean[]` mask.9+ *10+ * We expose a chunk-oriented API (`encodeWithErasure` / `decodeWithErasure`)11+ * because the rest of the codex pipeline thinks in terms of individual12+ * envelopes. Both adapters allocate a fresh buffer per call — the library is13+ * not re-entrant on a shared buffer.14+ */15+16+import { ReedSolomonErasure } from "@digitaldefiance/reed-solomon-erasure.wasm";17+import {18+ CHUNK_SIZE,19+ CodexError,20+ PARITY_SHARDS,21+} from "../../shared/codex-types";22+import { getReedSolomon } from "./rs-loader";23+24+/**25+ * The shared rs-loader caches a single `ReedSolomonErasure` instance, which26+ * is fine for `encode()` but trips a known library bug on the SECOND call to27+ * `reconstruct()` against the same instance: the internal __wbindgen state28+ * survives between calls and the second reconstruction reports29+ * RESULT_ERROR_TOO_FEW_SHARDS_PRESENT even when enough shards are present.30+ *31+ * To get correct behaviour we (a) use the loader to ensure the WASM is32+ * actually instantiable and to cache the bytes lazily, and (b) build a fresh33+ * `ReedSolomonErasure` instance for every reconstruction. The cost is a few34+ * milliseconds per call — negligible compared to compressing the vault and35+ * rendering Aztec codes.36+ */37+let cachedWasmBytes: Uint8Array | null = null;38+39+async function freshReedSolomon(): Promise<ReedSolomonErasure> {40+ if (cachedWasmBytes) {41+ return ReedSolomonErasure.fromBytes(cachedWasmBytes);42+ }43+ // Capture bytes by intercepting the loader's first instantiation. The44+ // loader caches its own promise, so calling it twice is cheap on the second45+ // pass. We pull bytes from the WASM memory image after the fact — see46+ // `loadWasmBytes` below for the lazy initialization.47+ await getReedSolomon();48+ if (!cachedWasmBytes) {49+ cachedWasmBytes = await loadWasmBytes();50+ }51+ return ReedSolomonErasure.fromBytes(cachedWasmBytes);52+}53+54+/**55+ * Load the WASM bytes through the same probing logic rs-loader uses, so this56+ * file does not assume any specific filesystem layout. In the popup the57+ * loader's fetch path is the source of truth; in vitest it's the on-disk58+ * path under `node_modules/`. This helper is deliberately a duplicate of59+ * the bytes-only portion of rs-loader — we cannot touch rs-loader itself60+ * (orchestrator-owned), and we need raw bytes to work around the library's61+ * reuse bug.62+ */63+async function loadWasmBytes(): Promise<Uint8Array> {64+ const inPopup =65+ typeof chrome !== "undefined" &&66+ typeof chrome.runtime !== "undefined" &&67+ typeof chrome.runtime.getURL === "function";68+ if (inPopup) {69+ const url = chrome.runtime.getURL("codex/reed_solomon_erasure_bg.wasm");70+ const response = await fetch(url);71+ if (!response.ok) {72+ throw new CodexError(73+ "ERASURE_CODING_VERIFICATION_FAILED",74+ `Failed to fetch WASM: ${response.status}`,75+ );76+ }77+ return new Uint8Array(await response.arrayBuffer());78+ }79+ const fs = await import("node:fs");80+ const path = await import("node:path");81+ const wasmPath = path.resolve(82+ "node_modules/@digitaldefiance/reed-solomon-erasure.wasm/dist/reed_solomon_erasure_bg.wasm",83+ );84+ return new Uint8Array(fs.readFileSync(wasmPath));85+}86+87+export interface EncodeResult {88+ dataChunks: Uint8Array[];89+ parityChunks: Uint8Array[];90+ totalChunks: number;91+ dataShards: number;92+}93+94+/**95+ * Split `data` into fixed-size shards and compute `PARITY_SHARDS` parity96+ * shards. Pads the trailing data shard with zeros — the original byte length97+ * is recovered via the `CodexPayload.vaultBlob` (no length prefix needed on98+ * the wire because the outer JSON already bounds the data).99+ */100+export async function encodeWithErasure(data: Uint8Array): Promise<EncodeResult> {101+ const rs = await freshReedSolomon();102+103+ const dataShards = Math.max(1, Math.ceil(data.length / CHUNK_SIZE));104+ const totalShards = dataShards + PARITY_SHARDS;105+ const buffer = new Uint8Array(totalShards * CHUNK_SIZE);106+ buffer.set(data, 0); // Trailing bytes after `data.length` stay zero — the pad.107+108+ const code = rs.encode(buffer, dataShards, PARITY_SHARDS);109+ if (code !== ReedSolomonErasure.RESULT_OK) {110+ throw new CodexError(111+ "ERASURE_CODING_VERIFICATION_FAILED",112+ `Reed-Solomon encode failed: ${ReedSolomonErasure.getResultMessage(code)}`,113+ );114+ }115+116+ // Slice() copies so each shard owns its memory and callers cannot mutate117+ // the working buffer through a returned view.118+ const dataChunks: Uint8Array[] = [];119+ for (let i = 0; i < dataShards; i++) {120+ dataChunks.push(buffer.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));121+ }122+ const parityChunks: Uint8Array[] = [];123+ for (let i = 0; i < PARITY_SHARDS; i++) {124+ const base = (dataShards + i) * CHUNK_SIZE;125+ parityChunks.push(buffer.slice(base, base + CHUNK_SIZE));126+ }127+128+ return { dataChunks, parityChunks, totalChunks: totalShards, dataShards };129+}130+131+/**132+ * Reassemble the data-shard region from a partial set of chunks. Missing133+ * shards are reconstructed from parity. The returned buffer is exactly134+ * `dataShards * CHUNK_SIZE` bytes long, including any trailing pad bytes135+ * — callers strip the pad using their out-of-band length information.136+ */137+export async function decodeWithErasure(138+ chunks: Map<number, Uint8Array>,139+ dataShards: number,140+ totalShards: number,141+): Promise<Uint8Array> {142+ const parityShards = totalShards - dataShards;143+ if (parityShards < 0) {144+ throw new CodexError(145+ "ERASURE_CODING_VERIFICATION_FAILED",146+ `Invalid shard layout: dataShards=${dataShards} totalShards=${totalShards}`,147+ );148+ }149+150+ if (chunks.size < dataShards) {151+ throw new CodexError(152+ "INSUFFICIENT_CHUNKS",153+ `Need at least ${dataShards} chunks, have ${chunks.size}`,154+ );155+ }156+157+ const rs = await freshReedSolomon();158+ const buffer = new Uint8Array(totalShards * CHUNK_SIZE);159+ const available: boolean[] = new Array(totalShards).fill(false);160+161+ for (const [index, chunk] of chunks) {162+ if (index < 0 || index >= totalShards) {163+ throw new CodexError(164+ "ERASURE_CODING_VERIFICATION_FAILED",165+ `Chunk index ${index} out of range [0, ${totalShards})`,166+ );167+ }168+ if (chunk.length !== CHUNK_SIZE) {169+ throw new CodexError(170+ "ERASURE_CODING_VERIFICATION_FAILED",171+ `Chunk ${index} has length ${chunk.length}, expected ${CHUNK_SIZE}`,172+ );173+ }174+ buffer.set(chunk, index * CHUNK_SIZE);175+ available[index] = true;176+ }177+178+ const code = rs.reconstruct(buffer, dataShards, parityShards, available);179+ if (code !== ReedSolomonErasure.RESULT_OK) {180+ // The library distinguishes "not enough shards" with its own code; map it181+ // back to our INSUFFICIENT_CHUNKS so handlers upstream can surface a182+ // clean "scan more codes" prompt instead of a generic library error.183+ if (code === ReedSolomonErasure.RESULT_ERROR_TOO_FEW_SHARDS_PRESENT) {184+ throw new CodexError(185+ "INSUFFICIENT_CHUNKS",186+ `Need at least ${dataShards} valid chunks for reconstruction`,187+ );188+ }189+ throw new CodexError(190+ "ERASURE_CODING_VERIFICATION_FAILED",191+ `Reed-Solomon reconstruct failed: ${ReedSolomonErasure.getResultMessage(code)}`,192+ );193+ }194+195+ return buffer.slice(0, dataShards * CHUNK_SIZE);196+}src/popup/codex/generate.test.ts+259 −0
@@ -0,0 +1,259 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import {3+ CHUNK_SIZE,4+ CodexError,5+ PARITY_SHARDS,6+ STORAGE_KEY_VERIFY_CODE,7+ type CodexDeps,8+} from "../../shared/codex-types";9+import type { VaultEntry } from "../../shared/types";10+import {11+ base64UrlToBytes,12+ buildChunkEnvelope,13+ bytesToBase64Url,14+ sha256Hex,15+ verifyChunkIntegrity,16+} from "./helpers";17+import { decodeWithErasure, encodeWithErasure } from "./erasure";18+import { generateCodex } from "./generate";19+20+// ── chrome.storage shim ─────────────────────────────────────────────────────21+22+function installChromeStorageMock(): { storage: Record<string, unknown> } {23+ const storage: Record<string, unknown> = {};24+ const chromeMock = {25+ storage: {26+ local: {27+ get: vi.fn(async (key?: string | string[]) => {28+ if (!key) return { ...storage };29+ if (typeof key === "string") return { [key]: storage[key] };30+ return key.reduce<Record<string, unknown>>((acc, k) => {31+ acc[k] = storage[k];32+ return acc;33+ }, {});34+ }),35+ set: vi.fn(async (entries: Record<string, unknown>) => {36+ Object.assign(storage, entries);37+ }),38+ remove: vi.fn(async (k: string | string[]) => {39+ const keys = Array.isArray(k) ? k : [k];40+ for (const key of keys) delete storage[key];41+ }),42+ },43+ },44+ // Deliberately omit `runtime.getURL` so rs-loader.ts falls through to the45+ // Node WASM-from-disk path. With `chrome.runtime.getURL` defined the46+ // loader treats us as an extension popup and tries to `fetch()` the WASM,47+ // which fails in vitest's node environment.48+ runtime: {},49+ };50+ // @ts-expect-error — test-only injection of the chrome global51+ globalThis.chrome = chromeMock;52+ return { storage };53+}54+55+// ── Mock CodexDeps factory ──────────────────────────────────────────────────56+57+function makeDeps(overrides: Partial<CodexDeps> = {}): CodexDeps {58+ // A realistic-sized encrypted-looking blob, encoded as deterministic-but-59+ // high-entropy base64-ish bytes. The pipeline compresses the JSON wrapper,60+ // so for the reconstruction simulation to exercise multiple data shards we61+ // need post-compression size >= ~2KB. Real encrypted vault data is dense62+ // and incompressible — we model that with a pseudo-random byte stream63+ // expressed in a base64 alphabet so it survives JSON stringification.64+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";65+ // Linear congruential generator — deterministic per test run, but the66+ // output histogram is flat enough that deflate cannot compress it well.67+ let seed = 0x1234abcd;68+ const slotALen = 8192;69+ const chars: string[] = new Array(slotALen);70+ for (let i = 0; i < slotALen; i++) {71+ seed = (seed * 1103515245 + 12345) >>> 0;72+ chars[i] = alphabet[(seed >>> 16) & 0x3f]!;73+ }74+ const slotA = "ENCRYPTED::" + chars.join("");75+ const slotB: string | null = null;76+ const entries: VaultEntry[] = [77+ { id: "1", title: "GitHub", username: "alice", password: "secret", createdAt: 1, modifiedAt: 2 },78+ { id: "2", title: "Email", username: "bob", password: "hunter2", createdAt: 1, modifiedAt: 2 },79+ { id: "3", title: "Bank", username: "carol", password: "money", createdAt: 1, modifiedAt: 2 },80+ ];81+82+ return {83+ getSlotA: async () => slotA,84+ getSlotB: async () => slotB,85+ getLastModified: async () => "1700000000000",86+ getAllEntries: async () => entries,87+ getRecoveryWords: async () => null,88+ getVerifyCode: async () => "PX-TEST-CODE",89+ isVaultUnlocked: () => true,90+ ...overrides,91+ };92+}93+94+// ── Tests ───────────────────────────────────────────────────────────────────95+96+describe("helpers", () => {97+ beforeEach(() => {98+ installChromeStorageMock();99+ });100+101+ it("base64url round-trips arbitrary binary", () => {102+ const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 0x3f, 0x2b, 0x2f]);103+ const encoded = bytesToBase64Url(original);104+ expect(encoded).not.toMatch(/[+/=]/);105+ const decoded = base64UrlToBytes(encoded);106+ expect(Array.from(decoded)).toEqual(Array.from(original));107+ });108+109+ it("sha256Hex returns uppercase 64-char hex for known input", async () => {110+ const empty = new Uint8Array();111+ const hex = await sha256Hex(empty);112+ expect(hex).toBe("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855");113+ });114+115+ it("buildChunkEnvelope + verifyChunkIntegrity agree", async () => {116+ const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);117+ const env = await buildChunkEnvelope(chunk, 3, 7, false, "PX-AAAA-BBBB");118+ expect(env.v).toBe(2);119+ expect(env.i).toBe(3);120+ expect(env.t).toBe(7);121+ expect(env.p).toBe(false);122+ expect(env.c).toBe("PX-AAAA-BBBB");123+ expect(env.h).toHaveLength(8);124+ expect(await verifyChunkIntegrity(env)).toBe(true);125+ });126+127+ it("verifyChunkIntegrity rejects a corrupted envelope", async () => {128+ const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);129+ const env = await buildChunkEnvelope(chunk, 0, 4, false, "PX-AAAA-BBBB");130+ // Corrupt the data payload after-the-fact — the recorded hash no longer matches.131+ const tampered = { ...env, d: bytesToBase64Url(new Uint8Array(CHUNK_SIZE).fill(0x43)) };132+ expect(await verifyChunkIntegrity(tampered)).toBe(false);133+ });134+});135+136+describe("erasure", () => {137+ beforeEach(() => {138+ installChromeStorageMock();139+ });140+141+ it("round-trips with the full chunk set", async () => {142+ // Three full data shards plus tail143+ const original = new Uint8Array(CHUNK_SIZE * 3 + 17);144+ for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;145+146+ const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);147+ expect(dataShards).toBe(4);148+ expect(parityChunks.length).toBe(PARITY_SHARDS);149+ expect(totalChunks).toBe(dataShards + PARITY_SHARDS);150+151+ const present = new Map<number, Uint8Array>();152+ dataChunks.forEach((c, i) => present.set(i, c));153+ parityChunks.forEach((c, i) => present.set(dataShards + i, c));154+155+ const recovered = await decodeWithErasure(present, dataShards, totalChunks);156+ for (let i = 0; i < original.length; i++) {157+ expect(recovered[i]).toBe(original[i]);158+ }159+ });160+161+ it("reconstructs after losing two random data chunks", async () => {162+ const original = new Uint8Array(CHUNK_SIZE * 4);163+ for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;164+165+ const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);166+ const present = new Map<number, Uint8Array>();167+ dataChunks.forEach((c, i) => present.set(i, c));168+ parityChunks.forEach((c, i) => present.set(dataShards + i, c));169+170+ // Drop two distinct data shards171+ present.delete(0);172+ present.delete(2);173+174+ const recovered = await decodeWithErasure(present, dataShards, totalChunks);175+ for (let i = 0; i < original.length; i++) {176+ expect(recovered[i]).toBe(original[i]);177+ }178+ });179+180+ it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {181+ const original = new Uint8Array(CHUNK_SIZE * 4);182+ const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);183+ const present = new Map<number, Uint8Array>();184+ dataChunks.forEach((c, i) => present.set(i, c));185+ parityChunks.forEach((c, i) => present.set(dataShards + i, c));186+187+ // Drop three shards — exceeds parity budget188+ present.delete(0);189+ present.delete(1);190+ present.delete(2);191+192+ await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({193+ name: "CodexError",194+ code: "INSUFFICIENT_CHUNKS",195+ });196+ });197+});198+199+describe("generateCodex", () => {200+ beforeEach(() => {201+ installChromeStorageMock();202+ });203+204+ it("produces a non-trivial PDF and a coherent manifest", async () => {205+ const deps = makeDeps();206+ const { pdfBytes, manifest } = await generateCodex(deps, { includeRecoveryWords: false });207+208+ expect(pdfBytes.byteLength).toBeGreaterThan(1000);209+ // PDF magic header210+ expect(pdfBytes[0]).toBe(0x25); // '%'211+ expect(pdfBytes[1]).toBe(0x50); // 'P'212+ expect(pdfBytes[2]).toBe(0x44); // 'D'213+ expect(pdfBytes[3]).toBe(0x46); // 'F'214+215+ expect(manifest.verifyCode).toBe("PX-TEST-CODE");216+ expect(manifest.entryCount).toBe(3);217+ expect(manifest.parityChunks).toBe(PARITY_SHARDS);218+ expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);219+ expect(manifest.includesRecoveryWords).toBe(false);220+ expect(manifest.pageCount).toBeGreaterThanOrEqual(1);221+ expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);222+ }, 60_000);223+224+ it("rejects generation when the vault is locked", async () => {225+ const deps = makeDeps({ isVaultUnlocked: () => false });226+ await expect(generateCodex(deps, { includeRecoveryWords: false })).rejects.toMatchObject({227+ name: "CodexError",228+ code: "VAULT_LOCKED",229+ });230+ });231+232+ it("rejects generation when recovery words are missing", async () => {233+ const deps = makeDeps({ getRecoveryWords: async () => null });234+ await expect(generateCodex(deps, { includeRecoveryWords: true })).rejects.toMatchObject({235+ name: "CodexError",236+ code: "RECOVERY_WORDS_MISSING",237+ });238+ });239+240+ it("rejects generation when recovery words are invalid", async () => {241+ const deps = makeDeps({242+ getRecoveryWords: async () => [243+ "abandon", "ability", "able", "about", "above", "absent",244+ "absorb", "abstract", "absurd", "abuse", "access", "notaword",245+ ],246+ });247+ await expect(generateCodex(deps, { includeRecoveryWords: true })).rejects.toMatchObject({248+ name: "CodexError",249+ code: "RECOVERY_WORDS_INVALID",250+ });251+ });252+253+ it("persists the storage_key_verify_code constant correctly", () => {254+ // Smoke check the constant import — catches a rename / re-export regression255+ // before tests that depend on chrome.storage start failing in confusing ways.256+ expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");257+ expect(CodexError).toBeTypeOf("function");258+ });259+});src/popup/codex/generate.ts+371 −0
@@ -0,0 +1,371 @@1+/**2+ * generate.ts — full Codex generation pipeline.3+ *4+ * Runs entirely in the popup. The service-worker hands the popup a `CodexDeps`5+ * snapshot via the GENERATE_CODEX message and the popup is responsible for6+ * every step that follows: compression, erasure coding, Aztec rasterization,7+ * round-trip verification, and PDF rendering.8+ *9+ * Each step is wrapped in a verification gate. A failure at any stage aborts10+ * generation with a typed `CodexError` so the UI can surface a precise reason11+ * rather than a generic "something went wrong." This is the12+ * physical-disaster-recovery surface — silent corruption here means the13+ * user's only escape hatch is poisoned.14+ */15+16+import { deflate, inflate } from "pako";17+import { WORD_LIST } from "../../shared/recovery-word-list";18+import {19+ CHUNK_SIZE,20+ CODES_PER_PAGE,21+ CodexError,22+ PARITY_SHARDS,23+ type ChunkEnvelope,24+ type CodexDeps,25+ type CodexManifest,26+ type CodexOptions,27+ type CodexPayload,28+} from "../../shared/codex-types";29+import { generateVerifiedAztec } from "./aztec";30+import { decodeWithErasure, encodeWithErasure } from "./erasure";31+import {32+ base64UrlToBytes,33+ bytesToBase64Url,34+ buildChunkEnvelope,35+ sha256Hex,36+} from "./helpers";37+import { renderCodexPDF } from "./pdf";38+39+// ── Public surface ──────────────────────────────────────────────────────────40+41+export async function generateCodex(42+ deps: CodexDeps,43+ options: CodexOptions,44+): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {45+ // STEP 0 — preconditions46+ if (!deps.isVaultUnlocked()) {47+ throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");48+ }49+50+ // STEP 1 — vault blob51+ const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);52+ const verifyCode = await deps.getVerifyCode();53+54+ // STEP 2 — recovery words, if requested55+ let recoveryWords: string[] | null = null;56+ if (options.includeRecoveryWords) {57+ recoveryWords = await loadAndValidateRecoveryWords(deps);58+ }59+60+ // STEP 3 — canonical payload61+ const payload: CodexPayload = {62+ version: 2,63+ verifyCode,64+ vaultBlob,65+ timestamp,66+ entryCount,67+ vaultFingerprint,68+ generatedAt: Date.now(),69+ includesRecoveryWords: options.includeRecoveryWords,70+ };71+72+ // STEP 4 — compress + round-trip verify73+ const payloadJson = JSON.stringify(payload);74+ const payloadBytes = new TextEncoder().encode(payloadJson);75+ const compressed = deflate(payloadBytes, { level: 9 });76+ assertCompressionRoundTrip(compressed, payloadJson);77+78+ // STEP 5 — erasure encode + simulated-loss verify79+ const { dataChunks, parityChunks, totalChunks, dataShards } = await encodeWithErasure(compressed);80+ await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);81+82+ // STEP 6 — envelopes (data first, then parity, preserving global index ordering)83+ const envelopes: ChunkEnvelope[] = [];84+ for (let i = 0; i < dataChunks.length; i++) {85+ envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));86+ }87+ for (let i = 0; i < parityChunks.length; i++) {88+ envelopes.push(89+ await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),90+ );91+ }92+93+ // STEP 7 — verified Aztec PNGs (round-trip per code where DOM is available)94+ const aztecPngs: Uint8Array[] = [];95+ for (const env of envelopes) {96+ const img = await generateVerifiedAztec(env);97+ aztecPngs.push(img.pngBytes);98+ }99+100+ // STEP 8 — full reconstruction simulation (lose 2 random envelopes, rebuild)101+ await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);102+103+ // STEP 9 — render PDF104+ const manifest: CodexManifest = {105+ verifyCode,106+ totalChunks,107+ dataChunks: dataShards,108+ parityChunks: PARITY_SHARDS,109+ pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),110+ entryCount,111+ vaultFingerprint,112+ generatedAt: payload.generatedAt,113+ includesRecoveryWords: options.includeRecoveryWords,114+ };115+ const pdfBytes = await renderCodexPDF(aztecPngs, manifest, recoveryWords);116+117+ // STEP 10 — sanity check the PDF118+ if (!pdfBytes || pdfBytes.length < 1000) {119+ throw new CodexError(120+ "PDF_GENERATION_FAILED",121+ `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,122+ );123+ }124+125+ return { pdfBytes, manifest };126+}127+128+/**129+ * Reconstruct a CodexPayload from a partial set of envelopes (or raw chunk130+ * bytes). Exported because the restore-side agent reuses this exact path to131+ * rebuild the payload from scanned codes.132+ *133+ * Accepts either:134+ * - Map<index, ChunkEnvelope> — typical scan path135+ * - Map<index, Uint8Array> — raw bytes, used by the in-flight136+ * simulation during generation137+ */138+export async function reconstructPayloadFromChunks(139+ envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,140+ dataShards: number,141+ totalChunks: number,142+): Promise<CodexPayload> {143+ const chunkBytes = new Map<number, Uint8Array>();144+ for (const [index, value] of envelopes) {145+ if (value instanceof Uint8Array) {146+ chunkBytes.set(index, value);147+ } else {148+ chunkBytes.set(index, base64UrlToBytes(value.d));149+ }150+ }151+152+ const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);153+154+ // The trailing pad bytes are zeros up to the next chunk boundary. pako's155+ // inflate tolerates trailing junk after the deflate stream end marker, so156+ // we can hand it the padded buffer directly.157+ let inflated: Uint8Array;158+ try {159+ inflated = inflate(recovered);160+ } catch (err) {161+ throw new CodexError(162+ "RECONSTRUCTION_SIMULATION_FAILED",163+ `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,164+ );165+ }166+167+ const json = new TextDecoder().decode(inflated);168+ let payload: CodexPayload;169+ try {170+ payload = JSON.parse(json) as CodexPayload;171+ } catch (err) {172+ throw new CodexError(173+ "RECONSTRUCTION_SIMULATION_FAILED",174+ `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,175+ );176+ }177+ return payload;178+}179+180+/**181+ * Predict how many pages a Codex will occupy without doing the work to182+ * generate one. Used by the popup's ESTIMATE message to drive the "this will183+ * be N pages" UI hint before the user commits to printing. Speculative184+ * compression is fine here — we never persist the result.185+ */186+export async function estimateCodexPages(187+ slotA: string,188+ slotB: string | null,189+): Promise<number> {190+ const combined = JSON.stringify({ a: slotA, b: slotB ?? null });191+ const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));192+ // Stand-in payload — the exact verify code / timestamp / fingerprint don't193+ // affect the compressed size enough to matter for a page count estimate.194+ const stub: CodexPayload = {195+ version: 2,196+ verifyCode: "PX-XXXX-XXXX",197+ vaultBlob,198+ timestamp: 0,199+ entryCount: 0,200+ vaultFingerprint: "0000000000000000",201+ generatedAt: 0,202+ includesRecoveryWords: false,203+ };204+ const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });205+ const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));206+ const totalChunks = dataShards + PARITY_SHARDS;207+ return Math.ceil(totalChunks / CODES_PER_PAGE);208+}209+210+// ── Internal helpers ────────────────────────────────────────────────────────211+212+async function buildVaultBlob(deps: CodexDeps): Promise<{213+ vaultBlob: string;214+ timestamp: number;215+ entryCount: number;216+ vaultFingerprint: string;217+}> {218+ const slotA = await deps.getSlotA();219+ if (!slotA) {220+ throw new CodexError("VAULT_EMPTY", "No vault data found");221+ }222+ const slotB = await deps.getSlotB();223+ const lastModified = await deps.getLastModified();224+ const entries = await deps.getAllEntries();225+226+ const combined = JSON.stringify({ a: slotA, b: slotB ?? null });227+ const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));228+229+ const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 16);230+231+ const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();232+ const liveEntryCount = entries.filter((e) => !e.deletedAt).length;233+234+ return {235+ vaultBlob,236+ timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),237+ entryCount: liveEntryCount,238+ vaultFingerprint: fingerprint,239+ };240+}241+242+async function loadAndValidateRecoveryWords(deps: CodexDeps): Promise<string[]> {243+ const words = await deps.getRecoveryWords();244+ if (!words || words.length !== 12) {245+ throw new CodexError(246+ "RECOVERY_WORDS_MISSING",247+ "Recovery words not found. Generate recovery words in Settings → Security first.",248+ );249+ }250+ for (const word of words) {251+ if (!WORD_LIST.includes(word)) {252+ throw new CodexError(253+ "RECOVERY_WORDS_INVALID",254+ `Recovery word "${word}" is not valid. Please regenerate your recovery words in Settings → Security.`,255+ );256+ }257+ }258+ return words;259+}260+261+function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {262+ const inflated = inflate(compressed);263+ const restored = new TextDecoder().decode(inflated);264+ if (restored !== expectedJson) {265+ throw new CodexError(266+ "COMPRESSION_ROUNDTRIP_FAILED",267+ "Compression verification failed: inflate(deflate(payload)) did not match the original",268+ );269+ }270+}271+272+/**273+ * Confirm the encoded shards can be reconstructed from a partial set. We274+ * drop two random shards (the maximum loss our PARITY_SHARDS budget tolerates)275+ * and check byte-for-byte that the recovered prefix equals the original276+ * compressed blob.277+ */278+async function verifyErasureCoding(279+ dataChunks: Uint8Array[],280+ parityChunks: Uint8Array[],281+ dataShards: number,282+ totalChunks: number,283+ originalCompressed: Uint8Array,284+): Promise<void> {285+ const present = new Map<number, Uint8Array>();286+ dataChunks.forEach((chunk, i) => present.set(i, chunk));287+ parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));288+289+ const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));290+ for (const idx of removed) present.delete(idx);291+292+ let recovered: Uint8Array;293+ try {294+ recovered = await decodeWithErasure(present, dataShards, totalChunks);295+ } catch (err) {296+ if (err instanceof CodexError) {297+ throw new CodexError(298+ "ERASURE_CODING_VERIFICATION_FAILED",299+ `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,300+ );301+ }302+ throw err;303+ }304+305+ for (let i = 0; i < originalCompressed.length; i++) {306+ if (recovered[i] !== originalCompressed[i]) {307+ throw new CodexError(308+ "ERASURE_CODING_CONTENT_MISMATCH",309+ `Reconstructed data does not match original at byte ${i}`,310+ );311+ }312+ }313+}314+315+async function simulateAndVerifyReconstruction(316+ envelopes: ChunkEnvelope[],317+ dataShards: number,318+ totalChunks: number,319+ originalPayload: CodexPayload,320+): Promise<void> {321+ const present = new Map<number, Uint8Array>();322+ envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));323+324+ const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));325+ for (const idx of removed) present.delete(idx);326+327+ let reconstructed: CodexPayload;328+ try {329+ reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);330+ } catch (err) {331+ throw new CodexError(332+ "RECONSTRUCTION_SIMULATION_FAILED",333+ `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,334+ );335+ }336+337+ if (reconstructed.verifyCode !== originalPayload.verifyCode) {338+ throw new CodexError(339+ "RECONSTRUCTION_VERIFY_CODE_MISMATCH",340+ "Reconstructed Codex verify code does not match original",341+ );342+ }343+ if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {344+ throw new CodexError(345+ "RECONSTRUCTION_FINGERPRINT_MISMATCH",346+ "Reconstructed vault fingerprint does not match original",347+ );348+ }349+ if (reconstructed.entryCount !== originalPayload.entryCount) {350+ throw new CodexError(351+ "RECONSTRUCTION_COUNT_MISMATCH",352+ `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,353+ );354+ }355+}356+357+/**358+ * Pick `count` distinct indices uniformly at random from [0, total). Used by359+ * the verification simulations to model "any two chunks could be lost." Not360+ * security-sensitive — we just need diverse coverage across regenerations.361+ */362+function pickRandomIndices(total: number, count: number): number[] {363+ const indices = Array.from({ length: total }, (_, i) => i);364+ for (let i = indices.length - 1; i > 0; i--) {365+ const j = Math.floor(Math.random() * (i + 1));366+ const tmp = indices[i]!;367+ indices[i] = indices[j]!;368+ indices[j] = tmp;369+ }370+ return indices.slice(0, count);371+}src/popup/codex/helpers.ts+167 −0
@@ -0,0 +1,167 @@1+/**2+ * helpers.ts — shared utilities for Codex generation and restoration.3+ *4+ * These primitives are deliberately small and independent so they can be5+ * reused from both `generate.ts` and the (sibling-agent-owned) `restore.ts`6+ * without any cross-import surface.7+ *8+ * SHA-256 stands in for BLAKE3 from the design doc. SHA-256 is available9+ * everywhere via WebCrypto (no WASM, no polyfill), preimage-resistant, and10+ * gives a stable 8-char-prefix fingerprint that is more than sufficient for11+ * catching per-chunk corruption. The on-paper format (`codex-types.ts`)12+ * already documents SHA-256 as the canonical hash.13+ */14+15+import {16+ CodexError,17+ STORAGE_KEY_VERIFY_CODE,18+ type ChunkEnvelope,19+} from "../../shared/codex-types";20+21+// ── base64url ────────────────────────────────────────────────────────────────22+23+/**24+ * Encode bytes to base64url (RFC 4648 §5): URL-safe alphabet, no padding.25+ * Padding-free output keeps the ChunkEnvelope JSON as small as possible —26+ * every byte saved shrinks the printed page count.27+ */28+export function bytesToBase64Url(bytes: Uint8Array): string {29+ let binary = "";30+ for (let i = 0; i < bytes.length; i++) {31+ binary += String.fromCharCode(bytes[i]!);32+ }33+ const b64 = (typeof btoa !== "undefined" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64"));34+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");35+}36+37+/** Decode a base64url string back into raw bytes. Tolerant of missing padding. */38+export function base64UrlToBytes(s: string): Uint8Array {39+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");40+ // base64 requires length % 4 === 0; we strip padding on encode so add it back here.41+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);42+ const binary =43+ typeof atob !== "undefined"44+ ? atob(padded)45+ : Buffer.from(padded, "base64").toString("binary");46+ const out = new Uint8Array(binary.length);47+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);48+ return out;49+}50+51+// ── SHA-256 ─────────────────────────────────────────────────────────────────52+53+/**54+ * Return uppercase hex of SHA-256(bytes). Used for both the per-chunk integrity55+ * hash (8-char prefix) and the vault fingerprint (16-char prefix).56+ */57+export async function sha256Hex(bytes: Uint8Array): Promise<string> {58+ // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as59+ // a SharedArrayBuffer-backed view — some platforms reject that, and the TS60+ // BufferSource typing under `lib.dom` insists on ArrayBuffer-backed views.61+ const owned = new Uint8Array(bytes.byteLength);62+ owned.set(bytes);63+ const hash = await crypto.subtle.digest("SHA-256", owned);64+ const view = new Uint8Array(hash);65+ let hex = "";66+ for (let i = 0; i < view.length; i++) {67+ hex += view[i]!.toString(16).padStart(2, "0");68+ }69+ return hex.toUpperCase();70+}71+72+// ── Chunk envelopes ─────────────────────────────────────────────────────────73+74+/**75+ * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h`76+ * field is the first 8 hex chars of SHA-256(rawChunkBytes), so per-chunk77+ * corruption introduced after generation (smudge, scan glitch) is detectable78+ * at scan time.79+ */80+export async function buildChunkEnvelope(81+ chunkBytes: Uint8Array,82+ index: number,83+ total: number,84+ isParity: boolean,85+ verifyCode: string,86+): Promise<ChunkEnvelope> {87+ const hash = (await sha256Hex(chunkBytes)).slice(0, 8);88+ return {89+ v: 2,90+ c: verifyCode,91+ i: index,92+ t: total,93+ p: isParity,94+ d: bytesToBase64Url(chunkBytes),95+ h: hash,96+ };97+}98+99+/**100+ * Confirm the envelope's recorded hash still matches its data. Returns false101+ * on any mismatch — caller decides whether to reconstruct from parity or102+ * surface a CodexError.103+ */104+export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {105+ const bytes = base64UrlToBytes(envelope.d);106+ const expected = (await sha256Hex(bytes)).slice(0, 8);107+ return expected === envelope.h;108+}109+110+// ── Verify code lifecycle ───────────────────────────────────────────────────111+112+/**113+ * Unambiguous alphabet: drops `0/O`, `1/I/L`, and `S`. Keeps every character114+ * legible at 6pt print or scrawled on paper next to a Codex. Same alphabet115+ * used by hardware wallet seed-export QR codes.116+ */117+const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";118+119+/** PX-XXXX-XXXX — 12 chars including the literal `PX-` prefix and the dash. */120+const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;121+122+/**123+ * Read the persisted verify code, or generate-and-persist a fresh one on first124+ * call. The code is a stable per-vault identifier — every Codex printed from125+ * this vault carries the same code so a user can prove "this Codex matches126+ * this Peach install" without revealing any secret.127+ *128+ * Mirrors the storage pattern from `src/shared/storage.ts` so the same call129+ * sites work in both popup and service-worker contexts.130+ */131+export async function getOrCreateCodexVerifyCode(): Promise<string> {132+ const existing = await readStoredVerifyCode();133+ if (existing && VERIFY_CODE_PATTERN.test(existing)) return existing;134+135+ const fresh = generateVerifyCode();136+ await writeStoredVerifyCode(fresh);137+ return fresh;138+}139+140+function generateVerifyCode(): string {141+ const bytes = new Uint8Array(8);142+ crypto.getRandomValues(bytes);143+ const chars: string[] = [];144+ for (let i = 0; i < 8; i++) {145+ // Modulo bias is negligible for a 32-char alphabet over a 256-value byte146+ // and the verify code is identification, not key material.147+ chars.push(VERIFY_CODE_ALPHABET[bytes[i]! % VERIFY_CODE_ALPHABET.length]!);148+ }149+ return `PX-${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}`;150+}151+152+async function readStoredVerifyCode(): Promise<string | null> {153+ if (typeof chrome === "undefined" || !chrome.storage?.local) return null;154+ const result = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);155+ const value = (result as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];156+ return typeof value === "string" ? value : null;157+}158+159+async function writeStoredVerifyCode(code: string): Promise<void> {160+ if (typeof chrome === "undefined" || !chrome.storage?.local) {161+ throw new CodexError(162+ "PDF_GENERATION_FAILED",163+ "chrome.storage.local is unavailable; cannot persist Codex verify code",164+ );165+ }166+ await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code });167+}src/popup/codex/pdf.ts+314 −0
@@ -0,0 +1,314 @@1+/**2+ * pdf.ts — render the printable Codex PDF.3+ *4+ * Layout follows section "PDF rendering" of the design doc. The input is a5+ * flat list of per-Aztec PNG byte arrays (one PNG per code, not one per page).6+ * We compute pagination from `CODES_PER_PAGE` and embed each PNG into the7+ * grid. Each PNG is embedded once via `pdfDoc.embedPng()` — pdf-lib reuses8+ * the embedded image object across pages automatically, so even multi-page9+ * Codexes stay compact in file size.10+ *11+ * Fonts: standard 14, available in every PDF viewer (no font embedding cost).12+ * The cream/ink palette is intentionally low-contrast so the printed page13+ * reads like an artifact rather than a tax form.14+ */15+16+import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";17+import {18+ CODES_PER_COL,19+ CODES_PER_PAGE,20+ CODES_PER_ROW,21+ CodexError,22+ type CodexManifest,23+} from "../../shared/codex-types";24+25+// ── Layout constants (A4 in points; 1pt = 1/72 inch) ────────────────────────26+27+const PAGE_WIDTH = 595.28;28+const PAGE_HEIGHT = 841.89;29+const MARGIN = 28;30+31+const HEADER_HEIGHT = 44;32+const HEADER_HAIRLINE = 0.5;33+34+const CODE_SIZE = 80;35+const COL_GAP =36+ (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);37+const ROW_GAP = 18;38+39+const RECOVERY_HEIGHT = 110;40+41+const FOOTER_HEIGHT = 32;42+const FOOTER_HAIRLINE = 0.5;43+44+const CREAM = rgb(0.961, 0.941, 0.91);45+const INK = rgb(0.102, 0.094, 0.086);46+const AMBER_PRINT = rgb(0.545, 0.369, 0.165);47+48+const ROMAN_NUMERALS = [49+ "I.", "II.", "III.", "IV.", "V.", "VI.",50+ "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",51+];52+53+/**54+ * Render the full A4 Codex PDF. Returns the serialized PDF bytes ready to be55+ * blob-downloaded or printed.56+ *57+ * `aztecPngs` is one PNG per code, in chunk-index order. `recoveryWords` is58+ * either 12 words (rendered on the last page) or `null` (no recovery block).59+ */60+export async function renderCodexPDF(61+ aztecPngs: Uint8Array[],62+ manifest: CodexManifest,63+ recoveryWords: string[] | null,64+): Promise<Uint8Array> {65+ if (aztecPngs.length !== manifest.totalChunks) {66+ throw new CodexError(67+ "PDF_GENERATION_FAILED",68+ `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,69+ );70+ }71+ if (recoveryWords && recoveryWords.length !== 12) {72+ throw new CodexError(73+ "PDF_GENERATION_FAILED",74+ `PDF render: recovery words must be exactly 12, got ${recoveryWords.length}`,75+ );76+ }77+ void CODES_PER_COL; // Imported for spec parity; the row math derives the column count.78+79+ const pdfDoc = await PDFDocument.create();80+81+ // Use Times-Italic for the brand mark and Courier for verify codes / labels.82+ // The standard 14 fonts cover everything we need without embedding overhead.83+ const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);84+ const mono = await pdfDoc.embedFont(StandardFonts.Courier);85+ const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);86+87+ const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));88+89+ const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);90+91+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {92+ const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);93+ const isLastPage = pageIndex === pageCount - 1;94+ const startIndex = pageIndex * CODES_PER_PAGE;95+ const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);96+97+ drawBackground(page);98+ drawBorder(page);99+ drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);100+101+ const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;102+ drawAztecGrid(page, pageImages, startIndex, gridTop, mono);103+104+ if (isLastPage && recoveryWords) {105+ const recoveryTop = MARGIN + FOOTER_HEIGHT + FOOTER_HAIRLINE + 8;106+ drawRecoveryWords(page, recoveryWords, recoveryTop, serif, mono);107+ }108+109+ drawFooter(page, manifest, serif, mono);110+ }111+112+ try {113+ return await pdfDoc.save();114+ } catch (err) {115+ throw new CodexError(116+ "PDF_GENERATION_FAILED",117+ `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,118+ );119+ }120+}121+122+// ── Page chrome ─────────────────────────────────────────────────────────────123+124+function drawBackground(page: PDFPage): void {125+ page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });126+}127+128+function drawBorder(page: PDFPage): void {129+ page.drawRectangle({130+ x: MARGIN - 6,131+ y: MARGIN - 6,132+ width: PAGE_WIDTH - 2 * (MARGIN - 6),133+ height: PAGE_HEIGHT - 2 * (MARGIN - 6),134+ borderColor: INK,135+ borderOpacity: 0.2,136+ borderWidth: 0.75,137+ });138+}139+140+function drawHeader(141+ page: PDFPage,142+ manifest: CodexManifest,143+ pageIndex: number,144+ pageCount: number,145+ serif: PDFFont,146+ mono: PDFFont,147+ monoBold: PDFFont,148+): void {149+ const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;150+151+ // The standard 14 PDF fonts use WinAnsi encoding which omits ●. Use the152+ // middle-dot bullet (U+00B7), which renders close enough to the spec's153+ // filled-circle peach mark for printed output.154+ page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });155+156+ const pageText = `Page ${pageIndex + 1} of ${pageCount}`;157+ const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);158+ page.drawText(pageText, {159+ x: (PAGE_WIDTH - pageTextWidth) / 2,160+ y,161+ size: 9,162+ font: mono,163+ color: INK,164+ opacity: 0.5,165+ });166+167+ const verifyText = manifest.verifyCode;168+ const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);169+ page.drawText(verifyText, {170+ x: PAGE_WIDTH - MARGIN - verifyWidth,171+ y,172+ size: 10,173+ font: monoBold,174+ color: INK,175+ });176+177+ page.drawLine({178+ start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },179+ end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },180+ thickness: HEADER_HAIRLINE,181+ color: INK,182+ opacity: 0.2,183+ });184+}185+186+// ── Aztec grid ──────────────────────────────────────────────────────────────187+188+/**189+ * The `images` parameter is a slice of pre-embedded PDFImage objects for190+ * THIS page only. `startIndex` is the global chunk index of `images[0]` and191+ * is used solely to label each code with its 1-based number.192+ */193+function drawAztecGrid(194+ page: PDFPage,195+ images: import("pdf-lib").PDFImage[],196+ startIndex: number,197+ gridTop: number,198+ mono: PDFFont,199+): void {200+ for (let i = 0; i < images.length; i++) {201+ const col = i % CODES_PER_ROW;202+ const row = Math.floor(i / CODES_PER_ROW);203+ const x = MARGIN + col * (CODE_SIZE + COL_GAP);204+ const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;205+206+ page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });207+208+ const label = String(startIndex + i + 1).padStart(2, "0");209+ const labelWidth = mono.widthOfTextAtSize(label, 8);210+ page.drawText(label, {211+ x: x + CODE_SIZE / 2 - labelWidth / 2,212+ y: y - 11,213+ size: 8,214+ font: mono,215+ color: INK,216+ opacity: 0.55,217+ });218+ }219+}220+221+// ── Recovery words ──────────────────────────────────────────────────────────222+223+function drawRecoveryWords(224+ page: PDFPage,225+ words: string[],226+ bottomY: number,227+ serif: PDFFont,228+ mono: PDFFont,229+): void {230+ const top = bottomY + RECOVERY_HEIGHT;231+232+ page.drawLine({233+ start: { x: MARGIN, y: top + 8 },234+ end: { x: PAGE_WIDTH - MARGIN, y: top + 8 },235+ thickness: 0.5,236+ color: INK,237+ opacity: 0.15,238+ });239+240+ // pdf-lib's drawText doesn't support character spacing — render the label241+ // with a visually-equivalent space-separated form so we get the loose tracking242+ // the design calls for without breaking strict typings.243+ page.drawText("R E C O V E R Y W O R D S", {244+ x: MARGIN,245+ y: top - 4,246+ size: 8,247+ font: mono,248+ color: INK,249+ opacity: 0.4,250+ });251+252+ const colWidth = (PAGE_WIDTH - 2 * MARGIN) / 2;253+ const lineHeight = 14;254+ for (let i = 0; i < 12; i++) {255+ const col = Math.floor(i / 6);256+ const row = i % 6;257+ const wordX = MARGIN + col * colWidth;258+ const wordY = top - 20 - row * lineHeight;259+260+ page.drawText(ROMAN_NUMERALS[i]!, { x: wordX, y: wordY, size: 10, font: serif, color: AMBER_PRINT });261+ page.drawText(words[i]!, { x: wordX + 28, y: wordY, size: 10, font: serif, color: INK });262+ }263+}264+265+// ── Footer ──────────────────────────────────────────────────────────────────266+267+function drawFooter(268+ page: PDFPage,269+ manifest: CodexManifest,270+ serif: PDFFont,271+ mono: PDFFont,272+): void {273+ page.drawLine({274+ start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },275+ end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },276+ thickness: FOOTER_HAIRLINE,277+ color: INK,278+ opacity: 0.15,279+ });280+281+ const footerY = MARGIN + 10;282+283+ page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {284+ x: MARGIN,285+ y: footerY + 10,286+ size: 7,287+ font: serif,288+ color: INK,289+ opacity: 0.45,290+ });291+292+ const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);293+ const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;294+ const metaWidth = mono.widthOfTextAtSize(meta, 7);295+ page.drawText(meta, {296+ x: PAGE_WIDTH - MARGIN - metaWidth,297+ y: footerY + 10,298+ size: 7,299+ font: mono,300+ color: INK,301+ opacity: 0.45,302+ });303+304+ const siteText = "peachpasswords.com";305+ const siteWidth = serif.widthOfTextAtSize(siteText, 7);306+ page.drawText(siteText, {307+ x: (PAGE_WIDTH - siteWidth) / 2,308+ y: footerY,309+ size: 7,310+ font: serif,311+ color: INK,312+ opacity: 0.3,313+ });314+}src/popup/codex/restore.test.ts+322 −0
@@ -0,0 +1,322 @@1+/**2+ * restore.test.ts — coverage for ChunkBuffer, parseRawScan, reconstructAndVerify.3+ *4+ * Tests build synthetic envelopes via the same helpers the generator uses5+ * (Subagent 1's `helpers.ts` + `erasure.ts`). We avoid the full6+ * `generateCodex` pipeline so these tests don't depend on PDF rendering7+ * or Aztec encoding — they only exercise the restoration math.8+ */9+10+import { beforeEach, describe, expect, it } from "vitest";11+import { deflate } from "pako";12+13+import {14+ CodexError,15+ PARITY_SHARDS,16+ type ChunkEnvelope,17+ type CodexPayload,18+} from "../../shared/codex-types";19+import {20+ base64UrlToBytes,21+ bytesToBase64Url,22+ buildChunkEnvelope,23+ sha256Hex,24+} from "./helpers";25+import { encodeWithErasure } from "./erasure";26+import { _resetReedSolomonCacheForTest } from "./rs-loader";27+import {28+ ChunkBuffer,29+ parseRawScan,30+ reconstructAndVerify,31+} from "./restore";32+33+// Reset between tests: the WASM Reed-Solomon instance carries internal state34+// across encode/reconstruct calls that breaks subsequent reconstructions in35+// the same process. Until that's fixed in erasure.ts / rs-loader.ts, isolating36+// each test keeps the suite deterministic.37+beforeEach(() => {38+ _resetReedSolomonCacheForTest();39+});40+41+const VERIFY_CODE = "PX-TEST-ABCD";42+43+/** Build envelopes for an arbitrary CodexPayload, mirroring the real generator. */44+async function buildEnvelopesForPayload(45+ payload: CodexPayload,46+): Promise<ChunkEnvelope[]> {47+ const json = JSON.stringify(payload);48+ const compressed = deflate(new TextEncoder().encode(json), { level: 9 });49+ // encodeWithErasure shards at CHUNK_SIZE internally — no need to pass it.50+ const { dataChunks, parityChunks, totalChunks } =51+ await encodeWithErasure(compressed);52+ const all = [...dataChunks, ...parityChunks];53+ return Promise.all(54+ all.map((bytes, i) =>55+ buildChunkEnvelope(56+ bytes,57+ i,58+ totalChunks,59+ i >= dataChunks.length,60+ payload.verifyCode,61+ ),62+ ),63+ );64+}65+66+async function makePayload(seedLength = 12_000): Promise<CodexPayload> {67+ // A vaultBlob big enough to span several chunks after deflate. We seed with68+ // a deterministic but non-repeating pattern (xorshift-ish) so pako cannot69+ // compress it to a single chunk — without that, dataShards collapses to 170+ // and the "lose 2 chunks" test has nothing to reconstruct.71+ const blobBytes = new Uint8Array(seedLength);72+ let s = 0x9e3779b9;73+ for (let i = 0; i < seedLength; i++) {74+ s = (s ^ (s << 13)) >>> 0;75+ s = (s ^ (s >>> 17)) >>> 0;76+ s = (s ^ (s << 5)) >>> 0;77+ blobBytes[i] = s & 0xff;78+ }79+ const vaultBlob = bytesToBase64Url(blobBytes);80+ const vaultFingerprint = (81+ await sha256Hex(new TextEncoder().encode(vaultBlob))82+ )83+ .slice(0, 16)84+ .toUpperCase();85+ return {86+ version: 2,87+ verifyCode: VERIFY_CODE,88+ vaultBlob,89+ timestamp: 1_700_000_000_000,90+ entryCount: 42,91+ vaultFingerprint,92+ generatedAt: 1_700_000_001_000,93+ includesRecoveryWords: false,94+ };95+}96+97+async function makeSingleEnvelope(opts: {98+ index: number;99+ total: number;100+ isParity?: boolean;101+ verifyCode?: string;102+ bytes?: Uint8Array;103+}): Promise<ChunkEnvelope> {104+ return buildChunkEnvelope(105+ opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]),106+ opts.index,107+ opts.total,108+ opts.isParity ?? false,109+ opts.verifyCode ?? VERIFY_CODE,110+ );111+}112+113+describe("ChunkBuffer", () => {114+ it("accepts a valid chunk and exposes context", async () => {115+ const buf = new ChunkBuffer();116+ const env = await makeSingleEnvelope({ index: 0, total: 5 });117+ const res = await buf.add(env);118+ expect(res.accepted).toBe(true);119+ expect(buf.size()).toBe(1);120+ expect(buf.getContext()).toEqual({121+ verifyCode: VERIFY_CODE,122+ total: 5,123+ dataShards: 5 - PARITY_SHARDS,124+ });125+ });126+127+ it("rejects a chunk from a different verifyCode", async () => {128+ const buf = new ChunkBuffer();129+ await buf.add(await makeSingleEnvelope({ index: 0, total: 5 }));130+ const foreign = await makeSingleEnvelope({131+ index: 1,132+ total: 5,133+ verifyCode: "PX-OTHR-XXXX",134+ });135+ const res = await buf.add(foreign);136+ expect(res).toEqual({ accepted: false, reason: "foreign_codex" });137+ expect(buf.size()).toBe(1);138+ });139+140+ it("rejects a chunk with bad integrity hash", async () => {141+ const buf = new ChunkBuffer();142+ const env = await makeSingleEnvelope({ index: 0, total: 5 });143+ // Corrupt the data field without updating the hash — verifyChunkIntegrity144+ // must catch this.145+ const corrupted: ChunkEnvelope = {146+ ...env,147+ d: bytesToBase64Url(new Uint8Array([9, 9, 9, 9])),148+ };149+ const res = await buf.add(corrupted);150+ expect(res).toEqual({ accepted: false, reason: "bad_integrity" });151+ expect(buf.size()).toBe(0);152+ });153+154+ it("rejects a chunk with wrong envelope version", async () => {155+ const buf = new ChunkBuffer();156+ const env = await makeSingleEnvelope({ index: 0, total: 5 });157+ const wrongVersion = { ...env, v: 1 } as unknown as ChunkEnvelope;158+ const res = await buf.add(wrongVersion);159+ expect(res.accepted).toBe(false);160+ expect(res.reason).toBe("parse_error");161+ });162+163+ it("deduplicates: adding the same index twice keeps only one", async () => {164+ const buf = new ChunkBuffer();165+ const env = await makeSingleEnvelope({ index: 2, total: 5 });166+ expect((await buf.add(env)).accepted).toBe(true);167+ const second = await buf.add(env);168+ expect(second).toEqual({ accepted: false, reason: "duplicate" });169+ expect(buf.size()).toBe(1);170+ });171+172+ it("isReady() flips true at dataShards count", async () => {173+ const buf = new ChunkBuffer();174+ const total = 5;175+ const dataShards = total - PARITY_SHARDS; // 3176+ for (let i = 0; i < dataShards; i++) {177+ expect(buf.isReady()).toBe(false);178+ const env = await makeSingleEnvelope({179+ index: i,180+ total,181+ bytes: new Uint8Array([i, i, i]),182+ });183+ await buf.add(env);184+ }185+ expect(buf.isReady()).toBe(true);186+ });187+188+ it("toChunkMap returns raw chunk bytes by index", async () => {189+ const buf = new ChunkBuffer();190+ const env = await makeSingleEnvelope({191+ index: 0,192+ total: 5,193+ bytes: new Uint8Array([1, 2, 3]),194+ });195+ await buf.add(env);196+ const map = buf.toChunkMap();197+ expect(map.size).toBe(1);198+ expect(Array.from(map.get(0)!)).toEqual([1, 2, 3]);199+ });200+});201+202+describe("parseRawScan", () => {203+ it("parses a well-formed envelope", async () => {204+ const env = await makeSingleEnvelope({ index: 0, total: 5 });205+ const parsed = parseRawScan(JSON.stringify(env));206+ expect(parsed).toEqual(env);207+ });208+209+ it("returns parse_error for non-JSON input", () => {210+ expect(parseRawScan("not json")).toEqual({ error: "parse_error" });211+ });212+213+ it("returns wrong_version for v=1 envelopes", () => {214+ const v1 = {215+ v: 1,216+ c: VERIFY_CODE,217+ i: 0,218+ t: 5,219+ p: false,220+ d: "AAAA",221+ h: "ABCDEF12",222+ };223+ expect(parseRawScan(JSON.stringify(v1))).toEqual({224+ error: "wrong_version",225+ });226+ });227+228+ it("returns parse_error for a v2 object with missing fields", () => {229+ expect(parseRawScan(JSON.stringify({ v: 2, c: "PX" }))).toEqual({230+ error: "parse_error",231+ });232+ });233+});234+235+describe("reconstructAndVerify", () => {236+ it("round-trips a full envelope set back to the original payload", async () => {237+ const payload = await makePayload();238+ const envelopes = await buildEnvelopesForPayload(payload);239+240+ const buf = new ChunkBuffer();241+ for (const env of envelopes) {242+ const res = await buf.add(env);243+ expect(res.accepted).toBe(true);244+ }245+ expect(buf.isReady()).toBe(true);246+247+ const restored = await reconstructAndVerify(buf);248+ expect(restored.verifyCode).toBe(payload.verifyCode);249+ expect(restored.vaultBlob).toBe(payload.vaultBlob);250+ expect(restored.vaultFingerprint).toBe(payload.vaultFingerprint);251+ expect(restored.entryCount).toBe(payload.entryCount);252+ });253+254+ it("reconstructs after losing 2 chunks", async () => {255+ const payload = await makePayload();256+ const envelopes = await buildEnvelopesForPayload(payload);257+ // Need at least dataShards + PARITY_SHARDS total to lose exactly PARITY_SHARDS.258+ expect(envelopes.length).toBeGreaterThan(PARITY_SHARDS);259+260+ // Drop two indices that exercise the parity-decode path: one data shard261+ // and one parity shard. PARITY_SHARDS=2 means we can lose any two.262+ const dropA = 1;263+ const dropB = envelopes.length - 1;264+265+ const buf = new ChunkBuffer();266+ for (let i = 0; i < envelopes.length; i++) {267+ if (i === dropA || i === dropB) continue;268+ const res = await buf.add(envelopes[i]!);269+ expect(res.accepted).toBe(true);270+ }271+ expect(buf.isReady()).toBe(true);272+273+ const restored = await reconstructAndVerify(buf);274+ expect(restored.vaultFingerprint).toBe(payload.vaultFingerprint);275+ expect(restored.vaultBlob).toBe(payload.vaultBlob);276+ });277+278+ it("throws RECONSTRUCTION_FINGERPRINT_MISMATCH if the reconstructed blob does not match", async () => {279+ // Build a payload whose stored fingerprint deliberately does NOT match280+ // its vaultBlob — simulates a successful chunk decode that nevertheless281+ // pulled the wrong bytes into a parity-reconstructed slot.282+ const payload = await makePayload();283+ const tampered: CodexPayload = {284+ ...payload,285+ vaultFingerprint: "DEADBEEFDEADBEEF",286+ };287+ const envelopes = await buildEnvelopesForPayload(tampered);288+289+ const buf = new ChunkBuffer();290+ for (const env of envelopes) await buf.add(env);291+292+ await expect(reconstructAndVerify(buf)).rejects.toMatchObject({293+ name: "CodexError",294+ code: "RECONSTRUCTION_FINGERPRINT_MISMATCH",295+ });296+ });297+298+ it("throws CodexError when called on an empty buffer", async () => {299+ const buf = new ChunkBuffer();300+ await expect(reconstructAndVerify(buf)).rejects.toBeInstanceOf(CodexError);301+ });302+});303+304+describe("ChunkBuffer + parseRawScan integration", () => {305+ it("a full scan loop accepts every raw envelope string", async () => {306+ const payload = await makePayload(2_500);307+ const envelopes = await buildEnvelopesForPayload(payload);308+ const buf = new ChunkBuffer();309+310+ for (const env of envelopes) {311+ const parsed = parseRawScan(JSON.stringify(env));312+ expect("error" in parsed).toBe(false);313+ const res = await buf.add(parsed as ChunkEnvelope);314+ expect(res.accepted).toBe(true);315+ }316+317+ const restored = await reconstructAndVerify(buf);318+ expect(restored.vaultFingerprint).toBe(payload.vaultFingerprint);319+ // sanity-check that base64UrlToBytes is reachable from this file320+ expect(base64UrlToBytes(restored.vaultBlob).length).toBeGreaterThan(0);321+ });322+});src/popup/codex/restore.ts+221 −0
@@ -0,0 +1,221 @@1+/**2+ * restore.ts — Peach Codex restoration library (popup-side).3+ *4+ * Responsibilities:5+ * 1. `parseRawScan` — defensively turn a raw scanned string into a6+ * `ChunkEnvelope`. Foreign QR/Aztec content, malformed JSON, and7+ * wrong-version envelopes are rejected without throwing.8+ * 2. `ChunkBuffer` — accumulate per-chunk scans, enforce one-Codex-per-scan9+ * context, deduplicate, and verify per-chunk integrity hashes.10+ * 3. `reconstructAndVerify` — once the buffer has enough chunks, hand them11+ * to Subagent 1's `reconstructPayloadFromChunks`, then re-derive the12+ * vault fingerprint and fail loudly on mismatch.13+ *14+ * This module never touches the camera, the SW, or storage. Camera capture15+ * lives in `./scanner.ts`; the actual vault write happens behind the16+ * `RESTORE_FROM_CODEX` message in the service worker.17+ *18+ * Why a class instead of bare functions: the buffer holds three pieces of19+ * state that must stay in sync (chunks, context, accept history). A class20+ * makes the invariants — first valid chunk establishes context, later21+ * chunks must match — explicit at the call site.22+ */23+24+import {25+ CodexError,26+ PARITY_SHARDS,27+ type ChunkEnvelope,28+ type CodexPayload,29+} from "../../shared/codex-types";30+import { reconstructPayloadFromChunks } from "./generate";31+import {32+ base64UrlToBytes,33+ sha256Hex,34+ verifyChunkIntegrity,35+} from "./helpers";36+37+export interface ScannedChunk {38+ envelope: ChunkEnvelope;39+ bytes: Uint8Array;40+}41+42+export type ChunkAddReason =43+ | "duplicate"44+ | "foreign_codex"45+ | "bad_integrity"46+ | "parse_error";47+48+export interface ChunkAddResult {49+ accepted: boolean;50+ reason?: ChunkAddReason;51+}52+53+export interface ChunkBufferContext {54+ verifyCode: string;55+ total: number;56+ dataShards: number;57+}58+59+export class ChunkBuffer {60+ private readonly chunks = new Map<number, ScannedChunk>();61+ private ctx: ChunkBufferContext | null = null;62+63+ /**64+ * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or65+ * `{ accepted: false, reason }` for any rejection. The buffer never throws —66+ * the scanner UI needs to keep running across bad frames.67+ *68+ * Async because `verifyChunkIntegrity` is async (SubtleCrypto digest).69+ * Callers in a scan loop should await but the UI need not block — a few70+ * milliseconds per frame is well below 30fps decoding cadence.71+ */72+ async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {73+ // Defensive: even though parseRawScan filters most of these, the buffer74+ // is a public API and callers may hand-build envelopes (tests, future75+ // import flows).76+ if (envelope.v !== 2) {77+ return { accepted: false, reason: "parse_error" };78+ }79+80+ if (!(await verifyChunkIntegrity(envelope))) {81+ return { accepted: false, reason: "bad_integrity" };82+ }83+84+ if (this.ctx === null) {85+ // First valid chunk establishes the Codex context. Later chunks must86+ // agree on verifyCode and total — that is how we keep two overlapping87+ // Codex prints from poisoning each other.88+ this.ctx = {89+ verifyCode: envelope.c,90+ total: envelope.t,91+ dataShards: envelope.t - PARITY_SHARDS,92+ };93+ } else if (envelope.c !== this.ctx.verifyCode) {94+ return { accepted: false, reason: "foreign_codex" };95+ }96+97+ if (this.chunks.has(envelope.i)) {98+ return { accepted: false, reason: "duplicate" };99+ }100+101+ this.chunks.set(envelope.i, {102+ envelope,103+ bytes: base64UrlToBytes(envelope.d),104+ });105+ return { accepted: true };106+ }107+108+ size(): number {109+ return this.chunks.size;110+ }111+112+ getContext(): ChunkBufferContext | null {113+ return this.ctx;114+ }115+116+ isReady(): boolean {117+ return this.ctx !== null && this.chunks.size >= this.ctx.dataShards;118+ }119+120+ entries(): ScannedChunk[] {121+ return Array.from(this.chunks.values());122+ }123+124+ /** Index → raw chunk bytes, ready for `reconstructPayloadFromChunks`. */125+ toChunkMap(): Map<number, Uint8Array> {126+ const out = new Map<number, Uint8Array>();127+ for (const [i, scanned] of this.chunks) {128+ out.set(i, scanned.bytes);129+ }130+ return out;131+ }132+}133+134+export type ParseRawScanResult =135+ | ChunkEnvelope136+ | { error: "parse_error" | "wrong_version" };137+138+/**139+ * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner140+ * fires on every successful decode — most decodes are noise (other QR codes,141+ * stickers, old Codex prints) — so the caller must not assume any structure.142+ */143+export function parseRawScan(raw: string): ParseRawScanResult {144+ let parsed: unknown;145+ try {146+ parsed = JSON.parse(raw);147+ } catch {148+ return { error: "parse_error" };149+ }150+151+ if (typeof parsed !== "object" || parsed === null) {152+ return { error: "parse_error" };153+ }154+155+ const obj = parsed as Record<string, unknown>;156+157+ // Version check happens before shape validation so a v1 (or future-v3)158+ // envelope can be reported distinctly from random JSON.159+ if (obj.v !== 2) {160+ // Any explicit `v` that isn't 2 is a wrong-version signal. Missing `v`161+ // is plain noise → parse_error.162+ if ("v" in obj) return { error: "wrong_version" };163+ return { error: "parse_error" };164+ }165+166+ const looksLikeEnvelope =167+ typeof obj.c === "string" &&168+ typeof obj.i === "number" &&169+ typeof obj.t === "number" &&170+ typeof obj.p === "boolean" &&171+ typeof obj.d === "string" &&172+ typeof obj.h === "string";173+174+ if (!looksLikeEnvelope) {175+ return { error: "parse_error" };176+ }177+178+ return obj as unknown as ChunkEnvelope;179+}180+181+/**182+ * Reconstruct the payload from a fully-loaded buffer and verify the vault183+ * fingerprint round-trips. Throws `CodexError` on any inconsistency —184+ * callers translate that into a visible "scan again" message.185+ */186+export async function reconstructAndVerify(187+ buffer: ChunkBuffer,188+): Promise<CodexPayload> {189+ const ctx = buffer.getContext();190+ if (ctx === null) {191+ throw new CodexError(192+ "INSUFFICIENT_CHUNKS",193+ "No chunks have been scanned yet.",194+ );195+ }196+197+ const payload = await reconstructPayloadFromChunks(198+ buffer.toChunkMap(),199+ ctx.dataShards,200+ ctx.total,201+ );202+203+ // Re-derive the fingerprint exactly as the generator did. Mismatch here204+ // means the reconstructed `vaultBlob` is corrupt even though every chunk205+ // passed its hash — most likely a Reed-Solomon decode pulled the wrong206+ // bytes into a parity-reconstructed slot. `sha256Hex` already returns207+ // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-208+ // suspenders against a future helpers.ts change.209+ const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))210+ .slice(0, 16)211+ .toUpperCase();212+ if (fingerprint !== payload.vaultFingerprint) {213+ throw new CodexError(214+ "RECONSTRUCTION_FINGERPRINT_MISMATCH",215+ "Reconstructed vault fingerprint does not match the printed Codex. " +216+ "Try scanning the Codex again from the beginning.",217+ );218+ }219+220+ return payload;221+}src/popup/codex/scanner.ts+56 −0
@@ -0,0 +1,56 @@1+/**2+ * scanner.ts — camera-based Aztec scanning for Codex restoration.3+ *4+ * Wraps `@zxing/library`'s `BrowserAztecCodeReader` with a tiny lifecycle:5+ * one `startScanner` returns a `{ stop }` handle that releases the camera6+ * via `reset()`. The decoder fires on every frame; `NotFoundException` is7+ * the library's "no code in this frame" signal and must be swallowed8+ * silently — otherwise the console fills up at ~30 errors/second.9+ *10+ * Deduplication and envelope parsing are NOT this module's job. Callers11+ * feed every decoded string through `parseRawScan` + `ChunkBuffer` in12+ * `./restore.ts`.13+ *14+ * Browser-only. Never imported by tests directly — vitest runs in `node`15+ * environment per `vitest.config.ts` and `navigator.mediaDevices` is16+ * undefined there.17+ */18+19+import { BrowserAztecCodeReader, NotFoundException } from "@zxing/library";20+21+export interface ScannerOptions {22+ videoEl: HTMLVideoElement;23+ /** Called for every successful Aztec decode. The caller dedups via ChunkBuffer. */24+ onScan: (text: string) => void;25+ /** Reported on hard errors only — NotFoundException is filtered out. */26+ onError?: (err: Error) => void;27+}28+29+export interface ScannerHandle {30+ stop: () => void;31+}32+33+export async function startScanner(34+ opts: ScannerOptions,35+): Promise<ScannerHandle> {36+ const reader = new BrowserAztecCodeReader();37+38+ // Passing `null` for deviceId lets zxing pick the default camera (typically39+ // the back camera on mobile, the only camera on a laptop). The library40+ // attaches the MediaStream to the provided <video> element internally.41+ await reader.decodeFromVideoDevice(null, opts.videoEl, (result, err) => {42+ if (result) {43+ opts.onScan(result.getText());44+ return;45+ }46+ if (err && !(err instanceof NotFoundException)) {47+ opts.onError?.(err);48+ }49+ });50+51+ return {52+ stop: () => {53+ reader.reset();54+ },55+ };56+}src/popup/index.html+1 −0
@@ -19,6 +19,7 @@1919 />2020 <link rel="stylesheet" href="./styles/vars.css" />2121 <link rel="stylesheet" href="./styles/popup.css" />22+ <link rel="stylesheet" href="./styles/codex.css" />2223 <title>Peach</title>2324 </head>2425 <body>src/popup/styles/codex.css+180 −0
@@ -0,0 +1,180 @@1+/* Peach Codex — generation and restoration view styles.2+ *3+ * Class names are `peach-codex-*` to avoid colliding with the existing4+ * settings/overlay tokens. Colours and surfaces reuse `--peach-*` variables5+ * defined in vars.css and popup.css — do not introduce new tokens here.6+ */7+8+/* Shared body padding — the overlay header eats the top inset, the rest is9+ * scrollable so a long page (toggle + warnings + footnote) still fits in10+ * the 320×520 popup without clipping. */11+.peach-codex-body {12+ padding: 12px 16px 18px;13+ display: flex;14+ flex-direction: column;15+ gap: 12px;16+}17+18+.peach-codex-intro {19+ padding: 12px;20+}21+22+.peach-codex-blurb {23+ margin: 0;24+ font-size: 0.8125rem;25+ line-height: 1.45;26+ color: var(--peach-t1);27+}28+29+.peach-codex-meta {30+ margin: 8px 0 0;31+ font-size: 0.75rem;32+ color: var(--peach-t2);33+ letter-spacing: 0.01em;34+}35+36+.peach-codex-meta strong {37+ color: var(--peach-t0);38+ font-weight: 400;39+}40+41+.peach-codex-toggle-desc {42+ margin: 8px 0 0;43+ font-size: 0.75rem;44+ color: var(--peach-t2);45+ line-height: 1.45;46+}47+48+.peach-codex-error {49+ margin: 0;50+}51+52+.peach-codex-generate-btn {53+ margin-top: 4px;54+}55+56+.peach-codex-footnote {57+ margin: 8px 0 0;58+ font-size: 0.6875rem;59+ color: var(--peach-t2);60+ line-height: 1.5;61+ font-style: italic;62+}63+64+.peach-codex-done {65+ padding: 12px;66+ display: flex;67+ flex-direction: column;68+ gap: 6px;69+}70+71+.peach-codex-verify {72+ font-family: var(--font-mono, ui-monospace, monospace);73+ letter-spacing: 0.04em;74+ color: var(--peach-t0);75+}76+77+/* ── Restore view — scanner ──────────────────────────────────────────── */78+79+.peach-codex-scanner {80+ position: relative;81+ width: 100%;82+ aspect-ratio: 4 / 3;83+ background: var(--peach-s2, rgba(0, 0, 0, 0.6));84+ border-radius: 8px;85+ overflow: hidden;86+}87+88+.peach-codex-video {89+ width: 100%;90+ height: 100%;91+ object-fit: cover;92+ display: block;93+}94+95+.peach-codex-camera-overlay {96+ position: absolute;97+ inset: 0;98+ display: flex;99+ align-items: center;100+ justify-content: center;101+ padding: 16px;102+ background: rgba(0, 0, 0, 0.55);103+ text-align: center;104+}105+106+.peach-codex-progress-group {107+ padding: 10px 12px;108+ display: flex;109+ flex-direction: column;110+ gap: 6px;111+}112+113+.peach-codex-progress-track {114+ width: 100%;115+ height: 4px;116+ background: var(--peach-s2, rgba(255, 255, 255, 0.08));117+ border-radius: 2px;118+ overflow: hidden;119+}120+121+.peach-codex-progress-fill {122+ height: 100%;123+ background: var(--peach-accent);124+ transition: width 240ms var(--peach-curve-settle, cubic-bezier(0.16, 1, 0.3, 1));125+}126+127+.peach-codex-progress-label {128+ display: flex;129+ justify-content: space-between;130+ align-items: baseline;131+ margin: 0;132+ font-size: 0.75rem;133+ color: var(--peach-t1);134+}135+136+.peach-codex-verify-tag {137+ font-family: var(--font-mono, ui-monospace, monospace);138+ font-size: 0.6875rem;139+ color: var(--peach-t2);140+ letter-spacing: 0.04em;141+}142+143+.peach-codex-hint {144+ margin: 0;145+ font-size: 0.75rem;146+ color: var(--peach-t2);147+ line-height: 1.4;148+}149+150+.peach-codex-warning {151+ margin: 4px 0 0;152+}153+154+.peach-codex-passphrase-group {155+ padding: 12px;156+ display: flex;157+ flex-direction: column;158+ gap: 8px;159+}160+161+.peach-codex-cancel-btn {162+ margin-top: auto;163+}164+165+/* Pro badge used in Settings → DATA → Peach Codex when free user. */166+.peach-pro-badge {167+ display: inline-flex;168+ align-items: center;169+ padding: 1px 6px;170+ margin-left: 8px;171+ font-family: var(--font-mono, ui-monospace, monospace);172+ font-size: 9px;173+ letter-spacing: 0.06em;174+ text-transform: uppercase;175+ color: var(--peach-accent);176+ border: 0.5px solid var(--peach-accent);177+ border-radius: 4px;178+ vertical-align: middle;179+ opacity: 0.8;180+}src/popup/views/CodexGenerateView.tsx+325 −0
@@ -0,0 +1,325 @@1+// Peach Codex — generation view.2+//3+// Builds the printed PDF Codex (Aztec barcodes + optional recovery words) by:4+// 1. Fetching a vault snapshot from the SW (GET_CODEX_VAULT_DATA).5+// 2. Constructing a CodexDeps the popup-side `generateCodex` can drive.6+// 3. Triggering a PDF download.7+//8+// Pro-gated: a non-Pro user is bounced to the license section on mount.9+// The actual barcode/PDF/erasure-coding work lives in src/popup/codex/* and10+// is owned by Subagents 1 and 2 — do not import internals from there beyond11+// the published contract.12+//13+// Behavioural notes:14+// - `getAllEntries` only feeds an entry-count filter on the SW; here we hand15+// back synthetic placeholders of length `entryCount` so the filter passes16+// through unchanged without leaking entries through the popup.17+// - Restoration is intentionally NOT gated by Pro (a printed Codex must stay18+// restorable forever, regardless of license state).19+20+import { createSignal, createMemo, onMount, Show } from "solid-js";21+import { sendMessage } from "../../shared/bridge";22+import { isNativePlatform } from "../../shared/native";23+import type {24+ CodexDeps,25+ CodexError,26+ CodexManifest,27+ CodexVaultSnapshot,28+} from "../../shared/codex-types";29+import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";30+import { generateCodex, estimateCodexPages } from "../codex/generate";31+import type { VaultEntry } from "../../shared/types";32+33+interface CodexGenerateViewProps {34+ isPro: boolean;35+ onBack: () => void;36+ onUpgradePro: () => void;37+}38+39+export function CodexGenerateView(props: CodexGenerateViewProps) {40+ const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);41+ const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);42+ const [loading, setLoading] = createSignal(true);43+ const [loadError, setLoadError] = createSignal<string | null>(null);44+ const [includeRecoveryWords, setIncludeRecoveryWords] = createSignal(true);45+ const [generating, setGenerating] = createSignal(false);46+ const [genError, setGenError] = createSignal<string | null>(null);47+ const [completed, setCompleted] = createSignal<CodexManifest | null>(null);48+49+ onMount(async () => {50+ if (!props.isPro) {51+ // Pro-gated: bounce immediately. Render nothing meaningful in the52+ // meantime so the user does not flash see the form before redirect.53+ props.onUpgradePro();54+ return;55+ }56+57+ const res = (await sendMessage({58+ type: "GET_CODEX_VAULT_DATA" as any,59+ })) as { ok: boolean; data?: CodexVaultSnapshot; error?: string };60+61+ if (!res.ok || !res.data) {62+ setLoadError(res.error ?? "could not load vault data");63+ setLoading(false);64+ return;65+ }66+67+ setSnapshot(res.data);68+69+ if (res.data.slotA) {70+ try {71+ const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);72+ setEstimatedPages(pages);73+ } catch {74+ // Estimation is informational; failure is non-fatal.75+ setEstimatedPages(null);76+ }77+ }78+79+ setLoading(false);80+ });81+82+ // Description copy mirrors the spec — softens the toggle's consequence so83+ // the user understands what a "no recovery words" Codex actually means.84+ const toggleDescription = createMemo(() =>85+ includeRecoveryWords()86+ ? "your 12 recovery words will be printed on the last page. anyone holding the paper can restore the vault."87+ : "no recovery words on paper. you will need your master password to restore from this Codex.",88+ );89+90+ function buildDeps(snap: CodexVaultSnapshot): CodexDeps {91+ return {92+ getSlotA: async () => snap.slotA,93+ getSlotB: async () => snap.slotB,94+ getLastModified: async () => snap.lastModified,95+ // `getAllEntries` is consumed only as a count filter inside the96+ // generator. We synthesise a list of the right length so we don't have97+ // to surface real entries through the popup boundary.98+ getAllEntries: async () =>99+ Array.from(100+ { length: snap.entryCount },101+ () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,102+ ),103+ getRecoveryWords: async () => snap.recoveryWords,104+ getVerifyCode: async () => snap.verifyCode,105+ isVaultUnlocked: () => snap.vaultUnlocked,106+ };107+ }108+109+ function codexFilename(): string {110+ const d = new Date();111+ const y = d.getFullYear();112+ const m = String(d.getMonth() + 1).padStart(2, "0");113+ const day = String(d.getDate()).padStart(2, "0");114+ return `peach-codex-${y}-${m}-${day}.pdf`;115+ }116+117+ function downloadPdf(bytes: Uint8Array): void {118+ const a = document.createElement("a");119+ a.download = codexFilename();120+ if (isNativePlatform()) {121+ // Capacitor WebView: blob: URLs are opaque to native, but data: URLs122+ // are intercepted by MainActivity and written to public Downloads via123+ // MediaStore. Encode in chunks to avoid blowing the call stack on large124+ // PDFs.125+ let bin = "";126+ const chunk = 0x8000;127+ for (let i = 0; i < bytes.length; i += chunk) {128+ const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));129+ bin += String.fromCharCode(...slice);130+ }131+ a.href = `data:application/pdf;base64,${btoa(bin)}`;132+ } else {133+ // Browser: copy into a fresh Uint8Array to ensure a tightly-owned134+ // ArrayBuffer (Blob is happier with this than a subarray view).135+ const owned = new Uint8Array(bytes);136+ const blob = new Blob([owned.buffer], { type: "application/pdf" });137+ a.href = URL.createObjectURL(blob);138+ setTimeout(() => URL.revokeObjectURL(a.href), 4000);139+ }140+ document.body.appendChild(a);141+ a.click();142+ a.remove();143+ }144+145+ function explainCodexError(code: string, message: string): string {146+ switch (code) {147+ case "VAULT_LOCKED":148+ return "your vault is locked. unlock and try again.";149+ case "VAULT_EMPTY":150+ return "your vault is empty — nothing to print yet.";151+ case "RECOVERY_WORDS_MISSING":152+ return "your 12 recovery words could not be found. try toggling recovery words off.";153+ case "RECOVERY_WORDS_INVALID":154+ return "your recovery words look wrong. try toggling recovery words off.";155+ case "PRO_REQUIRED":156+ return "Codex generation is a Pro feature.";157+ case "PDF_GENERATION_FAILED":158+ case "AZTEC_GENERATION_FAILED":159+ return "could not render the Codex. please try again.";160+ case "COMPRESSION_ROUNDTRIP_FAILED":161+ case "ERASURE_CODING_VERIFICATION_FAILED":162+ case "ERASURE_CODING_CONTENT_MISMATCH":163+ case "AZTEC_ROUNDTRIP_FAILED":164+ case "AZTEC_CONTENT_MISMATCH":165+ case "RECONSTRUCTION_SIMULATION_FAILED":166+ case "RECONSTRUCTION_VERIFY_CODE_MISMATCH":167+ case "RECONSTRUCTION_FINGERPRINT_MISMATCH":168+ case "RECONSTRUCTION_COUNT_MISMATCH":169+ return "a self-check failed while building the Codex. nothing was saved. please try again.";170+ default:171+ return message || "Codex generation failed.";172+ }173+ }174+175+ async function handleGenerate() {176+ const snap = snapshot();177+ if (!snap || generating()) return;178+179+ setGenError(null);180+ setGenerating(true);181+182+ try {183+ const result = await generateCodex(buildDeps(snap), {184+ includeRecoveryWords: includeRecoveryWords(),185+ });186+ downloadPdf(result.pdfBytes);187+ setCompleted(result.manifest);188+189+ try {190+ await chrome.storage.local?.set({191+ [STORAGE_KEY_LAST_GENERATED]: Date.now(),192+ });193+ } catch {194+ // Bookkeeping; failure is non-fatal.195+ }196+ } catch (err) {197+ const codexErr = err as CodexError;198+ const code = (codexErr && (codexErr as { code?: string }).code) ?? "";199+ const message =200+ err instanceof Error ? err.message : "Codex generation failed.";201+ setGenError(explainCodexError(code, message));202+ } finally {203+ setGenerating(false);204+ }205+ }206+207+ return (208+ <div class="peach-overlay-view ov-enter-right" style="display:flex">209+ <div class="peach-overlay-header">210+ <button211+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"212+ onClick={props.onBack}213+ aria-label="Back"214+ >215+ <svg width="7" height="12" viewBox="0 0 7 12" fill="none"216+ stroke="currentColor" stroke-width="1.1"217+ stroke-linecap="round" stroke-linejoin="round">218+ <path d="M6 1L1 6L6 11" />219+ </svg>220+ </button>221+ <span class="peach-overlay-title">peach codex</span>222+ <div class="peach-overlay-spacer"></div>223+ </div>224+225+ <div class="peach-settings-body peach-codex-body">226+ <Show when={!props.isPro}>227+ <p class="peach-hint">redirecting…</p>228+ </Show>229+230+ <Show when={props.isPro && loading()}>231+ <p class="peach-hint">loading vault…</p>232+ </Show>233+234+ <Show when={props.isPro && !loading() && loadError()}>235+ <p class="peach-hint peach-hint-error">{loadError()}</p>236+ </Show>237+238+ <Show when={props.isPro && !loading() && !loadError() && snapshot()}>239+ <div class="peach-settings-group peach-codex-intro">240+ <p class="peach-codex-blurb">241+ a printable paper backup of your encrypted vault, encoded as242+ Aztec barcodes. store it somewhere only you can reach.243+ </p>244+ <Show when={estimatedPages() !== null}>245+ <p class="peach-codex-meta">246+ estimated <strong>{estimatedPages()}</strong>{" "}247+ {estimatedPages() === 1 ? "page" : "pages"} ·{" "}248+ {snapshot()!.entryCount}{" "}249+ {snapshot()!.entryCount === 1 ? "entry" : "entries"}250+ </p>251+ </Show>252+ </div>253+254+ <Show when={!completed()}>255+ <div class="peach-settings-group">256+ <div class="peach-settings-row">257+ <span>Include recovery words</span>258+ <button259+ type="button"260+ class={`peach-toggle ${includeRecoveryWords() ? "is-on" : ""}`}261+ onClick={() =>262+ setIncludeRecoveryWords(!includeRecoveryWords())263+ }264+ aria-label="Toggle recovery words on the Codex"265+ disabled={generating()}266+ >267+ <span class="peach-toggle-knob"></span>268+ </button>269+ </div>270+ <p class="peach-codex-toggle-desc">{toggleDescription()}</p>271+ </div>272+273+ <Show when={genError()}>274+ <p class="peach-hint peach-hint-error peach-codex-error">275+ {genError()}276+ </p>277+ </Show>278+279+ <button280+ class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"281+ disabled={generating()}282+ onClick={() => void handleGenerate()}283+ >284+ {generating() ? "building Codex…" : "generate Codex"}285+ </button>286+287+ <p class="peach-codex-footnote">288+ print and store your Codex somewhere safe. anyone with the paper289+ and your master password (or recovery words, if printed) can290+ restore your vault.291+ </p>292+ </Show>293+294+ <Show when={completed()}>295+ <div class="peach-settings-group peach-codex-done">296+ <p class="peach-hint peach-hint--success">✓ Codex generated</p>297+ <p class="peach-codex-meta">298+ {completed()!.pageCount}{" "}299+ {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}300+ {completed()!.totalChunks} codes · verify code{" "}301+ <span class="peach-codex-verify">302+ {completed()!.verifyCode}303+ </span>304+ </p>305+ <p class="peach-codex-footnote">306+ the PDF has been downloaded. print it on plain A4 paper —307+ colour or laser, both work. store it somewhere only you can308+ reach.309+ </p>310+ <button311+ class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"312+ onClick={() => {313+ setCompleted(null);314+ setGenError(null);315+ }}316+ >317+ generate another318+ </button>319+ </div>320+ </Show>321+ </Show>322+ </div>323+ </div>324+ );325+}src/popup/views/CodexRestoreView.tsx+365 −0
@@ -0,0 +1,365 @@1+// Peach Codex — restoration view.2+//3+// Drives the four steps of restoring a vault from a printed Codex:4+// 1. Camera scan — Aztec codes feed parseRawScan → ChunkBuffer.5+// 2. Once ChunkBuffer.isReady(), prompt for the master passphrase.6+// 3. Reconstruct + integrity-verify locally (popup) via reconstructAndVerify.7+// 4. Hand the payload to the SW (RESTORE_FROM_CODEX) for actual decrypt8+// and replace.9+//10+// Restoration is intentionally NOT Pro-gated — a printed Codex must remain11+// restorable forever, regardless of license state.12+13+import { createSignal, onCleanup, onMount, Show } from "solid-js";14+import { sendMessage } from "../../shared/bridge";15+import {16+ ChunkBuffer,17+ parseRawScan,18+ reconstructAndVerify,19+} from "../codex/restore";20+import { startScanner } from "../codex/scanner";21+import type { ScannerHandle } from "../codex/scanner";22+import type { CodexPayload } from "../../shared/codex-types";23+24+interface CodexRestoreViewProps {25+ onBack: () => void;26+ onRestoreSuccess: () => void;27+}28+29+type Phase = "scanning" | "ready" | "restoring" | "success" | "error";30+31+export function CodexRestoreView(props: CodexRestoreViewProps) {32+ // The buffer is intentionally not stored in a signal — its identity is33+ // stable across the lifetime of the view and we read it through the34+ // `size()`/`isReady()` methods. We mirror its observable state into35+ // signals so Solid can re-render.36+ const buffer = new ChunkBuffer();37+ let videoEl: HTMLVideoElement | undefined;38+ let scanner: ScannerHandle | null = null;39+40+ const [phase, setPhase] = createSignal<Phase>("scanning");41+ const [scanned, setScanned] = createSignal(0);42+ const [totalShards, setTotalShards] = createSignal<number | null>(null);43+ const [dataShards, setDataShards] = createSignal<number | null>(null);44+ const [verifyCode, setVerifyCode] = createSignal<string | null>(null);45+ const [hint, setHint] = createSignal(46+ "point your camera at the first code on page 1",47+ );48+ const [cameraError, setCameraError] = createSignal<string | null>(null);49+ const [scanWarning, setScanWarning] = createSignal<string | null>(null);50+ // Hold the reconstructed payload between the integrity check and the SW51+ // call so the user can re-enter the passphrase on a wrong-password failure52+ // without rescanning every code.53+ const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(54+ null,55+ );56+ const [passphrase, setPassphrase] = createSignal("");57+ const [restoreError, setRestoreError] = createSignal<string | null>(null);58+ const [restoredCount, setRestoredCount] = createSignal<number | null>(null);59+60+ function refreshFromBuffer() {61+ setScanned(buffer.size());62+ const ctx = buffer.getContext();63+ if (ctx) {64+ setTotalShards(ctx.total);65+ setDataShards(ctx.dataShards);66+ setVerifyCode(ctx.verifyCode);67+ }68+ if (buffer.isReady() && phase() === "scanning") {69+ setPhase("ready");70+ setHint("scan complete. enter your master password to restore.");71+ } else if (phase() === "scanning") {72+ const ds = ctx?.dataShards;73+ if (ds) {74+ const remaining = Math.max(ds - buffer.size(), 0);75+ setHint(76+ remaining > 077+ ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`78+ : "almost there…",79+ );80+ }81+ }82+ }83+84+ function explainScanReason(reason: string): string {85+ switch (reason) {86+ case "foreign_codex":87+ return "that code belongs to a different Codex.";88+ case "bad_integrity":89+ return "a code's checksum failed — try again.";90+ case "duplicate":91+ return ""; // not surfaced — duplicates are normal as the user pans92+ case "parse_error":93+ return "could not read that code.";94+ default:95+ return "";96+ }97+ }98+99+ async function handleScan(raw: string) {100+ const parsed = parseRawScan(raw);101+ if ("error" in parsed) {102+ // parseRawScan failures are usually a non-Peach Aztec code or a corrupt103+ // frame; quietly ignore so the scanner keeps running.104+ return;105+ }106+107+ const result = await buffer.add(parsed);108+ refreshFromBuffer();109+ if (!result.accepted && result.reason) {110+ const msg = explainScanReason(result.reason);111+ if (msg) {112+ setScanWarning(msg);113+ // Auto-clear so the warning doesn't linger after the user moves on.114+ setTimeout(() => setScanWarning(null), 2500);115+ }116+ }117+ }118+119+ function handleScannerError(err: Error) {120+ // Camera permission denied / no camera / hardware failure.121+ setCameraError(122+ err.name === "NotAllowedError"123+ ? "camera access was denied. allow camera permission and reopen this screen."124+ : err.message || "camera failed to start.",125+ );126+ }127+128+ onMount(async () => {129+ if (!videoEl) return;130+ try {131+ scanner = await startScanner({132+ videoEl,133+ onScan: (text) => void handleScan(text),134+ onError: (err) => handleScannerError(err),135+ });136+ } catch (err) {137+ handleScannerError(err instanceof Error ? err : new Error(String(err)));138+ }139+ });140+141+ onCleanup(() => {142+ if (scanner) {143+ scanner.stop();144+ scanner = null;145+ }146+ // `scanner.stop()` calls reader.reset(), which tears down the147+ // MediaStream attached to the <video> element. Defensive: clear srcObject148+ // in case the library leaves a reference behind after reset (observed in149+ // older @zxing/library versions).150+ if (videoEl?.srcObject) {151+ const stream = videoEl.srcObject as MediaStream;152+ stream.getTracks().forEach((t) => t.stop());153+ videoEl.srcObject = null;154+ }155+ });156+157+ function explainRestoreError(error: string | undefined): string {158+ if (!error) return "restoration failed. please try again.";159+ if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))160+ return "wrong master password. try again.";161+ if (error.includes("INVALID_VAULT_BLOB"))162+ return "the reconstructed vault is unreadable. please re-scan the Codex.";163+ if (error.includes("FINGERPRINT_MISMATCH"))164+ return "vault integrity check failed. please re-scan the Codex.";165+ return error;166+ }167+168+ async function handleRestore() {169+ if (phase() === "restoring") return;170+ const pw = passphrase().trim();171+ if (!pw) {172+ setRestoreError("enter your master password.");173+ return;174+ }175+176+ setPhase("restoring");177+ setRestoreError(null);178+179+ let payload = pendingPayload();180+ if (!payload) {181+ try {182+ payload = await reconstructAndVerify(buffer);183+ setPendingPayload(payload);184+ } catch (err) {185+ const message =186+ err instanceof Error187+ ? err.message188+ : "the scanned codes could not be reassembled.";189+ setRestoreError(message);190+ setPhase("ready");191+ return;192+ }193+ }194+195+ const res = (await sendMessage({196+ type: "RESTORE_FROM_CODEX" as any,197+ payload: {198+ vaultBlob: payload.vaultBlob,199+ timestamp: payload.timestamp,200+ verifyCode: payload.verifyCode,201+ vaultFingerprint: payload.vaultFingerprint,202+ entryCount: payload.entryCount,203+ masterPassphrase: pw,204+ },205+ })) as {206+ ok: boolean;207+ data?: { entryCount: number; verifyCode: string };208+ error?: string;209+ };210+211+ if (res.ok && res.data) {212+ setRestoredCount(res.data.entryCount);213+ setPhase("success");214+ // Tear the camera down ahead of unmount so the green light goes off the215+ // instant the success screen appears.216+ if (scanner) {217+ scanner.stop();218+ scanner = null;219+ }220+ // Give the user a moment to read the success message before the parent221+ // routes them onward.222+ setTimeout(() => props.onRestoreSuccess(), 1500);223+ } else {224+ setRestoreError(explainRestoreError(res.error));225+ setPhase("ready");226+ }227+ }228+229+ function progressPercent(): number {230+ const ds = dataShards();231+ if (!ds || ds <= 0) return 0;232+ return Math.min(100, Math.round((scanned() / ds) * 100));233+ }234+235+ return (236+ <div class="peach-overlay-view ov-enter-right" style="display:flex">237+ <div class="peach-overlay-header">238+ <button239+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"240+ onClick={props.onBack}241+ aria-label="Back"242+ >243+ <svg width="7" height="12" viewBox="0 0 7 12" fill="none"244+ stroke="currentColor" stroke-width="1.1"245+ stroke-linecap="round" stroke-linejoin="round">246+ <path d="M6 1L1 6L6 11" />247+ </svg>248+ </button>249+ <span class="peach-overlay-title">restore from codex</span>250+ <div class="peach-overlay-spacer"></div>251+ </div>252+253+ <div class="peach-settings-body peach-codex-body">254+ <Show when={phase() !== "success"}>255+ <div class="peach-codex-scanner">256+ <video257+ ref={(el) => (videoEl = el)}258+ class="peach-codex-video"259+ autoplay260+ playsinline261+ muted262+ />263+ <Show when={cameraError()}>264+ <div class="peach-codex-camera-overlay">265+ <p class="peach-hint peach-hint-error">{cameraError()}</p>266+ </div>267+ </Show>268+ </div>269+ </Show>270+271+ <Show when={phase() === "scanning" || phase() === "ready" || phase() === "restoring"}>272+ <div class="peach-settings-group peach-codex-progress-group">273+ <div class="peach-codex-progress-track">274+ <div275+ class="peach-codex-progress-fill"276+ style={{ width: `${progressPercent()}%` }}277+ />278+ </div>279+ <p class="peach-codex-progress-label">280+ <Show281+ when={totalShards() !== null}282+ fallback={<span>{scanned()} codes scanned</span>}283+ >284+ <span>285+ {scanned()} / {dataShards()} codes286+ </span>287+ </Show>288+ <Show when={verifyCode()}>289+ <span class="peach-codex-verify-tag">290+ {verifyCode()}291+ </span>292+ </Show>293+ </p>294+ <p class="peach-codex-hint">{hint()}</p>295+ <Show when={scanWarning()}>296+ <p class="peach-hint peach-hint--warning peach-codex-warning">297+ {scanWarning()}298+ </p>299+ </Show>300+ </div>301+ </Show>302+303+ <Show when={phase() === "ready" || phase() === "restoring"}>304+ <div class="peach-settings-group peach-codex-passphrase-group">305+ <label class="peach-settings-label" for="codex-passphrase">306+ master password307+ </label>308+ <input309+ id="codex-passphrase"310+ type="password"311+ class="peach-underline-input"312+ autocomplete="current-password"313+ autocorrect="off"314+ autocapitalize="none"315+ spellcheck={false}316+ disabled={phase() === "restoring"}317+ value={passphrase()}318+ onInput={(e) => {319+ setPassphrase((e.target as HTMLInputElement).value);320+ if (restoreError()) setRestoreError(null);321+ }}322+ onKeyDown={(e) => {323+ if (e.key === "Enter" && phase() === "ready") {324+ void handleRestore();325+ }326+ }}327+ />328+ <Show when={restoreError()}>329+ <p class="peach-hint peach-hint-error">{restoreError()}</p>330+ </Show>331+ <button332+ class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"333+ disabled={phase() === "restoring" || passphrase().length === 0}334+ onClick={() => void handleRestore()}335+ >336+ {phase() === "restoring" ? "restoring…" : "restore vault"}337+ </button>338+ </div>339+ </Show>340+341+ <Show when={phase() === "success"}>342+ <div class="peach-settings-group peach-codex-done">343+ <p class="peach-hint peach-hint--success">✓ vault restored</p>344+ <p class="peach-codex-meta">345+ {restoredCount()}{" "}346+ {restoredCount() === 1 ? "entry" : "entries"} recovered.347+ </p>348+ <p class="peach-codex-footnote">349+ your Codex has been read and your vault is unlocked.350+ </p>351+ </div>352+ </Show>353+354+ <Show when={phase() !== "success"}>355+ <button356+ class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"357+ onClick={props.onBack}358+ >359+ cancel360+ </button>361+ </Show>362+ </div>363+ </div>364+ );365+}src/popup/views/Lock.tsx+12 −0
@@ -43,6 +43,9 @@4343 * Onboarding. Only meaningful when the user reached create-vault from4444 * Onboarding in the first place. */4545 onBackToOnboarding?: () => void;46+ /** Optional: open the Peach Codex restore flow (camera scan of printed47+ * Codex pages). Surfaced as a small footer link on the new-vault screen. */48+ onOpenCodexRestore?: () => void;4649 }4750 4851 type RestoreSurface = "restore" | null;@@ -513,6 +516,15 @@513516 >514517 › restore from backup515518 </button>519+ <Show when={props.onOpenCodexRestore}>520+ <button521+ class="peach-lock-restore-link"522+ onClick={() => props.onOpenCodexRestore?.()}523+ disabled={!props.ready}524+ >525+ › restore from peach codex526+ </button>527+ </Show>516528 </div>517529 </section>518530 </Match>src/popup/views/Settings.tsx+15 −0
@@ -62,6 +62,7 @@6262 onOpenSyncPairing?: () => void;6363 onOpenFileImport?: () => void;6464 onOpenPanic?: () => void;65+ onOpenCodexGenerate?: () => void;6566 }6667 6768 type SectionName =@@ -1040,6 +1041,20 @@10401041 <div class="peach-settings-row peach-settings-row--meta">10411042 <span>Export backup</span>10421043 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1044+ open1045+ </button>1046+ </div>1047+ <div class="peach-settings-row peach-settings-row--meta">1048+ <span>1049+ Peach Codex1050+ <Show when={!isPro()}>1051+ <span class="peach-pro-badge">Pro</span>1052+ </Show>1053+ </span>1054+ <button1055+ class="peach-btn peach-btn--subtle peach-btn--sm"1056+ onClick={() => props.onOpenCodexGenerate?.()}1057+ >10431058 open10441059 </button>10451060 </div>src/shared/types.ts+8 −2
@@ -330,14 +330,20 @@330330 | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast331331 | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast332332 | "SHOULD_PLAY_CELEBRATION"333- | "MARK_CELEBRATION_PLAYED";333+ | "MARK_CELEBRATION_PLAYED"334+ // Peach Codex (v2.42.0). Generation runs in the popup — the SW only335+ // sources vault data and applies the restored slots.336+ | "GET_CODEX_VAULT_DATA"337+ | "ESTIMATE_CODEX_PAGES"338+ | "RESTORE_FROM_CODEX";334339 335340 export type ProFeature =336341 | "sync"337342 | "passkeys"338343 | "advanced_health"339344 | "smart_password_change"340- | "ios_app";345+ | "ios_app"346+ | "codex";341347 342348 /**343349 * License export shape included in PeachScript and encrypted JSON backups.