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