helpers.ts

7.5 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 (32-char prefix, 128 bits) and the vault fingerprint (32-char prefix,56 * 128 bits). AUD-004: prefixes were extended from 64 to 128 bits.57 */58export async function sha256Hex(bytes: Uint8Array): Promise<string> {59  // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as60  // a SharedArrayBuffer-backed view — some platforms reject that, and the TS61  // BufferSource typing under `lib.dom` insists on ArrayBuffer-backed views.62  const owned = new Uint8Array(bytes.byteLength);63  owned.set(bytes);64  const hash = await crypto.subtle.digest("SHA-256", owned);65  const view = new Uint8Array(hash);66  let hex = "";67  for (let i = 0; i < view.length; i++) {68    hex += view[i]!.toString(16).padStart(2, "0");69  }70  return hex.toUpperCase();71}7273// ── Chunk envelopes ─────────────────────────────────────────────────────────7475/**76 * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h`77 * field is the first 32 hex chars (128 bits) of SHA-256(rawChunkBytes), so78 * per-chunk corruption introduced after generation (smudge, scan glitch) is79 * detectable at scan time. AUD-004 raised the prefix from 64 to 128 bits.80 */81export async function buildChunkEnvelope(82  chunkBytes: Uint8Array,83  index: number,84  total: number,85  isParity: boolean,86  verifyCode: string,87): Promise<ChunkEnvelope> {88  const hash = (await sha256Hex(chunkBytes)).slice(0, 32);89  return {90    v: 2,91    c: verifyCode,92    i: index,93    t: total,94    p: isParity,95    d: bytesToBase64Url(chunkBytes),96    h: hash,97  };98}99100/**101 * Confirm the envelope's recorded hash still matches its data. Returns false102 * on any mismatch — caller decides whether to reconstruct from parity or103 * surface a CodexError.104 */105export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {106  // AUD-004: accept only the documented prefix lengths — 16 (legacy v1) and107  // 32 (current v2). An empty or truncated hash must not verify true via a108  // zero-length prefix match, and an oversize hash cannot be a valid SHA-256109  // prefix at all.110  if (envelope.h.length !== 16 && envelope.h.length !== 32) return false;111  const bytes = base64UrlToBytes(envelope.d);112  const fullHash = await sha256Hex(bytes);113  const expected = fullHash.slice(0, envelope.h.length);114  return expected === envelope.h;115}116117// ── Verify code lifecycle ───────────────────────────────────────────────────118119/**120 * Unambiguous alphabet: drops `0/O`, `1/I/L`, and `S`. Keeps every character121 * legible at 6pt print or scrawled on paper next to a Codex. Same alphabet122 * used by hardware wallet seed-export QR codes.123 */124const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";125126/** PX-XXXX-XXXX — 12 chars including the literal `PX-` prefix and the dash. */127const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;128129/**130 * Read the persisted verify code, or generate-and-persist a fresh one on first131 * call. The code is a stable per-vault identifier — every Codex printed from132 * this vault carries the same code so a user can prove "this Codex matches133 * this Peach install" without revealing any secret.134 *135 * Mirrors the storage pattern from `src/shared/storage.ts` so the same call136 * sites work in both popup and service-worker contexts.137 */138export async function getOrCreateCodexVerifyCode(): Promise<string> {139  const existing = await readStoredVerifyCode();140  if (existing && VERIFY_CODE_PATTERN.test(existing)) return existing;141142  const fresh = generateVerifyCode();143  await writeStoredVerifyCode(fresh);144  return fresh;145}146147function generateVerifyCode(): string {148  const bytes = new Uint8Array(8);149  crypto.getRandomValues(bytes);150  const chars: string[] = [];151  for (let i = 0; i < 8; i++) {152    // Modulo bias is negligible for a 32-char alphabet over a 256-value byte153    // and the verify code is identification, not key material.154    chars.push(VERIFY_CODE_ALPHABET[bytes[i]! % VERIFY_CODE_ALPHABET.length]!);155  }156  return `PX-${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}`;157}158159async function readStoredVerifyCode(): Promise<string | null> {160  if (typeof chrome === "undefined" || !chrome.storage?.local) return null;161  const result = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);162  const value = (result as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];163  return typeof value === "string" ? value : null;164}165166async function writeStoredVerifyCode(code: string): Promise<void> {167  if (typeof chrome === "undefined" || !chrome.storage?.local) {168    throw new CodexError(169      "PDF_GENERATION_FAILED",170      "chrome.storage.local is unavailable; cannot persist Codex verify code",171    );172  }173  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code });174}175176/** Removes the stored verify code so the next call to getOrCreateCodexVerifyCode generates a fresh one. */177export async function clearVerifyCode(): Promise<void> {178  if (typeof chrome === "undefined" || !chrome.storage?.local) return;179  await chrome.storage.local.remove(STORAGE_KEY_VERIFY_CODE);180}