codex-types.ts

9.9 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: 800 bytes → 1068 chars → ~1168-char envelope.182 * The empirical bwip-js/ZXing ceiling at eclevel=25 is ~1386 chars; staying183 * ~200 chars under gives reliable headroom across cheap printers, camera184 * angles, and ZXing decode variance. The original 1024-byte value produced185 * ~1468-char envelopes — over the ceiling, causing AZTEC_ROUNDTRIP_FAILED.186 *187 * Old Codexes printed with a different chunk size still restore — the188 * erasure decoder derives shard size from the scanned chunks, not from189 * this constant.190 */191export const CHUNK_SIZE = 800;192193/**194 * Maximum data shards per RS stripe. The underlying reed-solomon-erasure195 * library operates over GF(2⁸), which hard-caps total shards (data + parity)196 * at 256. Holding data at 200 leaves room for the maximum 20-parity case197 * (200 + 20 = 220, well under the cap) and is the threshold above which we198 * split the compressed payload into multiple independent stripes.199 */200export const MAX_DATA_SHARDS_PER_STRIPE = 200;201202/** Floor on parity shards: a Codex must always survive losing at least 2 codes. */203export const CODEX_MIN_PARITY_SHARDS = 2;204205/** Ceiling on parity shards: caps printing overhead for very large vaults. */206export const CODEX_MAX_PARITY_SHARDS = 20;207208/** Target ratio of parity to data shards (5%). */209export const CODEX_PARITY_RATIO = 0.05;210211/**212 * Number of parity shards for a Codex with `dataShards` data shards. Scales213 * with vault size — small vaults pin to the minimum, huge vaults cap at the214 * maximum. Survives the loss of up to the returned count of codes anywhere215 * in the printed document. The function is monotonic non-decreasing in216 * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity).217 *218 * Examples:219 *   13 → 2   (minimum floor)220 *   56 → 3   (5% bracket)221 *   213 → 11222 *   660 → 20 (maximum cap)223 */224export function computeParityShards(dataShards: number): number {225  return Math.min(226    CODEX_MAX_PARITY_SHARDS,227    Math.max(228      CODEX_MIN_PARITY_SHARDS,229      Math.ceil(dataShards * CODEX_PARITY_RATIO),230    ),231  );232}233234/**235 * Recover (dataShards, parityShards) from a total chunk count printed on a236 * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there237 * is no manifest to consult — so we invert `computeParityShards` by trying238 * each candidate parity count in [MIN, MAX] and picking the one that round239 * trips. Monotonicity of `computeParityShards` guarantees a unique solution.240 */241export function deriveShardCounts(totalChunks: number): {242  dataShards: number;243  parityShards: number;244} {245  for (246    let parityShards = CODEX_MIN_PARITY_SHARDS;247    parityShards <= CODEX_MAX_PARITY_SHARDS;248    parityShards++249  ) {250    const dataShards = totalChunks - parityShards;251    if (dataShards < 1) continue;252    if (computeParityShards(dataShards) === parityShards) {253      return { dataShards, parityShards };254    }255  }256  throw new CodexError(257    "ERASURE_CODING_VERIFICATION_FAILED",258    `Cannot derive shard layout for totalChunks=${totalChunks}`,259  );260}261262/** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */263export const AZTEC_ECC_PERCENT = 25;264265/** PDF layout: 6 columns × 6 rows = 36 codes per A4 page. */266export const CODES_PER_ROW = 6;267export const CODES_PER_COL = 6;268export const CODES_PER_PAGE = CODES_PER_ROW * CODES_PER_COL;269270/** Stable storage keys for Codex bookkeeping. */271export const STORAGE_KEY_VERIFY_CODE = "peach_codex_verify_code";272export const STORAGE_KEY_LAST_GENERATED = "peach_codex_last_generated";273export const STORAGE_KEY_LAST_RESTORED = "peach_last_restored_at";