63fdf3a4458d

strip recovery words from Codex PDF and payload: remove includesRecoveryWords/CodexOptions/loadAndValidateRecoveryWords; add CodexProgress milestones to generateCodex; export pickConfirmationPositions

time
2026-05-15 17:53 UTC · 5 months ago
machine
koh2
parent
cf09399ea0d6
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+1 −0src/popup/codex/generate.test.ts+9 −23src/popup/codex/generate.ts+30 −41src/popup/codex/pdf.ts+5 −69src/popup/codex/restore.test.ts+0 −1src/popup/styles/codex.css+120 −0src/popup/views/CodexGenerateView.tsx+257 −90src/shared/codex-types.ts+11 −10
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.42.0",4+  "version": "2.43.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.42.0",4+  "version": "2.43.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.42.0",3+  "version": "2.43.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/App.tsx+1 −0
@@ -1486,6 +1486,7 @@14861486                   isPro={proStatus()}14871487                   onBack={() => setView("settings")}14881488                   onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1489+                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}14891490                 />14901491               </Suspense>14911492             </Show>

view file

src/popup/codex/generate.test.ts+9 −23
@@ -84,7 +84,6 @@8484     getSlotB: async () => slotB,8585     getLastModified: async () => "1700000000000",8686     getAllEntries: async () => entries,87-    getRecoveryWords: async () => null,8887     getVerifyCode: async () => "PX-TEST-CODE",8988     isVaultUnlocked: () => true,9089     ...overrides,@@ -203,7 +202,7 @@203202 204203   it("produces a non-trivial PDF and a coherent manifest", async () => {205204     const deps = makeDeps();206-    const { pdfBytes, manifest } = await generateCodex(deps, { includeRecoveryWords: false });205+    const { pdfBytes, manifest } = await generateCodex(deps);207206 208207     expect(pdfBytes.byteLength).toBeGreaterThan(1000);209208     // PDF magic header@@ -216,39 +215,26 @@216215     expect(manifest.entryCount).toBe(3);217216     expect(manifest.parityChunks).toBe(PARITY_SHARDS);218217     expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);219-    expect(manifest.includesRecoveryWords).toBe(false);220218     expect(manifest.pageCount).toBeGreaterThanOrEqual(1);221219     expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);222220   }, 60_000);223221 224222   it("rejects generation when the vault is locked", async () => {225223     const deps = makeDeps({ isVaultUnlocked: () => false });226-    await expect(generateCodex(deps, { includeRecoveryWords: false })).rejects.toMatchObject({224+    await expect(generateCodex(deps)).rejects.toMatchObject({227225       name: "CodexError",228226       code: "VAULT_LOCKED",229227     });230228   });231229 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",230+  it("reports progress at named milestones", async () => {231+    const deps = makeDeps();232+    const steps: string[] = [];233+    await generateCodex(deps, (step) => {234+      if (!steps.includes(step)) steps.push(step);237235     });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-  });236+    expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);237+  }, 60_000);252238 253239   it("persists the storage_key_verify_code constant correctly", () => {254240     // Smoke check the constant import — catches a rename / re-export regression

view file

src/popup/codex/generate.ts+30 −41
@@ -14,7 +14,6 @@1414  */1515 1616 import { deflate, inflate } from "pako";17-import { WORD_LIST } from "../../shared/recovery-word-list";1817 import {1918   CHUNK_SIZE,2019   CODES_PER_PAGE,@@ -23,8 +22,8 @@2322   type ChunkEnvelope,2423   type CodexDeps,2524   type CodexManifest,26-  type CodexOptions,2725   type CodexPayload,26+  type CodexProgress,2827 } from "../../shared/codex-types";2928 import { generateVerifiedAztec } from "./aztec";3029 import { decodeWithErasure, encodeWithErasure } from "./erasure";@@ -40,7 +39,7 @@4039 4140 export async function generateCodex(4241   deps: CodexDeps,43-  options: CodexOptions,42+  onProgress?: CodexProgress,4443 ): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {4544   // STEP 0 — preconditions4645   if (!deps.isVaultUnlocked()) {@@ -51,13 +50,7 @@5150   const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);5251   const verifyCode = await deps.getVerifyCode();5352 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 payload53+  // STEP 2 — canonical payload (no recovery words — those live on paper only)6154   const payload: CodexPayload = {6255     version: 2,6356     verifyCode,@@ -66,20 +59,21 @@6659     entryCount,6760     vaultFingerprint,6861     generatedAt: Date.now(),69-    includesRecoveryWords: options.includeRecoveryWords,7062   };7163 72-  // STEP 4 — compress + round-trip verify64+  // STEP 3 — compress + round-trip verify65+  onProgress?.("compress");7366   const payloadJson = JSON.stringify(payload);7467   const payloadBytes = new TextEncoder().encode(payloadJson);7568   const compressed = deflate(payloadBytes, { level: 9 });7669   assertCompressionRoundTrip(compressed, payloadJson);7770 78-  // STEP 5 — erasure encode + simulated-loss verify71+  // STEP 4 — erasure encode + simulated-loss verify72+  onProgress?.("erasure");7973   const { dataChunks, parityChunks, totalChunks, dataShards } = await encodeWithErasure(compressed);8074   await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);8175 82-  // STEP 6 — envelopes (data first, then parity, preserving global index ordering)76+  // STEP 5 — envelopes (data first, then parity, preserving global index ordering)8377   const envelopes: ChunkEnvelope[] = [];8478   for (let i = 0; i < dataChunks.length; i++) {8579     envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));@@ -90,17 +84,21 @@9084     );9185   }9286 93-  // STEP 7 — verified Aztec PNGs (round-trip per code where DOM is available)87+  // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)9488   const aztecPngs: Uint8Array[] = [];95-  for (const env of envelopes) {96-    const img = await generateVerifiedAztec(env);89+  for (let i = 0; i < envelopes.length; i++) {90+    onProgress?.("aztec", i, envelopes.length);91+    const img = await generateVerifiedAztec(envelopes[i]!);9792     aztecPngs.push(img.pngBytes);9893   }94+  onProgress?.("aztec", envelopes.length, envelopes.length);9995 100-  // STEP 8 — full reconstruction simulation (lose 2 random envelopes, rebuild)96+  // STEP 7 — full reconstruction simulation (lose 2 random envelopes, rebuild)97+  onProgress?.("verify");10198   await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);10299 103-  // STEP 9 — render PDF100+  // STEP 8 — render PDF101+  onProgress?.("pdf");104102   const manifest: CodexManifest = {105103     verifyCode,106104     totalChunks,@@ -110,9 +108,8 @@110108     entryCount,111109     vaultFingerprint,112110     generatedAt: payload.generatedAt,113-    includesRecoveryWords: options.includeRecoveryWords,114111   };115-  const pdfBytes = await renderCodexPDF(aztecPngs, manifest, recoveryWords);112+  const pdfBytes = await renderCodexPDF(aztecPngs, manifest);116113 117114   // STEP 10 — sanity check the PDF118115   if (!pdfBytes || pdfBytes.length < 1000) {@@ -199,7 +196,6 @@199196     entryCount: 0,200197     vaultFingerprint: "0000000000000000",201198     generatedAt: 0,202-    includesRecoveryWords: false,203199   };204200   const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });205201   const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));@@ -207,6 +203,18 @@207203   return Math.ceil(totalChunks / CODES_PER_PAGE);208204 }209205 206+/** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation207+ *  gate. Sorted ascending so they read naturally in the prompt. */208+export function pickConfirmationPositions(): [number, number, number] {209+  const positions = Array.from({ length: 12 }, (_, i) => i + 1);210+  for (let i = positions.length - 1; i > 0; i--) {211+    const j = Math.floor(Math.random() * (i + 1));212+    [positions[i], positions[j]] = [positions[j]!, positions[i]!];213+  }214+  const picked = [positions[0]!, positions[1]!, positions[2]!] as const;215+  return [...picked].sort((a, b) => a - b) as [number, number, number];216+}217+210218 // ── Internal helpers ────────────────────────────────────────────────────────211219 212220 async function buildVaultBlob(deps: CodexDeps): Promise<{@@ -237,25 +245,6 @@237245     entryCount: liveEntryCount,238246     vaultFingerprint: fingerprint,239247   };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;259248 }260249 261250 function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {

view file

src/popup/codex/pdf.ts+5 −69
@@ -11,6 +11,9 @@1111  * Fonts: standard 14, available in every PDF viewer (no font embedding cost).1212  * The cream/ink palette is intentionally low-contrast so the printed page1313  * reads like an artifact rather than a tax form.14+ *15+ * Recovery words never appear in the PDF — they are paper-only, kept16+ * physically separate from the Codex by design (see peach_codex_addendum.md).1417  */1518 1619 import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";@@ -36,31 +39,20 @@3639   (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);3740 const ROW_GAP = 18;3841 39-const RECOVERY_HEIGHT = 110;40-4142 const FOOTER_HEIGHT = 32;4243 const FOOTER_HAIRLINE = 0.5;4344 4445 const CREAM = rgb(0.961, 0.941, 0.91);4546 const INK = rgb(0.102, 0.094, 0.086);46-const AMBER_PRINT = rgb(0.545, 0.369, 0.165);4747 48-const ROMAN_NUMERALS = [49-  "I.", "II.", "III.", "IV.", "V.", "VI.",50-  "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",51-];52-5348 /**5449  * 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).50+ * blob-downloaded or printed. `aztecPngs` is one PNG per code, in chunk-index51+ * order. Recovery words are never drawn here — they are paper-only.5952  */6053 export async function renderCodexPDF(6154   aztecPngs: Uint8Array[],6255   manifest: CodexManifest,63-  recoveryWords: string[] | null,6456 ): Promise<Uint8Array> {6557   if (aztecPngs.length !== manifest.totalChunks) {6658     throw new CodexError(@@ -68,12 +60,6 @@6860       `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,6961     );7062   }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-  }7763   void CODES_PER_COL; // Imported for spec parity; the row math derives the column count.7864 7965   const pdfDoc = await PDFDocument.create();@@ -90,7 +76,6 @@9076 9177   for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {9278     const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);93-    const isLastPage = pageIndex === pageCount - 1;9479     const startIndex = pageIndex * CODES_PER_PAGE;9580     const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);9681 @@ -101,11 +86,6 @@10186     const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;10287     drawAztecGrid(page, pageImages, startIndex, gridTop, mono);10388 104-    if (isLastPage && recoveryWords) {105-      const recoveryTop = MARGIN + FOOTER_HEIGHT + FOOTER_HAIRLINE + 8;106-      drawRecoveryWords(page, recoveryWords, recoveryTop, serif, mono);107-    }108-10989     drawFooter(page, manifest, serif, mono);11090   }11191 @@ -215,50 +195,6 @@215195       color: INK,216196       opacity: 0.55,217197     });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 });262198   }263199 }264200 

view file

src/popup/codex/restore.test.ts+0 −1
@@ -90,7 +90,6 @@9090     entryCount: 42,9191     vaultFingerprint,9292     generatedAt: 1_700_000_001_000,93-    includesRecoveryWords: false,9493   };9594 }9695 

view file

src/popup/styles/codex.css+120 −0
@@ -15,6 +15,126 @@1515   gap: 12px;1616 }1717 18+/* When the body hosts the ambient runes layer, give it a positioning context19+ * so the absolute-positioned RecoveryRuneBackground anchors to the body. */20+.peach-codex-body--runes {21+  position: relative;22+  overflow: hidden;23+  isolation: isolate;24+}25+.peach-codex-body--runes > * {26+  position: relative;27+  z-index: 1;28+}29+30+/* Recovery-words confirmation — onboarding feel, but housed in an overlay.31+ * Cormorant Garamond italic on the headlines mirrors the lock screen so the32+ * user lands in the same mental space as "set up recovery words". */33+.peach-codex-recovery-headline {34+  display: flex;35+  flex-direction: column;36+  gap: 6px;37+  margin-top: 4px;38+}39+.peach-codex-recovery-title {40+  font-family: "Cormorant Garamond", "Times New Roman", serif;41+  font-style: italic;42+  font-weight: 400;43+  font-size: 1.25rem;44+  letter-spacing: -0.005em;45+  color: var(--peach-t0);46+  margin: 0;47+}48+.peach-codex-recovery-body {49+  margin: 0;50+  font-size: 0.8125rem;51+  line-height: 1.45;52+  color: var(--peach-t1);53+}54+.peach-codex-recovery-warn {55+  margin: 0;56+  font-size: 0.75rem;57+  line-height: 1.45;58+  color: var(--peach-accent);59+  font-style: italic;60+}61+62+.peach-codex-words-grid {63+  list-style: none;64+  margin: 4px 0 0;65+  padding: 12px;66+  display: grid;67+  grid-template-columns: repeat(2, 1fr);68+  gap: 6px 16px;69+  border: 1px solid var(--peach-t3);70+  border-radius: 8px;71+  background: rgba(245, 240, 234, 0.04);72+}73+.peach-codex-word-row {74+  display: flex;75+  align-items: baseline;76+  gap: 6px;77+}78+.peach-codex-word-numeral {79+  font-family: "Cormorant Garamond", "Times New Roman", serif;80+  font-style: italic;81+  font-size: 0.75rem;82+  color: var(--peach-accent);83+  width: 28px;84+  flex: 0 0 28px;85+  letter-spacing: 0.02em;86+}87+.peach-codex-word-text {88+  font-family: "IBM Plex Mono", monospace;89+  font-size: 0.6875rem;90+  letter-spacing: 0.06em;91+  color: var(--peach-t0);92+  text-transform: lowercase;93+}94+95+.peach-codex-confirm-section {96+  display: flex;97+  flex-direction: column;98+  gap: 10px;99+  margin-top: 4px;100+}101+.peach-codex-confirm-label {102+  font-family: "IBM Plex Mono", monospace;103+  font-size: 0.625rem;104+  letter-spacing: 0.04em;105+  color: var(--peach-t2);106+  margin: 0;107+}108+.peach-codex-confirm-row {109+  display: flex;110+  align-items: center;111+  gap: 8px;112+}113+.peach-codex-confirm-pos {114+  font-family: "IBM Plex Mono", monospace;115+  font-size: 0.5625rem;116+  color: var(--peach-t2);117+  min-width: 52px;118+  letter-spacing: 0.04em;119+}120+.peach-codex-confirm-input {121+  flex: 1;122+  background: transparent;123+  border: none;124+  border-bottom: 1px solid var(--peach-t3);125+  color: var(--peach-t0);126+  font-family: "IBM Plex Mono", monospace;127+  font-size: 0.75rem;128+  letter-spacing: 0.06em;129+  padding: 4px 2px 3px;130+  outline: none;131+  text-transform: lowercase;132+  transition: border-color 120ms ease;133+}134+.peach-codex-confirm-input:focus {135+  border-bottom-color: var(--peach-accent);136+}137+18138 .peach-codex-intro {19139   padding: 12px;20140 }

