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";424344454647export interface StripeChunks {48 49 chunks: Map<number, Uint8Array>;50 dataShards: number;51 totalChunks: number;52}53545556export async function generateCodex(57 deps: CodexDeps,58 onProgress?: CodexProgress,59): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {60 61 if (!deps.isVaultUnlocked()) {62 throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");63 }6465 66 const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);67 const verifyCode = await deps.getVerifyCode();6869 70 const payload: CodexPayload = {71 version: 2,72 verifyCode,73 vaultBlob,74 timestamp,75 entryCount,76 vaultFingerprint,77 generatedAt: Date.now(),78 };7980 81 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 88 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 95 96 97 98 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 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 140 141 onProgress?.("verify");142 await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);143144 145 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 162 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}171172173174175176177178179180export 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 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 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 211 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}234235236237238239240241export 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 248 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 261 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}273274275276export 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}285286287288async 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 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}329330331332333334335336337async 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 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 396 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}447448449450451452453function 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}