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 onProgress?.("aztec", envelopes.length, envelopes.length);139140 141 142 onProgress?.("verify");143 await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);144145 146 onProgress?.("pdf");147 const manifest: CodexManifest = {148 verifyCode,149 totalChunks,150 dataChunks: stripeResult.totalDataChunks,151 parityChunks: stripeResult.totalParityChunks,152 stripeCount,153 dataPerStripe: stripeResult.stripes.map((s) => s.dataShards),154 parityPerStripe: stripeResult.stripes.map((s) => s.parityShards),155 pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),156 entryCount,157 vaultFingerprint,158 generatedAt: payload.generatedAt,159 };160 const pdfBytes = await renderCodexPDF(aztecPngs, manifest);161162 163 if (!pdfBytes || pdfBytes.length < 1000) {164 throw new CodexError(165 "PDF_GENERATION_FAILED",166 `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,167 );168 }169170 return { pdfBytes, manifest };171}172173174175176177178179180181export async function reconstructPayloadFromStripes(182 stripes: StripeChunks[],183): Promise<CodexPayload> {184 if (stripes.length === 0) {185 throw new CodexError(186 "INSUFFICIENT_CHUNKS",187 "Cannot reconstruct payload: no stripes provided",188 );189 }190191 192 const stripeData: Uint8Array[] = [];193 for (const stripe of stripes) {194 const recovered = await decodeWithErasure(195 stripe.chunks,196 stripe.dataShards,197 stripe.totalChunks,198 );199 stripeData.push(recovered);200 }201202 203 const totalLen = stripeData.reduce((s, b) => s + b.length, 0);204 const combined = new Uint8Array(totalLen);205 let offset = 0;206 for (const part of stripeData) {207 combined.set(part, offset);208 offset += part.length;209 }210211 212 213 let inflated: Uint8Array;214 try {215 inflated = inflate(combined);216 } catch (err) {217 throw new CodexError(218 "RECONSTRUCTION_SIMULATION_FAILED",219 `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,220 );221 }222223 const json = new TextDecoder().decode(inflated);224 let payload: CodexPayload;225 try {226 payload = JSON.parse(json) as CodexPayload;227 } catch (err) {228 throw new CodexError(229 "RECONSTRUCTION_SIMULATION_FAILED",230 `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,231 );232 }233 return payload;234}235236237238239240241242export async function estimateCodexPages(243 slotA: string,244 slotB: string | null,245): Promise<{ pages: number; parityShards: number }> {246 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });247 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));248 249 250 const stub: CodexPayload = {251 version: 2,252 verifyCode: "PX-XXXX-XXXX",253 vaultBlob,254 timestamp: 0,255 entryCount: 0,256 vaultFingerprint: "0000000000000000",257 generatedAt: 0,258 };259 const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });260 const totalDataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));261 262 263 const stripeShardCounts = planStripeShardCounts(totalDataShards);264 let totalChunks = 0;265 let totalParity = 0;266 for (const dataShards of stripeShardCounts) {267 const parity = computeParityShards(dataShards);268 totalChunks += dataShards + parity;269 totalParity += parity;270 }271 const pages = Math.ceil(totalChunks / CODES_PER_PAGE);272 return { pages, parityShards: totalParity };273}274275276277async function buildVaultBlob(deps: CodexDeps): Promise<{278 vaultBlob: string;279 timestamp: number;280 entryCount: number;281 vaultFingerprint: string;282}> {283 const slotA = await deps.getSlotA();284 if (!slotA) {285 throw new CodexError("VAULT_EMPTY", "No vault data found");286 }287 const slotB = await deps.getSlotB();288 const lastModified = await deps.getLastModified();289 const entries = await deps.getAllEntries();290291 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });292 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));293294 295 const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);296297 const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();298 const liveEntryCount = entries.filter((e) => !e.deletedAt).length;299300 return {301 vaultBlob,302 timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),303 entryCount: liveEntryCount,304 vaultFingerprint: fingerprint,305 };306}307308function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {309 const inflated = inflate(compressed);310 const restored = new TextDecoder().decode(inflated);311 if (restored !== expectedJson) {312 throw new CodexError(313 "COMPRESSION_ROUNDTRIP_FAILED",314 "Compression verification failed: inflate(deflate(payload)) did not match the original",315 );316 }317}318319320321322323324325326async function verifyErasureCoding(327 stripes: EncodeResult[],328 originalCompressed: Uint8Array,329): Promise<void> {330 let byteCursor = 0;331 for (let sIdx = 0; sIdx < stripes.length; sIdx++) {332 const stripe = stripes[sIdx]!;333 const present = new Map<number, Uint8Array>();334 stripe.dataChunks.forEach((chunk, i) => present.set(i, chunk));335 stripe.parityChunks.forEach((chunk, i) =>336 present.set(stripe.dataShards + i, chunk),337 );338339 const removed = pickRandomIndices(340 stripe.totalChunks,341 Math.min(stripe.parityShards, stripe.totalChunks - 1),342 );343 for (const idx of removed) present.delete(idx);344345 let recovered: Uint8Array;346 try {347 recovered = await decodeWithErasure(348 present,349 stripe.dataShards,350 stripe.totalChunks,351 );352 } catch (err) {353 if (err instanceof CodexError) {354 throw new CodexError(355 "ERASURE_CODING_VERIFICATION_FAILED",356 `Erasure coding simulation failed for stripe ${sIdx} when losing chunks ${removed.join(", ")}: ${err.message}`,357 );358 }359 throw err;360 }361362 363 const stripeRealBytes = Math.min(364 stripe.dataShards * CHUNK_SIZE,365 Math.max(0, originalCompressed.length - byteCursor),366 );367 for (let i = 0; i < stripeRealBytes; i++) {368 if (recovered[i] !== originalCompressed[byteCursor + i]) {369 throw new CodexError(370 "ERASURE_CODING_CONTENT_MISMATCH",371 `Reconstructed stripe ${sIdx} differs from original at byte ${byteCursor + i}`,372 );373 }374 }375 byteCursor += stripe.dataShards * CHUNK_SIZE;376 }377}378379async function simulateAndVerifyReconstruction(380 envelopes: ChunkEnvelope[],381 stripes: EncodeResult[],382 originalPayload: CodexPayload,383): Promise<void> {384 385 386 const perStripe: StripeChunks[] = stripes.map((s) => ({387 chunks: new Map<number, Uint8Array>(),388 dataShards: s.dataShards,389 totalChunks: s.totalChunks,390 }));391 for (const env of envelopes) {392 const stripe = env.v === 3 ? (env.s ?? 0) : 0;393 perStripe[stripe]!.chunks.set(env.i, base64UrlToBytes(env.d));394 }395396 const removedSummary: string[] = [];397 for (let sIdx = 0; sIdx < stripes.length; sIdx++) {398 const stripe = stripes[sIdx]!;399 const removed = pickRandomIndices(400 stripe.totalChunks,401 Math.min(stripe.parityShards, stripe.totalChunks - 1),402 );403 for (const idx of removed) perStripe[sIdx]!.chunks.delete(idx);404 removedSummary.push(`stripe ${sIdx}: [${removed.join(", ")}]`);405 }406407 let reconstructed: CodexPayload;408 try {409 reconstructed = await reconstructPayloadFromStripes(perStripe);410 } catch (err) {411 throw new CodexError(412 "RECONSTRUCTION_SIMULATION_FAILED",413 `Codex reconstruction simulation failed. Lost chunks: ${removedSummary.join("; ")}. ${err instanceof Error ? err.message : String(err)}`,414 );415 }416417 if (reconstructed.verifyCode !== originalPayload.verifyCode) {418 throw new CodexError(419 "RECONSTRUCTION_VERIFY_CODE_MISMATCH",420 "Reconstructed Codex verify code does not match original",421 );422 }423 if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {424 throw new CodexError(425 "RECONSTRUCTION_FINGERPRINT_MISMATCH",426 "Reconstructed vault fingerprint does not match original",427 );428 }429 if (reconstructed.entryCount !== originalPayload.entryCount) {430 throw new CodexError(431 "RECONSTRUCTION_COUNT_MISMATCH",432 `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,433 );434 }435}436437438439440441442function pickRandomIndices(total: number, count: number): number[] {443 const indices = Array.from({ length: total }, (_, i) => i);444 for (let i = indices.length - 1; i > 0; i--) {445 const j = Math.floor(Math.random() * (i + 1));446 const tmp = indices[i]!;447 indices[i] = indices[j]!;448 indices[j] = tmp;449 }450 return indices.slice(0, count);451}