/** * codex-types.ts — shared shapes for the Peach Codex. * * The Codex is a printed A4 PDF that encodes the user's encrypted vault as * Aztec barcodes. Recovery words are never included in the PDF — they live * on paper separately by design. See peach_codex_v2.md + peach_codex_addendum.md * for the full design doc. * * These types are the contract between four execution boundaries: * - service worker (`src/background/codex-handlers.ts`) * - popup library (`src/popup/codex/*`) * - popup views (`src/popup/views/CodexGenerateView.tsx`, `CodexRestoreView.tsx`) * - tests (`src/popup/codex/*.test.ts`) * * Bump `version` (and `ChunkEnvelope.v`) only on breaking changes to the * on-paper format. */ import type { VaultEntry } from "./types"; /** Payload that the Codex encodes. Compressed, chunked, erasure-coded, then printed. */ export interface CodexPayload { /** Format version. Increment on breaking changes. */ version: 2; /** 12-char alphanumeric: `PX-XXXX-XXXX`. Identifies a vault across Codex prints. */ verifyCode: string; /** Base64url-encoded JSON `{ a: slotA, b: slotB|null }` — both encrypted vault slots. */ vaultBlob: string; /** Vault last-modified unix timestamp (ms). Used to compare against on-device vault on restore. */ timestamp: number; /** Visible entry count for user confirmation at restore time. */ entryCount: number; /** First 32 chars (128 bits) of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. */ vaultFingerprint: string; /** When this Codex PDF was generated. Printed in the footer. */ generatedAt: number; } /** * One Aztec barcode's contents. Each envelope is a JSON-encoded chunk of the * compressed payload plus a per-chunk integrity hash. * * Field names are intentionally single letters to keep the encoded JSON * compact — every byte saved shrinks the printed page count. * * The current format splits the payload into N independent RS stripes so * totals can exceed the GF(2⁸) 256-shard limit. `s` is the 0-based stripe * index, `S` is the total number of stripes, and `i` / `t` are within-stripe. */ export interface ChunkEnvelope { /** Envelope format version. */ v: 3; /** First 12 chars of `verifyCode` ("PX-XXXX-XXXX"). Lets the scanner reject foreign Codexes. */ c: string; /** Stripe index, 0-based. */ s: number; /** Total stripes in this Codex. */ S: number; /** Chunk index within the stripe, 0-based. */ i: number; /** Total chunk count in this stripe (data + parity). */ t: number; /** True if this is a Reed-Solomon parity shard. */ p: boolean; /** Chunk data, base64url-encoded. */ d: string; /** First 32 chars (128 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-128 collision odds. */ h: string; } /** Bookkeeping returned alongside the generated PDF. */ export interface CodexManifest { verifyCode: string; /** Total Aztec codes across all stripes (data + parity). */ totalChunks: number; /** Total data shards across all stripes. */ dataChunks: number; /** Total parity shards across all stripes. */ parityChunks: number; /** Number of independent RS stripes (≥ 1). */ stripeCount: number; /** Data-shard count for each stripe, in stripe order. */ dataPerStripe: number[]; /** Parity-shard count for each stripe, in stripe order. */ parityPerStripe: number[]; pageCount: number; entryCount: number; vaultFingerprint: string; generatedAt: number; } /** Dependencies the generator needs from the service worker. */ export interface CodexDeps { getSlotA: () => Promise; getSlotB: () => Promise; getLastModified: () => Promise; getAllEntries: () => Promise; getVerifyCode: () => Promise; isVaultUnlocked: () => boolean; } /** * Progress reporter for the generator. Called at named milestones; for the * Aztec step `current`/`total` advance per code so the UI can render a bar. * `imageUrl` is a blob URL for the just-generated Aztec PNG — only present * during the "aztec" step so the UI can show a live gallery of real codes. */ export type CodexProgress = ( step: "compress" | "erasure" | "aztec" | "verify" | "pdf", current?: number, total?: number, imageUrl?: string, ) => void; /** Payload sent from the popup to the SW to perform the actual vault restoration. */ export interface CodexRestorePayload { vaultBlob: string; timestamp: number; verifyCode: string; vaultFingerprint: string; entryCount: number; masterPassphrase: string; } /** Vault data the SW exposes to the popup so it can build a CodexPayload. */ export interface CodexVaultSnapshot { slotA: string | null; slotB: string | null; lastModified: string | null; entryCount: number; recoveryWords: string[] | null; verifyCode: string; vaultUnlocked: boolean; } /** * Every Codex error is loud and named. The `code` field is a stable identifier * for log analysis; the human-readable message lives in `.message`. */ export class CodexError extends Error { constructor( public code: CodexErrorCode, message: string, ) { super(message); this.name = "CodexError"; } } export type CodexErrorCode = | "VAULT_LOCKED" | "VAULT_EMPTY" | "COMPRESSION_ROUNDTRIP_FAILED" | "ERASURE_CODING_VERIFICATION_FAILED" | "ERASURE_CODING_CONTENT_MISMATCH" | "AZTEC_GENERATION_FAILED" | "AZTEC_ROUNDTRIP_FAILED" | "AZTEC_CONTENT_MISMATCH" | "INSUFFICIENT_CHUNKS" | "RECONSTRUCTION_SIMULATION_FAILED" | "RECONSTRUCTION_VERIFY_CODE_MISMATCH" | "RECONSTRUCTION_FINGERPRINT_MISMATCH" | "RECONSTRUCTION_COUNT_MISMATCH" | "PDF_GENERATION_FAILED" | "RS_LIBRARY_FAILED" | "PRO_REQUIRED" | "WRONG_PASSPHRASE" | "INVALID_VAULT_BLOB" | "RESTORE_FAILED"; // ── Constants — shared across generation and restoration ───────────────────── /** * Bytes of binary data per chunk. Each chunk becomes one Aztec code. * * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata. * base64url expands by 4/3: 350 bytes → 467 chars → ~577-char envelope. * ZXing selects a 75-module Aztec code at eclevel=25, producing a 316px PNG * (75×4+16) at the standard scale=4. At 80pt CODE_SIZE and 300 DPI printing, * 75 modules occupy 333 dots → 4.44 dots/module, comfortably above ZXing's * minimum of ~4 dots/module for reliable camera decode. * * The previous value was 800 (117-module codes, 2.85 dots/module at 300 DPI — * too small for reliable camera scanning). Old Codexes printed with a * different chunk size still restore — the erasure decoder derives shard size * from the scanned chunks, not from this constant. */ export const CHUNK_SIZE = 350; /** * Maximum data shards per RS stripe. The underlying reed-solomon-erasure * library operates over GF(2⁸), which hard-caps total shards (data + parity) * at 256. Holding data at 200 leaves room for the maximum 35-parity case * (200 + 35 = 235, well under the cap) and is the threshold above which we * split the compressed payload into multiple independent stripes. */ export const MAX_DATA_SHARDS_PER_STRIPE = 200; /** * Upper bound on the stripe count `S` accepted from a scanned envelope. A real * Codex prints only a handful of stripes; this ceiling exists purely to stop a * crafted `S` (e.g. 2^31) from driving O(S) allocation on the restore hot path * (PCH-01-011). 64 is far above any real layout. */ export const MAX_STRIPE_COUNT = 64; /** Floor on parity shards: a Codex must always survive losing at least 2 codes. */ export const CODEX_MIN_PARITY_SHARDS = 2; /** Ceiling on parity shards: caps printing overhead for very large vaults. */ export const CODEX_MAX_PARITY_SHARDS = 35; /** Target ratio of parity to data shards (15%). */ export const CODEX_PARITY_RATIO = 0.15; /** * Number of parity shards for a Codex with `dataShards` data shards. Scales * with vault size — small vaults pin to the minimum, huge vaults cap at the * maximum. Survives the loss of up to the returned count of codes anywhere * in the printed document. The function is monotonic non-decreasing in * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity). * * Examples: * 13 → 2 (minimum floor) * 56 → 9 (15% bracket) * 213 → 32 * 660 → 35 (maximum cap) */ export function computeParityShards(dataShards: number): number { return Math.min( CODEX_MAX_PARITY_SHARDS, Math.max( CODEX_MIN_PARITY_SHARDS, Math.ceil(dataShards * CODEX_PARITY_RATIO), ), ); } /** * Recover (dataShards, parityShards) from a total chunk count printed on a * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there * is no manifest to consult — so we invert `computeParityShards` by trying * each candidate parity count in [MIN, MAX] and picking the one that round * trips. Monotonicity of `computeParityShards` guarantees a unique solution. */ export function deriveShardCounts(totalChunks: number): { dataShards: number; parityShards: number; } { for ( let parityShards = CODEX_MIN_PARITY_SHARDS; parityShards <= CODEX_MAX_PARITY_SHARDS; parityShards++ ) { const dataShards = totalChunks - parityShards; if (dataShards < 1) continue; // PCH-01-010: a saturated parity count (35) round-trips for any // dataShards >= 234, so without this bound computeParityShards's saturation // makes the inversion accept absurd totals (e.g. 1e9), wedging a stripe that // isReady() can never satisfy. Reject anything a real generator could never // have produced — this is what makes the uniqueness claim in restore.ts true. if (dataShards > MAX_DATA_SHARDS_PER_STRIPE) continue; if (computeParityShards(dataShards) === parityShards) { return { dataShards, parityShards }; } } throw new CodexError( "ERASURE_CODING_VERIFICATION_FAILED", `Cannot derive shard layout for totalChunks=${totalChunks}`, ); } /** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */ export const AZTEC_ECC_PERCENT = 25; /** PDF layout: 6 columns × 6 rows = 36 codes per A4 page. */ export const CODES_PER_ROW = 6; export const CODES_PER_COL = 6; export const CODES_PER_PAGE = CODES_PER_ROW * CODES_PER_COL; /** Stable storage keys for Codex bookkeeping. */ export const STORAGE_KEY_VERIFY_CODE = "peach_codex_verify_code"; export const STORAGE_KEY_LAST_GENERATED = "peach_codex_last_generated"; export const STORAGE_KEY_LAST_RESTORED = "peach_last_restored_at";