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 { decodeWithErasure, encodeWithErasure } from "./erasure";30import {31 base64UrlToBytes,32 bytesToBase64Url,33 buildChunkEnvelope,34 sha256Hex,35} from "./helpers";36import { renderCodexPDF } from "./pdf";37383940export async function generateCodex(41 deps: CodexDeps,42 onProgress?: CodexProgress,43): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {44 45 if (!deps.isVaultUnlocked()) {46 throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");47 }4849 50 const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);51 const verifyCode = await deps.getVerifyCode();5253 54 const payload: CodexPayload = {55 version: 2,56 verifyCode,57 vaultBlob,58 timestamp,59 entryCount,60 vaultFingerprint,61 generatedAt: Date.now(),62 };6364 65 onProgress?.("compress");66 const payloadJson = JSON.stringify(payload);67 const payloadBytes = new TextEncoder().encode(payloadJson);68 const compressed = deflate(payloadBytes, { level: 9 });69 assertCompressionRoundTrip(compressed, payloadJson);7071 72 onProgress?.("erasure");73 const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =74 await encodeWithErasure(compressed);75 await verifyErasureCoding(76 dataChunks,77 parityChunks,78 dataShards,79 parityShards,80 totalChunks,81 compressed,82 );8384 85 const envelopes: ChunkEnvelope[] = [];86 for (let i = 0; i < dataChunks.length; i++) {87 envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));88 }89 for (let i = 0; i < parityChunks.length; i++) {90 envelopes.push(91 await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),92 );93 }9495 96 const aztecPngs: Uint8Array[] = [];97 for (let i = 0; i < envelopes.length; i++) {98 onProgress?.("aztec", i, envelopes.length);99 const img = await generateVerifiedAztec(envelopes[i]!);100 aztecPngs.push(img.pngBytes);101 }102 onProgress?.("aztec", envelopes.length, envelopes.length);103104 105 106 onProgress?.("verify");107 await simulateAndVerifyReconstruction(108 envelopes,109 dataShards,110 parityShards,111 totalChunks,112 payload,113 );114115 116 onProgress?.("pdf");117 const manifest: CodexManifest = {118 verifyCode,119 totalChunks,120 dataChunks: dataShards,121 parityChunks: parityShards,122 pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),123 entryCount,124 vaultFingerprint,125 generatedAt: payload.generatedAt,126 };127 const pdfBytes = await renderCodexPDF(aztecPngs, manifest);128129 130 if (!pdfBytes || pdfBytes.length < 1000) {131 throw new CodexError(132 "PDF_GENERATION_FAILED",133 `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,134 );135 }136137 return { pdfBytes, manifest };138}139140141142143144145146147148149150export async function reconstructPayloadFromChunks(151 envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,152 dataShards: number,153 totalChunks: number,154): Promise<CodexPayload> {155 const chunkBytes = new Map<number, Uint8Array>();156 for (const [index, value] of envelopes) {157 if (value instanceof Uint8Array) {158 chunkBytes.set(index, value);159 } else {160 chunkBytes.set(index, base64UrlToBytes(value.d));161 }162 }163164 const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);165166 167 168 169 let inflated: Uint8Array;170 try {171 inflated = inflate(recovered);172 } catch (err) {173 throw new CodexError(174 "RECONSTRUCTION_SIMULATION_FAILED",175 `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,176 );177 }178179 const json = new TextDecoder().decode(inflated);180 let payload: CodexPayload;181 try {182 payload = JSON.parse(json) as CodexPayload;183 } catch (err) {184 throw new CodexError(185 "RECONSTRUCTION_SIMULATION_FAILED",186 `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,187 );188 }189 return payload;190}191192193194195196197198export async function estimateCodexPages(199 slotA: string,200 slotB: string | null,201): Promise<{ pages: number; parityShards: number }> {202 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });203 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));204 205 206 const stub: CodexPayload = {207 version: 2,208 verifyCode: "PX-XXXX-XXXX",209 vaultBlob,210 timestamp: 0,211 entryCount: 0,212 vaultFingerprint: "0000000000000000",213 generatedAt: 0,214 };215 const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });216 const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));217 const parityShards = computeParityShards(dataShards);218 const totalChunks = dataShards + parityShards;219 const pages = Math.ceil(totalChunks / CODES_PER_PAGE);220 return { pages, parityShards };221}222223224225export function pickConfirmationPositions(): [number, number, number] {226 const positions = Array.from({ length: 12 }, (_, i) => i + 1);227 for (let i = positions.length - 1; i > 0; i--) {228 const j = Math.floor(Math.random() * (i + 1));229 [positions[i], positions[j]] = [positions[j]!, positions[i]!];230 }231 const picked = [positions[0]!, positions[1]!, positions[2]!] as const;232 return [...picked].sort((a, b) => a - b) as [number, number, number];233}234235236237async function buildVaultBlob(deps: CodexDeps): Promise<{238 vaultBlob: string;239 timestamp: number;240 entryCount: number;241 vaultFingerprint: string;242}> {243 const slotA = await deps.getSlotA();244 if (!slotA) {245 throw new CodexError("VAULT_EMPTY", "No vault data found");246 }247 const slotB = await deps.getSlotB();248 const lastModified = await deps.getLastModified();249 const entries = await deps.getAllEntries();250251 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });252 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));253254 255 const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);256257 const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();258 const liveEntryCount = entries.filter((e) => !e.deletedAt).length;259260 return {261 vaultBlob,262 timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),263 entryCount: liveEntryCount,264 vaultFingerprint: fingerprint,265 };266}267268function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {269 const inflated = inflate(compressed);270 const restored = new TextDecoder().decode(inflated);271 if (restored !== expectedJson) {272 throw new CodexError(273 "COMPRESSION_ROUNDTRIP_FAILED",274 "Compression verification failed: inflate(deflate(payload)) did not match the original",275 );276 }277}278279280281282283284285async function verifyErasureCoding(286 dataChunks: Uint8Array[],287 parityChunks: Uint8Array[],288 dataShards: number,289 parityShards: number,290 totalChunks: number,291 originalCompressed: Uint8Array,292): Promise<void> {293 const present = new Map<number, Uint8Array>();294 dataChunks.forEach((chunk, i) => present.set(i, chunk));295 parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));296297 const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));298 for (const idx of removed) present.delete(idx);299300 let recovered: Uint8Array;301 try {302 recovered = await decodeWithErasure(present, dataShards, totalChunks);303 } catch (err) {304 if (err instanceof CodexError) {305 throw new CodexError(306 "ERASURE_CODING_VERIFICATION_FAILED",307 `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,308 );309 }310 throw err;311 }312313 for (let i = 0; i < originalCompressed.length; i++) {314 if (recovered[i] !== originalCompressed[i]) {315 throw new CodexError(316 "ERASURE_CODING_CONTENT_MISMATCH",317 `Reconstructed data does not match original at byte ${i}`,318 );319 }320 }321}322323async function simulateAndVerifyReconstruction(324 envelopes: ChunkEnvelope[],325 dataShards: number,326 parityShards: number,327 totalChunks: number,328 originalPayload: CodexPayload,329): Promise<void> {330 const present = new Map<number, Uint8Array>();331 envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));332333 const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));334 for (const idx of removed) present.delete(idx);335336 let reconstructed: CodexPayload;337 try {338 reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);339 } catch (err) {340 throw new CodexError(341 "RECONSTRUCTION_SIMULATION_FAILED",342 `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,343 );344 }345346 if (reconstructed.verifyCode !== originalPayload.verifyCode) {347 throw new CodexError(348 "RECONSTRUCTION_VERIFY_CODE_MISMATCH",349 "Reconstructed Codex verify code does not match original",350 );351 }352 if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {353 throw new CodexError(354 "RECONSTRUCTION_FINGERPRINT_MISMATCH",355 "Reconstructed vault fingerprint does not match original",356 );357 }358 if (reconstructed.entryCount !== originalPayload.entryCount) {359 throw new CodexError(360 "RECONSTRUCTION_COUNT_MISMATCH",361 `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,362 );363 }364}365366367368369370371function pickRandomIndices(total: number, count: number): number[] {372 const indices = Array.from({ length: total }, (_, i) => i);373 for (let i = indices.length - 1; i > 0; i--) {374 const j = Math.floor(Math.random() * (i + 1));375 const tmp = indices[i]!;376 indices[i] = indices[j]!;377 indices[j] = tmp;378 }379 return indices.slice(0, count);380}