generate.ts

12.8 KB · raw

1/**2 * generate.ts — full Codex generation pipeline.3 *4 * Runs entirely in the popup. The service-worker hands the popup a `CodexDeps`5 * snapshot via the GENERATE_CODEX message and the popup is responsible for6 * every step that follows: compression, erasure coding, Aztec rasterization,7 * round-trip verification, and PDF rendering.8 *9 * Each step is wrapped in a verification gate. A failure at any stage aborts10 * generation with a typed `CodexError` so the UI can surface a precise reason11 * rather than a generic "something went wrong." This is the12 * physical-disaster-recovery surface — silent corruption here means the13 * user's only escape hatch is poisoned.14 */1516import { deflate, inflate } from "pako";17import {18  CHUNK_SIZE,19  CODES_PER_PAGE,20  CodexError,21  PARITY_SHARDS,22  type ChunkEnvelope,23  type CodexDeps,24  type CodexManifest,25  type CodexPayload,26  type CodexProgress,27} from "../../shared/codex-types";28import { generateVerifiedAztec } from "./aztec";29import { decodeWithErasure, encodeWithErasure } from "./erasure";30import {31  base64UrlToBytes,32  bytesToBase64Url,33  buildChunkEnvelope,34  sha256Hex,35} from "./helpers";36import { renderCodexPDF } from "./pdf";3738// ── Public surface ──────────────────────────────────────────────────────────3940export async function generateCodex(41  deps: CodexDeps,42  onProgress?: CodexProgress,43): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {44  // STEP 0 — preconditions45  if (!deps.isVaultUnlocked()) {46    throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");47  }4849  // STEP 1 — vault blob50  const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);51  const verifyCode = await deps.getVerifyCode();5253  // STEP 2 — canonical payload (no recovery words — those live on paper only)54  const payload: CodexPayload = {55    version: 2,56    verifyCode,57    vaultBlob,58    timestamp,59    entryCount,60    vaultFingerprint,61    generatedAt: Date.now(),62  };6364  // STEP 3 — compress + round-trip verify65  onProgress?.("compress");66  const payloadJson = JSON.stringify(payload);67  const payloadBytes = new TextEncoder().encode(payloadJson);68  const compressed = deflate(payloadBytes, { level: 9 });69  assertCompressionRoundTrip(compressed, payloadJson);7071  // STEP 4 — erasure encode + simulated-loss verify72  onProgress?.("erasure");73  const { dataChunks, parityChunks, totalChunks, dataShards } = await encodeWithErasure(compressed);74  await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);7576  // STEP 5 — envelopes (data first, then parity, preserving global index ordering)77  const envelopes: ChunkEnvelope[] = [];78  for (let i = 0; i < dataChunks.length; i++) {79    envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));80  }81  for (let i = 0; i < parityChunks.length; i++) {82    envelopes.push(83      await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),84    );85  }8687  // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)88  const aztecPngs: Uint8Array[] = [];89  for (let i = 0; i < envelopes.length; i++) {90    onProgress?.("aztec", i, envelopes.length);91    const img = await generateVerifiedAztec(envelopes[i]!);92    aztecPngs.push(img.pngBytes);93  }94  onProgress?.("aztec", envelopes.length, envelopes.length);9596  // STEP 7 — full reconstruction simulation (lose 2 random envelopes, rebuild)97  onProgress?.("verify");98  await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);99100  // STEP 8 — render PDF101  onProgress?.("pdf");102  const manifest: CodexManifest = {103    verifyCode,104    totalChunks,105    dataChunks: dataShards,106    parityChunks: PARITY_SHARDS,107    pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),108    entryCount,109    vaultFingerprint,110    generatedAt: payload.generatedAt,111  };112  const pdfBytes = await renderCodexPDF(aztecPngs, manifest);113114  // STEP 10 — sanity check the PDF115  if (!pdfBytes || pdfBytes.length < 1000) {116    throw new CodexError(117      "PDF_GENERATION_FAILED",118      `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,119    );120  }121122  return { pdfBytes, manifest };123}124125/**126 * Reconstruct a CodexPayload from a partial set of envelopes (or raw chunk127 * bytes). Exported because the restore-side agent reuses this exact path to128 * rebuild the payload from scanned codes.129 *130 * Accepts either:131 *   - Map<index, ChunkEnvelope>  — typical scan path132 *   - Map<index, Uint8Array>     — raw bytes, used by the in-flight133 *                                  simulation during generation134 */135export async function reconstructPayloadFromChunks(136  envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,137  dataShards: number,138  totalChunks: number,139): Promise<CodexPayload> {140  const chunkBytes = new Map<number, Uint8Array>();141  for (const [index, value] of envelopes) {142    if (value instanceof Uint8Array) {143      chunkBytes.set(index, value);144    } else {145      chunkBytes.set(index, base64UrlToBytes(value.d));146    }147  }148149  const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);150151  // The trailing pad bytes are zeros up to the next chunk boundary. pako's152  // inflate tolerates trailing junk after the deflate stream end marker, so153  // we can hand it the padded buffer directly.154  let inflated: Uint8Array;155  try {156    inflated = inflate(recovered);157  } catch (err) {158    throw new CodexError(159      "RECONSTRUCTION_SIMULATION_FAILED",160      `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,161    );162  }163164  const json = new TextDecoder().decode(inflated);165  let payload: CodexPayload;166  try {167    payload = JSON.parse(json) as CodexPayload;168  } catch (err) {169    throw new CodexError(170      "RECONSTRUCTION_SIMULATION_FAILED",171      `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,172    );173  }174  return payload;175}176177/**178 * Predict how many pages a Codex will occupy without doing the work to179 * generate one. Used by the popup's ESTIMATE message to drive the "this will180 * be N pages" UI hint before the user commits to printing. Speculative181 * compression is fine here — we never persist the result.182 */183export async function estimateCodexPages(184  slotA: string,185  slotB: string | null,186): Promise<number> {187  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });188  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));189  // Stand-in payload — the exact verify code / timestamp / fingerprint don't190  // affect the compressed size enough to matter for a page count estimate.191  const stub: CodexPayload = {192    version: 2,193    verifyCode: "PX-XXXX-XXXX",194    vaultBlob,195    timestamp: 0,196    entryCount: 0,197    vaultFingerprint: "0000000000000000",198    generatedAt: 0,199  };200  const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });201  const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));202  const totalChunks = dataShards + PARITY_SHARDS;203  return Math.ceil(totalChunks / CODES_PER_PAGE);204}205206/** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation207 *  gate. Sorted ascending so they read naturally in the prompt. */208export function pickConfirmationPositions(): [number, number, number] {209  const positions = Array.from({ length: 12 }, (_, i) => i + 1);210  for (let i = positions.length - 1; i > 0; i--) {211    const j = Math.floor(Math.random() * (i + 1));212    [positions[i], positions[j]] = [positions[j]!, positions[i]!];213  }214  const picked = [positions[0]!, positions[1]!, positions[2]!] as const;215  return [...picked].sort((a, b) => a - b) as [number, number, number];216}217218// ── Internal helpers ────────────────────────────────────────────────────────219220async function buildVaultBlob(deps: CodexDeps): Promise<{221  vaultBlob: string;222  timestamp: number;223  entryCount: number;224  vaultFingerprint: string;225}> {226  const slotA = await deps.getSlotA();227  if (!slotA) {228    throw new CodexError("VAULT_EMPTY", "No vault data found");229  }230  const slotB = await deps.getSlotB();231  const lastModified = await deps.getLastModified();232  const entries = await deps.getAllEntries();233234  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });235  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));236237  // AUD-004: 32 hex chars = 128 bits (was 16 chars / 64 bits).238  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);239240  const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();241  const liveEntryCount = entries.filter((e) => !e.deletedAt).length;242243  return {244    vaultBlob,245    timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),246    entryCount: liveEntryCount,247    vaultFingerprint: fingerprint,248  };249}250251function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {252  const inflated = inflate(compressed);253  const restored = new TextDecoder().decode(inflated);254  if (restored !== expectedJson) {255    throw new CodexError(256      "COMPRESSION_ROUNDTRIP_FAILED",257      "Compression verification failed: inflate(deflate(payload)) did not match the original",258    );259  }260}261262/**263 * Confirm the encoded shards can be reconstructed from a partial set. We264 * drop two random shards (the maximum loss our PARITY_SHARDS budget tolerates)265 * and check byte-for-byte that the recovered prefix equals the original266 * compressed blob.267 */268async function verifyErasureCoding(269  dataChunks: Uint8Array[],270  parityChunks: Uint8Array[],271  dataShards: number,272  totalChunks: number,273  originalCompressed: Uint8Array,274): Promise<void> {275  const present = new Map<number, Uint8Array>();276  dataChunks.forEach((chunk, i) => present.set(i, chunk));277  parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));278279  const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));280  for (const idx of removed) present.delete(idx);281282  let recovered: Uint8Array;283  try {284    recovered = await decodeWithErasure(present, dataShards, totalChunks);285  } catch (err) {286    if (err instanceof CodexError) {287      throw new CodexError(288        "ERASURE_CODING_VERIFICATION_FAILED",289        `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,290      );291    }292    throw err;293  }294295  for (let i = 0; i < originalCompressed.length; i++) {296    if (recovered[i] !== originalCompressed[i]) {297      throw new CodexError(298        "ERASURE_CODING_CONTENT_MISMATCH",299        `Reconstructed data does not match original at byte ${i}`,300      );301    }302  }303}304305async function simulateAndVerifyReconstruction(306  envelopes: ChunkEnvelope[],307  dataShards: number,308  totalChunks: number,309  originalPayload: CodexPayload,310): Promise<void> {311  const present = new Map<number, Uint8Array>();312  envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));313314  const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));315  for (const idx of removed) present.delete(idx);316317  let reconstructed: CodexPayload;318  try {319    reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);320  } catch (err) {321    throw new CodexError(322      "RECONSTRUCTION_SIMULATION_FAILED",323      `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,324    );325  }326327  if (reconstructed.verifyCode !== originalPayload.verifyCode) {328    throw new CodexError(329      "RECONSTRUCTION_VERIFY_CODE_MISMATCH",330      "Reconstructed Codex verify code does not match original",331    );332  }333  if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {334    throw new CodexError(335      "RECONSTRUCTION_FINGERPRINT_MISMATCH",336      "Reconstructed vault fingerprint does not match original",337    );338  }339  if (reconstructed.entryCount !== originalPayload.entryCount) {340    throw new CodexError(341      "RECONSTRUCTION_COUNT_MISMATCH",342      `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,343    );344  }345}346347/**348 * Pick `count` distinct indices uniformly at random from [0, total). Used by349 * the verification simulations to model "any two chunks could be lost." Not350 * security-sensitive — we just need diverse coverage across regenerations.351 */352function pickRandomIndices(total: number, count: number): number[] {353  const indices = Array.from({ length: total }, (_, i) => i);354  for (let i = indices.length - 1; i > 0; i--) {355    const j = Math.floor(Math.random() * (i + 1));356    const tmp = indices[i]!;357    indices[i] = indices[j]!;358    indices[j] = tmp;359  }360  return indices.slice(0, count);361}