generate.ts

13.1 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  computeParityShards,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, parityShards } =74    await encodeWithErasure(compressed);75  await verifyErasureCoding(76    dataChunks,77    parityChunks,78    dataShards,79    parityShards,80    totalChunks,81    compressed,82  );8384  // STEP 5 — envelopes (data first, then parity, preserving global index ordering)85  const envelopes: ChunkEnvelope[] = [];86  for (let i = 0; i < dataChunks.length; i++) {87    envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));88  }89  for (let i = 0; i < parityChunks.length; i++) {90    envelopes.push(91      await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),92    );93  }9495  // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)96  const aztecPngs: Uint8Array[] = [];97  for (let i = 0; i < envelopes.length; i++) {98    onProgress?.("aztec", i, envelopes.length);99    const img = await generateVerifiedAztec(envelopes[i]!);100    aztecPngs.push(img.pngBytes);101  }102  onProgress?.("aztec", envelopes.length, envelopes.length);103104  // STEP 7 — full reconstruction simulation (lose the maximum tolerated105  // number of envelopes for this Codex, then rebuild)106  onProgress?.("verify");107  await simulateAndVerifyReconstruction(108    envelopes,109    dataShards,110    parityShards,111    totalChunks,112    payload,113  );114115  // STEP 8 — render PDF116  onProgress?.("pdf");117  const manifest: CodexManifest = {118    verifyCode,119    totalChunks,120    dataChunks: dataShards,121    parityChunks: parityShards,122    pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),123    entryCount,124    vaultFingerprint,125    generatedAt: payload.generatedAt,126  };127  const pdfBytes = await renderCodexPDF(aztecPngs, manifest);128129  // STEP 10 — sanity check the PDF130  if (!pdfBytes || pdfBytes.length < 1000) {131    throw new CodexError(132      "PDF_GENERATION_FAILED",133      `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,134    );135  }136137  return { pdfBytes, manifest };138}139140/**141 * Reconstruct a CodexPayload from a partial set of envelopes (or raw chunk142 * bytes). Exported because the restore-side agent reuses this exact path to143 * rebuild the payload from scanned codes.144 *145 * Accepts either:146 *   - Map<index, ChunkEnvelope>  — typical scan path147 *   - Map<index, Uint8Array>     — raw bytes, used by the in-flight148 *                                  simulation during generation149 */150export async function reconstructPayloadFromChunks(151  envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,152  dataShards: number,153  totalChunks: number,154): Promise<CodexPayload> {155  const chunkBytes = new Map<number, Uint8Array>();156  for (const [index, value] of envelopes) {157    if (value instanceof Uint8Array) {158      chunkBytes.set(index, value);159    } else {160      chunkBytes.set(index, base64UrlToBytes(value.d));161    }162  }163164  const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);165166  // The trailing pad bytes are zeros up to the next chunk boundary. pako's167  // inflate tolerates trailing junk after the deflate stream end marker, so168  // we can hand it the padded buffer directly.169  let inflated: Uint8Array;170  try {171    inflated = inflate(recovered);172  } catch (err) {173    throw new CodexError(174      "RECONSTRUCTION_SIMULATION_FAILED",175      `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,176    );177  }178179  const json = new TextDecoder().decode(inflated);180  let payload: CodexPayload;181  try {182    payload = JSON.parse(json) as CodexPayload;183  } catch (err) {184    throw new CodexError(185      "RECONSTRUCTION_SIMULATION_FAILED",186      `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,187    );188  }189  return payload;190}191192/**193 * Predict how many pages a Codex will occupy without doing the work to194 * generate one. Used by the popup's ESTIMATE message to drive the "this will195 * be N pages" UI hint before the user commits to printing. Speculative196 * compression is fine here — we never persist the result.197 */198export async function estimateCodexPages(199  slotA: string,200  slotB: string | null,201): Promise<{ pages: number; parityShards: number }> {202  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });203  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));204  // Stand-in payload — the exact verify code / timestamp / fingerprint don't205  // affect the compressed size enough to matter for a page count estimate.206  const stub: CodexPayload = {207    version: 2,208    verifyCode: "PX-XXXX-XXXX",209    vaultBlob,210    timestamp: 0,211    entryCount: 0,212    vaultFingerprint: "0000000000000000",213    generatedAt: 0,214  };215  const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });216  const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));217  const parityShards = computeParityShards(dataShards);218  const totalChunks = dataShards + parityShards;219  const pages = Math.ceil(totalChunks / CODES_PER_PAGE);220  return { pages, parityShards };221}222223/** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation224 *  gate. Sorted ascending so they read naturally in the prompt. */225export function pickConfirmationPositions(): [number, number, number] {226  const positions = Array.from({ length: 12 }, (_, i) => i + 1);227  for (let i = positions.length - 1; i > 0; i--) {228    const j = Math.floor(Math.random() * (i + 1));229    [positions[i], positions[j]] = [positions[j]!, positions[i]!];230  }231  const picked = [positions[0]!, positions[1]!, positions[2]!] as const;232  return [...picked].sort((a, b) => a - b) as [number, number, number];233}234235// ── Internal helpers ────────────────────────────────────────────────────────236237async function buildVaultBlob(deps: CodexDeps): Promise<{238  vaultBlob: string;239  timestamp: number;240  entryCount: number;241  vaultFingerprint: string;242}> {243  const slotA = await deps.getSlotA();244  if (!slotA) {245    throw new CodexError("VAULT_EMPTY", "No vault data found");246  }247  const slotB = await deps.getSlotB();248  const lastModified = await deps.getLastModified();249  const entries = await deps.getAllEntries();250251  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });252  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));253254  // AUD-004: 32 hex chars = 128 bits (was 16 chars / 64 bits).255  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);256257  const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();258  const liveEntryCount = entries.filter((e) => !e.deletedAt).length;259260  return {261    vaultBlob,262    timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),263    entryCount: liveEntryCount,264    vaultFingerprint: fingerprint,265  };266}267268function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {269  const inflated = inflate(compressed);270  const restored = new TextDecoder().decode(inflated);271  if (restored !== expectedJson) {272    throw new CodexError(273      "COMPRESSION_ROUNDTRIP_FAILED",274      "Compression verification failed: inflate(deflate(payload)) did not match the original",275    );276  }277}278279/**280 * Confirm the encoded shards can be reconstructed from a partial set. We281 * drop `parityShards` random shards (the maximum loss the parity budget282 * tolerates for this Codex) and check byte-for-byte that the recovered283 * prefix equals the original compressed blob.284 */285async function verifyErasureCoding(286  dataChunks: Uint8Array[],287  parityChunks: Uint8Array[],288  dataShards: number,289  parityShards: number,290  totalChunks: number,291  originalCompressed: Uint8Array,292): Promise<void> {293  const present = new Map<number, Uint8Array>();294  dataChunks.forEach((chunk, i) => present.set(i, chunk));295  parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));296297  const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));298  for (const idx of removed) present.delete(idx);299300  let recovered: Uint8Array;301  try {302    recovered = await decodeWithErasure(present, dataShards, totalChunks);303  } catch (err) {304    if (err instanceof CodexError) {305      throw new CodexError(306        "ERASURE_CODING_VERIFICATION_FAILED",307        `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,308      );309    }310    throw err;311  }312313  for (let i = 0; i < originalCompressed.length; i++) {314    if (recovered[i] !== originalCompressed[i]) {315      throw new CodexError(316        "ERASURE_CODING_CONTENT_MISMATCH",317        `Reconstructed data does not match original at byte ${i}`,318      );319    }320  }321}322323async function simulateAndVerifyReconstruction(324  envelopes: ChunkEnvelope[],325  dataShards: number,326  parityShards: number,327  totalChunks: number,328  originalPayload: CodexPayload,329): Promise<void> {330  const present = new Map<number, Uint8Array>();331  envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));332333  const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));334  for (const idx of removed) present.delete(idx);335336  let reconstructed: CodexPayload;337  try {338    reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);339  } catch (err) {340    throw new CodexError(341      "RECONSTRUCTION_SIMULATION_FAILED",342      `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,343    );344  }345346  if (reconstructed.verifyCode !== originalPayload.verifyCode) {347    throw new CodexError(348      "RECONSTRUCTION_VERIFY_CODE_MISMATCH",349      "Reconstructed Codex verify code does not match original",350    );351  }352  if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {353    throw new CodexError(354      "RECONSTRUCTION_FINGERPRINT_MISMATCH",355      "Reconstructed vault fingerprint does not match original",356    );357  }358  if (reconstructed.entryCount !== originalPayload.entryCount) {359    throw new CodexError(360      "RECONSTRUCTION_COUNT_MISMATCH",361      `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,362    );363  }364}365366/**367 * Pick `count` distinct indices uniformly at random from [0, total). Used by368 * the verification simulations to model "any two chunks could be lost." Not369 * security-sensitive — we just need diverse coverage across regenerations.370 */371function pickRandomIndices(total: number, count: number): number[] {372  const indices = Array.from({ length: total }, (_, i) => i);373  for (let i = indices.length - 1; i > 0; i--) {374    const j = Math.floor(Math.random() * (i + 1));375    const tmp = indices[i]!;376    indices[i] = indices[j]!;377    indices[j] = tmp;378  }379  return indices.slice(0, count);380}