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
21 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 −2
src/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+});

view file

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+}

view file

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.

view file

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.

view file

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>

view file

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+}

view file

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+}

view file

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+});

view file

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+}

view file

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+}

view file

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+}

view file

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+});

view file

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+}

view file

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+}

view file

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>

view file

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+}

view file

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+}

view file

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+}

view file

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>

view file

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>

view file

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.

view file