helpers.ts

7.0 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// ── SHA-256 ─────────────────────────────────────────────────────────────────5253/**54 * Return uppercase hex of SHA-256(bytes). Used for both the per-chunk integrity55 * hash (8-char prefix) and the vault fingerprint (16-char prefix).56 */57export async function sha256Hex(bytes: Uint8Array): Promise<string> {58  // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as59  // a SharedArrayBuffer-backed view — some platforms reject that, and the TS60  // BufferSource typing under `lib.dom` insists on ArrayBuffer-backed views.61  const owned = new Uint8Array(bytes.byteLength);62  owned.set(bytes);63  const hash = await crypto.subtle.digest("SHA-256", owned);64  const view = new Uint8Array(hash);65  let hex = "";66  for (let i = 0; i < view.length; i++) {67    hex += view[i]!.toString(16).padStart(2, "0");68  }69  return hex.toUpperCase();70}7172// ── Chunk envelopes ─────────────────────────────────────────────────────────7374/**75 * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h`76 * field is the first 8 hex chars of SHA-256(rawChunkBytes), so per-chunk77 * corruption introduced after generation (smudge, scan glitch) is detectable78 * at scan time.79 */80export async function buildChunkEnvelope(81  chunkBytes: Uint8Array,82  index: number,83  total: number,84  isParity: boolean,85  verifyCode: string,86): Promise<ChunkEnvelope> {87  const hash = (await sha256Hex(chunkBytes)).slice(0, 8);88  return {89    v: 2,90    c: verifyCode,91    i: index,92    t: total,93    p: isParity,94    d: bytesToBase64Url(chunkBytes),95    h: hash,96  };97}9899/**100 * Confirm the envelope's recorded hash still matches its data. Returns false101 * on any mismatch — caller decides whether to reconstruct from parity or102 * surface a CodexError.103 */104export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {105  const bytes = base64UrlToBytes(envelope.d);106  const expected = (await sha256Hex(bytes)).slice(0, 8);107  return expected === envelope.h;108}109110// ── Verify code lifecycle ───────────────────────────────────────────────────111112/**113 * Unambiguous alphabet: drops `0/O`, `1/I/L`, and `S`. Keeps every character114 * legible at 6pt print or scrawled on paper next to a Codex. Same alphabet115 * used by hardware wallet seed-export QR codes.116 */117const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";118119/** PX-XXXX-XXXX — 12 chars including the literal `PX-` prefix and the dash. */120const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;121122/**123 * Read the persisted verify code, or generate-and-persist a fresh one on first124 * call. The code is a stable per-vault identifier — every Codex printed from125 * this vault carries the same code so a user can prove "this Codex matches126 * this Peach install" without revealing any secret.127 *128 * Mirrors the storage pattern from `src/shared/storage.ts` so the same call129 * sites work in both popup and service-worker contexts.130 */131export async function getOrCreateCodexVerifyCode(): Promise<string> {132  const existing = await readStoredVerifyCode();133  if (existing && VERIFY_CODE_PATTERN.test(existing)) return existing;134135  const fresh = generateVerifyCode();136  await writeStoredVerifyCode(fresh);137  return fresh;138}139140function generateVerifyCode(): string {141  const bytes = new Uint8Array(8);142  crypto.getRandomValues(bytes);143  const chars: string[] = [];144  for (let i = 0; i < 8; i++) {145    // Modulo bias is negligible for a 32-char alphabet over a 256-value byte146    // and the verify code is identification, not key material.147    chars.push(VERIFY_CODE_ALPHABET[bytes[i]! % VERIFY_CODE_ALPHABET.length]!);148  }149  return `PX-${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}`;150}151152async function readStoredVerifyCode(): Promise<string | null> {153  if (typeof chrome === "undefined" || !chrome.storage?.local) return null;154  const result = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);155  const value = (result as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];156  return typeof value === "string" ? value : null;157}158159async function writeStoredVerifyCode(code: string): Promise<void> {160  if (typeof chrome === "undefined" || !chrome.storage?.local) {161    throw new CodexError(162      "PDF_GENERATION_FAILED",163      "chrome.storage.local is unavailable; cannot persist Codex verify code",164    );165  }166  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code });167}168169/** Removes the stored verify code so the next call to getOrCreateCodexVerifyCode generates a fresh one. */170export async function clearVerifyCode(): Promise<void> {171  if (typeof chrome === "undefined" || !chrome.storage?.local) return;172  await chrome.storage.local.remove(STORAGE_KEY_VERIFY_CODE);173}