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 class ChunkBuffer {82 private verifyCode: string | null = null;83 private stripeCount: number | null = null;84 private readonly stripes = new Map<number, StripeState>();8586 878889909192939495 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {96 if (envelope.v !== 2 && envelope.v !== 3) {97 return { accepted: false, reason: "parse_error" };98 }99100 if (!(await verifyChunkIntegrity(envelope))) {101 return { accepted: false, reason: "bad_integrity" };102 }103104 const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;105 const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;106107 if (this.verifyCode === null) {108 109 110 111 this.verifyCode = envelope.c;112 this.stripeCount = stripeCount;113 } else if (envelope.c !== this.verifyCode) {114 return { accepted: false, reason: "foreign_codex" };115 } else if (this.stripeCount !== stripeCount) {116 return { accepted: false, reason: "foreign_codex" };117 }118119 if (stripe < 0 || stripe >= this.stripeCount!) {120 return { accepted: false, reason: "parse_error" };121 }122123 let stripeState = this.stripes.get(stripe);124 if (!stripeState) {125 126 127 128 129 let dataShards: number;130 try {131 dataShards = deriveShardCounts(envelope.t).dataShards;132 } catch {133 return { accepted: false, reason: "parse_error" };134 }135 stripeState = { total: envelope.t, dataShards, chunks: new Map() };136 this.stripes.set(stripe, stripeState);137 } else if (stripeState.total !== envelope.t) {138 139 140 return { accepted: false, reason: "parse_error" };141 }142143 if (stripeState.chunks.has(envelope.i)) {144 return { accepted: false, reason: "duplicate" };145 }146 if (envelope.i < 0 || envelope.i >= envelope.t) {147 return { accepted: false, reason: "parse_error" };148 }149150 stripeState.chunks.set(envelope.i, {151 envelope,152 bytes: base64UrlToBytes(envelope.d),153 });154 return { accepted: true };155 }156157 size(): number {158 let count = 0;159 for (const s of this.stripes.values()) count += s.chunks.size;160 return count;161 }162163 getContext(): ChunkBufferContext | null {164 if (this.verifyCode === null || this.stripeCount === null) return null;165 let total = 0;166 let dataShards = 0;167 let scanned = 0;168 for (const s of this.stripes.values()) {169 total += s.total;170 dataShards += s.dataShards;171 scanned += s.chunks.size;172 }173 return {174 verifyCode: this.verifyCode,175 stripeCount: this.stripeCount,176 total,177 dataShards,178 scanned,179 allStripesObserved: this.stripes.size === this.stripeCount,180 };181 }182183 isReady(): boolean {184 if (this.verifyCode === null || this.stripeCount === null) return false;185 if (this.stripes.size < this.stripeCount) return false;186 for (let i = 0; i < this.stripeCount; i++) {187 const s = this.stripes.get(i);188 if (!s || s.chunks.size < s.dataShards) return false;189 }190 return true;191 }192193 entries(): ScannedChunk[] {194 const out: ScannedChunk[] = [];195 for (const s of this.stripes.values()) {196 for (const c of s.chunks.values()) out.push(c);197 }198 return out;199 }200201 202 toStripeChunks(): StripeChunks[] {203 if (this.stripeCount === null) return [];204 const out: StripeChunks[] = [];205 for (let i = 0; i < this.stripeCount; i++) {206 const s = this.stripes.get(i);207 if (!s) {208 throw new CodexError(209 "INSUFFICIENT_CHUNKS",210 `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,211 );212 }213 const chunks = new Map<number, Uint8Array>();214 for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);215 out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });216 }217 return out;218 }219}220221export type ParseRawScanResult =222 | ChunkEnvelope223 | { error: "parse_error" | "wrong_version" };224225226227228229230231232233234export function parseRawScan(raw: string): ParseRawScanResult {235 let parsed: unknown;236 try {237 parsed = JSON.parse(raw);238 } catch {239 return { error: "parse_error" };240 }241242 if (typeof parsed !== "object" || parsed === null) {243 return { error: "parse_error" };244 }245246 const obj = parsed as Record<string, unknown>;247248 if (obj.v !== 2 && obj.v !== 3) {249 if ("v" in obj) return { error: "wrong_version" };250 return { error: "parse_error" };251 }252253 const looksLikeEnvelope =254 typeof obj.c === "string" &&255 typeof obj.i === "number" &&256 typeof obj.t === "number" &&257 typeof obj.p === "boolean" &&258 typeof obj.d === "string" &&259 typeof obj.h === "string";260261 if (!looksLikeEnvelope) {262 return { error: "parse_error" };263 }264265 266 if (obj.v === 3) {267 if (typeof obj.s !== "number" || typeof obj.S !== "number") {268 return { error: "parse_error" };269 }270 }271272 return obj as unknown as ChunkEnvelope;273}274275276277278279280export async function reconstructAndVerify(281 buffer: ChunkBuffer,282): Promise<CodexPayload> {283 const ctx = buffer.getContext();284 if (ctx === null) {285 throw new CodexError(286 "INSUFFICIENT_CHUNKS",287 "No chunks have been scanned yet.",288 );289 }290291 const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());292293 294 295 296 297 298 299 300 301 302 303 const fingerprintLen = payload.vaultFingerprint.length;304 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))305 .slice(0, fingerprintLen)306 .toUpperCase();307 if (fingerprint !== payload.vaultFingerprint) {308 throw new CodexError(309 "RECONSTRUCTION_FINGERPRINT_MISMATCH",310 "Reconstructed vault fingerprint does not match the printed Codex. " +311 "Try scanning the Codex again from the beginning.",312 );313 }314315 return payload;316}