12345678910111213141516import { deflate, inflate } from "pako";17import {18 CHUNK_SIZE,19 CODES_PER_PAGE,20 CodexError,21 PARITY_SHARDS,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 } = await encodeWithErasure(compressed);74 await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);7576 77 const envelopes: ChunkEnvelope[] = [];78 for (let i = 0; i < dataChunks.length; i++) {79 envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));80 }81 for (let i = 0; i < parityChunks.length; i++) {82 envelopes.push(83 await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),84 );85 }8687 88 const aztecPngs: Uint8Array[] = [];89 for (let i = 0; i < envelopes.length; i++) {90 onProgress?.("aztec", i, envelopes.length);91 const img = await generateVerifiedAztec(envelopes[i]!);92 aztecPngs.push(img.pngBytes);93 }94 onProgress?.("aztec", envelopes.length, envelopes.length);9596 97 onProgress?.("verify");98 await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);99100 101 onProgress?.("pdf");102 const manifest: CodexManifest = {103 verifyCode,104 totalChunks,105 dataChunks: dataShards,106 parityChunks: PARITY_SHARDS,107 pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),108 entryCount,109 vaultFingerprint,110 generatedAt: payload.generatedAt,111 };112 const pdfBytes = await renderCodexPDF(aztecPngs, manifest);113114 115 if (!pdfBytes || pdfBytes.length < 1000) {116 throw new CodexError(117 "PDF_GENERATION_FAILED",118 `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,119 );120 }121122 return { pdfBytes, manifest };123}124125126127128129130131132133134135export async function reconstructPayloadFromChunks(136 envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,137 dataShards: number,138 totalChunks: number,139): Promise<CodexPayload> {140 const chunkBytes = new Map<number, Uint8Array>();141 for (const [index, value] of envelopes) {142 if (value instanceof Uint8Array) {143 chunkBytes.set(index, value);144 } else {145 chunkBytes.set(index, base64UrlToBytes(value.d));146 }147 }148149 const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);150151 152 153 154 let inflated: Uint8Array;155 try {156 inflated = inflate(recovered);157 } catch (err) {158 throw new CodexError(159 "RECONSTRUCTION_SIMULATION_FAILED",160 `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,161 );162 }163164 const json = new TextDecoder().decode(inflated);165 let payload: CodexPayload;166 try {167 payload = JSON.parse(json) as CodexPayload;168 } catch (err) {169 throw new CodexError(170 "RECONSTRUCTION_SIMULATION_FAILED",171 `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,172 );173 }174 return payload;175}176177178179180181182183export async function estimateCodexPages(184 slotA: string,185 slotB: string | null,186): Promise<number> {187 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });188 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));189 190 191 const stub: CodexPayload = {192 version: 2,193 verifyCode: "PX-XXXX-XXXX",194 vaultBlob,195 timestamp: 0,196 entryCount: 0,197 vaultFingerprint: "0000000000000000",198 generatedAt: 0,199 };200 const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });201 const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));202 const totalChunks = dataShards + PARITY_SHARDS;203 return Math.ceil(totalChunks / CODES_PER_PAGE);204}205206207208export function pickConfirmationPositions(): [number, number, number] {209 const positions = Array.from({ length: 12 }, (_, i) => i + 1);210 for (let i = positions.length - 1; i > 0; i--) {211 const j = Math.floor(Math.random() * (i + 1));212 [positions[i], positions[j]] = [positions[j]!, positions[i]!];213 }214 const picked = [positions[0]!, positions[1]!, positions[2]!] as const;215 return [...picked].sort((a, b) => a - b) as [number, number, number];216}217218219220async function buildVaultBlob(deps: CodexDeps): Promise<{221 vaultBlob: string;222 timestamp: number;223 entryCount: number;224 vaultFingerprint: string;225}> {226 const slotA = await deps.getSlotA();227 if (!slotA) {228 throw new CodexError("VAULT_EMPTY", "No vault data found");229 }230 const slotB = await deps.getSlotB();231 const lastModified = await deps.getLastModified();232 const entries = await deps.getAllEntries();233234 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });235 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));236237 238 const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);239240 const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();241 const liveEntryCount = entries.filter((e) => !e.deletedAt).length;242243 return {244 vaultBlob,245 timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),246 entryCount: liveEntryCount,247 vaultFingerprint: fingerprint,248 };249}250251function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {252 const inflated = inflate(compressed);253 const restored = new TextDecoder().decode(inflated);254 if (restored !== expectedJson) {255 throw new CodexError(256 "COMPRESSION_ROUNDTRIP_FAILED",257 "Compression verification failed: inflate(deflate(payload)) did not match the original",258 );259 }260}261262263264265266267268async function verifyErasureCoding(269 dataChunks: Uint8Array[],270 parityChunks: Uint8Array[],271 dataShards: number,272 totalChunks: number,273 originalCompressed: Uint8Array,274): Promise<void> {275 const present = new Map<number, Uint8Array>();276 dataChunks.forEach((chunk, i) => present.set(i, chunk));277 parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));278279 const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));280 for (const idx of removed) present.delete(idx);281282 let recovered: Uint8Array;283 try {284 recovered = await decodeWithErasure(present, dataShards, totalChunks);285 } catch (err) {286 if (err instanceof CodexError) {287 throw new CodexError(288 "ERASURE_CODING_VERIFICATION_FAILED",289 `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,290 );291 }292 throw err;293 }294295 for (let i = 0; i < originalCompressed.length; i++) {296 if (recovered[i] !== originalCompressed[i]) {297 throw new CodexError(298 "ERASURE_CODING_CONTENT_MISMATCH",299 `Reconstructed data does not match original at byte ${i}`,300 );301 }302 }303}304305async function simulateAndVerifyReconstruction(306 envelopes: ChunkEnvelope[],307 dataShards: number,308 totalChunks: number,309 originalPayload: CodexPayload,310): Promise<void> {311 const present = new Map<number, Uint8Array>();312 envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));313314 const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));315 for (const idx of removed) present.delete(idx);316317 let reconstructed: CodexPayload;318 try {319 reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);320 } catch (err) {321 throw new CodexError(322 "RECONSTRUCTION_SIMULATION_FAILED",323 `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,324 );325 }326327 if (reconstructed.verifyCode !== originalPayload.verifyCode) {328 throw new CodexError(329 "RECONSTRUCTION_VERIFY_CODE_MISMATCH",330 "Reconstructed Codex verify code does not match original",331 );332 }333 if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {334 throw new CodexError(335 "RECONSTRUCTION_FINGERPRINT_MISMATCH",336 "Reconstructed vault fingerprint does not match original",337 );338 }339 if (reconstructed.entryCount !== originalPayload.entryCount) {340 throw new CodexError(341 "RECONSTRUCTION_COUNT_MISMATCH",342 `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,343 );344 }345}346347348349350351352function pickRandomIndices(total: number, count: number): number[] {353 const indices = Array.from({ length: total }, (_, i) => i);354 for (let i = indices.length - 1; i > 0; i--) {355 const j = Math.floor(Math.random() * (i + 1));356 const tmp = indices[i]!;357 indices[i] = indices[j]!;358 indices[j] = tmp;359 }360 return indices.slice(0, count);361}