generate.ts

15.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 } 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 {30  decodeWithErasure,31  encodeIntoStripes,32  planStripeShardCounts,33  type EncodeResult,34} from "./erasure";35import {36  base64UrlToBytes,37  bytesToBase64Url,38  buildChunkEnvelope,39  sha256Hex,40} from "./helpers";41import { renderCodexPDF } from "./pdf";42export { pickConfirmationPositions } from "./confirmation";4344/**45 * Per-stripe scanned-chunk bundle. Restore code groups envelopes by stripe46 * and passes one of these per stripe to `reconstructPayloadFromStripes`.47 */48export interface StripeChunks {49  /** Within-stripe index → raw chunk bytes. */50  chunks: Map<number, Uint8Array>;51  dataShards: number;52  totalChunks: number;53}5455// ── Public surface ──────────────────────────────────────────────────────────5657export async function generateCodex(58  deps: CodexDeps,59  onProgress?: CodexProgress,60): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {61  // STEP 0 — preconditions62  if (!deps.isVaultUnlocked()) {63    throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");64  }6566  // STEP 1 — vault blob67  const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);68  const verifyCode = await deps.getVerifyCode();6970  // STEP 2 — canonical payload (no recovery words — those live on paper only)71  const payload: CodexPayload = {72    version: 2,73    verifyCode,74    vaultBlob,75    timestamp,76    entryCount,77    vaultFingerprint,78    generatedAt: Date.now(),79  };8081  // STEP 3 — compress + round-trip verify82  onProgress?.("compress");83  const payloadJson = JSON.stringify(payload);84  const payloadBytes = new TextEncoder().encode(payloadJson);85  const compressed = deflate(payloadBytes, { level: 9 });86  assertCompressionRoundTrip(compressed, payloadJson);8788  // STEP 4 — erasure encode (multi-stripe) + simulated-loss verify per stripe89  onProgress?.("erasure");90  const stripeResult = await encodeIntoStripes(compressed);91  await verifyErasureCoding(stripeResult.stripes, compressed);92  const stripeCount = stripeResult.stripes.length;93  const totalChunks = stripeResult.totalChunks;9495  // STEP 5 — envelopes. One envelope per shard, tagged with its stripe so96  // restore can route it. Within a stripe, data shards come first (indices97  // 0..dataShards-1), then parity (dataShards..totalChunks-1). Across stripes,98  // we emit stripe 0 first, then stripe 1, etc., so the PDF lays them out in99  // a predictable order on the page.100  const envelopes: ChunkEnvelope[] = [];101  for (let sIdx = 0; sIdx < stripeCount; sIdx++) {102    const stripe = stripeResult.stripes[sIdx]!;103    for (let i = 0; i < stripe.dataChunks.length; i++) {104      envelopes.push(105        await buildChunkEnvelope(106          stripe.dataChunks[i]!,107          i,108          stripe.totalChunks,109          false,110          verifyCode,111          sIdx,112          stripeCount,113        ),114      );115    }116    for (let i = 0; i < stripe.parityChunks.length; i++) {117      envelopes.push(118        await buildChunkEnvelope(119          stripe.parityChunks[i]!,120          stripe.dataShards + i,121          stripe.totalChunks,122          true,123          verifyCode,124          sIdx,125          stripeCount,126        ),127      );128    }129  }130131  // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)132  const aztecPngs: Uint8Array[] = [];133  for (let i = 0; i < envelopes.length; i++) {134    onProgress?.("aztec", i, envelopes.length);135    const img = await generateVerifiedAztec(envelopes[i]!);136    aztecPngs.push(img.pngBytes);137    // Pass a blob URL so the view can render a live gallery of real codes.138    // Only created when OffscreenCanvas is available (same guard as round-trip).139    // Copy into a fresh Uint8Array to satisfy Blob's ArrayBuffer type constraint140    // (img.pngBytes may be backed by a SharedArrayBuffer in some type environments).141    let blobUrl: string | undefined;142    if (typeof URL !== "undefined" && typeof Blob !== "undefined") {143      const owned = new Uint8Array(img.pngBytes);144      blobUrl = URL.createObjectURL(new Blob([owned], { type: "image/png" }));145    }146    onProgress?.("aztec", i + 1, envelopes.length, blobUrl);147  }148149  // STEP 7 — full reconstruction simulation (lose the maximum tolerated150  // number of envelopes per stripe, then rebuild end-to-end)151  onProgress?.("verify");152  await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);153154  // STEP 8 — render PDF155  onProgress?.("pdf");156  const manifest: CodexManifest = {157    verifyCode,158    totalChunks,159    dataChunks: stripeResult.totalDataChunks,160    parityChunks: stripeResult.totalParityChunks,161    stripeCount,162    dataPerStripe: stripeResult.stripes.map((s) => s.dataShards),163    parityPerStripe: stripeResult.stripes.map((s) => s.parityShards),164    pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),165    entryCount,166    vaultFingerprint,167    generatedAt: payload.generatedAt,168  };169  const pdfBytes = await renderCodexPDF(aztecPngs, manifest);170171  // STEP 10 — sanity check the PDF172  if (!pdfBytes || pdfBytes.length < 1000) {173    throw new CodexError(174      "PDF_GENERATION_FAILED",175      `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,176    );177  }178179  return { pdfBytes, manifest };180}181182/**183 * Reconstruct a CodexPayload from a partial set of envelopes grouped per184 * stripe. Each `StripeChunks` entry carries the chunks scanned for that185 * stripe plus the stripe's (dataShards, totalChunks) shape — restore obtains186 * those from the envelope's `t` field via `deriveShardCounts`. Stripes must187 * be supplied in stripe-index order so the concatenated data matches the188 * original compressed payload byte-for-byte.189 */190export async function reconstructPayloadFromStripes(191  stripes: StripeChunks[],192): Promise<CodexPayload> {193  if (stripes.length === 0) {194    throw new CodexError(195      "INSUFFICIENT_CHUNKS",196      "Cannot reconstruct payload: no stripes provided",197    );198  }199200  // Decode each stripe to its `dataShards * shardSize` byte region.201  const stripeData: Uint8Array[] = [];202  for (const stripe of stripes) {203    const recovered = await decodeWithErasure(204      stripe.chunks,205      stripe.dataShards,206      stripe.totalChunks,207    );208    stripeData.push(recovered);209  }210211  // Concatenate stripe data in stripe order — the original compressed payload.212  const totalLen = stripeData.reduce((s, b) => s + b.length, 0);213  const combined = new Uint8Array(totalLen);214  let offset = 0;215  for (const part of stripeData) {216    combined.set(part, offset);217    offset += part.length;218  }219220  // Trailing zero-pad bytes are tolerated by pako's inflate after the deflate221  // stream end marker, so we can hand it the concatenated buffer directly.222  let inflated: Uint8Array;223  try {224    inflated = inflate(combined);225  } catch (err) {226    throw new CodexError(227      "RECONSTRUCTION_SIMULATION_FAILED",228      `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,229    );230  }231232  const json = new TextDecoder().decode(inflated);233  let payload: CodexPayload;234  try {235    payload = JSON.parse(json) as CodexPayload;236  } catch (err) {237    throw new CodexError(238      "RECONSTRUCTION_SIMULATION_FAILED",239      `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,240    );241  }242  return payload;243}244245/**246 * Predict how many pages a Codex will occupy without doing the work to247 * generate one. Used by the popup's ESTIMATE message to drive the "this will248 * be N pages" UI hint before the user commits to printing. Speculative249 * compression is fine here — we never persist the result.250 */251export async function estimateCodexPages(252  slotA: string,253  slotB: string | null,254): Promise<{ pages: number; parityShards: number }> {255  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });256  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));257  // Stand-in payload — the exact verify code / timestamp / fingerprint don't258  // affect the compressed size enough to matter for a page count estimate.259  const stub: CodexPayload = {260    version: 2,261    verifyCode: "PX-XXXX-XXXX",262    vaultBlob,263    timestamp: 0,264    entryCount: 0,265    vaultFingerprint: "0000000000000000",266    generatedAt: 0,267  };268  const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });269  const totalDataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));270  // Stripe planning matches encodeIntoStripes so the estimate stays honest271  // for vaults large enough to be split.272  const stripeShardCounts = planStripeShardCounts(totalDataShards);273  let totalChunks = 0;274  let totalParity = 0;275  for (const dataShards of stripeShardCounts) {276    const parity = computeParityShards(dataShards);277    totalChunks += dataShards + parity;278    totalParity += parity;279  }280  const pages = Math.ceil(totalChunks / CODES_PER_PAGE);281  return { pages, parityShards: totalParity };282}283284// ── Internal helpers ────────────────────────────────────────────────────────285286async function buildVaultBlob(deps: CodexDeps): Promise<{287  vaultBlob: string;288  timestamp: number;289  entryCount: number;290  vaultFingerprint: string;291}> {292  const slotA = await deps.getSlotA();293  if (!slotA) {294    throw new CodexError("VAULT_EMPTY", "No vault data found");295  }296  const slotB = await deps.getSlotB();297  const lastModified = await deps.getLastModified();298  const entries = await deps.getAllEntries();299300  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });301  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));302303  // AUD-004: 32 hex chars = 128 bits (was 16 chars / 64 bits).304  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);305306  const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();307  const liveEntryCount = entries.filter((e) => !e.deletedAt).length;308309  return {310    vaultBlob,311    timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),312    entryCount: liveEntryCount,313    vaultFingerprint: fingerprint,314  };315}316317function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {318  const inflated = inflate(compressed);319  const restored = new TextDecoder().decode(inflated);320  if (restored !== expectedJson) {321    throw new CodexError(322      "COMPRESSION_ROUNDTRIP_FAILED",323      "Compression verification failed: inflate(deflate(payload)) did not match the original",324    );325  }326}327328/**329 * Confirm each stripe's encoded shards can be reconstructed after losing the330 * maximum number of chunks the stripe's parity budget tolerates. Checks the331 * recovered bytes against the original compressed payload slice for that332 * stripe — byte-for-byte equality across the populated bytes; trailing333 * zero-pad in the last stripe is ignored.334 */335async function verifyErasureCoding(336  stripes: EncodeResult[],337  originalCompressed: Uint8Array,338): Promise<void> {339  let byteCursor = 0;340  for (let sIdx = 0; sIdx < stripes.length; sIdx++) {341    const stripe = stripes[sIdx]!;342    const present = new Map<number, Uint8Array>();343    stripe.dataChunks.forEach((chunk, i) => present.set(i, chunk));344    stripe.parityChunks.forEach((chunk, i) =>345      present.set(stripe.dataShards + i, chunk),346    );347348    const removed = pickRandomIndices(349      stripe.totalChunks,350      Math.min(stripe.parityShards, stripe.totalChunks - 1),351    );352    for (const idx of removed) present.delete(idx);353354    let recovered: Uint8Array;355    try {356      recovered = await decodeWithErasure(357        present,358        stripe.dataShards,359        stripe.totalChunks,360      );361    } catch (err) {362      if (err instanceof CodexError) {363        throw new CodexError(364          "ERASURE_CODING_VERIFICATION_FAILED",365          `Erasure coding simulation failed for stripe ${sIdx} when losing chunks ${removed.join(", ")}: ${err.message}`,366        );367      }368      throw err;369    }370371    // Number of real payload bytes in this stripe (rest is zero pad).372    const stripeRealBytes = Math.min(373      stripe.dataShards * CHUNK_SIZE,374      Math.max(0, originalCompressed.length - byteCursor),375    );376    for (let i = 0; i < stripeRealBytes; i++) {377      if (recovered[i] !== originalCompressed[byteCursor + i]) {378        throw new CodexError(379          "ERASURE_CODING_CONTENT_MISMATCH",380          `Reconstructed stripe ${sIdx} differs from original at byte ${byteCursor + i}`,381        );382      }383    }384    byteCursor += stripe.dataShards * CHUNK_SIZE;385  }386}387388async function simulateAndVerifyReconstruction(389  envelopes: ChunkEnvelope[],390  stripes: EncodeResult[],391  originalPayload: CodexPayload,392): Promise<void> {393  // Group envelopes by stripe so we can drop up to `parityShards` per stripe394  // and still reconstruct.395  const perStripe: StripeChunks[] = stripes.map((s) => ({396    chunks: new Map<number, Uint8Array>(),397    dataShards: s.dataShards,398    totalChunks: s.totalChunks,399  }));400  for (const env of envelopes) {401    const stripe = env.v === 3 ? (env.s ?? 0) : 0;402    perStripe[stripe]!.chunks.set(env.i, base64UrlToBytes(env.d));403  }404405  const removedSummary: string[] = [];406  for (let sIdx = 0; sIdx < stripes.length; sIdx++) {407    const stripe = stripes[sIdx]!;408    const removed = pickRandomIndices(409      stripe.totalChunks,410      Math.min(stripe.parityShards, stripe.totalChunks - 1),411    );412    for (const idx of removed) perStripe[sIdx]!.chunks.delete(idx);413    removedSummary.push(`stripe ${sIdx}: [${removed.join(", ")}]`);414  }415416  let reconstructed: CodexPayload;417  try {418    reconstructed = await reconstructPayloadFromStripes(perStripe);419  } catch (err) {420    throw new CodexError(421      "RECONSTRUCTION_SIMULATION_FAILED",422      `Codex reconstruction simulation failed. Lost chunks: ${removedSummary.join("; ")}. ${err instanceof Error ? err.message : String(err)}`,423    );424  }425426  if (reconstructed.verifyCode !== originalPayload.verifyCode) {427    throw new CodexError(428      "RECONSTRUCTION_VERIFY_CODE_MISMATCH",429      "Reconstructed Codex verify code does not match original",430    );431  }432  if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {433    throw new CodexError(434      "RECONSTRUCTION_FINGERPRINT_MISMATCH",435      "Reconstructed vault fingerprint does not match original",436    );437  }438  if (reconstructed.entryCount !== originalPayload.entryCount) {439    throw new CodexError(440      "RECONSTRUCTION_COUNT_MISMATCH",441      `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,442    );443  }444}445446/**447 * Pick `count` distinct indices uniformly at random from [0, total). Used by448 * the verification simulations to model "any two chunks could be lost." Not449 * security-sensitive — we just need diverse coverage across regenerations.450 */451function pickRandomIndices(total: number, count: number): number[] {452  const indices = Array.from({ length: total }, (_, i) => i);453  for (let i = indices.length - 1; i > 0; i--) {454    const j = Math.floor(Math.random() * (i + 1));455    const tmp = indices[i]!;456    indices[i] = indices[j]!;457    indices[j] = tmp;458  }459  return indices.slice(0, count);460}