12345678910111213141516import { 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";444546474849export interface StripeChunks {50 51 chunks: Map<number, Uint8Array>;52 dataShards: number;53 totalChunks: number;54}55565758export async function generateCodex(59 deps: CodexDeps,60 onProgress?: CodexProgress,61): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {62 63 if (!deps.isVaultUnlocked()) {64 throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");65 }6667 68 const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);69 const verifyCode = await deps.getVerifyCode();7071 72 const payload: CodexPayload = {73 version: 2,74 verifyCode,75 vaultBlob,76 timestamp,77 entryCount,78 vaultFingerprint,79 generatedAt: Date.now(),80 };8182 83 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 90 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 97 98 99 100 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 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 139 140 141 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 151 152 onProgress?.("verify");153 await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);154155 156 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 173 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}182183184185186187188189190191export 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 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 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 222 223 224 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}247248249250251252253254export 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 261 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 274 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}286287288289async 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 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}319320321322export 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}351352353354355356357358359async 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 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 418 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}469470471472473474475function 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}