view file

src/popup/views/CodexGenerateView.tsx+257 −90
@@ -1,55 +1,76 @@11 // Peach Codex — generation view.22 //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.3+// Two-step flow (see peach_codex_addendum.md):4+//   1. words  — show the 12 recovery words and require the user to type 35+//               randomly-chosen ones correctly before generation can proceed.6+//               The Codex PDF never contains recovery words; this is the7+//               due-diligence gate that pushes the user to commit them to8+//               paper separately.9+//   2. gen    — run the popup-side `generateCodex` pipeline, surface progress,10+//               trigger the PDF download.11+//   3. done   — reminder to store the Codex and recovery words separately.712 //813 // 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).14+// Restoration (CodexRestoreView) stays free — a printed Codex must restore15+// regardless of license state.1916 20-import { createSignal, createMemo, onMount, Show } from "solid-js";17+import { createSignal, createMemo, For, onMount, Show } from "solid-js";2118 import { sendMessage } from "../../shared/bridge";2219 import { isNativePlatform } from "../../shared/native";20+import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";2321 import type {2422   CodexDeps,2523   CodexError,2624   CodexManifest,25+  CodexProgress,2726   CodexVaultSnapshot,2827 } from "../../shared/codex-types";2928 import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";30-import { generateCodex, estimateCodexPages } from "../codex/generate";29+import {30+  estimateCodexPages,31+  generateCodex,32+  pickConfirmationPositions,33+} from "../codex/generate";3134 import type { VaultEntry } from "../../shared/types";3235 3336 interface CodexGenerateViewProps {3437   isPro: boolean;3538   onBack: () => void;3639   onUpgradePro: () => void;40+  onOpenSecurity: () => void;3741 }3842 43+type Step = "words" | "generating" | "done";44+45+const ROMAN = [46+  "I.", "II.", "III.", "IV.", "V.", "VI.",47+  "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",48+];49+3950 export function CodexGenerateView(props: CodexGenerateViewProps) {4051   const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);4152   const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);4253   const [loading, setLoading] = createSignal(true);4354   const [loadError, setLoadError] = createSignal<string | null>(null);44-  const [includeRecoveryWords, setIncludeRecoveryWords] = createSignal(true);45-  const [generating, setGenerating] = createSignal(false);55+  const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);56+57+  const [step, setStep] = createSignal<Step>("words");58+  const [confirmPositions, setConfirmPositions] = createSignal<59+    [number, number, number]60+  >([3, 7, 11]);61+  const [confirmInputs, setConfirmInputs] = createSignal<62+    [string, string, string]63+  >(["", "", ""]);64+65+  const [progressStep, setProgressStep] = createSignal<string>("");66+  const [progressCurrent, setProgressCurrent] = createSignal<number>(0);67+  const [progressTotal, setProgressTotal] = createSignal<number>(0);68+4669   const [genError, setGenError] = createSignal<string | null>(null);4770   const [completed, setCompleted] = createSignal<CodexManifest | null>(null);4871 4972   onMount(async () => {5073     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.5374       props.onUpgradePro();5475       return;5576     }@@ -66,6 +87,15 @@6687 6788     setSnapshot(res.data);6889 90+    const words = res.data.recoveryWords;91+    if (!words || words.length !== 12) {92+      setMissingRecoveryWords(true);93+      setLoading(false);94+      return;95+    }96+97+    setConfirmPositions(pickConfirmationPositions());98+6999     if (res.data.slotA) {70100       try {71101         const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);@@ -79,13 +109,17 @@79109     setLoading(false);80110   });81111 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-  );112+  const allCorrect = createMemo(() => {113+    const snap = snapshot();114+    const words = snap?.recoveryWords;115+    if (!words || words.length !== 12) return false;116+    const positions = confirmPositions();117+    const inputs = confirmInputs();118+    return positions.every(119+      (pos, i) =>120+        inputs[i]!.toLowerCase().trim() === words[pos - 1]!.toLowerCase(),121+    );122+  });89123 90124   function buildDeps(snap: CodexVaultSnapshot): CodexDeps {91125     return {@@ -93,14 +127,13 @@93127       getSlotB: async () => snap.slotB,94128       getLastModified: async () => snap.lastModified,95129       // `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.130+      // generator — synthesise placeholders so real entries don't cross131+      // the popup boundary.98132       getAllEntries: async () =>99133         Array.from(100134           { length: snap.entryCount },101135           () => ({ deletedAt: null, archived: false }) as unknown as VaultEntry,102136         ),103-      getRecoveryWords: async () => snap.recoveryWords,104137       getVerifyCode: async () => snap.verifyCode,105138       isVaultUnlocked: () => snap.vaultUnlocked,106139     };@@ -120,8 +153,8 @@120153     if (isNativePlatform()) {121154       // Capacitor WebView: blob: URLs are opaque to native, but data: URLs122155       // are intercepted by MainActivity and written to public Downloads via123-      // MediaStore. Encode in chunks to avoid blowing the call stack on large124-      // PDFs.156+      // MediaStore. Encode in chunks to avoid blowing the call stack on157+      // large PDFs.125158       let bin = "";126159       const chunk = 0x8000;127160       for (let i = 0; i < bytes.length; i += chunk) {@@ -148,10 +181,6 @@148181         return "your vault is locked. unlock and try again.";149182       case "VAULT_EMPTY":150183         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.";155184       case "PRO_REQUIRED":156185         return "Codex generation is a Pro feature.";157186       case "PDF_GENERATION_FAILED":@@ -172,19 +201,25 @@172201     }173202   }174203 204+  const onProgress: CodexProgress = (s, current, total) => {205+    setProgressStep(s);206+    setProgressCurrent(current ?? 0);207+    setProgressTotal(total ?? 0);208+  };209+175210   async function handleGenerate() {176211     const snap = snapshot();177-    if (!snap || generating()) return;212+    if (!snap || !allCorrect() || step() === "generating") return;178213 179214     setGenError(null);180-    setGenerating(true);215+    setStep("generating");216+    setProgressStep("compress");181217 182218     try {183-      const result = await generateCodex(buildDeps(snap), {184-        includeRecoveryWords: includeRecoveryWords(),185-      });219+      const result = await generateCodex(buildDeps(snap), onProgress);186220       downloadPdf(result.pdfBytes);187221       setCompleted(result.manifest);222+      setStep("done");188223 189224       try {190225         await chrome.storage.local?.set({@@ -199,11 +234,53 @@199234       const message =200235         err instanceof Error ? err.message : "Codex generation failed.";201236       setGenError(explainCodexError(code, message));202-    } finally {203-      setGenerating(false);237+      setStep("words");204238     }205239   }206240 241+  function progressLabel(): string {242+    switch (progressStep()) {243+      case "compress":244+        return "compressing vault";245+      case "erasure":246+        return "building chunks";247+      case "aztec": {248+        const total = progressTotal();249+        const current = progressCurrent();250+        if (total > 0) return `generating codes (${current} of ${total})`;251+        return "generating codes";252+      }253+      case "verify":254+        return "self-checking";255+      case "pdf":256+        return "creating PDF";257+      default:258+        return "preparing";259+    }260+  }261+262+  function progressPercent(): number {263+    const total = progressTotal();264+    const current = progressCurrent();265+    // Map step to a coarse progress band so the bar advances monotonically266+    // even when intra-step progress (aztec N of M) is fine-grained.267+    switch (progressStep()) {268+      case "compress":269+        return 8;270+      case "erasure":271+        return 18;272+      case "aztec":273+        if (total <= 0) return 30;274+        return 30 + Math.floor((current / total) * 55); // 30 → 85275+      case "verify":276+        return 90;277+      case "pdf":278+        return 96;279+      default:280+        return 4;281+    }282+  }283+207284   return (208285     <div class="peach-overlay-view ov-enter-right" style="display:flex">209286       <div class="peach-overlay-header">@@ -211,6 +288,7 @@211288           class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"212289           onClick={props.onBack}213290           aria-label="Back"291+          disabled={step() === "generating"}214292         >215293           <svg width="7" height="12" viewBox="0 0 7 12" fill="none"216294             stroke="currentColor" stroke-width="1.1"@@ -222,7 +300,9 @@222300         <div class="peach-overlay-spacer"></div>223301       </div>224302 225-      <div class="peach-settings-body peach-codex-body">303+      <div class="peach-settings-body peach-codex-body peach-codex-body--runes">304+        <RecoveryRuneBackground />305+226306         <Show when={!props.isPro}>227307           <p class="peach-hint">redirecting…</p>228308         </Show>@@ -235,12 +315,55 @@235315           <p class="peach-hint peach-hint-error">{loadError()}</p>236316         </Show>237317 238-        <Show when={props.isPro && !loading() && !loadError() && snapshot()}>318+        <Show when={props.isPro && !loading() && !loadError() && missingRecoveryWords()}>239319           <div class="peach-settings-group peach-codex-intro">240320             <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.321+              the Codex prints your encrypted vault as Aztec codes. before322+              you can generate one, set up your 12 recovery words in323+              Settings → Security.243324             </p>325+          </div>326+          <button327+            class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"328+            onClick={props.onOpenSecurity}329+          >330+            open Security settings331+          </button>332+        </Show>333+334+        <Show335+          when={336+            props.isPro &&337+            !loading() &&338+            !loadError() &&339+            !missingRecoveryWords() &&340+            snapshot()341+          }342+        >343+          <Show when={step() === "words"}>344+            <div class="peach-codex-recovery-headline">345+              <h2 class="peach-codex-recovery-title">your recovery words.</h2>346+              <p class="peach-codex-recovery-body">347+                write these down and store them somewhere safe — separately348+                from your Codex.349+              </p>350+              <p class="peach-codex-recovery-warn">351+                together they restore your vault without your master352+                passphrase. keep them apart.353+              </p>354+            </div>355+356+            <ol class="peach-codex-words-grid" start={1}>357+              <For each={snapshot()!.recoveryWords ?? []}>358+                {(word, i) => (359+                  <li class="peach-codex-word-row">360+                    <span class="peach-codex-word-numeral">{ROMAN[i()]}</span>361+                    <span class="peach-codex-word-text">{word}</span>362+                  </li>363+                )}364+              </For>365+            </ol>366+244367             <Show when={estimatedPages() !== null}>245368               <p class="peach-codex-meta">246369                 estimated <strong>{estimatedPages()}</strong>{" "}@@ -249,25 +372,40 @@249372                 {snapshot()!.entryCount === 1 ? "entry" : "entries"}250373               </p>251374             </Show>252-          </div>253375 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>376+            <div class="peach-codex-confirm-section">377+              <p class="peach-codex-confirm-label">378+                to continue, enter words {confirmPositions().join(", ")}:379+              </p>380+              <For each={confirmPositions()}>381+                {(pos, i) => (382+                  <div class="peach-codex-confirm-row">383+                    <span class="peach-codex-confirm-pos">word {pos}</span>384+                    <input385+                      class="peach-codex-confirm-input"386+                      type="text"387+                      autocomplete="off"388+                      autocorrect="off"389+                      autocapitalize="none"390+                      spellcheck={false}391+                      value={confirmInputs()[i()]}392+                      onInput={(e) => {393+                        const next = [...confirmInputs()] as [394+                          string,395+                          string,396+                          string,397+                        ];398+                        next[i()] = (e.target as HTMLInputElement).value;399+                        setConfirmInputs(next);400+                      }}401+                      onKeyDown={(e) => {402+                        if (e.key === "Enter" && allCorrect())403+                          void handleGenerate();404+                      }}405+                    />406+                  </div>407+                )}408+              </For>271409             </div>272410 273411             <Show when={genError()}>@@ -278,22 +416,52 @@278416 279417             <button280418               class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"281-              disabled={generating()}419+              disabled={!allCorrect()}282420               onClick={() => void handleGenerate()}283421             >284-              {generating() ? "building Codex…" : "generate Codex"}422+              generate my Codex285423             </button>424+          </Show>286425 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>426+          <Show when={step() === "generating"}>427+            <div class="peach-codex-recovery-headline">428+              <h2 class="peach-codex-recovery-title">429+                generating your codex…430+              </h2>431+            </div>432+433+            <div class="peach-codex-progress-group">434+              <p class="peach-codex-progress-label">435+                <span>{progressLabel()}</span>436+                <span class="peach-codex-verify-tag">437+                  {snapshot()?.verifyCode}438+                </span>439+              </p>440+              <div class="peach-codex-progress-track">441+                <div442+                  class="peach-codex-progress-fill"443+                  style={`width:${progressPercent()}%`}444+                />445+              </div>446+            </div>292447           </Show>293448 294-          <Show when={completed()}>295-            <div class="peach-settings-group peach-codex-done">296-              <p class="peach-hint peach-hint--success">✓ Codex generated</p>449+          <Show when={step() === "done"}>450+            <div class="peach-codex-recovery-headline">451+              <h2 class="peach-codex-recovery-title">452+                your codex is downloading.453+              </h2>454+              <p class="peach-codex-recovery-body">455+                store it somewhere safe — a fireproof box, a safe, or a USB456+                drive kept offline.457+              </p>458+              <p class="peach-codex-recovery-warn">459+                your recovery words go somewhere else entirely. never store460+                them with the Codex.461+              </p>462+            </div>463+464+            <Show when={completed()}>297465               <p class="peach-codex-meta">298466                 {completed()!.pageCount}{" "}299467                 {completed()!.pageCount === 1 ? "page" : "pages"} ·{" "}@@ -302,21 +470,20 @@302470                   {completed()!.verifyCode}303471                 </span>304472               </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>473+            </Show>474+475+            <button476+              class="peach-btn peach-btn--subtle peach-btn--sm peach-btn--full"477+              onClick={() => {478+                setCompleted(null);479+                setGenError(null);480+                setConfirmInputs(["", "", ""]);481+                setConfirmPositions(pickConfirmationPositions());482+                setStep("words");483+              }}484+            >485+              generate another486+            </button>320487           </Show>321488         </Show>322489       </div>

