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 base64UrlToBytes,34 sha256Hex,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 9596979899100101102103 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {104 if (envelope.v !== 2 && envelope.v !== 3) {105 return { accepted: false, reason: "parse_error" };106 }107108 if (!(await verifyChunkIntegrity(envelope))) {109 return { accepted: false, reason: "bad_integrity" };110 }111112 const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;113 const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;114115 116 117 118 if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {119 return { accepted: false, reason: "parse_error" };120 }121122 if (this.verifyCode === null) {123 124 125 126 this.verifyCode = envelope.c;127 this.stripeCount = stripeCount;128 } else if (envelope.c !== this.verifyCode) {129 return { accepted: false, reason: "foreign_codex" };130 } else if (this.stripeCount !== stripeCount) {131 return { accepted: false, reason: "foreign_codex" };132 }133134 if (stripe < 0 || stripe >= this.stripeCount!) {135 return { accepted: false, reason: "parse_error" };136 }137138 let stripeState = this.stripes.get(stripe);139 if (!stripeState) {140 141 142 143 144 let dataShards: number;145 try {146 dataShards = deriveShardCounts(envelope.t).dataShards;147 } catch {148 return { accepted: false, reason: "parse_error" };149 }150 stripeState = { total: envelope.t, dataShards, chunks: new Map() };151 this.stripes.set(stripe, stripeState);152 } else if (stripeState.total !== envelope.t) {153 154 155 return { accepted: false, reason: "parse_error" };156 }157158 if (stripeState.chunks.has(envelope.i)) {159 return { accepted: false, reason: "duplicate" };160 }161 if (envelope.i < 0 || envelope.i >= envelope.t) {162 return { accepted: false, reason: "parse_error" };163 }164165 stripeState.chunks.set(envelope.i, {166 envelope,167 bytes: base64UrlToBytes(envelope.d),168 });169 return { accepted: true };170 }171172 size(): number {173 let count = 0;174 for (const s of this.stripes.values()) count += s.chunks.size;175 return count;176 }177178 getContext(): ChunkBufferContext | null {179 if (this.verifyCode === null || this.stripeCount === null) return null;180 let total = 0;181 let dataShards = 0;182 let scanned = 0;183 for (const s of this.stripes.values()) {184 total += s.total;185 dataShards += s.dataShards;186 scanned += s.chunks.size;187 }188 return {189 verifyCode: this.verifyCode,190 stripeCount: this.stripeCount,191 total,192 dataShards,193 scanned,194 allStripesObserved: this.stripes.size === this.stripeCount,195 };196 }197198 isReady(): boolean {199 if (this.verifyCode === null || this.stripeCount === null) return false;200 if (this.stripes.size < this.stripeCount) return false;201 for (let i = 0; i < this.stripeCount; i++) {202 const s = this.stripes.get(i);203 if (!s || s.chunks.size < s.dataShards) return false;204 }205 return true;206 }207208 stripeReadiness(): StripeReadiness[] {209 if (this.stripeCount === null) return [];210 const out: StripeReadiness[] = [];211 for (let i = 0; i < this.stripeCount; i++) {212 const s = this.stripes.get(i);213 const found = s?.chunks.size ?? 0;214 const required = s?.dataShards ?? 0;215 out.push({216 stripe: i,217 found,218 required,219 isReady: !!s && found >= required,220 });221 }222 return out;223 }224225 entries(): ScannedChunk[] {226 const out: ScannedChunk[] = [];227 for (const s of this.stripes.values()) {228 for (const c of s.chunks.values()) out.push(c);229 }230 return out;231 }232233 234 toStripeChunks(): StripeChunks[] {235 if (this.stripeCount === null) return [];236 const out: StripeChunks[] = [];237 for (let i = 0; i < this.stripeCount; i++) {238 const s = this.stripes.get(i);239 if (!s) {240 throw new CodexError(241 "INSUFFICIENT_CHUNKS",242 `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,243 );244 }245 const chunks = new Map<number, Uint8Array>();246 for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);247 out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });248 }249 return out;250 }251}252253export type ParseRawScanResult =254 | ChunkEnvelope255 | { error: "parse_error" | "wrong_version" };256257258259260261262263264265266export function parseRawScan(raw: string): ParseRawScanResult {267 let parsed: unknown;268 try {269 parsed = JSON.parse(raw);270 } catch {271 return { error: "parse_error" };272 }273274 if (typeof parsed !== "object" || parsed === null) {275 return { error: "parse_error" };276 }277278 const obj = parsed as Record<string, unknown>;279280 if (obj.v !== 2 && obj.v !== 3) {281 if ("v" in obj) return { error: "wrong_version" };282 return { error: "parse_error" };283 }284285 const looksLikeEnvelope =286 typeof obj.c === "string" &&287 Number.isInteger(obj.i) && 288 Number.isInteger(obj.t) &&289 typeof obj.p === "boolean" &&290 typeof obj.d === "string" &&291 typeof obj.h === "string";292293 if (!looksLikeEnvelope) {294 return { error: "parse_error" };295 }296297 298 if (obj.v === 3) {299 if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {300 return { error: "parse_error" };301 }302 }303304 return obj as unknown as ChunkEnvelope;305}306307308309310311312export async function reconstructAndVerify(313 buffer: ChunkBuffer,314): Promise<CodexPayload> {315 const ctx = buffer.getContext();316 if (ctx === null) {317 throw new CodexError(318 "INSUFFICIENT_CHUNKS",319 "No chunks have been scanned yet.",320 );321 }322323 const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());324325 326 327 328 329 330 331 332 333 334 335 const fingerprintLen = payload.vaultFingerprint.length;336 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))337 .slice(0, fingerprintLen)338 .toUpperCase();339 if (fingerprint !== payload.vaultFingerprint) {340 throw new CodexError(341 "RECONSTRUCTION_FINGERPRINT_MISMATCH",342 "Reconstructed vault fingerprint does not match the printed Codex. " +343 "Try scanning the Codex again from the beginning.",344 );345 }346347 return payload;348}