123456789101112131415161718192021222324import {25 CodexError,26 deriveShardCounts,27 type ChunkEnvelope,28 type CodexPayload,29} from "../../shared/codex-types";30import { reconstructPayloadFromStripes, type StripeChunks } 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}5253interface StripeState {54 total: number;55 dataShards: number;56 chunks: Map<number, ScannedChunk>;57}58596061626364656667export interface ChunkBufferContext {68 verifyCode: string;69 70 stripeCount: number;71 72 total: number;73 74 dataShards: number;75 76 scanned: number;77 78 allStripesObserved: boolean;79}8081export interface StripeReadiness {82 stripe: number;83 found: number;84 required: number;85 isReady: boolean;86}8788export class ChunkBuffer {89 private verifyCode: string | null = null;90 private stripeCount: number | null = null;91 private readonly stripes = new Map<number, StripeState>();9293 949596979899100101102 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {103 if (envelope.v !== 2 && envelope.v !== 3) {104 return { accepted: false, reason: "parse_error" };105 }106107 if (!(await verifyChunkIntegrity(envelope))) {108 return { accepted: false, reason: "bad_integrity" };109 }110111 const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;112 const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;113114 if (this.verifyCode === null) {115 116 117 118 this.verifyCode = envelope.c;119 this.stripeCount = stripeCount;120 } else if (envelope.c !== this.verifyCode) {121 return { accepted: false, reason: "foreign_codex" };122 } else if (this.stripeCount !== stripeCount) {123 return { accepted: false, reason: "foreign_codex" };124 }125126 if (stripe < 0 || stripe >= this.stripeCount!) {127 return { accepted: false, reason: "parse_error" };128 }129130 let stripeState = this.stripes.get(stripe);131 if (!stripeState) {132 133 134 135 136 let dataShards: number;137 try {138 dataShards = deriveShardCounts(envelope.t).dataShards;139 } catch {140 return { accepted: false, reason: "parse_error" };141 }142 stripeState = { total: envelope.t, dataShards, chunks: new Map() };143 this.stripes.set(stripe, stripeState);144 } else if (stripeState.total !== envelope.t) {145 146 147 return { accepted: false, reason: "parse_error" };148 }149150 if (stripeState.chunks.has(envelope.i)) {151 return { accepted: false, reason: "duplicate" };152 }153 if (envelope.i < 0 || envelope.i >= envelope.t) {154 return { accepted: false, reason: "parse_error" };155 }156157 stripeState.chunks.set(envelope.i, {158 envelope,159 bytes: base64UrlToBytes(envelope.d),160 });161 return { accepted: true };162 }163164 size(): number {165 let count = 0;166 for (const s of this.stripes.values()) count += s.chunks.size;167 return count;168 }169170 getContext(): ChunkBufferContext | null {171 if (this.verifyCode === null || this.stripeCount === null) return null;172 let total = 0;173 let dataShards = 0;174 let scanned = 0;175 for (const s of this.stripes.values()) {176 total += s.total;177 dataShards += s.dataShards;178 scanned += s.chunks.size;179 }180 return {181 verifyCode: this.verifyCode,182 stripeCount: this.stripeCount,183 total,184 dataShards,185 scanned,186 allStripesObserved: this.stripes.size === this.stripeCount,187 };188 }189190 isReady(): boolean {191 if (this.verifyCode === null || this.stripeCount === null) return false;192 if (this.stripes.size < this.stripeCount) return false;193 for (let i = 0; i < this.stripeCount; i++) {194 const s = this.stripes.get(i);195 if (!s || s.chunks.size < s.dataShards) return false;196 }197 return true;198 }199200 stripeReadiness(): StripeReadiness[] {201 if (this.stripeCount === null) return [];202 const out: StripeReadiness[] = [];203 for (let i = 0; i < this.stripeCount; i++) {204 const s = this.stripes.get(i);205 const found = s?.chunks.size ?? 0;206 const required = s?.dataShards ?? 0;207 out.push({208 stripe: i,209 found,210 required,211 isReady: !!s && found >= required,212 });213 }214 return out;215 }216217 entries(): ScannedChunk[] {218 const out: ScannedChunk[] = [];219 for (const s of this.stripes.values()) {220 for (const c of s.chunks.values()) out.push(c);221 }222 return out;223 }224225 226 toStripeChunks(): StripeChunks[] {227 if (this.stripeCount === null) return [];228 const out: StripeChunks[] = [];229 for (let i = 0; i < this.stripeCount; i++) {230 const s = this.stripes.get(i);231 if (!s) {232 throw new CodexError(233 "INSUFFICIENT_CHUNKS",234 `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,235 );236 }237 const chunks = new Map<number, Uint8Array>();238 for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);239 out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });240 }241 return out;242 }243}244245export type ParseRawScanResult =246 | ChunkEnvelope247 | { error: "parse_error" | "wrong_version" };248249250251252253254255256257258export function parseRawScan(raw: string): ParseRawScanResult {259 let parsed: unknown;260 try {261 parsed = JSON.parse(raw);262 } catch {263 return { error: "parse_error" };264 }265266 if (typeof parsed !== "object" || parsed === null) {267 return { error: "parse_error" };268 }269270 const obj = parsed as Record<string, unknown>;271272 if (obj.v !== 2 && obj.v !== 3) {273 if ("v" in obj) return { error: "wrong_version" };274 return { error: "parse_error" };275 }276277 const looksLikeEnvelope =278 typeof obj.c === "string" &&279 typeof obj.i === "number" &&280 typeof obj.t === "number" &&281 typeof obj.p === "boolean" &&282 typeof obj.d === "string" &&283 typeof obj.h === "string";284285 if (!looksLikeEnvelope) {286 return { error: "parse_error" };287 }288289 290 if (obj.v === 3) {291 if (typeof obj.s !== "number" || typeof obj.S !== "number") {292 return { error: "parse_error" };293 }294 }295296 return obj as unknown as ChunkEnvelope;297}298299300301302303304export async function reconstructAndVerify(305 buffer: ChunkBuffer,306): Promise<CodexPayload> {307 const ctx = buffer.getContext();308 if (ctx === null) {309 throw new CodexError(310 "INSUFFICIENT_CHUNKS",311 "No chunks have been scanned yet.",312 );313 }314315 const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());316317 318 319 320 321 322 323 324 325 326 327 const fingerprintLen = payload.vaultFingerprint.length;328 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))329 .slice(0, fingerprintLen)330 .toUpperCase();331 if (fingerprint !== payload.vaultFingerprint) {332 throw new CodexError(333 "RECONSTRUCTION_FINGERPRINT_MISMATCH",334 "Reconstructed vault fingerprint does not match the printed Codex. " +335 "Try scanning the Codex again from the beginning.",336 );337 }338339 return payload;340}