codex-types.ts

10.8 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. A v2 Codex must always be decodable by future Peach17 * versions.18 */1920import type { VaultEntry } from "./types";2122/** Payload that the Codex encodes. Compressed, chunked, erasure-coded, then printed. */23export interface CodexPayload {24  /** Format version. Increment on breaking changes. */25  version: 2;26  /** 12-char alphanumeric: `PX-XXXX-XXXX`. Identifies a vault across Codex prints. */27  verifyCode: string;28  /** Base64url-encoded JSON `{ a: slotA, b: slotB|null }` — both encrypted vault slots. */29  vaultBlob: string;30  /** Vault last-modified unix timestamp (ms). Used to compare against on-device vault on restore. */31  timestamp: number;32  /** Visible entry count for user confirmation at restore time. */33  entryCount: number;34  /** First 32 chars (128 bits) of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. AUD-004: was 16 chars; legacy Codexes still verify. */35  vaultFingerprint: string;36  /** When this Codex PDF was generated. Printed in the footer. */37  generatedAt: number;38}3940/**41 * One Aztec barcode's contents. Each envelope is a JSON-encoded chunk of the42 * compressed payload plus a per-chunk integrity hash.43 *44 * Field names are intentionally single letters to keep the encoded JSON45 * compact — every byte saved shrinks the printed page count.46 *47 * Format history:48 *  - v2: single Reed-Solomon block; `i` / `t` are global.49 *  - v3: payload is split into N independent RS stripes so totals can exceed50 *    the GF(2⁸) 256-shard limit. `s` is the 0-based stripe index, `S` is the51 *    total number of stripes, and `i` / `t` are now within-stripe. A v252 *    envelope is equivalent to a v3 envelope with `s=0, S=1`.53 */54export interface ChunkEnvelope {55  /** Envelope format version. v2 = single-stripe; v3 = multi-stripe. */56  v: 2 | 3;57  /** First 12 chars of `verifyCode` ("PX-XXXX-XXXX"). Lets the scanner reject foreign Codexes. */58  c: string;59  /** Stripe index, 0-based. v2 envelopes omit this; treat as 0. */60  s?: number;61  /** Total stripes in this Codex. v2 envelopes omit this; treat as 1. */62  S?: number;63  /** Chunk index within the stripe, 0-based. */64  i: number;65  /** Total chunk count in this stripe (data + parity). */66  t: number;67  /** True if this is a Reed-Solomon parity shard. */68  p: boolean;69  /** Chunk data, base64url-encoded. */70  d: string;71  /** First 32 chars (128 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-128 collision odds. AUD-004: was 16 chars; legacy Codexes still verify. */72  h: string;73}7475/** Bookkeeping returned alongside the generated PDF. */76export interface CodexManifest {77  verifyCode: string;78  /** Total Aztec codes across all stripes (data + parity). */79  totalChunks: number;80  /** Total data shards across all stripes. */81  dataChunks: number;82  /** Total parity shards across all stripes. */83  parityChunks: number;84  /** Number of independent RS stripes (≥ 1). */85  stripeCount: number;86  /** Data-shard count for each stripe, in stripe order. */87  dataPerStripe: number[];88  /** Parity-shard count for each stripe, in stripe order. */89  parityPerStripe: number[];90  pageCount: number;91  entryCount: number;92  vaultFingerprint: string;93  generatedAt: number;94}9596/** Dependencies the generator needs from the service worker. */97export interface CodexDeps {98  getSlotA: () => Promise<string | null>;99  getSlotB: () => Promise<string | null>;100  getLastModified: () => Promise<string | null>;101  getAllEntries: () => Promise<VaultEntry[]>;102  getVerifyCode: () => Promise<string>;103  isVaultUnlocked: () => boolean;104}105106/**107 * Progress reporter for the generator. Called at named milestones; for the108 * Aztec step `current`/`total` advance per code so the UI can render a bar.109 * `imageUrl` is a blob URL for the just-generated Aztec PNG — only present110 * during the "aztec" step so the UI can show a live gallery of real codes.111 */112export type CodexProgress = (113  step: "compress" | "erasure" | "aztec" | "verify" | "pdf",114  current?: number,115  total?: number,116  imageUrl?: string,117) => void;118119/** Payload sent from the popup to the SW to perform the actual vault restoration. */120export interface CodexRestorePayload {121  vaultBlob: string;122  timestamp: number;123  verifyCode: string;124  vaultFingerprint: string;125  entryCount: number;126  masterPassphrase: string;127}128129/** Vault data the SW exposes to the popup so it can build a CodexPayload. */130export interface CodexVaultSnapshot {131  slotA: string | null;132  slotB: string | null;133  lastModified: string | null;134  entryCount: number;135  recoveryWords: string[] | null;136  verifyCode: string;137  vaultUnlocked: boolean;138}139140/**141 * Every Codex error is loud and named. The `code` field is a stable identifier142 * for log analysis; the human-readable message lives in `.message`.143 */144export class CodexError extends Error {145  constructor(146    public code: CodexErrorCode,147    message: string,148  ) {149    super(message);150    this.name = "CodexError";151  }152}153154export type CodexErrorCode =155  | "VAULT_LOCKED"156  | "VAULT_EMPTY"157  | "COMPRESSION_ROUNDTRIP_FAILED"158  | "ERASURE_CODING_VERIFICATION_FAILED"159  | "ERASURE_CODING_CONTENT_MISMATCH"160  | "AZTEC_GENERATION_FAILED"161  | "AZTEC_ROUNDTRIP_FAILED"162  | "AZTEC_CONTENT_MISMATCH"163  | "INSUFFICIENT_CHUNKS"164  | "RECONSTRUCTION_SIMULATION_FAILED"165  | "RECONSTRUCTION_VERIFY_CODE_MISMATCH"166  | "RECONSTRUCTION_FINGERPRINT_MISMATCH"167  | "RECONSTRUCTION_COUNT_MISMATCH"168  | "PDF_GENERATION_FAILED"169  | "RS_LIBRARY_FAILED"170  | "PRO_REQUIRED"171  | "WRONG_PASSPHRASE"172  | "INVALID_VAULT_BLOB"173  | "RESTORE_FAILED";174175// ── Constants — shared across generation and restoration ─────────────────────176177/**178 * Bytes of binary data per chunk. Each chunk becomes one Aztec code.179 *180 * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata.181 * base64url expands by 4/3: 350 bytes → 467 chars → ~577-char envelope.182 * ZXing selects a 75-module Aztec code at eclevel=25, producing a 316px PNG183 * (75×4+16) at the standard scale=4. At 80pt CODE_SIZE and 300 DPI printing,184 * 75 modules occupy 333 dots → 4.44 dots/module, comfortably above ZXing's185 * minimum of ~4 dots/module for reliable camera decode.186 *187 * The previous value was 800 (117-module codes, 2.85 dots/module at 300 DPI —188 * too small for reliable camera scanning). Old Codexes printed with a189 * different chunk size still restore — the erasure decoder derives shard size190 * from the scanned chunks, not from this constant.191 */192export const CHUNK_SIZE = 350;193194/**195 * Maximum data shards per RS stripe. The underlying reed-solomon-erasure196 * library operates over GF(2⁸), which hard-caps total shards (data + parity)197 * at 256. Holding data at 200 leaves room for the maximum 35-parity case198 * (200 + 35 = 235, well under the cap) and is the threshold above which we199 * split the compressed payload into multiple independent stripes.200 */201export const MAX_DATA_SHARDS_PER_STRIPE = 200;202203/**204 * Upper bound on the stripe count `S` accepted from a scanned envelope. A real205 * Codex prints only a handful of stripes; this ceiling exists purely to stop a206 * crafted `S` (e.g. 2^31) from driving O(S) allocation on the restore hot path207 * (PCH-01-011). 64 is far above any real layout.208 */209export const MAX_STRIPE_COUNT = 64;210211/** Floor on parity shards: a Codex must always survive losing at least 2 codes. */212export const CODEX_MIN_PARITY_SHARDS = 2;213214/** Ceiling on parity shards: caps printing overhead for very large vaults. */215export const CODEX_MAX_PARITY_SHARDS = 35;216217/** Target ratio of parity to data shards (15%). */218export const CODEX_PARITY_RATIO = 0.15;219220/**221 * Number of parity shards for a Codex with `dataShards` data shards. Scales222 * with vault size — small vaults pin to the minimum, huge vaults cap at the223 * maximum. Survives the loss of up to the returned count of codes anywhere224 * in the printed document. The function is monotonic non-decreasing in225 * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity).226 *227 * Examples:228 *   13 → 2   (minimum floor)229 *   56 → 9   (15% bracket)230 *   213 → 32231 *   660 → 35 (maximum cap)232 */233export function computeParityShards(dataShards: number): number {234  return Math.min(235    CODEX_MAX_PARITY_SHARDS,236    Math.max(237      CODEX_MIN_PARITY_SHARDS,238      Math.ceil(dataShards * CODEX_PARITY_RATIO),239    ),240  );241}242243/**244 * Recover (dataShards, parityShards) from a total chunk count printed on a245 * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there246 * is no manifest to consult — so we invert `computeParityShards` by trying247 * each candidate parity count in [MIN, MAX] and picking the one that round248 * trips. Monotonicity of `computeParityShards` guarantees a unique solution.249 */250export function deriveShardCounts(totalChunks: number): {251  dataShards: number;252  parityShards: number;253} {254  for (255    let parityShards = CODEX_MIN_PARITY_SHARDS;256    parityShards <= CODEX_MAX_PARITY_SHARDS;257    parityShards++258  ) {259    const dataShards = totalChunks - parityShards;260    if (dataShards < 1) continue;261    // PCH-01-010: a saturated parity count (35) round-trips for any262    // dataShards >= 234, so without this bound computeParityShards's saturation263    // makes the inversion accept absurd totals (e.g. 1e9), wedging a stripe that264    // isReady() can never satisfy. Reject anything a real generator could never265    // have produced — this is what makes the uniqueness claim in restore.ts true.266    if (dataShards > MAX_DATA_SHARDS_PER_STRIPE) continue;267    if (computeParityShards(dataShards) === parityShards) {268      return { dataShards, parityShards };269    }270  }271  throw new CodexError(272    "ERASURE_CODING_VERIFICATION_FAILED",273    `Cannot derive shard layout for totalChunks=${totalChunks}`,274  );275}276277/** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */278export const AZTEC_ECC_PERCENT = 25;279280/** PDF layout: 6 columns × 6 rows = 36 codes per A4 page. */281export const CODES_PER_ROW = 6;282export const CODES_PER_COL = 6;283export const CODES_PER_PAGE = CODES_PER_ROW * CODES_PER_COL;284285/** Stable storage keys for Codex bookkeeping. */286export const STORAGE_KEY_VERIFY_CODE = "peach_codex_verify_code";287export const STORAGE_KEY_LAST_GENERATED = "peach_codex_last_generated";288export const STORAGE_KEY_LAST_RESTORED = "peach_last_restored_at";