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";4243/**44 * Per-stripe scanned-chunk bundle. Restore code groups envelopes by stripe45 * and passes one of these per stripe to `reconstructPayloadFromStripes`.46 */47export interface StripeChunks {48  /** Within-stripe index → raw chunk bytes. */49  chunks: Map<number, Uint8Array>;50  dataShards: number;51  totalChunks: number;52}5354// ── Public surface ──────────────────────────────────────────────────────────5556export async function generateCodex(57  deps: CodexDeps,58  onProgress?: CodexProgress,59): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {60  // STEP 0 — preconditions61  if (!deps.isVaultUnlocked()) {62    throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");63  }6465  // STEP 1 — vault blob66  const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);67  const verifyCode = await deps.getVerifyCode();6869  // STEP 2 — canonical payload (no recovery words — those live on paper only)70  const payload: CodexPayload = {71    version: 2,72    verifyCode,73    vaultBlob,74    timestamp,75    entryCount,76    vaultFingerprint,77    generatedAt: Date.now(),78  };7980  // STEP 3 — compress + round-trip verify81  onProgress?.("compress");82  const payloadJson = JSON.stringify(payload);83  const payloadBytes = new TextEncoder().encode(payloadJson);84  const compressed = deflate(payloadBytes, { level: 9 });85  assertCompressionRoundTrip(compressed, payloadJson);8687  // STEP 4 — erasure encode (multi-stripe) + simulated-loss verify per stripe88  onProgress?.("erasure");89  const stripeResult = await encodeIntoStripes(compressed);90  await verifyErasureCoding(stripeResult.stripes, compressed);91  const stripeCount = stripeResult.stripes.length;92  const totalChunks = stripeResult.totalChunks;9394  // STEP 5 — envelopes. One envelope per shard, tagged with its stripe so95  // restore can route it. Within a stripe, data shards come first (indices96  // 0..dataShards-1), then parity (dataShards..totalChunks-1). Across stripes,97  // we emit stripe 0 first, then stripe 1, etc., so the PDF lays them out in98  // a predictable order on the page.99  const envelopes: ChunkEnvelope[] = [];100  for (let sIdx = 0; sIdx < stripeCount; sIdx++) {101    const stripe = stripeResult.stripes[sIdx]!;102    for (let i = 0; i < stripe.dataChunks.length; i++) {103      envelopes.push(104        await buildChunkEnvelope(105          stripe.dataChunks[i]!,106          i,107          stripe.totalChunks,108          false,109          verifyCode,110          sIdx,111          stripeCount,112        ),113      );114    }115    for (let i = 0; i < stripe.parityChunks.length; i++) {116      envelopes.push(117        await buildChunkEnvelope(118          stripe.parityChunks[i]!,119          stripe.dataShards + i,120          stripe.totalChunks,121          true,122          verifyCode,123          sIdx,124          stripeCount,125        ),126      );127    }128  }129130  // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)131  const aztecPngs: Uint8Array[] = [];132  for (let i = 0; i < envelopes.length; i++) {133    onProgress?.("aztec", i, envelopes.length);134    const img = await generateVerifiedAztec(envelopes[i]!);135    aztecPngs.push(img.pngBytes);136  }137  onProgress?.("aztec", envelopes.length, envelopes.length);138139  // STEP 7 — full reconstruction simulation (lose the maximum tolerated140  // number of envelopes per stripe, then rebuild end-to-end)141  onProgress?.("verify");142  await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);143144  // STEP 8 — render PDF145  onProgress?.("pdf");146  const manifest: CodexManifest = {147    verifyCode,148    totalChunks,149    dataChunks: stripeResult.totalDataChunks,150    parityChunks: stripeResult.totalParityChunks,151    stripeCount,152    dataPerStripe: stripeResult.stripes.map((s) => s.dataShards),153    parityPerStripe: stripeResult.stripes.map((s) => s.parityShards),154    pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),155    entryCount,156    vaultFingerprint,157    generatedAt: payload.generatedAt,158  };159  const pdfBytes = await renderCodexPDF(aztecPngs, manifest);160161  // STEP 10 — sanity check the PDF162  if (!pdfBytes || pdfBytes.length < 1000) {163    throw new CodexError(164      "PDF_GENERATION_FAILED",165      `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,166    );167  }168169  return { pdfBytes, manifest };170}171172/**173 * Reconstruct a CodexPayload from a partial set of envelopes grouped per174 * stripe. Each `StripeChunks` entry carries the chunks scanned for that175 * stripe plus the stripe's (dataShards, totalChunks) shape — restore obtains176 * those from the envelope's `t` field via `deriveShardCounts`. Stripes must177 * be supplied in stripe-index order so the concatenated data matches the178 * original compressed payload byte-for-byte.179 */180export async function reconstructPayloadFromStripes(181  stripes: StripeChunks[],182): Promise<CodexPayload> {183  if (stripes.length === 0) {184    throw new CodexError(185      "INSUFFICIENT_CHUNKS",186      "Cannot reconstruct payload: no stripes provided",187    );188  }189190  // Decode each stripe to its `dataShards * shardSize` byte region.191  const stripeData: Uint8Array[] = [];192  for (const stripe of stripes) {193    const recovered = await decodeWithErasure(194      stripe.chunks,195      stripe.dataShards,196      stripe.totalChunks,197    );198    stripeData.push(recovered);199  }200201  // Concatenate stripe data in stripe order — the original compressed payload.202  const totalLen = stripeData.reduce((s, b) => s + b.length, 0);203  const combined = new Uint8Array(totalLen);204  let offset = 0;205  for (const part of stripeData) {206    combined.set(part, offset);207    offset += part.length;208  }209210  // Trailing zero-pad bytes are tolerated by pako's inflate after the deflate211  // stream end marker, so we can hand it the concatenated buffer directly.212  let inflated: Uint8Array;213  try {214    inflated = inflate(combined);215  } catch (err) {216    throw new CodexError(217      "RECONSTRUCTION_SIMULATION_FAILED",218      `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,219    );220  }221222  const json = new TextDecoder().decode(inflated);223  let payload: CodexPayload;224  try {225    payload = JSON.parse(json) as CodexPayload;226  } catch (err) {227    throw new CodexError(228      "RECONSTRUCTION_SIMULATION_FAILED",229      `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,230    );231  }232  return payload;233}234235/**236 * Predict how many pages a Codex will occupy without doing the work to237 * generate one. Used by the popup's ESTIMATE message to drive the "this will238 * be N pages" UI hint before the user commits to printing. Speculative239 * compression is fine here — we never persist the result.240 */241export async function estimateCodexPages(242  slotA: string,243  slotB: string | null,244): Promise<{ pages: number; parityShards: number }> {245  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });246  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));247  // Stand-in payload — the exact verify code / timestamp / fingerprint don't248  // affect the compressed size enough to matter for a page count estimate.249  const stub: CodexPayload = {250    version: 2,251    verifyCode: "PX-XXXX-XXXX",252    vaultBlob,253    timestamp: 0,254    entryCount: 0,255    vaultFingerprint: "0000000000000000",256    generatedAt: 0,257  };258  const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });259  const totalDataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));260  // Stripe planning matches encodeIntoStripes so the estimate stays honest261  // for vaults large enough to be split.262  const stripeShardCounts = planStripeShardCounts(totalDataShards);263  let totalChunks = 0;264  let totalParity = 0;265  for (const dataShards of stripeShardCounts) {266    const parity = computeParityShards(dataShards);267    totalChunks += dataShards + parity;268    totalParity += parity;269  }270  const pages = Math.ceil(totalChunks / CODES_PER_PAGE);271  return { pages, parityShards: totalParity };272}273274/** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation275 *  gate. Sorted ascending so they read naturally in the prompt. */276export function pickConfirmationPositions(): [number, number, number] {277  const positions = Array.from({ length: 12 }, (_, i) => i + 1);278  for (let i = positions.length - 1; i > 0; i--) {279    const j = Math.floor(Math.random() * (i + 1));280    [positions[i], positions[j]] = [positions[j]!, positions[i]!];281  }282  const picked = [positions[0]!, positions[1]!, positions[2]!] as const;283  return [...picked].sort((a, b) => a - b) as [number, number, number];284}285286// ── Internal helpers ────────────────────────────────────────────────────────287288async function buildVaultBlob(deps: CodexDeps): Promise<{289  vaultBlob: string;290  timestamp: number;291  entryCount: number;292  vaultFingerprint: string;293}> {294  const slotA = await deps.getSlotA();295  if (!slotA) {296    throw new CodexError("VAULT_EMPTY", "No vault data found");297  }298  const slotB = await deps.getSlotB();299  const lastModified = await deps.getLastModified();300  const entries = await deps.getAllEntries();301302  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });303  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));304305  // AUD-004: 32 hex chars = 128 bits (was 16 chars / 64 bits).306  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);307308  const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();309  const liveEntryCount = entries.filter((e) => !e.deletedAt).length;310311  return {312    vaultBlob,313    timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),314    entryCount: liveEntryCount,315    vaultFingerprint: fingerprint,316  };317}318319function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {320  const inflated = inflate(compressed);321  const restored = new TextDecoder().decode(inflated);322  if (restored !== expectedJson) {323    throw new CodexError(324      "COMPRESSION_ROUNDTRIP_FAILED",325      "Compression verification failed: inflate(deflate(payload)) did not match the original",326    );327  }328}329330/**331 * Confirm each stripe's encoded shards can be reconstructed after losing the332 * maximum number of chunks the stripe's parity budget tolerates. Checks the333 * recovered bytes against the original compressed payload slice for that334 * stripe — byte-for-byte equality across the populated bytes; trailing335 * zero-pad in the last stripe is ignored.336 */337async function verifyErasureCoding(338  stripes: EncodeResult[],339  originalCompressed: Uint8Array,340): Promise<void> {341  let byteCursor = 0;342  for (let sIdx = 0; sIdx < stripes.length; sIdx++) {343    const stripe = stripes[sIdx]!;344    const present = new Map<number, Uint8Array>();345    stripe.dataChunks.forEach((chunk, i) => present.set(i, chunk));346    stripe.parityChunks.forEach((chunk, i) =>347      present.set(stripe.dataShards + i, chunk),348    );349350    const removed = pickRandomIndices(351      stripe.totalChunks,352      Math.min(stripe.parityShards, stripe.totalChunks - 1),353    );354    for (const idx of removed) present.delete(idx);355356    let recovered: Uint8Array;357    try {358      recovered = await decodeWithErasure(359        present,360        stripe.dataShards,361        stripe.totalChunks,362      );363    } catch (err) {364      if (err instanceof CodexError) {365        throw new CodexError(366          "ERASURE_CODING_VERIFICATION_FAILED",367          `Erasure coding simulation failed for stripe ${sIdx} when losing chunks ${removed.join(", ")}: ${err.message}`,368        );369      }370      throw err;371    }372373    // Number of real payload bytes in this stripe (rest is zero pad).374    const stripeRealBytes = Math.min(375      stripe.dataShards * CHUNK_SIZE,376      Math.max(0, originalCompressed.length - byteCursor),377    );378    for (let i = 0; i < stripeRealBytes; i++) {379      if (recovered[i] !== originalCompressed[byteCursor + i]) {380        throw new CodexError(381          "ERASURE_CODING_CONTENT_MISMATCH",382          `Reconstructed stripe ${sIdx} differs from original at byte ${byteCursor + i}`,383        );384      }385    }386    byteCursor += stripe.dataShards * CHUNK_SIZE;387  }388}389390async function simulateAndVerifyReconstruction(391  envelopes: ChunkEnvelope[],392  stripes: EncodeResult[],393  originalPayload: CodexPayload,394): Promise<void> {395  // Group envelopes by stripe so we can drop up to `parityShards` per stripe396  // and still reconstruct.397  const perStripe: StripeChunks[] = stripes.map((s) => ({398    chunks: new Map<number, Uint8Array>(),399    dataShards: s.dataShards,400    totalChunks: s.totalChunks,401  }));402  for (const env of envelopes) {403    const stripe = env.v === 3 ? (env.s ?? 0) : 0;404    perStripe[stripe]!.chunks.set(env.i, base64UrlToBytes(env.d));405  }406407  const removedSummary: string[] = [];408  for (let sIdx = 0; sIdx < stripes.length; sIdx++) {409    const stripe = stripes[sIdx]!;410    const removed = pickRandomIndices(411      stripe.totalChunks,412      Math.min(stripe.parityShards, stripe.totalChunks - 1),413    );414    for (const idx of removed) perStripe[sIdx]!.chunks.delete(idx);415    removedSummary.push(`stripe ${sIdx}: [${removed.join(", ")}]`);416  }417418  let reconstructed: CodexPayload;419  try {420    reconstructed = await reconstructPayloadFromStripes(perStripe);421  } catch (err) {422    throw new CodexError(423      "RECONSTRUCTION_SIMULATION_FAILED",424      `Codex reconstruction simulation failed. Lost chunks: ${removedSummary.join("; ")}. ${err instanceof Error ? err.message : String(err)}`,425    );426  }427428  if (reconstructed.verifyCode !== originalPayload.verifyCode) {429    throw new CodexError(430      "RECONSTRUCTION_VERIFY_CODE_MISMATCH",431      "Reconstructed Codex verify code does not match original",432    );433  }434  if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {435    throw new CodexError(436      "RECONSTRUCTION_FINGERPRINT_MISMATCH",437      "Reconstructed vault fingerprint does not match original",438    );439  }440  if (reconstructed.entryCount !== originalPayload.entryCount) {441    throw new CodexError(442      "RECONSTRUCTION_COUNT_MISMATCH",443      `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,444    );445  }446}447448/**449 * Pick `count` distinct indices uniformly at random from [0, total). Used by450 * the verification simulations to model "any two chunks could be lost." Not451 * security-sensitive — we just need diverse coverage across regenerations.452 */453function pickRandomIndices(total: number, count: number): number[] {454  const indices = Array.from({ length: total }, (_, i) => i);455  for (let i = indices.length - 1; i > 0; i--) {456    const j = Math.floor(Math.random() * (i + 1));457    const tmp = indices[i]!;458    indices[i] = indices[j]!;459    indices[j] = tmp;460  }461  return indices.slice(0, count);462}