view file

src/shared/codex-types.ts+11 −10
@@ -2,7 +2,8 @@22  * codex-types.ts — shared shapes for the Peach Codex.33  *44  * The Codex is a printed A4 PDF that encodes the user's encrypted vault as5- * Aztec barcodes plus optional recovery words. See /Users/june/Downloads/peach_codex_v2.md5+ * Aztec barcodes. Recovery words are never included in the PDF — they live6+ * on paper separately by design. See peach_codex_v2.md + peach_codex_addendum.md67  * for the full design doc.78  *89  * These types are the contract between four execution boundaries:@@ -34,8 +35,6 @@3435   vaultFingerprint: string;3536   /** When this Codex PDF was generated. Printed in the footer. */3637   generatedAt: number;37-  /** True if the last page includes the 12 recovery words. */38-  includesRecoveryWords: boolean;3938 }4039 4140 /**@@ -72,7 +71,6 @@7271   entryCount: number;7372   vaultFingerprint: string;7473   generatedAt: number;75-  includesRecoveryWords: boolean;7674 }7775 7876 /** Dependencies the generator needs from the service worker. */@@ -81,14 +79,19 @@8179   getSlotB: () => Promise<string | null>;8280   getLastModified: () => Promise<string | null>;8381   getAllEntries: () => Promise<VaultEntry[]>;84-  getRecoveryWords: () => Promise<string[] | null>;8582   getVerifyCode: () => Promise<string>;8683   isVaultUnlocked: () => boolean;8784 }8885 89-export interface CodexOptions {90-  includeRecoveryWords: boolean;91-}86+/**87+ * Progress reporter for the generator. Called at named milestones; for the88+ * Aztec step `current`/`total` advance per code so the UI can render a bar.89+ */90+export type CodexProgress = (91+  step: "compress" | "erasure" | "aztec" | "verify" | "pdf",92+  current?: number,93+  total?: number,94+) => void;9295 9396 /** Payload sent from the popup to the SW to perform the actual vault restoration. */9497 export interface CodexRestorePayload {@@ -128,8 +131,6 @@128131 export type CodexErrorCode =129132   | "VAULT_LOCKED"130133   | "VAULT_EMPTY"131-  | "RECOVERY_WORDS_MISSING"132-  | "RECOVERY_WORDS_INVALID"133134   | "COMPRESSION_ROUNDTRIP_FAILED"134135   | "ERASURE_CODING_VERIFICATION_FAILED"135136   | "ERASURE_CODING_CONTENT_MISMATCH"

view file