12345678910111213141516import { 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";434445464748export interface StripeChunks {49 50 chunks: Map<number, Uint8Array>;51 dataShards: number;52 totalChunks: number;53}54555657export async function generateCodex(58 deps: CodexDeps,59 onProgress?: CodexProgress,60): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {61 62 if (!deps.isVaultUnlocked()) {63 throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");64 }6566 67 const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);68 const verifyCode = await deps.getVerifyCode();6970 71 const payload: CodexPayload = {72 version: 2,73 verifyCode,74 vaultBlob,75 timestamp,76 entryCount,77 vaultFingerprint,78 generatedAt: Date.now(),79 };8081 82 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 89 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 96 97 98 99 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 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 138 139 140 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 150 151 onProgress?.("verify");152 await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);153154 155 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 172 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}181182183184185186187188189190export 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 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 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 221 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}244245246247248249250251export 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 258 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 271 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}283284285286async 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 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}327328329330331332333334335async 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 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 394 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}445446447448449450451function 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}