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 9596979899100101102103 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {104 if (envelope.v !== 2 && envelope.v !== 3) {105 return { accepted: false, reason: "parse_error" };106 }107108 109 110 111 112 113 const decoded = tryBase64UrlToBytes(envelope.d);114 if (decoded === null) {115 return { accepted: false, reason: "parse_error" };116 }117118 if (!(await verifyChunkIntegrity(envelope))) {119 return { accepted: false, reason: "bad_integrity" };120 }121122 const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;123 const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;124125 126 127 128 if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {129 return { accepted: false, reason: "parse_error" };130 }131132 if (this.verifyCode === null) {133 134 135 136 this.verifyCode = envelope.c;137 this.stripeCount = stripeCount;138 } else if (envelope.c !== this.verifyCode) {139 return { accepted: false, reason: "foreign_codex" };140 } else if (this.stripeCount !== stripeCount) {141 return { accepted: false, reason: "foreign_codex" };142 }143144 if (stripe < 0 || stripe >= this.stripeCount!) {145 return { accepted: false, reason: "parse_error" };146 }147148 let stripeState = this.stripes.get(stripe);149 if (!stripeState) {150 151 152 153 154 let dataShards: number;155 try {156 dataShards = deriveShardCounts(envelope.t).dataShards;157 } catch {158 return { accepted: false, reason: "parse_error" };159 }160 stripeState = { total: envelope.t, dataShards, chunks: new Map() };161 this.stripes.set(stripe, stripeState);162 } else if (stripeState.total !== envelope.t) {163 164 165 return { accepted: false, reason: "parse_error" };166 }167168 169 170 171 if (envelope.i < 0 || envelope.i >= envelope.t) {172 return { accepted: false, reason: "parse_error" };173 }174 if (stripeState.chunks.has(envelope.i)) {175 return { accepted: false, reason: "duplicate" };176 }177178 stripeState.chunks.set(envelope.i, {179 envelope,180 bytes: decoded, 181 });182 return { accepted: true };183 }184185 size(): number {186 let count = 0;187 for (const s of this.stripes.values()) count += s.chunks.size;188 return count;189 }190191 getContext(): ChunkBufferContext | null {192 if (this.verifyCode === null || this.stripeCount === null) return null;193 let total = 0;194 let dataShards = 0;195 let scanned = 0;196 for (const s of this.stripes.values()) {197 total += s.total;198 dataShards += s.dataShards;199 scanned += s.chunks.size;200 }201 return {202 verifyCode: this.verifyCode,203 stripeCount: this.stripeCount,204 total,205 dataShards,206 scanned,207 allStripesObserved: this.stripes.size === this.stripeCount,208 };209 }210211 isReady(): boolean {212 if (this.verifyCode === null || this.stripeCount === null) return false;213 if (this.stripes.size < this.stripeCount) return false;214 for (let i = 0; i < this.stripeCount; i++) {215 const s = this.stripes.get(i);216 if (!s || s.chunks.size < s.dataShards) return false;217 }218 return true;219 }220221 stripeReadiness(): StripeReadiness[] {222 if (this.stripeCount === null) return [];223 const out: StripeReadiness[] = [];224 for (let i = 0; i < this.stripeCount; i++) {225 const s = this.stripes.get(i);226 const found = s?.chunks.size ?? 0;227 const required = s?.dataShards ?? 0;228 out.push({229 stripe: i,230 found,231 required,232 isReady: !!s && found >= required,233 });234 }235 return out;236 }237238 entries(): ScannedChunk[] {239 const out: ScannedChunk[] = [];240 for (const s of this.stripes.values()) {241 for (const c of s.chunks.values()) out.push(c);242 }243 return out;244 }245246 247 toStripeChunks(): StripeChunks[] {248 if (this.stripeCount === null) return [];249 const out: StripeChunks[] = [];250 for (let i = 0; i < this.stripeCount; i++) {251 const s = this.stripes.get(i);252 if (!s) {253 throw new CodexError(254 "INSUFFICIENT_CHUNKS",255 `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,256 );257 }258 const chunks = new Map<number, Uint8Array>();259 for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);260 out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });261 }262 return out;263 }264}265266export type ParseRawScanResult =267 | ChunkEnvelope268 | { error: "parse_error" | "wrong_version" };269270271272273274275276277278279export function parseRawScan(raw: string): ParseRawScanResult {280 let parsed: unknown;281 try {282 parsed = JSON.parse(raw);283 } catch {284 return { error: "parse_error" };285 }286287 if (typeof parsed !== "object" || parsed === null) {288 return { error: "parse_error" };289 }290291 const obj = parsed as Record<string, unknown>;292293 if (obj.v !== 2 && obj.v !== 3) {294 if ("v" in obj) return { error: "wrong_version" };295 return { error: "parse_error" };296 }297298 const looksLikeEnvelope =299 typeof obj.c === "string" &&300 Number.isInteger(obj.i) && 301 Number.isInteger(obj.t) &&302 typeof obj.p === "boolean" &&303 typeof obj.d === "string" &&304 typeof obj.h === "string";305306 if (!looksLikeEnvelope) {307 return { error: "parse_error" };308 }309310 311 if (obj.v === 3) {312 if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {313 return { error: "parse_error" };314 }315 }316317 return obj as unknown as ChunkEnvelope;318}319320321322323324325export async function reconstructAndVerify(326 buffer: ChunkBuffer,327): Promise<CodexPayload> {328 const ctx = buffer.getContext();329 if (ctx === null) {330 throw new CodexError(331 "INSUFFICIENT_CHUNKS",332 "No chunks have been scanned yet.",333 );334 }335336 const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());337338 339 340 341 342 343 344 345 346 347 348 const fingerprintLen = payload.vaultFingerprint.length;349 350 351 352 353 354 if (fingerprintLen !== 16 && fingerprintLen !== 32) {355 throw new CodexError(356 "RECONSTRUCTION_FINGERPRINT_MISMATCH",357 "Reconstructed vault fingerprint has an invalid length. " +358 "Try scanning the Codex again from the beginning.",359 );360 }361 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))362 .slice(0, fingerprintLen)363 .toUpperCase();364 if (fingerprint !== payload.vaultFingerprint) {365 throw new CodexError(366 "RECONSTRUCTION_FINGERPRINT_MISMATCH",367 "Reconstructed vault fingerprint does not match the printed Codex. " +368 "Try scanning the Codex again from the beginning.",369 );370 }371372 return payload;373}