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 { WORD_LIST } from "../../shared/recovery-word-list";18import {19  CHUNK_SIZE,20  CODES_PER_PAGE,21  CodexError,22  PARITY_SHARDS,23  type ChunkEnvelope,24  type CodexDeps,25  type CodexManifest,26  type CodexOptions,27  type CodexPayload,28} from "../../shared/codex-types";29import { generateVerifiedAztec } from "./aztec";30import { decodeWithErasure, encodeWithErasure } from "./erasure";31import {32  base64UrlToBytes,33  bytesToBase64Url,34  buildChunkEnvelope,35  sha256Hex,36} from "./helpers";37import { renderCodexPDF } from "./pdf";3839// ── Public surface ──────────────────────────────────────────────────────────4041export async function generateCodex(42  deps: CodexDeps,43  options: CodexOptions,44): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {45  // STEP 0 — preconditions46  if (!deps.isVaultUnlocked()) {47    throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");48  }4950  // STEP 1 — vault blob51  const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);52  const verifyCode = await deps.getVerifyCode();5354  // STEP 2 — recovery words, if requested55  let recoveryWords: string[] | null = null;56  if (options.includeRecoveryWords) {57    recoveryWords = await loadAndValidateRecoveryWords(deps);58  }5960  // STEP 3 — canonical payload61  const payload: CodexPayload = {62    version: 2,63    verifyCode,64    vaultBlob,65    timestamp,66    entryCount,67    vaultFingerprint,68    generatedAt: Date.now(),69    includesRecoveryWords: options.includeRecoveryWords,70  };7172  // STEP 4 — compress + round-trip verify73  const payloadJson = JSON.stringify(payload);74  const payloadBytes = new TextEncoder().encode(payloadJson);75  const compressed = deflate(payloadBytes, { level: 9 });76  assertCompressionRoundTrip(compressed, payloadJson);7778  // STEP 5 — erasure encode + simulated-loss verify79  const { dataChunks, parityChunks, totalChunks, dataShards } = await encodeWithErasure(compressed);80  await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);8182  // STEP 6 — envelopes (data first, then parity, preserving global index ordering)83  const envelopes: ChunkEnvelope[] = [];84  for (let i = 0; i < dataChunks.length; i++) {85    envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));86  }87  for (let i = 0; i < parityChunks.length; i++) {88    envelopes.push(89      await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),90    );91  }9293  // STEP 7 — verified Aztec PNGs (round-trip per code where DOM is available)94  const aztecPngs: Uint8Array[] = [];95  for (const env of envelopes) {96    const img = await generateVerifiedAztec(env);97    aztecPngs.push(img.pngBytes);98  }99100  // STEP 8 — full reconstruction simulation (lose 2 random envelopes, rebuild)101  await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);102103  // STEP 9 — render PDF104  const manifest: CodexManifest = {105    verifyCode,106    totalChunks,107    dataChunks: dataShards,108    parityChunks: PARITY_SHARDS,109    pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),110    entryCount,111    vaultFingerprint,112    generatedAt: payload.generatedAt,113    includesRecoveryWords: options.includeRecoveryWords,114  };115  const pdfBytes = await renderCodexPDF(aztecPngs, manifest, recoveryWords);116117  // STEP 10 — sanity check the PDF118  if (!pdfBytes || pdfBytes.length < 1000) {119    throw new CodexError(120      "PDF_GENERATION_FAILED",121      `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,122    );123  }124125  return { pdfBytes, manifest };126}127128/**129 * Reconstruct a CodexPayload from a partial set of envelopes (or raw chunk130 * bytes). Exported because the restore-side agent reuses this exact path to131 * rebuild the payload from scanned codes.132 *133 * Accepts either:134 *   - Map<index, ChunkEnvelope>  — typical scan path135 *   - Map<index, Uint8Array>     — raw bytes, used by the in-flight136 *                                  simulation during generation137 */138export async function reconstructPayloadFromChunks(139  envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,140  dataShards: number,141  totalChunks: number,142): Promise<CodexPayload> {143  const chunkBytes = new Map<number, Uint8Array>();144  for (const [index, value] of envelopes) {145    if (value instanceof Uint8Array) {146      chunkBytes.set(index, value);147    } else {148      chunkBytes.set(index, base64UrlToBytes(value.d));149    }150  }151152  const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);153154  // The trailing pad bytes are zeros up to the next chunk boundary. pako's155  // inflate tolerates trailing junk after the deflate stream end marker, so156  // we can hand it the padded buffer directly.157  let inflated: Uint8Array;158  try {159    inflated = inflate(recovered);160  } catch (err) {161    throw new CodexError(162      "RECONSTRUCTION_SIMULATION_FAILED",163      `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,164    );165  }166167  const json = new TextDecoder().decode(inflated);168  let payload: CodexPayload;169  try {170    payload = JSON.parse(json) as CodexPayload;171  } catch (err) {172    throw new CodexError(173      "RECONSTRUCTION_SIMULATION_FAILED",174      `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,175    );176  }177  return payload;178}179180/**181 * Predict how many pages a Codex will occupy without doing the work to182 * generate one. Used by the popup's ESTIMATE message to drive the "this will183 * be N pages" UI hint before the user commits to printing. Speculative184 * compression is fine here — we never persist the result.185 */186export async function estimateCodexPages(187  slotA: string,188  slotB: string | null,189): Promise<number> {190  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });191  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));192  // Stand-in payload — the exact verify code / timestamp / fingerprint don't193  // affect the compressed size enough to matter for a page count estimate.194  const stub: CodexPayload = {195    version: 2,196    verifyCode: "PX-XXXX-XXXX",197    vaultBlob,198    timestamp: 0,199    entryCount: 0,200    vaultFingerprint: "0000000000000000",201    generatedAt: 0,202    includesRecoveryWords: false,203  };204  const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });205  const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));206  const totalChunks = dataShards + PARITY_SHARDS;207  return Math.ceil(totalChunks / CODES_PER_PAGE);208}209210// ── Internal helpers ────────────────────────────────────────────────────────211212async function buildVaultBlob(deps: CodexDeps): Promise<{213  vaultBlob: string;214  timestamp: number;215  entryCount: number;216  vaultFingerprint: string;217}> {218  const slotA = await deps.getSlotA();219  if (!slotA) {220    throw new CodexError("VAULT_EMPTY", "No vault data found");221  }222  const slotB = await deps.getSlotB();223  const lastModified = await deps.getLastModified();224  const entries = await deps.getAllEntries();225226  const combined = JSON.stringify({ a: slotA, b: slotB ?? null });227  const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));228229  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 16);230231  const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();232  const liveEntryCount = entries.filter((e) => !e.deletedAt).length;233234  return {235    vaultBlob,236    timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),237    entryCount: liveEntryCount,238    vaultFingerprint: fingerprint,239  };240}241242async function loadAndValidateRecoveryWords(deps: CodexDeps): Promise<string[]> {243  const words = await deps.getRecoveryWords();244  if (!words || words.length !== 12) {245    throw new CodexError(246      "RECOVERY_WORDS_MISSING",247      "Recovery words not found. Generate recovery words in Settings → Security first.",248    );249  }250  for (const word of words) {251    if (!WORD_LIST.includes(word)) {252      throw new CodexError(253        "RECOVERY_WORDS_INVALID",254        `Recovery word "${word}" is not valid. Please regenerate your recovery words in Settings → Security.`,255      );256    }257  }258  return words;259}260261function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {262  const inflated = inflate(compressed);263  const restored = new TextDecoder().decode(inflated);264  if (restored !== expectedJson) {265    throw new CodexError(266      "COMPRESSION_ROUNDTRIP_FAILED",267      "Compression verification failed: inflate(deflate(payload)) did not match the original",268    );269  }270}271272/**273 * Confirm the encoded shards can be reconstructed from a partial set. We274 * drop two random shards (the maximum loss our PARITY_SHARDS budget tolerates)275 * and check byte-for-byte that the recovered prefix equals the original276 * compressed blob.277 */278async function verifyErasureCoding(279  dataChunks: Uint8Array[],280  parityChunks: Uint8Array[],281  dataShards: number,282  totalChunks: number,283  originalCompressed: Uint8Array,284): Promise<void> {285  const present = new Map<number, Uint8Array>();286  dataChunks.forEach((chunk, i) => present.set(i, chunk));287  parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));288289  const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));290  for (const idx of removed) present.delete(idx);291292  let recovered: Uint8Array;293  try {294    recovered = await decodeWithErasure(present, dataShards, totalChunks);295  } catch (err) {296    if (err instanceof CodexError) {297      throw new CodexError(298        "ERASURE_CODING_VERIFICATION_FAILED",299        `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,300      );301    }302    throw err;303  }304305  for (let i = 0; i < originalCompressed.length; i++) {306    if (recovered[i] !== originalCompressed[i]) {307      throw new CodexError(308        "ERASURE_CODING_CONTENT_MISMATCH",309        `Reconstructed data does not match original at byte ${i}`,310      );311    }312  }313}314315async function simulateAndVerifyReconstruction(316  envelopes: ChunkEnvelope[],317  dataShards: number,318  totalChunks: number,319  originalPayload: CodexPayload,320): Promise<void> {321  const present = new Map<number, Uint8Array>();322  envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));323324  const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));325  for (const idx of removed) present.delete(idx);326327  let reconstructed: CodexPayload;328  try {329    reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);330  } catch (err) {331    throw new CodexError(332      "RECONSTRUCTION_SIMULATION_FAILED",333      `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,334    );335  }336337  if (reconstructed.verifyCode !== originalPayload.verifyCode) {338    throw new CodexError(339      "RECONSTRUCTION_VERIFY_CODE_MISMATCH",340      "Reconstructed Codex verify code does not match original",341    );342  }343  if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {344    throw new CodexError(345      "RECONSTRUCTION_FINGERPRINT_MISMATCH",346      "Reconstructed vault fingerprint does not match original",347    );348  }349  if (reconstructed.entryCount !== originalPayload.entryCount) {350    throw new CodexError(351      "RECONSTRUCTION_COUNT_MISMATCH",352      `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,353    );354  }355}356357/**358 * Pick `count` distinct indices uniformly at random from [0, total). Used by359 * the verification simulations to model "any two chunks could be lost." Not360 * security-sensitive — we just need diverse coverage across regenerations.361 */362function pickRandomIndices(total: number, count: number): number[] {363  const indices = Array.from({ length: total }, (_, i) => i);364  for (let i = indices.length - 1; i > 0; i--) {365    const j = Math.floor(Math.random() * (i + 1));366    const tmp = indices[i]!;367    indices[i] = indices[j]!;368    indices[j] = tmp;369  }370  return indices.slice(0, count);371}