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
6 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 −4
src/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 }

view file

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 

view file

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,

view file

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   });

view file

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 chunk

view file

src/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;

view file