codex-types.ts

10.4 KB · raw

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";