helpers.ts

7.8 KB · raw

1/**2 * helpers.ts — shared utilities for Codex generation and restoration.3 *4 * These primitives are deliberately small and independent so they can be5 * reused from both `generate.ts` and the (sibling-agent-owned) `restore.ts`6 * without any cross-import surface.7 *8 * SHA-256 stands in for BLAKE3 from the design doc. SHA-256 is available9 * everywhere via WebCrypto (no WASM, no polyfill), preimage-resistant, and10 * gives a stable 8-char-prefix fingerprint that is more than sufficient for11 * catching per-chunk corruption. The on-paper format (`codex-types.ts`)12 * already documents SHA-256 as the canonical hash.13 */1415import {16  CodexError,17  STORAGE_KEY_VERIFY_CODE,18  type ChunkEnvelope,19} from "../../shared/codex-types";2021// ── base64url ────────────────────────────────────────────────────────────────2223/**24 * Encode bytes to base64url (RFC 4648 §5): URL-safe alphabet, no padding.25 * Padding-free output keeps the ChunkEnvelope JSON as small as possible —26 * every byte saved shrinks the printed page count.27 */28export function bytesToBase64Url(bytes: Uint8Array): string {29  let binary = "";30  for (let i = 0; i < bytes.length; i++) {31    binary += String.fromCharCode(bytes[i]!);32  }33  const b64 = (typeof btoa !== "undefined" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64"));34  return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");35}3637/** Decode a base64url string back into raw bytes. Tolerant of missing padding. */38export function base64UrlToBytes(s: string): Uint8Array {39  const b64 = s.replace(/-/g, "+").replace(/_/g, "/");40  // base64 requires length % 4 === 0; we strip padding on encode so add it back here.41  const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);42  const binary =43    typeof atob !== "undefined"44      ? atob(padded)45      : Buffer.from(padded, "base64").toString("binary");46  const out = new Uint8Array(binary.length);47  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);48  return out;49}5051/**52 * Total variant of base64UrlToBytes: returns null instead of throwing on53 * invalid input (atob throws InvalidCharacterError on out-of-alphabet chars).54 * Used by ChunkBuffer.add, whose documented contract is "never throws"55 * (PCH-01-026 #2).56 */57export function tryBase64UrlToBytes(s: string): Uint8Array | null {58  try {59    return base64UrlToBytes(s);60  } catch {61    return null;62  }63}6465// ── SHA-256 ─────────────────────────────────────────────────────────────────6667/**68 * Return uppercase hex of SHA-256(bytes). Used for both the per-chunk integrity69 * hash (32-char prefix, 128 bits) and the vault fingerprint (32-char prefix,70 * 128 bits).71 */72export async function sha256Hex(bytes: Uint8Array): Promise<string> {73  // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as74  // a SharedArrayBuffer-backed view — some platforms reject that, and the TS75  // BufferSource typing under `lib.dom` insists on ArrayBuffer-backed views.76  const owned = new Uint8Array(bytes.byteLength);77  owned.set(bytes);78  const hash = await crypto.subtle.digest("SHA-256", owned);79  const view = new Uint8Array(hash);80  let hex = "";81  for (let i = 0; i < view.length; i++) {82    hex += view[i]!.toString(16).padStart(2, "0");83  }84  return hex.toUpperCase();85}8687// ── Chunk envelopes ─────────────────────────────────────────────────────────8889/**90 * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h`91 * field is the first 32 hex chars (128 bits) of SHA-256(rawChunkBytes), so92 * per-chunk corruption introduced after generation (smudge, scan glitch) is93 * detectable at scan time.94 *95 * `index` / `total` are within-stripe; `stripe` / `stripeCount` describe the96 * stripe layout for v3. Generators always emit v3 envelopes — even when there97 * is only one stripe — so the format on paper is unambiguous.98 */99export async function buildChunkEnvelope(100  chunkBytes: Uint8Array,101  index: number,102  total: number,103  isParity: boolean,104  verifyCode: string,105  stripe: number = 0,106  stripeCount: number = 1,107): Promise<ChunkEnvelope> {108  const hash = (await sha256Hex(chunkBytes)).slice(0, 32);109  return {110    v: 3,111    c: verifyCode,112    s: stripe,113    S: stripeCount,114    i: index,115    t: total,116    p: isParity,117    d: bytesToBase64Url(chunkBytes),118    h: hash,119  };120}121122/**123 * Confirm the envelope's recorded hash still matches its data. Returns false124 * on any mismatch — caller decides whether to reconstruct from parity or125 * surface a CodexError.126 */127export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {128  if (envelope.h.length !== 32) return false;129  const bytes = base64UrlToBytes(envelope.d);130  const fullHash = await sha256Hex(bytes);131  const expected = fullHash.slice(0, envelope.h.length);132  return expected === envelope.h;133}134135// ── Verify code lifecycle ───────────────────────────────────────────────────136137/**138 * Unambiguous alphabet: drops `0/O`, `1/I/L`, and `S`. Keeps every character139 * legible at 6pt print or scrawled on paper next to a Codex. Same alphabet140 * used by hardware wallet seed-export QR codes.141 */142const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";143144/** PX-XXXX-XXXX — 12 chars including the literal `PX-` prefix and the dash. */145const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;146147/**148 * Read the persisted verify code, or generate-and-persist a fresh one on first149 * call. The code is a stable per-vault identifier — every Codex printed from150 * this vault carries the same code so a user can prove "this Codex matches151 * this Peach install" without revealing any secret.152 *153 * Mirrors the storage pattern from `src/shared/storage.ts` so the same call154 * sites work in both popup and service-worker contexts.155 */156export async function getOrCreateCodexVerifyCode(): Promise<string> {157  const existing = await readStoredVerifyCode();158  if (existing && VERIFY_CODE_PATTERN.test(existing)) return existing;159160  const fresh = generateVerifyCode();161  await writeStoredVerifyCode(fresh);162  return fresh;163}164165function generateVerifyCode(): string {166  const bytes = new Uint8Array(8);167  crypto.getRandomValues(bytes);168  const chars: string[] = [];169  for (let i = 0; i < 8; i++) {170    // Modulo bias is negligible for a 32-char alphabet over a 256-value byte171    // and the verify code is identification, not key material.172    chars.push(VERIFY_CODE_ALPHABET[bytes[i]! % VERIFY_CODE_ALPHABET.length]!);173  }174  return `PX-${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}`;175}176177async function readStoredVerifyCode(): Promise<string | null> {178  if (typeof chrome === "undefined" || !chrome.storage?.local) return null;179  const result = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);180  const value = (result as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];181  return typeof value === "string" ? value : null;182}183184async function writeStoredVerifyCode(code: string): Promise<void> {185  if (typeof chrome === "undefined" || !chrome.storage?.local) {186    throw new CodexError(187      "PDF_GENERATION_FAILED",188      "chrome.storage.local is unavailable; cannot persist Codex verify code",189    );190  }191  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code });192}193194/** Removes the stored verify code so the next call to getOrCreateCodexVerifyCode generates a fresh one. */195export async function clearVerifyCode(): Promise<void> {196  if (typeof chrome === "undefined" || !chrome.storage?.local) return;197  await chrome.storage.local.remove(STORAGE_KEY_VERIFY_CODE);198}