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
↓ peach-browser-63fdf3a4.tar.gzatlas at this save
files changed11 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 −10manifest.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": {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",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": {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>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 regressionsrc/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 {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 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 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 }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>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"