codex-types.ts

9.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 */110export type CodexProgress = (111  step: "compress" | "erasure" | "aztec" | "verify" | "pdf",112  current?: number,113  total?: number,114) => void;115116/** Payload sent from the popup to the SW to perform the actual vault restoration. */117export interface CodexRestorePayload {118  vaultBlob: string;119  timestamp: number;120  verifyCode: string;121  vaultFingerprint: string;122  entryCount: number;123  masterPassphrase: string;124}125126/** Vault data the SW exposes to the popup so it can build a CodexPayload. */127export interface CodexVaultSnapshot {128  slotA: string | null;129  slotB: string | null;130  lastModified: string | null;131  entryCount: number;132  recoveryWords: string[] | null;133  verifyCode: string;134  vaultUnlocked: boolean;135}136137/**138 * Every Codex error is loud and named. The `code` field is a stable identifier139 * for log analysis; the human-readable message lives in `.message`.140 */141export class CodexError extends Error {142  constructor(143    public code: CodexErrorCode,144    message: string,145  ) {146    super(message);147    this.name = "CodexError";148  }149}150151export type CodexErrorCode =152  | "VAULT_LOCKED"153  | "VAULT_EMPTY"154  | "COMPRESSION_ROUNDTRIP_FAILED"155  | "ERASURE_CODING_VERIFICATION_FAILED"156  | "ERASURE_CODING_CONTENT_MISMATCH"157  | "AZTEC_GENERATION_FAILED"158  | "AZTEC_ROUNDTRIP_FAILED"159  | "AZTEC_CONTENT_MISMATCH"160  | "INSUFFICIENT_CHUNKS"161  | "RECONSTRUCTION_SIMULATION_FAILED"162  | "RECONSTRUCTION_VERIFY_CODE_MISMATCH"163  | "RECONSTRUCTION_FINGERPRINT_MISMATCH"164  | "RECONSTRUCTION_COUNT_MISMATCH"165  | "PDF_GENERATION_FAILED"166  | "RS_LIBRARY_FAILED"167  | "PRO_REQUIRED"168  | "WRONG_PASSPHRASE"169  | "INVALID_VAULT_BLOB"170  | "RESTORE_FAILED";171172// ── Constants — shared across generation and restoration ─────────────────────173174/**175 * Bytes of binary data per chunk. Each chunk becomes one Aztec code.176 *177 * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata.178 * base64url expands by 4/3: 800 bytes → 1068 chars → ~1168-char envelope.179 * The empirical bwip-js/ZXing ceiling at eclevel=25 is ~1386 chars; staying180 * ~200 chars under gives reliable headroom across cheap printers, camera181 * angles, and ZXing decode variance. The original 1024-byte value produced182 * ~1468-char envelopes — over the ceiling, causing AZTEC_ROUNDTRIP_FAILED.183 *184 * Old Codexes printed with a different chunk size still restore — the185 * erasure decoder derives shard size from the scanned chunks, not from186 * this constant.187 */188export const CHUNK_SIZE = 800;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 20-parity case194 * (200 + 20 = 220, 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/** Floor on parity shards: a Codex must always survive losing at least 2 codes. */200export const CODEX_MIN_PARITY_SHARDS = 2;201202/** Ceiling on parity shards: caps printing overhead for very large vaults. */203export const CODEX_MAX_PARITY_SHARDS = 20;204205/** Target ratio of parity to data shards (5%). */206export const CODEX_PARITY_RATIO = 0.05;207208/**209 * Number of parity shards for a Codex with `dataShards` data shards. Scales210 * with vault size — small vaults pin to the minimum, huge vaults cap at the211 * maximum. Survives the loss of up to the returned count of codes anywhere212 * in the printed document. The function is monotonic non-decreasing in213 * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity).214 *215 * Examples:216 *   13 → 2   (minimum floor)217 *   56 → 3   (5% bracket)218 *   213 → 11219 *   660 → 20 (maximum cap)220 */221export function computeParityShards(dataShards: number): number {222  return Math.min(223    CODEX_MAX_PARITY_SHARDS,224    Math.max(225      CODEX_MIN_PARITY_SHARDS,226      Math.ceil(dataShards * CODEX_PARITY_RATIO),227    ),228  );229}230231/**232 * Recover (dataShards, parityShards) from a total chunk count printed on a233 * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there234 * is no manifest to consult — so we invert `computeParityShards` by trying235 * each candidate parity count in [MIN, MAX] and picking the one that round236 * trips. Monotonicity of `computeParityShards` guarantees a unique solution.237 */238export function deriveShardCounts(totalChunks: number): {239  dataShards: number;240  parityShards: number;241} {242  for (243    let parityShards = CODEX_MIN_PARITY_SHARDS;244    parityShards <= CODEX_MAX_PARITY_SHARDS;245    parityShards++246  ) {247    const dataShards = totalChunks - parityShards;248    if (dataShards < 1) continue;249    if (computeParityShards(dataShards) === parityShards) {250      return { dataShards, parityShards };251    }252  }253  throw new CodexError(254    "ERASURE_CODING_VERIFICATION_FAILED",255    `Cannot derive shard layout for totalChunks=${totalChunks}`,256  );257}258259/** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */260export const AZTEC_ECC_PERCENT = 25;261262/** PDF layout: 6 columns × 6 rows = 36 codes per A4 page. */263export const CODES_PER_ROW = 6;264export const CODES_PER_COL = 6;265export const CODES_PER_PAGE = CODES_PER_ROW * CODES_PER_COL;266267/** Stable storage keys for Codex bookkeeping. */268export const STORAGE_KEY_VERIFY_CODE = "peach_codex_verify_code";269export const STORAGE_KEY_LAST_GENERATED = "peach_codex_last_generated";270export const STORAGE_KEY_LAST_RESTORED = "peach_last_restored_at";