codex-types.ts
1/**2 * codex-types.ts — shared shapes for the Peach Codex.3 *4 * The Codex is a printed A4 PDF that encodes the user's encrypted vault as5 * 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.md7 * for the full design doc.8 *9 * These types are the contract between four execution boundaries:10 * - service worker (`src/background/codex-handlers.ts`)11 * - popup library (`src/popup/codex/*`)12 * - popup views (`src/popup/views/CodexGenerateView.tsx`, `CodexRestoreView.tsx`)13 * - tests (`src/popup/codex/*.test.ts`)14 *15 * Bump `version` (and `ChunkEnvelope.v`) only on breaking changes to the16 * on-paper format.17 */1819import type { VaultEntry } from "./types";2021/** Payload that the Codex encodes. Compressed, chunked, erasure-coded, then printed. */22export interface CodexPayload {23 /** Format version. Increment on breaking changes. */24 version: 2;25 /** 12-char alphanumeric: `PX-XXXX-XXXX`. Identifies a vault across Codex prints. */26 verifyCode: string;27 /** Base64url-encoded JSON `{ a: slotA, b: slotB|null }` — both encrypted vault slots. */28 vaultBlob: string;29 /** Vault last-modified unix timestamp (ms). Used to compare against on-device vault on restore. */30 timestamp: number;31 /** Visible entry count for user confirmation at restore time. */32 entryCount: number;33 /** First 32 chars (128 bits) of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. */34 vaultFingerprint: string;35 /** When this Codex PDF was generated. Printed in the footer. */36 generatedAt: number;37}3839/**40 * One Aztec barcode's contents. Each envelope is a JSON-encoded chunk of the41 * compressed payload plus a per-chunk integrity hash.42 *43 * Field names are intentionally single letters to keep the encoded JSON44 * compact — every byte saved shrinks the printed page count.45 *46 * The current format splits the payload into N independent RS stripes so47 * totals can exceed the GF(2⁸) 256-shard limit. `s` is the 0-based stripe48 * index, `S` is the total number of stripes, and `i` / `t` are within-stripe.49 */50export interface ChunkEnvelope {51 /** Envelope format version. */52 v: 3;53 /** First 12 chars of `verifyCode` ("PX-XXXX-XXXX"). Lets the scanner reject foreign Codexes. */54 c: string;55 /** Stripe index, 0-based. */56 s: number;57 /** Total stripes in this Codex. */58 S: number;59 /** Chunk index within the stripe, 0-based. */60 i: number;61 /** Total chunk count in this stripe (data + parity). */62 t: number;63 /** True if this is a Reed-Solomon parity shard. */64 p: boolean;65 /** Chunk data, base64url-encoded. */66 d: string;67 /** First 32 chars (128 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-128 collision odds. */68 h: string;69}7071/** Bookkeeping returned alongside the generated PDF. */72export interface CodexManifest {73 verifyCode: string;74 /** Total Aztec codes across all stripes (data + parity). */75 totalChunks: number;76 /** Total data shards across all stripes. */77 dataChunks: number;78 /** Total parity shards across all stripes. */79 parityChunks: number;80 /** Number of independent RS stripes (≥ 1). */81 stripeCount: number;82 /** Data-shard count for each stripe, in stripe order. */83 dataPerStripe: number[];84 /** Parity-shard count for each stripe, in stripe order. */85 parityPerStripe: number[];86 pageCount: number;87 entryCount: number;88 vaultFingerprint: string;89 generatedAt: number;90}9192/** Dependencies the generator needs from the service worker. */93export interface CodexDeps {94 getSlotA: () => Promise<string | null>;95 getSlotB: () => Promise<string | null>;96 getLastModified: () => Promise<string | null>;97 getAllEntries: () => Promise<VaultEntry[]>;98 getVerifyCode: () => Promise<string>;99 isVaultUnlocked: () => boolean;100}101102/**103 * Progress reporter for the generator. Called at named milestones; for the104 * Aztec step `current`/`total` advance per code so the UI can render a bar.105 * `imageUrl` is a blob URL for the just-generated Aztec PNG — only present106 * during the "aztec" step so the UI can show a live gallery of real codes.107 */108export type CodexProgress = (109 step: "compress" | "erasure" | "aztec" | "verify" | "pdf",110 current?: number,111 total?: number,112 imageUrl?: string,113) => void;114115/** Payload sent from the popup to the SW to perform the actual vault restoration. */116export interface CodexRestorePayload {117 vaultBlob: string;118 timestamp: number;119 verifyCode: string;120 vaultFingerprint: string;121 entryCount: number;122 masterPassphrase: string;123}124125/** Vault data the SW exposes to the popup so it can build a CodexPayload. */126export interface CodexVaultSnapshot {127 slotA: string | null;128 slotB: string | null;129 lastModified: string | null;130 entryCount: number;131 recoveryWords: string[] | null;132 verifyCode: string;133 vaultUnlocked: boolean;134}135136/**137 * Every Codex error is loud and named. The `code` field is a stable identifier138 * for log analysis; the human-readable message lives in `.message`.139 */140export class CodexError extends Error {141 constructor(142 public code: CodexErrorCode,143 message: string,144 ) {145 super(message);146 this.name = "CodexError";147 }148}149150export type CodexErrorCode =151 | "VAULT_LOCKED"152 | "VAULT_EMPTY"153 | "COMPRESSION_ROUNDTRIP_FAILED"154 | "ERASURE_CODING_VERIFICATION_FAILED"155 | "ERASURE_CODING_CONTENT_MISMATCH"156 | "AZTEC_GENERATION_FAILED"157 | "AZTEC_ROUNDTRIP_FAILED"158 | "AZTEC_CONTENT_MISMATCH"159 | "INSUFFICIENT_CHUNKS"160 | "RECONSTRUCTION_SIMULATION_FAILED"161 | "RECONSTRUCTION_VERIFY_CODE_MISMATCH"162 | "RECONSTRUCTION_FINGERPRINT_MISMATCH"163 | "RECONSTRUCTION_COUNT_MISMATCH"164 | "PDF_GENERATION_FAILED"165 | "RS_LIBRARY_FAILED"166 | "PRO_REQUIRED"167 | "WRONG_PASSPHRASE"168 | "INVALID_VAULT_BLOB"169 | "RESTORE_FAILED";170171// ── Constants — shared across generation and restoration ─────────────────────172173/**174 * Bytes of binary data per chunk. Each chunk becomes one Aztec code.175 *176 * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata.177 * base64url expands by 4/3: 350 bytes → 467 chars → ~577-char envelope.178 * ZXing selects a 75-module Aztec code at eclevel=25, producing a 316px PNG179 * (75×4+16) at the standard scale=4. At 80pt CODE_SIZE and 300 DPI printing,180 * 75 modules occupy 333 dots → 4.44 dots/module, comfortably above ZXing's181 * minimum of ~4 dots/module for reliable camera decode.182 *183 * The previous value was 800 (117-module codes, 2.85 dots/module at 300 DPI —184 * too small for reliable camera scanning). Old Codexes printed with a185 * different chunk size still restore — the erasure decoder derives shard size186 * from the scanned chunks, not from this constant.187 */188export const CHUNK_SIZE = 350;189190/**191 * Maximum data shards per RS stripe. The underlying reed-solomon-erasure192 * library operates over GF(2⁸), which hard-caps total shards (data + parity)193 * at 256. Holding data at 200 leaves room for the maximum 35-parity case194 * (200 + 35 = 235, well under the cap) and is the threshold above which we195 * split the compressed payload into multiple independent stripes.196 */197export const MAX_DATA_SHARDS_PER_STRIPE = 200;198199/**200 * Upper bound on the stripe count `S` accepted from a scanned envelope. A real201 * Codex prints only a handful of stripes; this ceiling exists purely to stop a202 * crafted `S` (e.g. 2^31) from driving O(S) allocation on the restore hot path203 * (PCH-01-011). 64 is far above any real layout.204 */205export const MAX_STRIPE_COUNT = 64;206207/** Floor on parity shards: a Codex must always survive losing at least 2 codes. */208export const CODEX_MIN_PARITY_SHARDS = 2;209210/** Ceiling on parity shards: caps printing overhead for very large vaults. */211export const CODEX_MAX_PARITY_SHARDS = 35;212213/** Target ratio of parity to data shards (15%). */214export const CODEX_PARITY_RATIO = 0.15;215216/**217 * Number of parity shards for a Codex with `dataShards` data shards. Scales218 * with vault size — small vaults pin to the minimum, huge vaults cap at the219 * maximum. Survives the loss of up to the returned count of codes anywhere220 * in the printed document. The function is monotonic non-decreasing in221 * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity).222 *223 * Examples:224 * 13 → 2 (minimum floor)225 * 56 → 9 (15% bracket)226 * 213 → 32227 * 660 → 35 (maximum cap)228 */229export function computeParityShards(dataShards: number): number {230 return Math.min(231 CODEX_MAX_PARITY_SHARDS,232 Math.max(233 CODEX_MIN_PARITY_SHARDS,234 Math.ceil(dataShards * CODEX_PARITY_RATIO),235 ),236 );237}238239/**240 * Recover (dataShards, parityShards) from a total chunk count printed on a241 * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there242 * is no manifest to consult — so we invert `computeParityShards` by trying243 * each candidate parity count in [MIN, MAX] and picking the one that round244 * trips. Monotonicity of `computeParityShards` guarantees a unique solution.245 */246export function deriveShardCounts(totalChunks: number): {247 dataShards: number;248 parityShards: number;249} {250 for (251 let parityShards = CODEX_MIN_PARITY_SHARDS;252 parityShards <= CODEX_MAX_PARITY_SHARDS;253 parityShards++254 ) {255 const dataShards = totalChunks - parityShards;256 if (dataShards < 1) continue;257 // PCH-01-010: a saturated parity count (35) round-trips for any258 // dataShards >= 234, so without this bound computeParityShards's saturation259 // makes the inversion accept absurd totals (e.g. 1e9), wedging a stripe that260 // isReady() can never satisfy. Reject anything a real generator could never261 // have produced — this is what makes the uniqueness claim in restore.ts true.262 if (dataShards > MAX_DATA_SHARDS_PER_STRIPE) continue;263 if (computeParityShards(dataShards) === parityShards) {264 return { dataShards, parityShards };265 }266 }267 throw new CodexError(268 "ERASURE_CODING_VERIFICATION_FAILED",269 `Cannot derive shard layout for totalChunks=${totalChunks}`,270 );271}272273/** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */274export const AZTEC_ECC_PERCENT = 25;275276/** PDF layout: 6 columns × 6 rows = 36 codes per A4 page. */277export const CODES_PER_ROW = 6;278export const CODES_PER_COL = 6;279export const CODES_PER_PAGE = CODES_PER_ROW * CODES_PER_COL;280281/** Stable storage keys for Codex bookkeeping. */282export const STORAGE_KEY_VERIFY_CODE = "peach_codex_verify_code";283export const STORAGE_KEY_LAST_GENERATED = "peach_codex_last_generated";284export const STORAGE_KEY_LAST_RESTORED = "peach_last_restored_at";