/** * helpers.ts — shared utilities for Codex generation and restoration. * * These primitives are deliberately small and independent so they can be * reused from both `generate.ts` and the (sibling-agent-owned) `restore.ts` * without any cross-import surface. * * SHA-256 stands in for BLAKE3 from the design doc. SHA-256 is available * everywhere via WebCrypto (no WASM, no polyfill), preimage-resistant, and * gives a stable 8-char-prefix fingerprint that is more than sufficient for * catching per-chunk corruption. The on-paper format (`codex-types.ts`) * already documents SHA-256 as the canonical hash. */ import { CodexError, STORAGE_KEY_VERIFY_CODE, type ChunkEnvelope, } from "../../shared/codex-types"; // ── base64url ──────────────────────────────────────────────────────────────── /** * Encode bytes to base64url (RFC 4648 §5): URL-safe alphabet, no padding. * Padding-free output keeps the ChunkEnvelope JSON as small as possible — * every byte saved shrinks the printed page count. */ export function bytesToBase64Url(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]!); } const b64 = (typeof btoa !== "undefined" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64")); return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } /** Decode a base64url string back into raw bytes. Tolerant of missing padding. */ export function base64UrlToBytes(s: string): Uint8Array { const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); // base64 requires length % 4 === 0; we strip padding on encode so add it back here. const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); const binary = typeof atob !== "undefined" ? atob(padded) : Buffer.from(padded, "base64").toString("binary"); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); return out; } /** * Total variant of base64UrlToBytes: returns null instead of throwing on * invalid input (atob throws InvalidCharacterError on out-of-alphabet chars). * Used by ChunkBuffer.add, whose documented contract is "never throws" * (PCH-01-026 #2). */ export function tryBase64UrlToBytes(s: string): Uint8Array | null { try { return base64UrlToBytes(s); } catch { return null; } } // ── SHA-256 ───────────────────────────────────────────────────────────────── /** * Return uppercase hex of SHA-256(bytes). Used for both the per-chunk integrity * hash (32-char prefix, 128 bits) and the vault fingerprint (32-char prefix, * 128 bits). */ export async function sha256Hex(bytes: Uint8Array): Promise { // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as // a SharedArrayBuffer-backed view — some platforms reject that, and the TS // BufferSource typing under `lib.dom` insists on ArrayBuffer-backed views. const owned = new Uint8Array(bytes.byteLength); owned.set(bytes); const hash = await crypto.subtle.digest("SHA-256", owned); const view = new Uint8Array(hash); let hex = ""; for (let i = 0; i < view.length; i++) { hex += view[i]!.toString(16).padStart(2, "0"); } return hex.toUpperCase(); } // ── Chunk envelopes ───────────────────────────────────────────────────────── /** * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h` * field is the first 32 hex chars (128 bits) of SHA-256(rawChunkBytes), so * per-chunk corruption introduced after generation (smudge, scan glitch) is * detectable at scan time. * * `index` / `total` are within-stripe; `stripe` / `stripeCount` describe the * stripe layout for v3. Generators always emit v3 envelopes — even when there * is only one stripe — so the format on paper is unambiguous. */ export async function buildChunkEnvelope( chunkBytes: Uint8Array, index: number, total: number, isParity: boolean, verifyCode: string, stripe: number = 0, stripeCount: number = 1, ): Promise { const hash = (await sha256Hex(chunkBytes)).slice(0, 32); return { v: 3, c: verifyCode, s: stripe, S: stripeCount, i: index, t: total, p: isParity, d: bytesToBase64Url(chunkBytes), h: hash, }; } /** * Confirm the envelope's recorded hash still matches its data. Returns false * on any mismatch — caller decides whether to reconstruct from parity or * surface a CodexError. */ export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise { if (envelope.h.length !== 32) return false; const bytes = base64UrlToBytes(envelope.d); const fullHash = await sha256Hex(bytes); const expected = fullHash.slice(0, envelope.h.length); return expected === envelope.h; } // ── Verify code lifecycle ─────────────────────────────────────────────────── /** * Unambiguous alphabet: drops `0/O`, `1/I/L`, and `S`. Keeps every character * legible at 6pt print or scrawled on paper next to a Codex. Same alphabet * used by hardware wallet seed-export QR codes. */ const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /** PX-XXXX-XXXX — 12 chars including the literal `PX-` prefix and the dash. */ const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/; /** * Read the persisted verify code, or generate-and-persist a fresh one on first * call. The code is a stable per-vault identifier — every Codex printed from * this vault carries the same code so a user can prove "this Codex matches * this Peach install" without revealing any secret. * * Mirrors the storage pattern from `src/shared/storage.ts` so the same call * sites work in both popup and service-worker contexts. */ export async function getOrCreateCodexVerifyCode(): Promise { const existing = await readStoredVerifyCode(); if (existing && VERIFY_CODE_PATTERN.test(existing)) return existing; const fresh = generateVerifyCode(); await writeStoredVerifyCode(fresh); return fresh; } function generateVerifyCode(): string { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); const chars: string[] = []; for (let i = 0; i < 8; i++) { // Modulo bias is negligible for a 32-char alphabet over a 256-value byte // and the verify code is identification, not key material. chars.push(VERIFY_CODE_ALPHABET[bytes[i]! % VERIFY_CODE_ALPHABET.length]!); } return `PX-${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}`; } async function readStoredVerifyCode(): Promise { if (typeof chrome === "undefined" || !chrome.storage?.local) return null; const result = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE); const value = (result as Record)[STORAGE_KEY_VERIFY_CODE]; return typeof value === "string" ? value : null; } async function writeStoredVerifyCode(code: string): Promise { if (typeof chrome === "undefined" || !chrome.storage?.local) { throw new CodexError( "PDF_GENERATION_FAILED", "chrome.storage.local is unavailable; cannot persist Codex verify code", ); } await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code }); } /** Removes the stored verify code so the next call to getOrCreateCodexVerifyCode generates a fresh one. */ export async function clearVerifyCode(): Promise { if (typeof chrome === "undefined" || !chrome.storage?.local) return; await chrome.storage.local.remove(STORAGE_KEY_VERIFY_CODE); }