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// ── 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 *81 * `index` / `total` are within-stripe; `stripe` / `stripeCount` describe the82 * stripe layout for v3. Generators always emit v3 envelopes — even when there83 * is only one stripe — so the format on paper is unambiguous.84 */85export async function buildChunkEnvelope(86  chunkBytes: Uint8Array,87  index: number,88  total: number,89  isParity: boolean,90  verifyCode: string,91  stripe: number = 0,92  stripeCount: number = 1,93): Promise<ChunkEnvelope> {94  const hash = (await sha256Hex(chunkBytes)).slice(0, 32);95  return {96    v: 3,97    c: verifyCode,98    s: stripe,99    S: stripeCount,100    i: index,101    t: total,102    p: isParity,103    d: bytesToBase64Url(chunkBytes),104    h: hash,105  };106}107108/**109 * Confirm the envelope's recorded hash still matches its data. Returns false110 * on any mismatch — caller decides whether to reconstruct from parity or111 * surface a CodexError.112 */113export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {114  // AUD-004: accept only the documented prefix lengths — 16 (legacy v1) and115  // 32 (current v2). An empty or truncated hash must not verify true via a116  // zero-length prefix match, and an oversize hash cannot be a valid SHA-256117  // prefix at all.118  if (envelope.h.length !== 16 && envelope.h.length !== 32) return false;119  const bytes = base64UrlToBytes(envelope.d);120  const fullHash = await sha256Hex(bytes);121  const expected = fullHash.slice(0, envelope.h.length);122  return expected === envelope.h;123}124125// ── Verify code lifecycle ───────────────────────────────────────────────────126127/**128 * Unambiguous alphabet: drops `0/O`, `1/I/L`, and `S`. Keeps every character129 * legible at 6pt print or scrawled on paper next to a Codex. Same alphabet130 * used by hardware wallet seed-export QR codes.131 */132const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";133134/** PX-XXXX-XXXX — 12 chars including the literal `PX-` prefix and the dash. */135const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;136137/**138 * Read the persisted verify code, or generate-and-persist a fresh one on first139 * call. The code is a stable per-vault identifier — every Codex printed from140 * this vault carries the same code so a user can prove "this Codex matches141 * this Peach install" without revealing any secret.142 *143 * Mirrors the storage pattern from `src/shared/storage.ts` so the same call144 * sites work in both popup and service-worker contexts.145 */146export async function getOrCreateCodexVerifyCode(): Promise<string> {147  const existing = await readStoredVerifyCode();148  if (existing && VERIFY_CODE_PATTERN.test(existing)) return existing;149150  const fresh = generateVerifyCode();151  await writeStoredVerifyCode(fresh);152  return fresh;153}154155function generateVerifyCode(): string {156  const bytes = new Uint8Array(8);157  crypto.getRandomValues(bytes);158  const chars: string[] = [];159  for (let i = 0; i < 8; i++) {160    // Modulo bias is negligible for a 32-char alphabet over a 256-value byte161    // and the verify code is identification, not key material.162    chars.push(VERIFY_CODE_ALPHABET[bytes[i]! % VERIFY_CODE_ALPHABET.length]!);163  }164  return `PX-${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}`;165}166167async function readStoredVerifyCode(): Promise<string | null> {168  if (typeof chrome === "undefined" || !chrome.storage?.local) return null;169  const result = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);170  const value = (result as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];171  return typeof value === "string" ? value : null;172}173174async function writeStoredVerifyCode(code: string): Promise<void> {175  if (typeof chrome === "undefined" || !chrome.storage?.local) {176    throw new CodexError(177      "PDF_GENERATION_FAILED",178      "chrome.storage.local is unavailable; cannot persist Codex verify code",179    );180  }181  await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code });182}183184/** Removes the stored verify code so the next call to getOrCreateCodexVerifyCode generates a fresh one. */185export async function clearVerifyCode(): Promise<void> {186  if (typeof chrome === "undefined" || !chrome.storage?.local) return;187  await chrome.storage.local.remove(STORAGE_KEY_VERIFY_CODE);188}