12345678910111213141516import { deflate, inflate } from "pako";17import { WORD_LIST } from "../../shared/recovery-word-list";18import {19 CHUNK_SIZE,20 CODES_PER_PAGE,21 CodexError,22 PARITY_SHARDS,23 type ChunkEnvelope,24 type CodexDeps,25 type CodexManifest,26 type CodexOptions,27 type CodexPayload,28} from "../../shared/codex-types";29import { generateVerifiedAztec } from "./aztec";30import { decodeWithErasure, encodeWithErasure } from "./erasure";31import {32 base64UrlToBytes,33 bytesToBase64Url,34 buildChunkEnvelope,35 sha256Hex,36} from "./helpers";37import { renderCodexPDF } from "./pdf";38394041export async function generateCodex(42 deps: CodexDeps,43 options: CodexOptions,44): Promise<{ pdfBytes: Uint8Array; manifest: CodexManifest }> {45 46 if (!deps.isVaultUnlocked()) {47 throw new CodexError("VAULT_LOCKED", "Vault must be unlocked to generate a Codex");48 }4950 51 const { vaultBlob, timestamp, entryCount, vaultFingerprint } = await buildVaultBlob(deps);52 const verifyCode = await deps.getVerifyCode();5354 55 let recoveryWords: string[] | null = null;56 if (options.includeRecoveryWords) {57 recoveryWords = await loadAndValidateRecoveryWords(deps);58 }5960 61 const payload: CodexPayload = {62 version: 2,63 verifyCode,64 vaultBlob,65 timestamp,66 entryCount,67 vaultFingerprint,68 generatedAt: Date.now(),69 includesRecoveryWords: options.includeRecoveryWords,70 };7172 73 const payloadJson = JSON.stringify(payload);74 const payloadBytes = new TextEncoder().encode(payloadJson);75 const compressed = deflate(payloadBytes, { level: 9 });76 assertCompressionRoundTrip(compressed, payloadJson);7778 79 const { dataChunks, parityChunks, totalChunks, dataShards } = await encodeWithErasure(compressed);80 await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);8182 83 const envelopes: ChunkEnvelope[] = [];84 for (let i = 0; i < dataChunks.length; i++) {85 envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));86 }87 for (let i = 0; i < parityChunks.length; i++) {88 envelopes.push(89 await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),90 );91 }9293 94 const aztecPngs: Uint8Array[] = [];95 for (const env of envelopes) {96 const img = await generateVerifiedAztec(env);97 aztecPngs.push(img.pngBytes);98 }99100 101 await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);102103 104 const manifest: CodexManifest = {105 verifyCode,106 totalChunks,107 dataChunks: dataShards,108 parityChunks: PARITY_SHARDS,109 pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),110 entryCount,111 vaultFingerprint,112 generatedAt: payload.generatedAt,113 includesRecoveryWords: options.includeRecoveryWords,114 };115 const pdfBytes = await renderCodexPDF(aztecPngs, manifest, recoveryWords);116117 118 if (!pdfBytes || pdfBytes.length < 1000) {119 throw new CodexError(120 "PDF_GENERATION_FAILED",121 `PDF generation produced an invalid file (${pdfBytes?.length ?? 0} bytes)`,122 );123 }124125 return { pdfBytes, manifest };126}127128129130131132133134135136137138export async function reconstructPayloadFromChunks(139 envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,140 dataShards: number,141 totalChunks: number,142): Promise<CodexPayload> {143 const chunkBytes = new Map<number, Uint8Array>();144 for (const [index, value] of envelopes) {145 if (value instanceof Uint8Array) {146 chunkBytes.set(index, value);147 } else {148 chunkBytes.set(index, base64UrlToBytes(value.d));149 }150 }151152 const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);153154 155 156 157 let inflated: Uint8Array;158 try {159 inflated = inflate(recovered);160 } catch (err) {161 throw new CodexError(162 "RECONSTRUCTION_SIMULATION_FAILED",163 `Decompression failed during reconstruction: ${err instanceof Error ? err.message : String(err)}`,164 );165 }166167 const json = new TextDecoder().decode(inflated);168 let payload: CodexPayload;169 try {170 payload = JSON.parse(json) as CodexPayload;171 } catch (err) {172 throw new CodexError(173 "RECONSTRUCTION_SIMULATION_FAILED",174 `Reconstructed payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,175 );176 }177 return payload;178}179180181182183184185186export async function estimateCodexPages(187 slotA: string,188 slotB: string | null,189): Promise<number> {190 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });191 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));192 193 194 const stub: CodexPayload = {195 version: 2,196 verifyCode: "PX-XXXX-XXXX",197 vaultBlob,198 timestamp: 0,199 entryCount: 0,200 vaultFingerprint: "0000000000000000",201 generatedAt: 0,202 includesRecoveryWords: false,203 };204 const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });205 const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));206 const totalChunks = dataShards + PARITY_SHARDS;207 return Math.ceil(totalChunks / CODES_PER_PAGE);208}209210211212async function buildVaultBlob(deps: CodexDeps): Promise<{213 vaultBlob: string;214 timestamp: number;215 entryCount: number;216 vaultFingerprint: string;217}> {218 const slotA = await deps.getSlotA();219 if (!slotA) {220 throw new CodexError("VAULT_EMPTY", "No vault data found");221 }222 const slotB = await deps.getSlotB();223 const lastModified = await deps.getLastModified();224 const entries = await deps.getAllEntries();225226 const combined = JSON.stringify({ a: slotA, b: slotB ?? null });227 const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));228229 const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 16);230231 const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();232 const liveEntryCount = entries.filter((e) => !e.deletedAt).length;233234 return {235 vaultBlob,236 timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),237 entryCount: liveEntryCount,238 vaultFingerprint: fingerprint,239 };240}241242async function loadAndValidateRecoveryWords(deps: CodexDeps): Promise<string[]> {243 const words = await deps.getRecoveryWords();244 if (!words || words.length !== 12) {245 throw new CodexError(246 "RECOVERY_WORDS_MISSING",247 "Recovery words not found. Generate recovery words in Settings → Security first.",248 );249 }250 for (const word of words) {251 if (!WORD_LIST.includes(word)) {252 throw new CodexError(253 "RECOVERY_WORDS_INVALID",254 `Recovery word "${word}" is not valid. Please regenerate your recovery words in Settings → Security.`,255 );256 }257 }258 return words;259}260261function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {262 const inflated = inflate(compressed);263 const restored = new TextDecoder().decode(inflated);264 if (restored !== expectedJson) {265 throw new CodexError(266 "COMPRESSION_ROUNDTRIP_FAILED",267 "Compression verification failed: inflate(deflate(payload)) did not match the original",268 );269 }270}271272273274275276277278async function verifyErasureCoding(279 dataChunks: Uint8Array[],280 parityChunks: Uint8Array[],281 dataShards: number,282 totalChunks: number,283 originalCompressed: Uint8Array,284): Promise<void> {285 const present = new Map<number, Uint8Array>();286 dataChunks.forEach((chunk, i) => present.set(i, chunk));287 parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));288289 const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));290 for (const idx of removed) present.delete(idx);291292 let recovered: Uint8Array;293 try {294 recovered = await decodeWithErasure(present, dataShards, totalChunks);295 } catch (err) {296 if (err instanceof CodexError) {297 throw new CodexError(298 "ERASURE_CODING_VERIFICATION_FAILED",299 `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,300 );301 }302 throw err;303 }304305 for (let i = 0; i < originalCompressed.length; i++) {306 if (recovered[i] !== originalCompressed[i]) {307 throw new CodexError(308 "ERASURE_CODING_CONTENT_MISMATCH",309 `Reconstructed data does not match original at byte ${i}`,310 );311 }312 }313}314315async function simulateAndVerifyReconstruction(316 envelopes: ChunkEnvelope[],317 dataShards: number,318 totalChunks: number,319 originalPayload: CodexPayload,320): Promise<void> {321 const present = new Map<number, Uint8Array>();322 envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));323324 const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));325 for (const idx of removed) present.delete(idx);326327 let reconstructed: CodexPayload;328 try {329 reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);330 } catch (err) {331 throw new CodexError(332 "RECONSTRUCTION_SIMULATION_FAILED",333 `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,334 );335 }336337 if (reconstructed.verifyCode !== originalPayload.verifyCode) {338 throw new CodexError(339 "RECONSTRUCTION_VERIFY_CODE_MISMATCH",340 "Reconstructed Codex verify code does not match original",341 );342 }343 if (reconstructed.vaultFingerprint !== originalPayload.vaultFingerprint) {344 throw new CodexError(345 "RECONSTRUCTION_FINGERPRINT_MISMATCH",346 "Reconstructed vault fingerprint does not match original",347 );348 }349 if (reconstructed.entryCount !== originalPayload.entryCount) {350 throw new CodexError(351 "RECONSTRUCTION_COUNT_MISMATCH",352 `Reconstructed entry count (${reconstructed.entryCount}) does not match original (${originalPayload.entryCount})`,353 );354 }355}356357358359360361362function pickRandomIndices(total: number, count: number): number[] {363 const indices = Array.from({ length: total }, (_, i) => i);364 for (let i = indices.length - 1; i > 0; i--) {365 const j = Math.floor(Math.random() * (i + 1));366 const tmp = indices[i]!;367 indices[i] = indices[j]!;368 indices[j] = tmp;369 }370 return indices.slice(0, count);371}