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 */47export interface ChunkEnvelope {48 /** Envelope format version. Increment on breaking changes. */49 v: 2;50 /** First 12 chars of `verifyCode` ("PX-XXXX-XXXX"). Lets the scanner reject foreign Codexes. */51 c: string;52 /** Chunk index, 0-based. */53 i: number;54 /** Total chunk count (data + parity). */55 t: number;56 /** True if this is a Reed-Solomon parity shard. */57 p: boolean;58 /** Chunk data, base64url-encoded. */59 d: string;60 /** 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. */61 h: string;62}6364/** Bookkeeping returned alongside the generated PDF. */65export interface CodexManifest {66 verifyCode: string;67 totalChunks: number;68 dataChunks: number;69 parityChunks: number;70 pageCount: number;71 entryCount: number;72 vaultFingerprint: string;73 generatedAt: number;74}7576/** Dependencies the generator needs from the service worker. */77export interface CodexDeps {78 getSlotA: () => Promise<string | null>;79 getSlotB: () => Promise<string | null>;80 getLastModified: () => Promise<string | null>;81 getAllEntries: () => Promise<VaultEntry[]>;82 getVerifyCode: () => Promise<string>;83 isVaultUnlocked: () => boolean;84}8586/**87 * Progress reporter for the generator. Called at named milestones; for the88 * Aztec step `current`/`total` advance per code so the UI can render a bar.89 */90export type CodexProgress = (91 step: "compress" | "erasure" | "aztec" | "verify" | "pdf",92 current?: number,93 total?: number,94) => void;9596/** Payload sent from the popup to the SW to perform the actual vault restoration. */97export interface CodexRestorePayload {98 vaultBlob: string;99 timestamp: number;100 verifyCode: string;101 vaultFingerprint: string;102 entryCount: number;103 masterPassphrase: string;104}105106/** Vault data the SW exposes to the popup so it can build a CodexPayload. */107export interface CodexVaultSnapshot {108 slotA: string | null;109 slotB: string | null;110 lastModified: string | null;111 entryCount: number;112 recoveryWords: string[] | null;113 verifyCode: string;114 vaultUnlocked: boolean;115}116117/**118 * Every Codex error is loud and named. The `code` field is a stable identifier119 * for log analysis; the human-readable message lives in `.message`.120 */121export class CodexError extends Error {122 constructor(123 public code: CodexErrorCode,124 message: string,125 ) {126 super(message);127 this.name = "CodexError";128 }129}130131export type CodexErrorCode =132 | "VAULT_LOCKED"133 | "VAULT_EMPTY"134 | "COMPRESSION_ROUNDTRIP_FAILED"135 | "ERASURE_CODING_VERIFICATION_FAILED"136 | "ERASURE_CODING_CONTENT_MISMATCH"137 | "AZTEC_GENERATION_FAILED"138 | "AZTEC_ROUNDTRIP_FAILED"139 | "AZTEC_CONTENT_MISMATCH"140 | "INSUFFICIENT_CHUNKS"141 | "RECONSTRUCTION_SIMULATION_FAILED"142 | "RECONSTRUCTION_VERIFY_CODE_MISMATCH"143 | "RECONSTRUCTION_FINGERPRINT_MISMATCH"144 | "RECONSTRUCTION_COUNT_MISMATCH"145 | "PDF_GENERATION_FAILED"146 | "RS_LIBRARY_FAILED"147 | "PRO_REQUIRED"148 | "WRONG_PASSPHRASE"149 | "INVALID_VAULT_BLOB"150 | "RESTORE_FAILED";151152// ── Constants — shared across generation and restoration ─────────────────────153154/**155 * Bytes of binary data per chunk. Each chunk becomes one Aztec code.156 *157 * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata.158 * base64url expands by 4/3: 800 bytes → 1068 chars → ~1168-char envelope.159 * The empirical bwip-js/ZXing ceiling at eclevel=25 is ~1386 chars; staying160 * ~200 chars under gives reliable headroom across cheap printers, camera161 * angles, and ZXing decode variance. The original 1024-byte value produced162 * ~1468-char envelopes — over the ceiling, causing AZTEC_ROUNDTRIP_FAILED.163 *164 * Old Codexes printed with a different chunk size still restore — the165 * erasure decoder derives shard size from the scanned chunks, not from166 * this constant.167 */168export const CHUNK_SIZE = 800;169170/** Floor on parity shards: a Codex must always survive losing at least 2 codes. */171export const CODEX_MIN_PARITY_SHARDS = 2;172173/** Ceiling on parity shards: caps printing overhead for very large vaults. */174export const CODEX_MAX_PARITY_SHARDS = 20;175176/** Target ratio of parity to data shards (5%). */177export const CODEX_PARITY_RATIO = 0.05;178179/**180 * Number of parity shards for a Codex with `dataShards` data shards. Scales181 * with vault size — small vaults pin to the minimum, huge vaults cap at the182 * maximum. Survives the loss of up to the returned count of codes anywhere183 * in the printed document. The function is monotonic non-decreasing in184 * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity).185 *186 * Examples:187 * 13 → 2 (minimum floor)188 * 56 → 3 (5% bracket)189 * 213 → 11190 * 660 → 20 (maximum cap)191 */192export function computeParityShards(dataShards: number): number {193 return Math.min(194 CODEX_MAX_PARITY_SHARDS,195 Math.max(196 CODEX_MIN_PARITY_SHARDS,197 Math.ceil(dataShards * CODEX_PARITY_RATIO),198 ),199 );200}201202/**203 * Recover (dataShards, parityShards) from a total chunk count printed on a204 * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there205 * is no manifest to consult — so we invert `computeParityShards` by trying206 * each candidate parity count in [MIN, MAX] and picking the one that round207 * trips. Monotonicity of `computeParityShards` guarantees a unique solution.208 */209export function deriveShardCounts(totalChunks: number): {210 dataShards: number;211 parityShards: number;212} {213 for (214 let parityShards = CODEX_MIN_PARITY_SHARDS;215 parityShards <= CODEX_MAX_PARITY_SHARDS;216 parityShards++217 ) {218 const dataShards = totalChunks - parityShards;219 if (dataShards < 1) continue;220 if (computeParityShards(dataShards) === parityShards) {221 return { dataShards, parityShards };222 }223 }224 throw new CodexError(225 "ERASURE_CODING_VERIFICATION_FAILED",226 `Cannot derive shard layout for totalChunks=${totalChunks}`,227 );228}229230/** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */231export const AZTEC_ECC_PERCENT = 25;232233/** PDF layout: 6 columns × 6 rows = 36 codes per A4 page. */234export const CODES_PER_ROW = 6;235export const CODES_PER_COL = 6;236export const CODES_PER_PAGE = CODES_PER_ROW * CODES_PER_COL;237238/** Stable storage keys for Codex bookkeeping. */239export const STORAGE_KEY_VERIFY_CODE = "peach_codex_verify_code";240export const STORAGE_KEY_LAST_GENERATED = "peach_codex_last_generated";241export const STORAGE_KEY_LAST_RESTORED = "peach_last_restored_at";