123456789101112131415161718192021222324import {25 CodexError,26 deriveShardCounts,27 MAX_STRIPE_COUNT,28 type ChunkEnvelope,29 type CodexPayload,30} from "../../shared/codex-types";31import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";32import {33 sha256Hex,34 tryBase64UrlToBytes,35 verifyChunkIntegrity,36} from "./helpers";3738export interface ScannedChunk {39 envelope: ChunkEnvelope;40 bytes: Uint8Array;41}4243export type ChunkAddReason =44 | "duplicate"45 | "foreign_codex"46 | "bad_integrity"47 | "parse_error";4849export interface ChunkAddResult {50 accepted: boolean;51 reason?: ChunkAddReason;52}5354interface StripeState {55 total: number;56 dataShards: number;57 chunks: Map<number, ScannedChunk>;58}59606162636465666768export interface ChunkBufferContext {69 verifyCode: string;70 71 stripeCount: number;72 73 total: number;74 75 dataShards: number;76 77 scanned: number;78 79 allStripesObserved: boolean;80}8182export interface StripeReadiness {83 stripe: number;84 found: number;85 required: number;86 isReady: boolean;87}8889export class ChunkBuffer {90 private verifyCode: string | null = null;91 private stripeCount: number | null = null;92 private readonly stripes = new Map<number, StripeState>();9394 9596979899100101 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {102 if (envelope.v !== 3) {103 return { accepted: false, reason: "parse_error" };104 }105106 107 108 109 110 111 const decoded = tryBase64UrlToBytes(envelope.d);112 if (decoded === null) {113 return { accepted: false, reason: "parse_error" };114 }115116 if (!(await verifyChunkIntegrity(envelope))) {117 return { accepted: false, reason: "bad_integrity" };118 }119120 const stripe = envelope.s;121 const stripeCount = envelope.S;122123 124 125 126 if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {127 return { accepted: false, reason: "parse_error" };128 }129130 if (this.verifyCode === null) {131 132 133 134 this.verifyCode = envelope.c;135 this.stripeCount = stripeCount;136 } else if (envelope.c !== this.verifyCode) {137 return { accepted: false, reason: "foreign_codex" };138 } else if (this.stripeCount !== stripeCount) {139 return { accepted: false, reason: "foreign_codex" };140 }141142 if (stripe < 0 || stripe >= this.stripeCount!) {143 return { accepted: false, reason: "parse_error" };144 }145146 let stripeState = this.stripes.get(stripe);147 if (!stripeState) {148 149 150 151 152 let dataShards: number;153 try {154 dataShards = deriveShardCounts(envelope.t).dataShards;155 } catch {156 return { accepted: false, reason: "parse_error" };157 }158 stripeState = { total: envelope.t, dataShards, chunks: new Map() };159 this.stripes.set(stripe, stripeState);160 } else if (stripeState.total !== envelope.t) {161 162 163 return { accepted: false, reason: "parse_error" };164 }165166 167 168 169 if (envelope.i < 0 || envelope.i >= envelope.t) {170 return { accepted: false, reason: "parse_error" };171 }172 if (stripeState.chunks.has(envelope.i)) {173 return { accepted: false, reason: "duplicate" };174 }175176 stripeState.chunks.set(envelope.i, {177 envelope,178 bytes: decoded, 179 });180 return { accepted: true };181 }182183 size(): number {184 let count = 0;185 for (const s of this.stripes.values()) count += s.chunks.size;186 return count;187 }188189 getContext(): ChunkBufferContext | null {190 if (this.verifyCode === null || this.stripeCount === null) return null;191 let total = 0;192 let dataShards = 0;193 let scanned = 0;194 for (const s of this.stripes.values()) {195 total += s.total;196 dataShards += s.dataShards;197 scanned += s.chunks.size;198 }199 return {200 verifyCode: this.verifyCode,201 stripeCount: this.stripeCount,202 total,203 dataShards,204 scanned,205 allStripesObserved: this.stripes.size === this.stripeCount,206 };207 }208209 isReady(): boolean {210 if (this.verifyCode === null || this.stripeCount === null) return false;211 if (this.stripes.size < this.stripeCount) return false;212 for (let i = 0; i < this.stripeCount; i++) {213 const s = this.stripes.get(i);214 if (!s || s.chunks.size < s.dataShards) return false;215 }216 return true;217 }218219 stripeReadiness(): StripeReadiness[] {220 if (this.stripeCount === null) return [];221 const out: StripeReadiness[] = [];222 for (let i = 0; i < this.stripeCount; i++) {223 const s = this.stripes.get(i);224 const found = s?.chunks.size ?? 0;225 const required = s?.dataShards ?? 0;226 out.push({227 stripe: i,228 found,229 required,230 isReady: !!s && found >= required,231 });232 }233 return out;234 }235236 entries(): ScannedChunk[] {237 const out: ScannedChunk[] = [];238 for (const s of this.stripes.values()) {239 for (const c of s.chunks.values()) out.push(c);240 }241 return out;242 }243244 245 toStripeChunks(): StripeChunks[] {246 if (this.stripeCount === null) return [];247 const out: StripeChunks[] = [];248 for (let i = 0; i < this.stripeCount; i++) {249 const s = this.stripes.get(i);250 if (!s) {251 throw new CodexError(252 "INSUFFICIENT_CHUNKS",253 `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,254 );255 }256 const chunks = new Map<number, Uint8Array>();257 for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);258 out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });259 }260 return out;261 }262}263264export type ParseRawScanResult =265 | ChunkEnvelope266 | { error: "parse_error" | "wrong_version" };267268269270271272273274275export function parseRawScan(raw: string): ParseRawScanResult {276 let parsed: unknown;277 try {278 parsed = JSON.parse(raw);279 } catch {280 return { error: "parse_error" };281 }282283 if (typeof parsed !== "object" || parsed === null) {284 return { error: "parse_error" };285 }286287 const obj = parsed as Record<string, unknown>;288289 if (obj.v !== 3) {290 if ("v" in obj) return { error: "wrong_version" };291 return { error: "parse_error" };292 }293294 const looksLikeEnvelope =295 typeof obj.c === "string" &&296 Number.isInteger(obj.i) && 297 Number.isInteger(obj.t) &&298 typeof obj.p === "boolean" &&299 typeof obj.d === "string" &&300 typeof obj.h === "string";301302 if (!looksLikeEnvelope) {303 return { error: "parse_error" };304 }305306 if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {307 return { error: "parse_error" };308 }309310 return obj as unknown as ChunkEnvelope;311}312313314315316317318export async function reconstructAndVerify(319 buffer: ChunkBuffer,320): Promise<CodexPayload> {321 const ctx = buffer.getContext();322 if (ctx === null) {323 throw new CodexError(324 "INSUFFICIENT_CHUNKS",325 "No chunks have been scanned yet.",326 );327 }328329 const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());330331 332 333 334 335 336 337 338 if (payload.vaultFingerprint.length !== 32) {339 throw new CodexError(340 "RECONSTRUCTION_FINGERPRINT_MISMATCH",341 "Reconstructed vault fingerprint has an invalid length. " +342 "Try scanning the Codex again from the beginning.",343 );344 }345 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))346 .slice(0, 32)347 .toUpperCase();348 if (fingerprint !== payload.vaultFingerprint) {349 throw new CodexError(350 "RECONSTRUCTION_FINGERPRINT_MISMATCH",351 "Reconstructed vault fingerprint does not match the printed Codex. " +352 "Try scanning the Codex again from the beginning.",353 );354 }355356 return payload;357}