codex.adversarial.test.ts

21.2 KB · raw

1/**2 * codex.adversarial.test.ts — Layer 3 §3.4 adversarial Codex tests.3 *4 * Exercises ChunkBuffer, parseRawScan, encodeWithErasure/decodeWithErasure,5 * and the reconstruction pipeline with adversarial inputs: tampered chunks,6 * wrong verify codes, parity-only sets, out-of-range indices, malformed7 * envelopes. Parameterized for >=200 tests.8 */910import { beforeEach, describe, expect, it } from "vitest";11import { deflate } from "pako";1213import {14  CodexError,15  CHUNK_SIZE,16  PARITY_SHARDS,17  type ChunkEnvelope,18  type CodexPayload,19} from "../../shared/codex-types";20import {21  bytesToBase64Url,22  buildChunkEnvelope,23  sha256Hex,24  verifyChunkIntegrity,25} from "./helpers";26import { encodeWithErasure, decodeWithErasure } from "./erasure";27import { _resetReedSolomonCacheForTest } from "./rs-loader";28import { ChunkBuffer, parseRawScan, reconstructAndVerify } from "./restore";2930beforeEach(() => {31  _resetReedSolomonCacheForTest();32});3334const VERIFY_CODE = "PX-TEST-1234";3536async function makeEnv(opts: {37  index: number;38  total: number;39  isParity?: boolean;40  verifyCode?: string;41  bytes?: Uint8Array;42}): Promise<ChunkEnvelope> {43  return buildChunkEnvelope(44    opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]),45    opts.index,46    opts.total,47    opts.isParity ?? false,48    opts.verifyCode ?? VERIFY_CODE49  );50}5152async function makePayload(seedLength = 12_000): Promise<CodexPayload> {53  const blobBytes = new Uint8Array(seedLength);54  let s = 0x9e3779b9;55  for (let i = 0; i < seedLength; i++) {56    s = (s ^ (s << 13)) >>> 0;57    s = (s ^ (s >>> 17)) >>> 0;58    s = (s ^ (s << 5)) >>> 0;59    blobBytes[i] = s & 0xff;60  }61  const vaultBlob = bytesToBase64Url(blobBytes);62  const vaultFingerprint = (63    await sha256Hex(new TextEncoder().encode(vaultBlob))64  )65    .slice(0, 16)66    .toUpperCase();67  return {68    version: 2,69    verifyCode: VERIFY_CODE,70    vaultBlob,71    timestamp: 1_700_000_000_000,72    entryCount: 42,73    vaultFingerprint,74    generatedAt: 1_700_000_001_000,75  };76}7778async function buildEnvelopesForPayload(79  payload: CodexPayload80): Promise<ChunkEnvelope[]> {81  const json = JSON.stringify(payload);82  const compressed = deflate(new TextEncoder().encode(json), { level: 9 });83  const { dataChunks, parityChunks, totalChunks } =84    await encodeWithErasure(compressed);85  const all = [...dataChunks, ...parityChunks];86  return Promise.all(87    all.map((bytes, i) =>88      buildChunkEnvelope(89        bytes,90        i,91        totalChunks,92        i >= dataChunks.length,93        payload.verifyCode94      )95    )96  );97}9899// ── parseRawScan adversarial ────────────────────────────────────────────────100101describe("parseRawScan adversarial", () => {102  const garbageInputs = [103    "",104    " ",105    "\n",106    "not json at all",107    "{not_valid_json",108    "null",109    "true",110    "42",111    '"a string"',112    "[]",113    "[1,2,3]",114  ];115  for (const raw of garbageInputs) {116    it(`returns parse_error for ${JSON.stringify(raw)}`, () => {117      const result = parseRawScan(raw);118      expect(result).toHaveProperty("error");119    });120  }121122  const wrongVersionInputs = [123    JSON.stringify({ v: 1, c: VERIFY_CODE, i: 0, t: 5 }),124    JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5 }),125    JSON.stringify({ v: 0, c: VERIFY_CODE, i: 0, t: 5 }),126    JSON.stringify({ v: "2", c: VERIFY_CODE, i: 0, t: 5 }),127    JSON.stringify({ v: -1 }),128    JSON.stringify({ v: 99 }),129  ];130  for (const raw of wrongVersionInputs) {131    it(`reports wrong_version for ${JSON.stringify(raw).slice(0, 40)}`, () => {132      const result = parseRawScan(raw);133      expect(result).toMatchObject({ error: "wrong_version" });134    });135  }136137  const missingFieldInputs = [138    JSON.stringify({ v: 2 }),139    JSON.stringify({ v: 2, c: VERIFY_CODE }),140    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0 }),141    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5 }),142    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false }),143    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }),144  ];145  for (const raw of missingFieldInputs) {146    it(`parse_error for missing fields: ${raw.slice(0, 60)}`, () => {147      const result = parseRawScan(raw);148      expect(result).toMatchObject({ error: "parse_error" });149    });150  }151152  it("returns envelope for a valid envelope JSON", async () => {153    const env = await makeEnv({ index: 0, total: 5 });154    const result = parseRawScan(JSON.stringify(env));155    expect(result).toMatchObject({ v: 2, c: VERIFY_CODE, i: 0, t: 5 });156  });157158  it("extra unknown fields do not break parseRawScan", async () => {159    const env = await makeEnv({ index: 0, total: 5 });160    const raw = JSON.stringify({ ...env, extra: "field", more: 42 });161    const result = parseRawScan(raw);162    expect(result).toMatchObject({ v: 2, c: VERIFY_CODE });163  });164});165166// ── ChunkBuffer adversarial ────────────────────────────────────────────────167168describe("ChunkBuffer adversarial", () => {169  it("foreign verifyCode is rejected as foreign_codex", async () => {170    const buf = new ChunkBuffer();171    await buf.add(await makeEnv({ index: 0, total: 5 }));172    const res = await buf.add(173      await makeEnv({ index: 1, total: 5, verifyCode: "PX-OTHR-XXXX" })174    );175    expect(res).toEqual({ accepted: false, reason: "foreign_codex" });176  });177178  it("tampered hash is rejected as bad_integrity", async () => {179    const buf = new ChunkBuffer();180    const env = await makeEnv({ index: 0, total: 5 });181    const tampered = { ...env, h: "00000000" };182    const res = await buf.add(tampered);183    expect(res).toEqual({ accepted: false, reason: "bad_integrity" });184  });185186  it("data tampered without rehashing is rejected", async () => {187    const buf = new ChunkBuffer();188    const env = await makeEnv({ index: 0, total: 5 });189    const tampered: ChunkEnvelope = {190      ...env,191      d: bytesToBase64Url(new Uint8Array([99, 99, 99])),192    };193    const res = await buf.add(tampered);194    expect(res).toEqual({ accepted: false, reason: "bad_integrity" });195  });196197  it("wrong envelope version is rejected as parse_error", async () => {198    const buf = new ChunkBuffer();199    const env = await makeEnv({ index: 0, total: 5 });200    const wrong = { ...env, v: 1 } as unknown as ChunkEnvelope;201    const res = await buf.add(wrong);202    expect(res).toEqual({ accepted: false, reason: "parse_error" });203  });204205  it("duplicate chunk index is dedup'd", async () => {206    const buf = new ChunkBuffer();207    const env = await makeEnv({ index: 2, total: 5 });208    expect((await buf.add(env)).accepted).toBe(true);209    const second = await buf.add(env);210    expect(second).toEqual({ accepted: false, reason: "duplicate" });211    expect(buf.size()).toBe(1);212  });213214  for (const idx of [0, 1, 2, 3, 4]) {215    it(`accepts chunk at index ${idx}`, async () => {216      const buf = new ChunkBuffer();217      const env = await makeEnv({ index: idx, total: 5 });218      const res = await buf.add(env);219      expect(res.accepted).toBe(true);220    });221  }222223  for (const total of [3, 5, 7, 10, 20]) {224    it(`context dataShards = total - PARITY_SHARDS at total=${total}`, async () => {225      const buf = new ChunkBuffer();226      await buf.add(await makeEnv({ index: 0, total }));227      const ctx = buf.getContext();228      expect(ctx?.dataShards).toBe(total - PARITY_SHARDS);229    });230  }231232  it("reconstructAndVerify on empty buffer throws CodexError", async () => {233    const buf = new ChunkBuffer();234    await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);235  });236});237238// ── verifyChunkIntegrity adversarial ────────────────────────────────────────239240describe("verifyChunkIntegrity adversarial", () => {241  it("correct envelope verifies true", async () => {242    const env = await makeEnv({ index: 0, total: 5 });243    expect(await verifyChunkIntegrity(env)).toBe(true);244  });245246  const corruptHashes = [247    "00000000",248    "FFFFFFFF",249    "ZZZZZZZZ",250    "abcdef12",251    "",252  ];253  for (const h of corruptHashes) {254    it(`tampered hash ${JSON.stringify(h)} verifies false`, async () => {255      const env = await makeEnv({ index: 0, total: 5 });256      const tampered = { ...env, h };257      expect(await verifyChunkIntegrity(tampered)).toBe(false);258    });259  }260261  for (let i = 0; i < 5; i++) {262    it(`random data tamper #${i} verifies false`, async () => {263      const env = await makeEnv({ index: 0, total: 5 });264      const bytes = new Uint8Array([265        Math.floor(Math.random() * 256),266        Math.floor(Math.random() * 256),267        Math.floor(Math.random() * 256),268      ]);269      const tampered = { ...env, d: bytesToBase64Url(bytes) };270      // The new data hash will almost certainly differ from env.h.271      const recomputed = (await sha256Hex(bytes)).slice(0, 16);272      if (recomputed === env.h) return; // 1-in-2^64 lottery; skip.273      expect(await verifyChunkIntegrity(tampered)).toBe(false);274    });275  }276});277278// ── encodeWithErasure / decodeWithErasure adversarial ───────────────────────279280describe("encodeWithErasure / decodeWithErasure adversarial", () => {281  it("encoding empty input still produces at least 1 data shard", async () => {282    const { dataChunks, parityChunks, totalChunks, dataShards } =283      await encodeWithErasure(new Uint8Array());284    expect(dataShards).toBeGreaterThanOrEqual(1);285    expect(totalChunks).toBe(dataShards + PARITY_SHARDS);286    expect(dataChunks.length).toBe(dataShards);287    expect(parityChunks.length).toBe(PARITY_SHARDS);288  });289290  for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) {291    it(`encode/decode round-trips data of size ${size}`, async () => {292      const data = crypto.getRandomValues(new Uint8Array(size));293      const { dataChunks, parityChunks, dataShards, totalChunks } =294        await encodeWithErasure(data);295      const map = new Map<number, Uint8Array>();296      [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));297      const decoded = await decodeWithErasure(map, dataShards, totalChunks);298      expect(decoded.length).toBe(dataShards * CHUNK_SIZE);299      // First `size` bytes of decoded should equal original.300      for (let i = 0; i < size; i++) {301        expect(decoded[i]).toBe(data[i]);302      }303    });304  }305306  it("decoding with empty chunk map throws CodexError", async () => {307    await expect(308      decodeWithErasure(new Map(), 5, 7)309    ).rejects.toThrow(CodexError);310  });311312  it("decoding with fewer chunks than dataShards throws CodexError", async () => {313    const data = crypto.getRandomValues(new Uint8Array(5000));314    const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(315      data316    );317    // Use only one data chunk — less than dataShards.318    const map = new Map<number, Uint8Array>();319    map.set(0, dataChunks[0]!);320    await expect(321      decodeWithErasure(map, dataShards, totalChunks)322    ).rejects.toThrow(CodexError);323  });324325  it("chunk index out of range throws CodexError", async () => {326    const data = crypto.getRandomValues(new Uint8Array(2048));327    const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(328      data329    );330    const map = new Map<number, Uint8Array>();331    map.set(999, dataChunks[0]!);332    await expect(333      decodeWithErasure(map, dataShards, totalChunks)334    ).rejects.toThrow(CodexError);335  });336337  it("chunk with wrong size throws CodexError", async () => {338    const data = crypto.getRandomValues(new Uint8Array(2048));339    const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(340      data341    );342    const map = new Map<number, Uint8Array>();343    // Stuff a wrong-size chunk at index 0.344    map.set(0, new Uint8Array(100));345    for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);346    await expect(347      decodeWithErasure(map, dataShards, totalChunks)348    ).rejects.toThrow(CodexError);349  });350351  it("dropping 1 data chunk (within parity budget) reconstructs", async () => {352    const data = crypto.getRandomValues(new Uint8Array(8000));353    const { dataChunks, parityChunks, dataShards, totalChunks } =354      await encodeWithErasure(data);355    const map = new Map<number, Uint8Array>();356    // Drop dataChunks[0]; rely on parity.357    for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);358    parityChunks.forEach((c, j) => map.set(dataShards + j, c));359    const decoded = await decodeWithErasure(map, dataShards, totalChunks);360    expect(decoded.length).toBe(dataShards * CHUNK_SIZE);361    for (let i = 0; i < data.length; i++) {362      expect(decoded[i]).toBe(data[i]);363    }364  });365366  it("dropping all data chunks (only parity present) fails when k < dataShards", async () => {367    const data = crypto.getRandomValues(new Uint8Array(8000));368    const { parityChunks, dataShards, totalChunks } = await encodeWithErasure(369      data370    );371    // Only parity, count = PARITY_SHARDS, typically < dataShards.372    if (parityChunks.length >= dataShards) return;373    const map = new Map<number, Uint8Array>();374    parityChunks.forEach((c, j) => map.set(dataShards + j, c));375    await expect(376      decodeWithErasure(map, dataShards, totalChunks)377    ).rejects.toThrow(CodexError);378  });379380  it("dropping too many data chunks (more than parity) fails cleanly", async () => {381    const data = crypto.getRandomValues(new Uint8Array(20000));382    const { dataChunks, parityChunks, dataShards, totalChunks } =383      await encodeWithErasure(data);384    if (dataShards <= PARITY_SHARDS + 1) return; // need enough shards to test.385    const map = new Map<number, Uint8Array>();386    // Drop PARITY_SHARDS + 1 data chunks.387    for (let i = PARITY_SHARDS + 1; i < dataShards; i++) {388      map.set(i, dataChunks[i]!);389    }390    parityChunks.forEach((c, j) => map.set(dataShards + j, c));391    await expect(392      decodeWithErasure(map, dataShards, totalChunks)393    ).rejects.toThrow(CodexError);394  });395});396397// ── reconstructAndVerify with full pipeline ────────────────────────────────398399describe("reconstructAndVerify adversarial", () => {400  it("round-trips a payload via ChunkBuffer + reconstructAndVerify", async () => {401    const payload = await makePayload(2000);402    const envs = await buildEnvelopesForPayload(payload);403    const buf = new ChunkBuffer();404    for (const e of envs) await buf.add(e);405    const reconstructed = await reconstructAndVerify(buf);406    expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);407    expect(reconstructed.entryCount).toBe(payload.entryCount);408  });409410  it("missing chunks but enough parity still reconstructs", async () => {411    const payload = await makePayload(5000);412    const envs = await buildEnvelopesForPayload(payload);413    if (envs.length <= PARITY_SHARDS + 1) return;414    const buf = new ChunkBuffer();415    // Skip the first data chunk (index 0). Fill the rest.416    for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);417    const reconstructed = await reconstructAndVerify(buf);418    expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);419  });420421  it("tampered chunk data (after acceptance) does not round-trip", async () => {422    const payload = await makePayload(3000);423    const envs = await buildEnvelopesForPayload(payload);424    const buf = new ChunkBuffer();425    // Tamper one envelope's `d` field by replacing with random bytes plus426    // recomputed hash so the integrity check passes.427    const evil = envs[0]!;428    const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE));429    const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 16);430    const fakeEnv: ChunkEnvelope = {431      ...evil,432      d: bytesToBase64Url(fakeBytes),433      h: fakeHash,434    };435    await buf.add(fakeEnv);436    for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);437    // Reconstruction should fail fingerprint check.438    await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);439  });440});441442// ── Aztec-decoded raw payload adversarial ──────────────────────────────────443444describe("parseRawScan with adversarial raw strings (no-crash)", () => {445  const inputs = [446    "{}",447    '{"a":1,"b":2}',448    '{"v":2}',449    '{"v":2,"c":""}',450    '{"v":2,"c":"' + "A".repeat(10000) + '"}',451    '{"v":2,"c":"PX-XXXX-XXXX","i":-1,"t":5,"p":false,"d":"","h":""}',452    '{"v":2,"c":"PX-XXXX-XXXX","i":99,"t":5,"p":false,"d":"","h":""}',453    '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":-5,"p":false,"d":"","h":""}',454    '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":"yes","d":"","h":""}',455    '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":false,"d":null,"h":null}',456  ];457  for (const raw of inputs) {458    it(`parseRawScan does not crash on ${raw.slice(0, 50)}`, () => {459      const result = parseRawScan(raw);460      expect(result).toBeDefined();461    });462  }463});464465// ── Verify code matching ────────────────────────────────────────────────────466467describe("ChunkBuffer verify code adversarial", () => {468  const foreignCodes = [469    "PX-OTHR-1234",470    "PX-AAAA-AAAA",471    "PX-1111-1111",472    "PX-9999-9999",473    "PX-ABCD-EFGH",474  ];475  for (const code of foreignCodes) {476    it(`buffer rejects foreign code ${code}`, async () => {477      const buf = new ChunkBuffer();478      await buf.add(await makeEnv({ index: 0, total: 5 }));479      const foreign = await makeEnv({ index: 1, total: 5, verifyCode: code });480      const res = await buf.add(foreign);481      expect(res.accepted).toBe(false);482      expect(res.reason).toBe("foreign_codex");483    });484  }485});486487// ── Round-trip fuzz across data sizes ───────────────────────────────────────488489describe("erasure round-trip size sweep", () => {490  for (const size of [491    0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1023, 1024, 1025, 1500, 2048,492    3000, 4096, 5000, 7500, 10000,493  ]) {494    it(`size ${size} round-trips correctly`, async () => {495      const data = crypto.getRandomValues(new Uint8Array(size));496      const { dataChunks, parityChunks, dataShards, totalChunks } =497        await encodeWithErasure(data);498      const map = new Map<number, Uint8Array>();499      [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));500      const decoded = await decodeWithErasure(map, dataShards, totalChunks);501      for (let i = 0; i < size; i++) {502        expect(decoded[i]).toBe(data[i]);503      }504    });505506    it(`size ${size} drop-first-parity still decodes`, async () => {507      const data = crypto.getRandomValues(new Uint8Array(size));508      const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(509        data510      );511      const map = new Map<number, Uint8Array>();512      dataChunks.forEach((c, i) => map.set(i, c));513      // No parity present, but all data; decode succeeds.514      const decoded = await decodeWithErasure(map, dataShards, totalChunks);515      for (let i = 0; i < size; i++) {516        expect(decoded[i]).toBe(data[i]);517      }518    });519  }520});521522// ── Boundary: many envelopes ────────────────────────────────────────────────523524describe("ChunkBuffer scale", () => {525  for (const total of [3, 5, 10]) {526    it(`buffer accepts ${total - PARITY_SHARDS} data chunks and reports ready`, async () => {527      const buf = new ChunkBuffer();528      const dataShards = total - PARITY_SHARDS;529      for (let i = 0; i < dataShards; i++) {530        const res = await buf.add(await makeEnv({ index: i, total }));531        expect(res.accepted).toBe(true);532      }533      expect(buf.isReady()).toBe(true);534    });535  }536});537538// ── AUD-004: 128-bit integrity prefixes ────────────────────────────────────539540describe("Codex integrity prefix length (AUD-004)", () => {541  it("chunk hash is 32 hex chars (128 bits)", async () => {542    const bytes = new Uint8Array([1, 2, 3, 4, 5]);543    const env = await buildChunkEnvelope(bytes, 0, 10, false, "PX-TEST-1234");544    expect(env.h).toHaveLength(32);545    expect(env.h).toMatch(/^[0-9A-F]{32}$/);546  });547548  it("verifyChunkIntegrity passes for current 32-char hash", async () => {549    const bytes = new Uint8Array([1, 2, 3]);550    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");551    expect(env.h).toHaveLength(32);552    expect(await verifyChunkIntegrity(env)).toBe(true);553  });554555  it("verifyChunkIntegrity still passes for legacy 16-char hash", async () => {556    const bytes = new Uint8Array([1, 2, 3]);557    const fullHash = await sha256Hex(bytes);558    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");559    const legacy = { ...env, h: fullHash.slice(0, 16) };560    expect(await verifyChunkIntegrity(legacy)).toBe(true);561  });562563  it("verifyChunkIntegrity rejects tampered 32-char hash", async () => {564    const bytes = new Uint8Array([1, 2, 3]);565    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");566    const tampered = { ...env, h: "X".repeat(32) };567    expect(await verifyChunkIntegrity(tampered)).toBe(false);568  });569570  it("verifyChunkIntegrity rejects tampered 16-char legacy hash", async () => {571    const bytes = new Uint8Array([1, 2, 3]);572    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");573    const tampered = { ...env, h: "X".repeat(16) };574    expect(await verifyChunkIntegrity(tampered)).toBe(false);575  });576});