123456789101112131415161718192021222324import {25 CodexError,26 PARITY_SHARDS,27 type ChunkEnvelope,28 type CodexPayload,29} from "../../shared/codex-types";30import { reconstructPayloadFromChunks } from "./generate";31import {32 base64UrlToBytes,33 sha256Hex,34 verifyChunkIntegrity,35} from "./helpers";3637export interface ScannedChunk {38 envelope: ChunkEnvelope;39 bytes: Uint8Array;40}4142export type ChunkAddReason =43 | "duplicate"44 | "foreign_codex"45 | "bad_integrity"46 | "parse_error";4748export interface ChunkAddResult {49 accepted: boolean;50 reason?: ChunkAddReason;51}5253export interface ChunkBufferContext {54 verifyCode: string;55 total: number;56 dataShards: number;57}5859export class ChunkBuffer {60 private readonly chunks = new Map<number, ScannedChunk>();61 private ctx: ChunkBufferContext | null = null;6263 646566676869707172 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {73 74 75 76 if (envelope.v !== 2) {77 return { accepted: false, reason: "parse_error" };78 }7980 if (!(await verifyChunkIntegrity(envelope))) {81 return { accepted: false, reason: "bad_integrity" };82 }8384 if (this.ctx === null) {85 86 87 88 this.ctx = {89 verifyCode: envelope.c,90 total: envelope.t,91 dataShards: envelope.t - PARITY_SHARDS,92 };93 } else if (envelope.c !== this.ctx.verifyCode) {94 return { accepted: false, reason: "foreign_codex" };95 }9697 if (this.chunks.has(envelope.i)) {98 return { accepted: false, reason: "duplicate" };99 }100101 this.chunks.set(envelope.i, {102 envelope,103 bytes: base64UrlToBytes(envelope.d),104 });105 return { accepted: true };106 }107108 size(): number {109 return this.chunks.size;110 }111112 getContext(): ChunkBufferContext | null {113 return this.ctx;114 }115116 isReady(): boolean {117 return this.ctx !== null && this.chunks.size >= this.ctx.dataShards;118 }119120 entries(): ScannedChunk[] {121 return Array.from(this.chunks.values());122 }123124 125 toChunkMap(): Map<number, Uint8Array> {126 const out = new Map<number, Uint8Array>();127 for (const [i, scanned] of this.chunks) {128 out.set(i, scanned.bytes);129 }130 return out;131 }132}133134export type ParseRawScanResult =135 | ChunkEnvelope136 | { error: "parse_error" | "wrong_version" };137138139140141142143export function parseRawScan(raw: string): ParseRawScanResult {144 let parsed: unknown;145 try {146 parsed = JSON.parse(raw);147 } catch {148 return { error: "parse_error" };149 }150151 if (typeof parsed !== "object" || parsed === null) {152 return { error: "parse_error" };153 }154155 const obj = parsed as Record<string, unknown>;156157 158 159 if (obj.v !== 2) {160 161 162 if ("v" in obj) return { error: "wrong_version" };163 return { error: "parse_error" };164 }165166 const looksLikeEnvelope =167 typeof obj.c === "string" &&168 typeof obj.i === "number" &&169 typeof obj.t === "number" &&170 typeof obj.p === "boolean" &&171 typeof obj.d === "string" &&172 typeof obj.h === "string";173174 if (!looksLikeEnvelope) {175 return { error: "parse_error" };176 }177178 return obj as unknown as ChunkEnvelope;179}180181182183184185186export async function reconstructAndVerify(187 buffer: ChunkBuffer,188): Promise<CodexPayload> {189 const ctx = buffer.getContext();190 if (ctx === null) {191 throw new CodexError(192 "INSUFFICIENT_CHUNKS",193 "No chunks have been scanned yet.",194 );195 }196197 const payload = await reconstructPayloadFromChunks(198 buffer.toChunkMap(),199 ctx.dataShards,200 ctx.total,201 );202203 204 205 206 207 208 209 210 211 212 213 const fingerprintLen = payload.vaultFingerprint.length;214 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))215 .slice(0, fingerprintLen)216 .toUpperCase();217 if (fingerprint !== payload.vaultFingerprint) {218 throw new CodexError(219 "RECONSTRUCTION_FINGERPRINT_MISMATCH",220 "Reconstructed vault fingerprint does not match the printed Codex. " +221 "Try scanning the Codex again from the beginning.",222 );223 }224225 return payload;226}