generate.ts

16.9 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, Inflate } from "pako";17import { IMPORT_LIMITS } from "../../shared/import-limits";18import {19  CHUNK_SIZE,20  CODES_PER_PAGE,21  CodexError,22  computeParityShards,23  type ChunkEnvelope,24  type CodexDeps,25  type CodexManifest,26  type CodexPayload,27  type CodexProgress,28} from "../../shared/codex-types";29import { generateVerifiedAztec } from "./aztec";30import {31  decodeWithErasure,32  encodeIntoStripes,33  planStripeShardCounts,34  type EncodeResult,35} from "./erasure";36import {37  base64UrlToBytes,38  bytesToBase64Url,39  buildChunkEnvelope,40  sha256Hex,41} from "./helpers";42import { renderCodexPDF } from "./pdf";43export { pickConfirmationPositions } from "./confirmation";4445/**46 * Per-stripe scanned-chunk bundle. Restore code groups envelopes by stripe47 * and passes one of these per stripe to `reconstructPayloadFromStripes`.48 */49export interface StripeChunks {50  /** Within-stripe index → raw chunk bytes. */51  chunks: Map<number, Uint8Array>;52  dataShards: number;53  totalChunks: number;54}5556// ── Public surface ──────────────────────────────────────────────────────────5758export async function generateCodex(59  deps: CodexDeps,60  onProgress?: CodexProgress,61): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {62  // STEP 0 — preconditions63  if (!deps.isVaultUnlocked()) {64    throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");65  }6667  // STEP 1 — vault blob68  const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);69  const verifyCode = await deps.getVerifyCode();7071  // STEP 2 — canonical payload (no recovery words — those live on paper only)72  const payload: CodexPayload = {73    version: 2,74    verifyCode,75    vaultBlob,76    timestamp,77    entryCount,78    vaultFingerprint,79    generatedAt: Date.now(),80  };8182  // STEP 3 — compress + round-trip verify83  onProgress?.("compress");84  const payloadJson = JSON.stringify(payload);85  const payloadBytes = new TextEncoder().encode(payloadJson);86  const compressed = deflate(payloadBytes, { level: 9 });87  assertCompressionRoundTrip(compressed, payloadJson);8889  // STEP 4 — erasure encode (multi-stripe) + simulated-loss verify per stripe90  onProgress?.("erasure");91  const stripeResult = await encodeIntoStripes(compressed);92  await verifyErasureCoding(stripeResult.stripes, compressed);93  const stripeCount = stripeResult.stripes.length;94  const totalChunks = stripeResult.totalChunks;9596  // STEP 5 — envelopes. One envelope per shard, tagged with its stripe so97  // restore can route it. Within a stripe, data shards come first (indices98  // 0..dataShards-1), then parity (dataShards..totalChunks-1). Across stripes,99  // we emit stripe 0 first, then stripe 1, etc., so the PDF lays them out in100  // a predictable order on the page.101  const envelopes: ChunkEnvelope[] = [];102  for (let sIdx = 0; sIdx < stripeCount; sIdx++) {103    const stripe = stripeResult.stripes[sIdx]!;104    for (let i = 0; i < stripe.dataChunks.length; i++) {105      envelopes.push(106        await buildChunkEnvelope(107          stripe.dataChunks[i]!,108          i,109          stripe.totalChunks,110          false,111          verifyCode,112          sIdx,113          stripeCount,114        ),115      );116    }117    for (let i = 0; i < stripe.parityChunks.length; i++) {118      envelopes.push(119        await buildChunkEnvelope(120          stripe.parityChunks[i]!,121          stripe.dataShards + i,122          stripe.totalChunks,123          true,124          verifyCode,125          sIdx,126          stripeCount,127        ),128      );129    }130  }131132  // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)133  const aztecPngs: Uint8Array[] = [];134  for (let i = 0; i < envelopes.length; i++) {135    onProgress?.("aztec", i, envelopes.length);136    const img = await generateVerifiedAztec(envelopes[i]!);137    aztecPngs.push(img.pngBytes);138    // Pass a blob URL so the view can render a live gallery of real codes.139    // Only created when OffscreenCanvas is available (same guard as round-trip).140    // Copy into a fresh Uint8Array to satisfy Blob's ArrayBuffer type constraint141    // (img.pngBytes may be backed by a SharedArrayBuffer in some type environments).142    let blobUrl: string | undefined;143    if (typeof URL !== "undefined" && typeof Blob !== "undefined") {144      const owned = new Uint8Array(img.pngBytes);145      blobUrl = URL.createObjectURL(new Blob([owned], { type: "image/png" }));146    }147    onProgress?.("aztec", i + 1, envelopes.length, blobUrl);148  }149150  // STEP 7 — full reconstruction simulation (lose the maximum tolerated151  // number of envelopes per stripe, then rebuild end-to-end)152  onProgress?.("verify");153  await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);154155  // STEP 8 — render PDF156  onProgress?.("pdf");157  const manifest: CodexManifest = {158    verifyCode,159    totalChunks,160    dataChunks: stripeResult.totalDataChunks,161    parityChunks: stripeResult.totalParityChunks,162    stripeCount,163    dataPerStripe: stripeResult.stripes.map((s) => s.dataShards),164    parityPerStripe: stripeResult.stripes.map((s) => s.parityShards),165    pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),166    entryCount,167    vaultFingerprint,168    generatedAt: payload.generatedAt,169  };170  const pdfBytes = await renderCodexPDF(aztecPngs, manifest);171172  // STEP 9 — sanity check the PDF173  if (!pdfBytes || pdfBytes.length < 1000) {174    throw new CodexError(175      "PDF_GENERATION_FAILED",176      `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,177    );178  }179180  return { pdfBytes, manifest };181}182183/**184 * Reconstruct a CodexPayload from a partial set of envelopes grouped per185 * stripe. Each `StripeChunks` entry carries the chunks scanned for that186 * stripe plus the stripe's (dataShards, totalChunks) shape — restore obtains187 * those from the envelope's `t` field via `deriveShardCounts`. Stripes must188 * be supplied in stripe-index order so the concatenated data matches the189 * original compressed payload byte-for-byte.190 */191export async function reconstructPayloadFromStripes(192  stripes: StripeChunks[],193): Promise<CodexPayload> {194  if (stripes.length === 0) {195    throw new CodexError(196      "INSUFFICIENT_CHUNKS",197      "Cannot reconstruct payload: no stripes provided",198    );199  }200201  // Decode each stripe to its `dataShards * shardSize` byte region.202  const stripeData: Uint8Array[] = [];203  for (const stripe of stripes) {204    const recovered = await decodeWithErasure(205      stripe.chunks,206      stripe.dataShards,207      stripe.totalChunks,208    );209    stripeData.push(recovered);210  }211212  // Concatenate stripe data in stripe order — the original compressed payload.213  const totalLen = stripeData.reduce((s, b) => s + b.length, 0);214  const combined = new Uint8Array(totalLen);215  let offset = 0;216  for (const part of stripeData) {217    combined.set(part, offset);218    offset += part.length;219  }220221  // Trailing zero-pad bytes are tolerated by pako's inflate after the deflate222  // stream end marker, so we can hand it the concatenated buffer directly.223  // MV-4: stream the inflate with a byte counter — one-shot inflate() has no224  // output bound and a crafted Codex payload could otherwise balloon to GiB.225  let inflated: Uint8Array;226  try {227    inflated = inflateBounded(combined, IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES);228  } catch (err) {229    throw new CodexError(230      "RECONSTRUCTION_SIMULATION_FAILED",231      `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,232    );233  }234235  const json = new TextDecoder().decode(inflated);236  let payload: CodexPayload;237  try {238    payload = JSON.parse(json) as CodexPayload;239  } catch (err) {240    throw new CodexError(241      "RECONSTRUCTION_SIMULATION_FAILED",242      `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,243    );244  }245  return payload;246}247248/**249 * Predict how many pages a Codex will occupy without doing the work to250 * generate one. Used by the popup's ESTIMATE message to drive the "this will251 * be N pages" UI hint before the user commits to printing. Speculative252 * compression is fine here — we never persist the result.253 */254export async function estimateCodexPages(255  slotA: string,256  slotB: string | null,257): Promise<{ pages: number; parityShards: number }> {258  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });259  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));260  // Stand-in payload — the exact verify code / timestamp / fingerprint don't261  // affect the compressed size enough to matter for a page count estimate.262  const stub: CodexPayload = {263    version: 2,264    verifyCode: "PX-XXXX-XXXX",265    vaultBlob,266    timestamp: 0,267    entryCount: 0,268    vaultFingerprint: "0000000000000000",269    generatedAt: 0,270  };271  const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });272  const totalDataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));273  // Stripe planning matches encodeIntoStripes so the estimate stays honest274  // for vaults large enough to be split.275  const stripeShardCounts = planStripeShardCounts(totalDataShards);276  let totalChunks = 0;277  let totalParity = 0;278  for (const dataShards of stripeShardCounts) {279    const parity = computeParityShards(dataShards);280    totalChunks += dataShards + parity;281    totalParity += parity;282  }283  const pages = Math.ceil(totalChunks / CODES_PER_PAGE);284  return { pages, parityShards: totalParity };285}286287// ── Internal helpers ────────────────────────────────────────────────────────288289async function buildVaultBlob(deps: CodexDeps): Promise<{290  vaultBlob: string;291  timestamp: number;292  entryCount: number;293  vaultFingerprint: string;294}> {295  const slotA = await deps.getSlotA();296  if (!slotA) {297    throw new CodexError("VAULT_EMPTY", "No vault data found");298  }299  const slotB = await deps.getSlotB();300  const lastModified = await deps.getLastModified();301  const entries = await deps.getAllEntries();302303  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });304  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));305306  // 32 hex chars = 128 bits.307  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);308309  const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();310  const liveEntryCount = entries.filter((e) => !e.deletedAt).length;311312  return {313    vaultBlob,314    timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),315    entryCount: liveEntryCount,316    vaultFingerprint: fingerprint,317  };318}319320/** Inflate with a hard output ceiling — aborts mid-stream past maxBytes.321 *  Exported for MV-4 bound tests. */322export function inflateBounded(input: Uint8Array, maxBytes: number): Uint8Array {323  const chunks: Uint8Array[] = [];324  let total = 0;325  const inf = new Inflate();326  inf.onData = (chunk: Uint8Array) => {327    total += chunk.length;328    if (total > maxBytes) {329      throw new Error(`IMPORT_TOO_LARGE: inflated output exceeds ${maxBytes} bytes`);330    }331    chunks.push(chunk);332  };333  inf.push(input, true);334  if (inf.err) throw new Error(inf.msg || `inflate error ${inf.err}`);335  const out = new Uint8Array(total);336  let off = 0;337  for (const c of chunks) { out.set(c, off); off += c.length; }338  return out;339}340341function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {342  const inflated = inflate(compressed);343  const restored = new TextDecoder().decode(inflated);344  if (restored !== expectedJson) {345    throw new CodexError(346      "COMPRESSION_ROUNDTRIP_FAILED",347      "Compression verification failed: inflate(deflate(payload)) did not match the original",348    );349  }350}351352/**353 * Confirm each stripe's encoded shards can be reconstructed after losing the354 * maximum number of chunks the stripe's parity budget tolerates. Checks the355 * recovered bytes against the original compressed payload slice for that356 * stripe — byte-for-byte equality across the populated bytes; trailing357 * zero-pad in the last stripe is ignored.358 */359async function verifyErasureCoding(360  stripes: EncodeResult[],361  originalCompressed: Uint8Array,362): Promise<void> {363  let byteCursor = 0;364  for (let sIdx = 0; sIdx < stripes.length; sIdx++) {365    const stripe = stripes[sIdx]!;366    const present = new Map<number, Uint8Array>();367    stripe.dataChunks.forEach((chunk, i) => present.set(i, chunk));368    stripe.parityChunks.forEach((chunk, i) =>369      present.set(stripe.dataShards + i, chunk),370    );371372    const removed = pickRandomIndices(373      stripe.totalChunks,374      Math.min(stripe.parityShards, stripe.totalChunks - 1),375    );376    for (const idx of removed) present.delete(idx);377378    let recovered: Uint8Array;379    try {380      recovered = await decodeWithErasure(381        present,382        stripe.dataShards,383        stripe.totalChunks,384      );385    } catch (err) {386      if (err instanceof CodexError) {387        throw new CodexError(388          "ERASURE_CODING_VERIFICATION_FAILED",389          `Erasure coding simulation failed for stripe ${sIdx} when losing chunks ${removed.join(", ")}: ${err.message}`,390        );391      }392      throw err;393    }394395    // Number of real payload bytes in this stripe (rest is zero pad).396    const stripeRealBytes = Math.min(397      stripe.dataShards * CHUNK_SIZE,398      Math.max(0, originalCompressed.length - byteCursor),399    );400    for (let i = 0; i < stripeRealBytes; i++) {401      if (recovered[i] !== originalCompressed[byteCursor + i]) {402        throw new CodexError(403          "ERASURE_CODING_CONTENT_MISMATCH",404          `Reconstructed stripe ${sIdx} differs from original at byte ${byteCursor + i}`,405        );406      }407    }408    byteCursor += stripe.dataShards * CHUNK_SIZE;409  }410}411412async function simulateAndVerifyReconstruction(413  envelopes: ChunkEnvelope[],414  stripes: EncodeResult[],415  originalPayload: CodexPayload,416): Promise<void> {417  // Group envelopes by stripe so we can drop up to `parityShards` per stripe418  // and still reconstruct.419  const perStripe: StripeChunks[] = stripes.map((s) => ({420    chunks: new Map<number, Uint8Array>(),421    dataShards: s.dataShards,422    totalChunks: s.totalChunks,423  }));424  for (const env of envelopes) {425    const stripe = env.v === 3 ? (env.s ?? 0) : 0;426    perStripe[stripe]!.chunks.set(env.i, base64UrlToBytes(env.d));427  }428429  const removedSummary: string[] = [];430  for (let sIdx = 0; sIdx < stripes.length; sIdx++) {431    const stripe = stripes[sIdx]!;432    const removed = pickRandomIndices(433      stripe.totalChunks,434      Math.min(stripe.parityShards, stripe.totalChunks - 1),435    );436    for (const idx of removed) perStripe[sIdx]!.chunks.delete(idx);437    removedSummary.push(`stripe ${sIdx}: [${removed.join(", ")}]`);438  }439440  let reconstructed: CodexPayload;441  try {442    reconstructed = await reconstructPayloadFromStripes(perStripe);443  } catch (err) {444    throw new CodexError(445      "RECONSTRUCTION_SIMULATION_FAILED",446      `Codex reconstruction simulation failed. Lost chunks: ${removedSummary.join("; ")}. ${err instanceof Error ? err.message : String(err)}`,447    );448  }449450  if (reconstructed.verifyCode !== originalPayload.verifyCode) {451    throw new CodexError(452      "RECONSTRUCTION_VERIFY_CODE_MISMATCH",453      "Reconstructed Codex verify code does not match original",454    );455  }456  if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {457    throw new CodexError(458      "RECONSTRUCTION_FINGERPRINT_MISMATCH",459      "Reconstructed vault fingerprint does not match original",460    );461  }462  if (reconstructed.entryCount !== originalPayload.entryCount) {463    throw new CodexError(464      "RECONSTRUCTION_COUNT_MISMATCH",465      `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,466    );467  }468}469470/**471 * Pick `count` distinct indices uniformly at random from [0, total). Used by472 * the verification simulations to model "any two chunks could be lost." Not473 * security-sensitive — we just need diverse coverage across regenerations.474 */475function pickRandomIndices(total: number, count: number): number[] {476  const indices = Array.from({ length: total }, (_, i) => i);477  for (let i = indices.length - 1; i > 0; i--) {478    const j = Math.floor(Math.random() * (i + 1));479    const tmp = indices[i]!;480    indices[i] = indices[j]!;481    indices[j] = tmp;482  }483  return indices.slice(0, count);484}