ed89dc7be482
split Codex encode into ≤ MAX_DATA_SHARDS_PER_STRIPE RS stripes to bypass GF(2^8) 256-shard cap; ChunkEnvelope v3 carries (s, S); generate/restore/types updated
- time
- 2026-05-19 18:35 UTC · 5 months ago
- machine
- koh2
- parent
- 69508a4a1463
↓ peach-browser-ed89dc7b.tar.gzatlas at this save
files changed6 files
src/popup/codex/erasure.ts+86 −0src/popup/codex/generate.ts+179 −97src/popup/codex/helpers.ts+9 −1src/popup/codex/restore.test.ts+4 −2src/popup/codex/restore.ts+126 −46src/shared/codex-types.ts+33 −4src/popup/codex/erasure.ts+86 −0
@@ -17,6 +17,7 @@1717 import {1818 CHUNK_SIZE,1919 CodexError,20+ MAX_DATA_SHARDS_PER_STRIPE,2021 computeParityShards,2122 } from "../../shared/codex-types";2223 import { getReedSolomon } from "./rs-loader";@@ -113,11 +114,46 @@113114 parityShards: number;114115 }115116 117+export interface MultiStripeEncodeResult {118+ stripes: EncodeResult[];119+ /** Sum of `dataShards` across stripes. */120+ totalDataChunks: number;121+ /** Sum of `parityShards` across stripes. */122+ totalParityChunks: number;123+ /** Sum of `totalChunks` across stripes = number of Aztec codes to print. */124+ totalChunks: number;125+}126+116127 /**128+ * Plan how many data shards belong to each stripe, balanced as evenly as129+ * possible so no single stripe approaches the GF(2⁸) cap. Returns an array of130+ * shard counts that sum to `totalDataShards` and where every element is131+ * ≤ MAX_DATA_SHARDS_PER_STRIPE.132+ */133+export function planStripeShardCounts(totalDataShards: number): number[] {134+ if (totalDataShards <= 0) return [];135+ const numStripes = Math.max(136+ 1,137+ Math.ceil(totalDataShards / MAX_DATA_SHARDS_PER_STRIPE),138+ );139+ const base = Math.floor(totalDataShards / numStripes);140+ const remainder = totalDataShards % numStripes;141+ const counts: number[] = [];142+ for (let i = 0; i < numStripes; i++) {143+ counts.push(base + (i < remainder ? 1 : 0));144+ }145+ return counts;146+}147+148+/**117149 * Split `data` into fixed-size shards plus `computeParityShards(dataShards)`118150 * parity shards. Pads the trailing data shard with zeros — the original byte119151 * length is recovered via the `CodexPayload.vaultBlob` (no length prefix120152 * needed on the wire because the outer JSON already bounds the data).153+ *154+ * Single-stripe primitive. The GF(2⁸) field caps `dataShards + parityShards`155+ * at 256; anything larger panics inside the WASM module, so call156+ * `encodeIntoStripes` instead for payloads that need more total shards.121157 */122158 export async function encodeWithErasure(data: Uint8Array): Promise<EncodeResult> {123159 const rs = await freshReedSolomon();@@ -125,6 +161,12 @@125161 const dataShards = Math.max(1, Math.ceil(data.length / CHUNK_SIZE));126162 const parityShards = computeParityShards(dataShards);127163 const totalShards = dataShards + parityShards;164+ if (totalShards > 256) {165+ throw new CodexError(166+ "ERASURE_CODING_VERIFICATION_FAILED",167+ `Single RS stripe cannot exceed 256 shards (got ${totalShards}; ${data.length} bytes / ${CHUNK_SIZE}-byte chunks). Use encodeIntoStripes for large payloads.`,168+ );169+ }128170 const buffer = new Uint8Array(totalShards * CHUNK_SIZE);129171 buffer.set(data, 0); // Trailing bytes after `data.length` stay zero — the pad.130172 @@ -261,4 +303,48 @@261303 }262304 263305 return buffer.slice(0, dataShards * shardSize);306+}307+308+/**309+ * Multi-stripe encode. Splits `data` into balanced RS stripes (each ≤310+ * MAX_DATA_SHARDS_PER_STRIPE data shards) and encodes each independently.311+ * The stripes are byte-contiguous: stripe `s` covers exactly312+ * `dataShardsForStripe(s) * CHUNK_SIZE` bytes of payload, with the last shard313+ * of the last stripe zero-padded when the payload doesn't align. Restore314+ * concatenates each stripe's decoded data region and inflates the whole thing315+ * — pako tolerates the trailing zeros.316+ */317+export async function encodeIntoStripes(318+ data: Uint8Array,319+): Promise<MultiStripeEncodeResult> {320+ const totalDataShards = Math.max(1, Math.ceil(data.length / CHUNK_SIZE));321+ const stripeShardCounts = planStripeShardCounts(totalDataShards);322+323+ const stripes: EncodeResult[] = [];324+ let byteCursor = 0;325+ for (const shardCount of stripeShardCounts) {326+ const stripeByteCap = shardCount * CHUNK_SIZE;327+ const stripeEnd = Math.min(byteCursor + stripeByteCap, data.length);328+ const stripeBytes = data.subarray(byteCursor, stripeEnd);329+ const result = await encodeWithErasure(stripeBytes);330+ // Sanity: encodeWithErasure infers dataShards from byte length; for any331+ // stripe but the last, the slice is exactly `shardCount * CHUNK_SIZE`332+ // bytes and the inferred count must match the plan. For the last stripe333+ // the byte length may be shorter (zero-pad happens inside the encoder)334+ // but the shard count still matches because the planner used the same335+ // ceil-division.336+ if (result.dataShards !== shardCount) {337+ throw new CodexError(338+ "ERASURE_CODING_VERIFICATION_FAILED",339+ `Stripe plan mismatch: planned ${shardCount} data shards, encoder produced ${result.dataShards}`,340+ );341+ }342+ stripes.push(result);343+ byteCursor += stripeByteCap;344+ }345+346+ const totalDataChunks = stripes.reduce((s, x) => s + x.dataShards, 0);347+ const totalParityChunks = stripes.reduce((s, x) => s + x.parityShards, 0);348+ const totalChunks = totalDataChunks + totalParityChunks;349+ return { stripes, totalDataChunks, totalParityChunks, totalChunks };264350 }src/popup/codex/generate.ts+179 −97
@@ -26,8 +26,13 @@2626 type CodexProgress,2727 } from "../../shared/codex-types";2828 import { generateVerifiedAztec } from "./aztec";29-import { decodeWithErasure, encodeWithErasure } from "./erasure";3029 import {30+ decodeWithErasure,31+ encodeIntoStripes,32+ planStripeShardCounts,33+ type EncodeResult,34+} from "./erasure";35+import {3136 base64UrlToBytes,3237 bytesToBase64Url,3338 buildChunkEnvelope,@@ -35,6 +40,17 @@3540 } from "./helpers";3641 import { renderCodexPDF } from "./pdf";3742 43+/**44+ * Per-stripe scanned-chunk bundle. Restore code groups envelopes by stripe45+ * and passes one of these per stripe to `reconstructPayloadFromStripes`.46+ */47+export interface StripeChunks {48+ /** Within-stripe index → raw chunk bytes. */49+ chunks: Map<number, Uint8Array>;50+ dataShards: number;51+ totalChunks: number;52+}53+3854 // ── Public surface ──────────────────────────────────────────────────────────3955 4056 export async function generateCodex(@@ -68,29 +84,48 @@6884 const compressed = deflate(payloadBytes, { level: 9 });6985 assertCompressionRoundTrip(compressed, payloadJson);7086 71- // STEP 4 — erasure encode + simulated-loss verify87+ // STEP 4 — erasure encode (multi-stripe) + simulated-loss verify per stripe7288 onProgress?.("erasure");73- const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =74- await encodeWithErasure(compressed);75- await verifyErasureCoding(76- dataChunks,77- parityChunks,78- dataShards,79- parityShards,80- totalChunks,81- compressed,82- );89+ const stripeResult = await encodeIntoStripes(compressed);90+ await verifyErasureCoding(stripeResult.stripes, compressed);91+ const stripeCount = stripeResult.stripes.length;92+ const totalChunks = stripeResult.totalChunks;8393 84- // STEP 5 — envelopes (data first, then parity, preserving global index ordering)94+ // STEP 5 — envelopes. One envelope per shard, tagged with its stripe so95+ // restore can route it. Within a stripe, data shards come first (indices96+ // 0..dataShards-1), then parity (dataShards..totalChunks-1). Across stripes,97+ // we emit stripe 0 first, then stripe 1, etc., so the PDF lays them out in98+ // a predictable order on the page.8599 const envelopes: ChunkEnvelope[] = [];86- for (let i = 0; i < dataChunks.length; i++) {87- envelopes.push(await buildChunkEnvelope(dataChunks[i]!, i, totalChunks, false, verifyCode));100+ for (let sIdx = 0; sIdx < stripeCount; sIdx++) {101+ const stripe = stripeResult.stripes[sIdx]!;102+ for (let i = 0; i < stripe.dataChunks.length; i++) {103+ envelopes.push(104+ await buildChunkEnvelope(105+ stripe.dataChunks[i]!,106+ i,107+ stripe.totalChunks,108+ false,109+ verifyCode,110+ sIdx,111+ stripeCount,112+ ),113+ );114+ }115+ for (let i = 0; i < stripe.parityChunks.length; i++) {116+ envelopes.push(117+ await buildChunkEnvelope(118+ stripe.parityChunks[i]!,119+ stripe.dataShards + i,120+ stripe.totalChunks,121+ true,122+ verifyCode,123+ sIdx,124+ stripeCount,125+ ),126+ );127+ }88128 }89- for (let i = 0; i < parityChunks.length; i++) {90- envelopes.push(91- await buildChunkEnvelope(parityChunks[i]!, dataChunks.length + i, totalChunks, true, verifyCode),92- );93- }94129 95130 // STEP 6 — verified Aztec PNGs (round-trip per code where DOM is available)96131 const aztecPngs: Uint8Array[] = [];@@ -102,23 +137,20 @@102137 onProgress?.("aztec", envelopes.length, envelopes.length);103138 104139 // STEP 7 — full reconstruction simulation (lose the maximum tolerated105- // number of envelopes for this Codex, then rebuild)140+ // number of envelopes per stripe, then rebuild end-to-end)106141 onProgress?.("verify");107- await simulateAndVerifyReconstruction(108- envelopes,109- dataShards,110- parityShards,111- totalChunks,112- payload,113- );142+ await simulateAndVerifyReconstruction(envelopes, stripeResult.stripes, payload);114143 115144 // STEP 8 — render PDF116145 onProgress?.("pdf");117146 const manifest: CodexManifest = {118147 verifyCode,119148 totalChunks,120- dataChunks: dataShards,121- parityChunks: parityShards,149+ dataChunks: stripeResult.totalDataChunks,150+ parityChunks: stripeResult.totalParityChunks,151+ stripeCount,152+ dataPerStripe: stripeResult.stripes.map((s) => s.dataShards),153+ parityPerStripe: stripeResult.stripes.map((s) => s.parityShards),122154 pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),123155 entryCount,124156 vaultFingerprint,@@ -138,37 +170,48 @@138170 }139171 140172 /**141- * Reconstruct a CodexPayload from a partial set of envelopes (or raw chunk142- * bytes). Exported because the restore-side agent reuses this exact path to143- * rebuild the payload from scanned codes.144- *145- * Accepts either:146- * - Map<index, ChunkEnvelope> — typical scan path147- * - Map<index, Uint8Array> — raw bytes, used by the in-flight148- * simulation during generation173+ * Reconstruct a CodexPayload from a partial set of envelopes grouped per174+ * stripe. Each `StripeChunks` entry carries the chunks scanned for that175+ * stripe plus the stripe's (dataShards, totalChunks) shape — restore obtains176+ * those from the envelope's `t` field via `deriveShardCounts`. Stripes must177+ * be supplied in stripe-index order so the concatenated data matches the178+ * original compressed payload byte-for-byte.149179 */150-export async function reconstructPayloadFromChunks(151- envelopes: Map<number, Uint8Array> | Map<number, ChunkEnvelope>,152- dataShards: number,153- totalChunks: number,180+export async function reconstructPayloadFromStripes(181+ stripes: StripeChunks[],154182 ): Promise<CodexPayload> {155- const chunkBytes = new Map<number, Uint8Array>();156- for (const [index, value] of envelopes) {157- if (value instanceof Uint8Array) {158- chunkBytes.set(index, value);159- } else {160- chunkBytes.set(index, base64UrlToBytes(value.d));161- }183+ if (stripes.length === 0) {184+ throw new CodexError(185+ "INSUFFICIENT_CHUNKS",186+ "Cannot reconstruct payload: no stripes provided",187+ );162188 }163189 164- const recovered = await decodeWithErasure(chunkBytes, dataShards, totalChunks);190+ // Decode each stripe to its `dataShards * shardSize` byte region.191+ const stripeData: Uint8Array[] = [];192+ for (const stripe of stripes) {193+ const recovered = await decodeWithErasure(194+ stripe.chunks,195+ stripe.dataShards,196+ stripe.totalChunks,197+ );198+ stripeData.push(recovered);199+ }165200 166- // The trailing pad bytes are zeros up to the next chunk boundary. pako's167- // inflate tolerates trailing junk after the deflate stream end marker, so168- // we can hand it the padded buffer directly.201+ // Concatenate stripe data in stripe order — the original compressed payload.202+ const totalLen = stripeData.reduce((s, b) => s + b.length, 0);203+ const combined = new Uint8Array(totalLen);204+ let offset = 0;205+ for (const part of stripeData) {206+ combined.set(part, offset);207+ offset += part.length;208+ }209+210+ // Trailing zero-pad bytes are tolerated by pako's inflate after the deflate211+ // stream end marker, so we can hand it the concatenated buffer directly.169212 let inflated: Uint8Array;170213 try {171- inflated = inflate(recovered);214+ inflated = inflate(combined);172215 } catch (err) {173216 throw new CodexError(174217 "RECONSTRUCTION_SIMULATION_FAILED",@@ -213,11 +256,19 @@213256 generatedAt: 0,214257 };215258 const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });216- const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));217- const parityShards = computeParityShards(dataShards);218- const totalChunks = dataShards + parityShards;259+ const totalDataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));260+ // Stripe planning matches encodeIntoStripes so the estimate stays honest261+ // for vaults large enough to be split.262+ const stripeShardCounts = planStripeShardCounts(totalDataShards);263+ let totalChunks = 0;264+ let totalParity = 0;265+ for (const dataShards of stripeShardCounts) {266+ const parity = computeParityShards(dataShards);267+ totalChunks += dataShards + parity;268+ totalParity += parity;269+ }219270 const pages = Math.ceil(totalChunks / CODES_PER_PAGE);220- return { pages, parityShards };271+ return { pages, parityShards: totalParity };221272 }222273 223274 /** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation@@ -277,69 +328,100 @@277328 }278329 279330 /**280- * Confirm the encoded shards can be reconstructed from a partial set. We281- * drop `parityShards` random shards (the maximum loss the parity budget282- * tolerates for this Codex) and check byte-for-byte that the recovered283- * prefix equals the original compressed blob.331+ * Confirm each stripe's encoded shards can be reconstructed after losing the332+ * maximum number of chunks the stripe's parity budget tolerates. Checks the333+ * recovered bytes against the original compressed payload slice for that334+ * stripe — byte-for-byte equality across the populated bytes; trailing335+ * zero-pad in the last stripe is ignored.284336 */285337 async function verifyErasureCoding(286- dataChunks: Uint8Array[],287- parityChunks: Uint8Array[],288- dataShards: number,289- parityShards: number,290- totalChunks: number,338+ stripes: EncodeResult[],291339 originalCompressed: Uint8Array,292340 ): Promise<void> {293- const present = new Map<number, Uint8Array>();294- dataChunks.forEach((chunk, i) => present.set(i, chunk));295- parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));341+ let byteCursor = 0;342+ for (let sIdx = 0; sIdx < stripes.length; sIdx++) {343+ const stripe = stripes[sIdx]!;344+ const present = new Map<number, Uint8Array>();345+ stripe.dataChunks.forEach((chunk, i) => present.set(i, chunk));346+ stripe.parityChunks.forEach((chunk, i) =>347+ present.set(stripe.dataShards + i, chunk),348+ );296349 297- const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));298- for (const idx of removed) present.delete(idx);350+ const removed = pickRandomIndices(351+ stripe.totalChunks,352+ Math.min(stripe.parityShards, stripe.totalChunks - 1),353+ );354+ for (const idx of removed) present.delete(idx);299355 300- let recovered: Uint8Array;301- try {302- recovered = await decodeWithErasure(present, dataShards, totalChunks);303- } catch (err) {304- if (err instanceof CodexError) {305- throw new CodexError(306- "ERASURE_CODING_VERIFICATION_FAILED",307- `Erasure coding simulation failed when losing chunks ${removed.join(", ")}: ${err.message}`,356+ let recovered: Uint8Array;357+ try {358+ recovered = await decodeWithErasure(359+ present,360+ stripe.dataShards,361+ stripe.totalChunks,308362 );363+ } catch (err) {364+ if (err instanceof CodexError) {365+ throw new CodexError(366+ "ERASURE_CODING_VERIFICATION_FAILED",367+ `Erasure coding simulation failed for stripe ${sIdx} when losing chunks ${removed.join(", ")}: ${err.message}`,368+ );369+ }370+ throw err;309371 }310- throw err;311- }312372 313- for (let i = 0; i < originalCompressed.length; i++) {314- if (recovered[i] !== originalCompressed[i]) {315- throw new CodexError(316- "ERASURE_CODING_CONTENT_MISMATCH",317- `Reconstructed data does not match original at byte ${i}`,318- );373+ // Number of real payload bytes in this stripe (rest is zero pad).374+ const stripeRealBytes = Math.min(375+ stripe.dataShards * CHUNK_SIZE,376+ Math.max(0, originalCompressed.length - byteCursor),377+ );378+ for (let i = 0; i < stripeRealBytes; i++) {379+ if (recovered[i] !== originalCompressed[byteCursor + i]) {380+ throw new CodexError(381+ "ERASURE_CODING_CONTENT_MISMATCH",382+ `Reconstructed stripe ${sIdx} differs from original at byte ${byteCursor + i}`,383+ );384+ }319385 }386+ byteCursor += stripe.dataShards * CHUNK_SIZE;320387 }321388 }322389 323390 async function simulateAndVerifyReconstruction(324391 envelopes: ChunkEnvelope[],325- dataShards: number,326- parityShards: number,327- totalChunks: number,392+ stripes: EncodeResult[],328393 originalPayload: CodexPayload,329394 ): Promise<void> {330- const present = new Map<number, Uint8Array>();331- envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));395+ // Group envelopes by stripe so we can drop up to `parityShards` per stripe396+ // and still reconstruct.397+ const perStripe: StripeChunks[] = stripes.map((s) => ({398+ chunks: new Map<number, Uint8Array>(),399+ dataShards: s.dataShards,400+ totalChunks: s.totalChunks,401+ }));402+ for (const env of envelopes) {403+ const stripe = env.v === 3 ? (env.s ?? 0) : 0;404+ perStripe[stripe]!.chunks.set(env.i, base64UrlToBytes(env.d));405+ }332406 333- const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));334- for (const idx of removed) present.delete(idx);407+ const removedSummary: string[] = [];408+ for (let sIdx = 0; sIdx < stripes.length; sIdx++) {409+ const stripe = stripes[sIdx]!;410+ const removed = pickRandomIndices(411+ stripe.totalChunks,412+ Math.min(stripe.parityShards, stripe.totalChunks - 1),413+ );414+ for (const idx of removed) perStripe[sIdx]!.chunks.delete(idx);415+ removedSummary.push(`stripe ${sIdx}: [${removed.join(", ")}]`);416+ }335417 336418 let reconstructed: CodexPayload;337419 try {338- reconstructed = await reconstructPayloadFromChunks(present, dataShards, totalChunks);420+ reconstructed = await reconstructPayloadFromStripes(perStripe);339421 } catch (err) {340422 throw new CodexError(341423 "RECONSTRUCTION_SIMULATION_FAILED",342- `Codex reconstruction simulation failed. Lost chunks: ${removed.join(", ")}. ${err instanceof Error ? err.message : String(err)}`,424+ `Codex reconstruction simulation failed. Lost chunks: ${removedSummary.join("; ")}. ${err instanceof Error ? err.message : String(err)}`,343425 );344426 }345427 src/popup/codex/helpers.ts+9 −1
@@ -77,6 +77,10 @@7777 * field is the first 32 hex chars (128 bits) of SHA-256(rawChunkBytes), so7878 * per-chunk corruption introduced after generation (smudge, scan glitch) is7979 * detectable at scan time. AUD-004 raised the prefix from 64 to 128 bits.80+ *81+ * `index` / `total` are within-stripe; `stripe` / `stripeCount` describe the82+ * stripe layout for v3. Generators always emit v3 envelopes — even when there83+ * is only one stripe — so the format on paper is unambiguous.8084 */8185 export async function buildChunkEnvelope(8286 chunkBytes: Uint8Array,@@ -84,11 +88,15 @@8488 total: number,8589 isParity: boolean,8690 verifyCode: string,91+ stripe: number = 0,92+ stripeCount: number = 1,8793 ): Promise<ChunkEnvelope> {8894 const hash = (await sha256Hex(chunkBytes)).slice(0, 32);8995 return {90- v: 2,96+ v: 3,9197 c: verifyCode,98+ s: stripe,99+ S: stripeCount,92100 i: index,93101 t: total,94102 p: isParity,src/popup/codex/restore.test.ts+4 −2
@@ -184,7 +184,7 @@184184 expect(buf.isReady()).toBe(true);185185 });186186 187- it("toChunkMap returns raw chunk bytes by index", async () => {187+ it("toStripeChunks returns raw chunk bytes per stripe", async () => {188188 const buf = new ChunkBuffer();189189 const env = await makeSingleEnvelope({190190 index: 0,@@ -192,7 +192,9 @@192192 bytes: new Uint8Array([1, 2, 3]),193193 });194194 await buf.add(env);195- const map = buf.toChunkMap();195+ const stripes = buf.toStripeChunks();196+ expect(stripes.length).toBe(1);197+ const map = stripes[0]!.chunks;196198 expect(map.size).toBe(1);197199 expect(Array.from(map.get(0)!)).toEqual([1, 2, 3]);198200 });src/popup/codex/restore.ts+126 −46
@@ -27,7 +27,7 @@2727 type ChunkEnvelope,2828 type CodexPayload,2929 } from "../../shared/codex-types";30-import { reconstructPayloadFromChunks } from "./generate";30+import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";3131 import {3232 base64UrlToBytes,3333 sha256Hex,@@ -50,30 +50,50 @@5050 reason?: ChunkAddReason;5151 }5252 53+interface StripeState {54+ total: number;55+ dataShards: number;56+ chunks: Map<number, ScannedChunk>;57+}58+59+/**60+ * Aggregate snapshot of the buffer state. Counts (`total`, `dataShards`,61+ * `scanned`) are sums across whichever stripes the buffer has *observed at62+ * least one envelope from*; until every stripe has been seen, they are63+ * lower bounds. `allStripesObserved` flips to `true` once the buffer has at64+ * least one envelope from each of the `stripeCount` stripes — that is when65+ * `total` and `dataShards` become final.66+ */5367 export interface ChunkBufferContext {5468 verifyCode: string;69+ /** Total stripes in this Codex (always 1 for legacy v=2 envelopes). */70+ stripeCount: number;71+ /** Running total chunks (data + parity) across observed stripes. */5572 total: number;73+ /** Running total data shards across observed stripes. */5674 dataShards: number;75+ /** Chunks accepted into the buffer so far. */76+ scanned: number;77+ /** True once at least one envelope from every stripe has been observed. */78+ allStripesObserved: boolean;5779 }5880 5981 export class ChunkBuffer {60- private readonly chunks = new Map<number, ScannedChunk>();61- private ctx: ChunkBufferContext | null = null;82+ private verifyCode: string | null = null;83+ private stripeCount: number | null = null;84+ private readonly stripes = new Map<number, StripeState>();6285 6386 /**6487 * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or6588 * `{ accepted: false, reason }` for any rejection. The buffer never throws —6689 * the scanner UI needs to keep running across bad frames.6790 *68- * Async because `verifyChunkIntegrity` is async (SubtleCrypto digest).69- * Callers in a scan loop should await but the UI need not block — a few70- * milliseconds per frame is well below 30fps decoding cadence.91+ * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2 is92+ * treated as a v3 envelope with `s=0, S=1` so the rest of the pipeline can93+ * be stripe-aware without branching.7194 */7295 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {73- // Defensive: even though parseRawScan filters most of these, the buffer74- // is a public API and callers may hand-build envelopes (tests, future75- // import flows).76- if (envelope.v !== 2) {96+ if (envelope.v !== 2 && envelope.v !== 3) {7797 return { accepted: false, reason: "parse_error" };7898 }7999 @@ -81,34 +101,53 @@81101 return { accepted: false, reason: "bad_integrity" };82102 }83103 84- if (this.ctx === null) {85- // First valid chunk establishes the Codex context. Later chunks must86- // agree on verifyCode and total — that is how we keep two overlapping87- // Codex prints from poisoning each other.88- //89- // parityShards is no longer fixed: derive it (and dataShards) from the90- // printed total via the inverse of `computeParityShards`. Malformed91- // totals throw — buffer.add returns parse_error.104+ const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;105+ const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;106+107+ if (this.verifyCode === null) {108+ // First valid chunk establishes verifyCode and stripeCount. Reject109+ // subsequent envelopes whose `c` or `S` disagrees so two overlapping110+ // Codex prints cannot poison each other.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+ }118+119+ if (stripe < 0 || stripe >= this.stripeCount!) {120+ return { accepted: false, reason: "parse_error" };121+ }122+123+ let stripeState = this.stripes.get(stripe);124+ if (!stripeState) {125+ // First envelope from a new stripe fixes that stripe's total + data126+ // shard count. `deriveShardCounts` inverts `computeParityShards` over127+ // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE so128+ // the inversion always has a unique solution.92129 let dataShards: number;93130 try {94131 dataShards = deriveShardCounts(envelope.t).dataShards;95132 } catch {96133 return { accepted: false, reason: "parse_error" };97134 }98- this.ctx = {99- verifyCode: envelope.c,100- total: envelope.t,101- dataShards,102- };103- } else if (envelope.c !== this.ctx.verifyCode) {104- return { accepted: false, reason: "foreign_codex" };135+ stripeState = { total: envelope.t, dataShards, chunks: new Map() };136+ this.stripes.set(stripe, stripeState);137+ } else if (stripeState.total !== envelope.t) {138+ // Two envelopes from the same stripe disagreeing on `t` means one of139+ // them is corrupt or from a different print run — reject.140+ return { accepted: false, reason: "parse_error" };105141 }106142 107- if (this.chunks.has(envelope.i)) {143+ if (stripeState.chunks.has(envelope.i)) {108144 return { accepted: false, reason: "duplicate" };109145 }146+ if (envelope.i < 0 || envelope.i >= envelope.t) {147+ return { accepted: false, reason: "parse_error" };148+ }110149 111- this.chunks.set(envelope.i, {150+ stripeState.chunks.set(envelope.i, {112151 envelope,113152 bytes: base64UrlToBytes(envelope.d),114153 });@@ -116,26 +155,64 @@116155 }117156 118157 size(): number {119- return this.chunks.size;158+ let count = 0;159+ for (const s of this.stripes.values()) count += s.chunks.size;160+ return count;120161 }121162 122163 getContext(): ChunkBufferContext | null {123- return this.ctx;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+ };124181 }125182 126183 isReady(): boolean {127- return this.ctx !== null && this.chunks.size >= this.ctx.dataShards;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;128191 }129192 130193 entries(): ScannedChunk[] {131- return Array.from(this.chunks.values());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;132199 }133200 134- /** Index → raw chunk bytes, ready for `reconstructPayloadFromChunks`. */135- toChunkMap(): Map<number, Uint8Array> {136- const out = new Map<number, Uint8Array>();137- for (const [i, scanned] of this.chunks) {138- out.set(i, scanned.bytes);201+ /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */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 });139216 }140217 return out;141218 }@@ -149,6 +226,10 @@149226 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner150227 * fires on every successful decode — most decodes are noise (other QR codes,151228 * stickers, old Codex prints) — so the caller must not assume any structure.229+ *230+ * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2231+ * envelopes are returned as-is — downstream stripe handling treats missing232+ * `s` / `S` as `0` / `1` respectively.152233 */153234 export function parseRawScan(raw: string): ParseRawScanResult {154235 let parsed: unknown;@@ -164,11 +245,7 @@164245 165246 const obj = parsed as Record<string, unknown>;166247 167- // Version check happens before shape validation so a v1 (or future-v3)168- // envelope can be reported distinctly from random JSON.169- if (obj.v !== 2) {170- // Any explicit `v` that isn't 2 is a wrong-version signal. Missing `v`171- // is plain noise → parse_error.248+ if (obj.v !== 2 && obj.v !== 3) {172249 if ("v" in obj) return { error: "wrong_version" };173250 return { error: "parse_error" };174251 }@@ -185,6 +262,13 @@185262 return { error: "parse_error" };186263 }187264 265+ // v3-only fields must be shaped correctly when present.266+ if (obj.v === 3) {267+ if (typeof obj.s !== "number" || typeof obj.S !== "number") {268+ return { error: "parse_error" };269+ }270+ }271+188272 return obj as unknown as ChunkEnvelope;189273 }190274 @@ -204,11 +288,7 @@204288 );205289 }206290 207- const payload = await reconstructPayloadFromChunks(208- buffer.toChunkMap(),209- ctx.dataShards,210- ctx.total,211- );291+ const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());212292 213293 // Re-derive the fingerprint exactly as the generator did. Mismatch here214294 // means the reconstructed `vaultBlob` is corrupt even though every chunksrc/shared/codex-types.ts+33 −4
@@ -43,15 +43,26 @@4343 *4444 * Field names are intentionally single letters to keep the encoded JSON4545 * compact — every byte saved shrinks the printed page count.46+ *47+ * Format history:48+ * - v2: single Reed-Solomon block; `i` / `t` are global.49+ * - v3: payload is split into N independent RS stripes so totals can exceed50+ * the GF(2⁸) 256-shard limit. `s` is the 0-based stripe index, `S` is the51+ * total number of stripes, and `i` / `t` are now within-stripe. A v252+ * envelope is equivalent to a v3 envelope with `s=0, S=1`.4653 */4754 export interface ChunkEnvelope {48- /** Envelope format version. Increment on breaking changes. */49- v: 2;55+ /** Envelope format version. v2 = single-stripe; v3 = multi-stripe. */56+ v: 2 | 3;5057 /** First 12 chars of `verifyCode` ("PX-XXXX-XXXX"). Lets the scanner reject foreign Codexes. */5158 c: string;52- /** Chunk index, 0-based. */59+ /** Stripe index, 0-based. v2 envelopes omit this; treat as 0. */60+ s?: number;61+ /** Total stripes in this Codex. v2 envelopes omit this; treat as 1. */62+ S?: number;63+ /** Chunk index within the stripe, 0-based. */5364 i: number;54- /** Total chunk count (data + parity). */65+ /** Total chunk count in this stripe (data + parity). */5566 t: number;5667 /** True if this is a Reed-Solomon parity shard. */5768 p: boolean;@@ -64,9 +75,18 @@6475 /** Bookkeeping returned alongside the generated PDF. */6576 export interface CodexManifest {6677 verifyCode: string;78+ /** Total Aztec codes across all stripes (data + parity). */6779 totalChunks: number;80+ /** Total data shards across all stripes. */6881 dataChunks: number;82+ /** Total parity shards across all stripes. */6983 parityChunks: number;84+ /** Number of independent RS stripes (≥ 1). */85+ stripeCount: number;86+ /** Data-shard count for each stripe, in stripe order. */87+ dataPerStripe: number[];88+ /** Parity-shard count for each stripe, in stripe order. */89+ parityPerStripe: number[];7090 pageCount: number;7191 entryCount: number;7292 vaultFingerprint: string;@@ -166,6 +186,15 @@166186 * this constant.167187 */168188 export const CHUNK_SIZE = 800;189+190+/**191+ * Maximum data shards per RS stripe. The underlying reed-solomon-erasure192+ * library operates over GF(2⁸), which hard-caps total shards (data + parity)193+ * at 256. Holding data at 200 leaves room for the maximum 20-parity case194+ * (200 + 20 = 220, well under the cap) and is the threshold above which we195+ * split the compressed payload into multiple independent stripes.196+ */197+export const MAX_DATA_SHARDS_PER_STRIPE = 200;169198 170199 /** Floor on parity shards: a Codex must always survive losing at least 2 codes. */171200 export const CODEX_MIN_PARITY_SHARDS = 2;