codex.adversarial.test.ts

21.4 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  computeParityShards,17  deriveShardCounts,18  type ChunkEnvelope,19  type CodexPayload,20} from "../../shared/codex-types";21import {22  bytesToBase64Url,23  buildChunkEnvelope,24  sha256Hex,25  verifyChunkIntegrity,26} from "./helpers";27import { encodeWithErasure, decodeWithErasure } from "./erasure";28import { _resetReedSolomonCacheForTest } from "./rs-loader";29import { ChunkBuffer, parseRawScan, reconstructAndVerify } from "./restore";3031beforeEach(() => {32  _resetReedSolomonCacheForTest();33});3435const VERIFY_CODE = "PX-TEST-1234";3637async function makeEnv(opts: {38  index: number;39  total: number;40  isParity?: boolean;41  verifyCode?: string;42  bytes?: Uint8Array;43}): Promise<ChunkEnvelope> {44  return buildChunkEnvelope(45    opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]),46    opts.index,47    opts.total,48    opts.isParity ?? false,49    opts.verifyCode ?? VERIFY_CODE50  );51}5253async function makePayload(seedLength = 12_000): Promise<CodexPayload> {54  const blobBytes = new Uint8Array(seedLength);55  let s = 0x9e3779b9;56  for (let i = 0; i < seedLength; i++) {57    s = (s ^ (s << 13)) >>> 0;58    s = (s ^ (s >>> 17)) >>> 0;59    s = (s ^ (s << 5)) >>> 0;60    blobBytes[i] = s & 0xff;61  }62  const vaultBlob = bytesToBase64Url(blobBytes);63  const vaultFingerprint = (64    await sha256Hex(new TextEncoder().encode(vaultBlob))65  )66    .slice(0, 16)67    .toUpperCase();68  return {69    version: 2,70    verifyCode: VERIFY_CODE,71    vaultBlob,72    timestamp: 1_700_000_000_000,73    entryCount: 42,74    vaultFingerprint,75    generatedAt: 1_700_000_001_000,76  };77}7879async function buildEnvelopesForPayload(80  payload: CodexPayload81): Promise<ChunkEnvelope[]> {82  const json = JSON.stringify(payload);83  const compressed = deflate(new TextEncoder().encode(json), { level: 9 });84  const { dataChunks, parityChunks, totalChunks } =85    await encodeWithErasure(compressed);86  const all = [...dataChunks, ...parityChunks];87  return Promise.all(88    all.map((bytes, i) =>89      buildChunkEnvelope(90        bytes,91        i,92        totalChunks,93        i >= dataChunks.length,94        payload.verifyCode95      )96    )97  );98}99100// ── parseRawScan adversarial ────────────────────────────────────────────────101102describe("parseRawScan adversarial", () => {103  const garbageInputs = [104    "",105    " ",106    "\n",107    "not json at all",108    "{not_valid_json",109    "null",110    "true",111    "42",112    '"a string"',113    "[]",114    "[1,2,3]",115  ];116  for (const raw of garbageInputs) {117    it(`returns parse_error for ${JSON.stringify(raw)}`, () => {118      const result = parseRawScan(raw);119      expect(result).toHaveProperty("error");120    });121  }122123  const wrongVersionInputs = [124    JSON.stringify({ v: 1, c: VERIFY_CODE, i: 0, t: 5 }),125    JSON.stringify({ v: 4, c: VERIFY_CODE, i: 0, t: 5 }),126    JSON.stringify({ v: 0, c: VERIFY_CODE, i: 0, t: 5 }),127    JSON.stringify({ v: "2", c: VERIFY_CODE, i: 0, t: 5 }),128    JSON.stringify({ v: -1 }),129    JSON.stringify({ v: 99 }),130  ];131  for (const raw of wrongVersionInputs) {132    it(`reports wrong_version for ${JSON.stringify(raw).slice(0, 40)}`, () => {133      const result = parseRawScan(raw);134      expect(result).toMatchObject({ error: "wrong_version" });135    });136  }137138  const missingFieldInputs = [139    JSON.stringify({ v: 2 }),140    JSON.stringify({ v: 2, c: VERIFY_CODE }),141    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0 }),142    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5 }),143    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false }),144    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }),145  ];146  for (const raw of missingFieldInputs) {147    it(`parse_error for missing fields: ${raw.slice(0, 60)}`, () => {148      const result = parseRawScan(raw);149      expect(result).toMatchObject({ error: "parse_error" });150    });151  }152153  it("returns envelope for a valid envelope JSON", async () => {154    const env = await makeEnv({ index: 0, total: 5 });155    const result = parseRawScan(JSON.stringify(env));156    expect(result).toMatchObject({ v: 3, c: VERIFY_CODE, i: 0, t: 5 });157  });158159  it("extra unknown fields do not break parseRawScan", async () => {160    const env = await makeEnv({ index: 0, total: 5 });161    const raw = JSON.stringify({ ...env, extra: "field", more: 42 });162    const result = parseRawScan(raw);163    expect(result).toMatchObject({ v: 3, c: VERIFY_CODE });164  });165});166167// ── ChunkBuffer adversarial ────────────────────────────────────────────────168169describe("ChunkBuffer adversarial", () => {170  it("foreign verifyCode is rejected as foreign_codex", async () => {171    const buf = new ChunkBuffer();172    await buf.add(await makeEnv({ index: 0, total: 5 }));173    const res = await buf.add(174      await makeEnv({ index: 1, total: 5, verifyCode: "PX-OTHR-XXXX" })175    );176    expect(res).toEqual({ accepted: false, reason: "foreign_codex" });177  });178179  it("tampered hash is rejected as bad_integrity", async () => {180    const buf = new ChunkBuffer();181    const env = await makeEnv({ index: 0, total: 5 });182    const tampered = { ...env, h: "00000000" };183    const res = await buf.add(tampered);184    expect(res).toEqual({ accepted: false, reason: "bad_integrity" });185  });186187  it("data tampered without rehashing is rejected", async () => {188    const buf = new ChunkBuffer();189    const env = await makeEnv({ index: 0, total: 5 });190    const tampered: ChunkEnvelope = {191      ...env,192      d: bytesToBase64Url(new Uint8Array([99, 99, 99])),193    };194    const res = await buf.add(tampered);195    expect(res).toEqual({ accepted: false, reason: "bad_integrity" });196  });197198  it("wrong envelope version is rejected as parse_error", async () => {199    const buf = new ChunkBuffer();200    const env = await makeEnv({ index: 0, total: 5 });201    const wrong = { ...env, v: 1 } as unknown as ChunkEnvelope;202    const res = await buf.add(wrong);203    expect(res).toEqual({ accepted: false, reason: "parse_error" });204  });205206  it("duplicate chunk index is dedup'd", async () => {207    const buf = new ChunkBuffer();208    const env = await makeEnv({ index: 2, total: 5 });209    expect((await buf.add(env)).accepted).toBe(true);210    const second = await buf.add(env);211    expect(second).toEqual({ accepted: false, reason: "duplicate" });212    expect(buf.size()).toBe(1);213  });214215  for (const idx of [0, 1, 2, 3, 4]) {216    it(`accepts chunk at index ${idx}`, async () => {217      const buf = new ChunkBuffer();218      const env = await makeEnv({ index: idx, total: 5 });219      const res = await buf.add(env);220      expect(res.accepted).toBe(true);221    });222  }223224  for (const total of [3, 5, 7, 10, 20]) {225    it(`context dataShards matches deriveShardCounts at total=${total}`, async () => {226      const buf = new ChunkBuffer();227      await buf.add(await makeEnv({ index: 0, total }));228      const ctx = buf.getContext();229      expect(ctx?.dataShards).toBe(deriveShardCounts(total).dataShards);230    });231  }232233  it("reconstructAndVerify on empty buffer throws CodexError", async () => {234    const buf = new ChunkBuffer();235    await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);236  });237});238239// ── verifyChunkIntegrity adversarial ────────────────────────────────────────240241describe("verifyChunkIntegrity adversarial", () => {242  it("correct envelope verifies true", async () => {243    const env = await makeEnv({ index: 0, total: 5 });244    expect(await verifyChunkIntegrity(env)).toBe(true);245  });246247  const corruptHashes = [248    "00000000",249    "FFFFFFFF",250    "ZZZZZZZZ",251    "abcdef12",252    "",253  ];254  for (const h of corruptHashes) {255    it(`tampered hash ${JSON.stringify(h)} verifies false`, async () => {256      const env = await makeEnv({ index: 0, total: 5 });257      const tampered = { ...env, h };258      expect(await verifyChunkIntegrity(tampered)).toBe(false);259    });260  }261262  for (let i = 0; i < 5; i++) {263    it(`random data tamper #${i} verifies false`, async () => {264      const env = await makeEnv({ index: 0, total: 5 });265      const bytes = new Uint8Array([266        Math.floor(Math.random() * 256),267        Math.floor(Math.random() * 256),268        Math.floor(Math.random() * 256),269      ]);270      const tampered = { ...env, d: bytesToBase64Url(bytes) };271      // The new data hash will almost certainly differ from env.h.272      const recomputed = (await sha256Hex(bytes)).slice(0, 16);273      if (recomputed === env.h) return; // 1-in-2^64 lottery; skip.274      expect(await verifyChunkIntegrity(tampered)).toBe(false);275    });276  }277});278279// ── encodeWithErasure / decodeWithErasure adversarial ───────────────────────280281describe("encodeWithErasure / decodeWithErasure adversarial", () => {282  it("encoding empty input still produces at least 1 data shard", async () => {283    const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =284      await encodeWithErasure(new Uint8Array());285    expect(dataShards).toBeGreaterThanOrEqual(1);286    expect(parityShards).toBe(computeParityShards(dataShards));287    expect(totalChunks).toBe(dataShards + parityShards);288    expect(dataChunks.length).toBe(dataShards);289    expect(parityChunks.length).toBe(parityShards);290  });291292  for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) {293    it(`encode/decode round-trips data of size ${size}`, async () => {294      const data = crypto.getRandomValues(new Uint8Array(size));295      const { dataChunks, parityChunks, dataShards, totalChunks } =296        await encodeWithErasure(data);297      const map = new Map<number, Uint8Array>();298      [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));299      const decoded = await decodeWithErasure(map, dataShards, totalChunks);300      expect(decoded.length).toBe(dataShards * CHUNK_SIZE);301      // First `size` bytes of decoded should equal original.302      for (let i = 0; i < size; i++) {303        expect(decoded[i]).toBe(data[i]);304      }305    });306  }307308  it("decoding with empty chunk map throws CodexError", async () => {309    await expect(310      decodeWithErasure(new Map(), 5, 7)311    ).rejects.toThrow(CodexError);312  });313314  it("decoding with fewer chunks than dataShards throws CodexError", async () => {315    const data = crypto.getRandomValues(new Uint8Array(5000));316    const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(317      data318    );319    // Use only one data chunk — less than dataShards.320    const map = new Map<number, Uint8Array>();321    map.set(0, dataChunks[0]!);322    await expect(323      decodeWithErasure(map, dataShards, totalChunks)324    ).rejects.toThrow(CodexError);325  });326327  it("chunk index out of range throws CodexError", async () => {328    const data = crypto.getRandomValues(new Uint8Array(2048));329    const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(330      data331    );332    const map = new Map<number, Uint8Array>();333    map.set(999, dataChunks[0]!);334    await expect(335      decodeWithErasure(map, dataShards, totalChunks)336    ).rejects.toThrow(CodexError);337  });338339  it("chunk with wrong size throws CodexError", async () => {340    const data = crypto.getRandomValues(new Uint8Array(2048));341    const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(342      data343    );344    const map = new Map<number, Uint8Array>();345    // Stuff a wrong-size chunk at index 0.346    map.set(0, new Uint8Array(100));347    for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);348    await expect(349      decodeWithErasure(map, dataShards, totalChunks)350    ).rejects.toThrow(CodexError);351  });352353  it("dropping 1 data chunk (within parity budget) reconstructs", async () => {354    const data = crypto.getRandomValues(new Uint8Array(8000));355    const { dataChunks, parityChunks, dataShards, totalChunks } =356      await encodeWithErasure(data);357    const map = new Map<number, Uint8Array>();358    // Drop dataChunks[0]; rely on parity.359    for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);360    parityChunks.forEach((c, j) => map.set(dataShards + j, c));361    const decoded = await decodeWithErasure(map, dataShards, totalChunks);362    expect(decoded.length).toBe(dataShards * CHUNK_SIZE);363    for (let i = 0; i < data.length; i++) {364      expect(decoded[i]).toBe(data[i]);365    }366  });367368  it("dropping all data chunks (only parity present) fails when k < dataShards", async () => {369    const data = crypto.getRandomValues(new Uint8Array(8000));370    const { parityChunks, dataShards, totalChunks } = await encodeWithErasure(371      data372    );373    // Only parity present; with parity < dataShards the decoder must refuse.374    if (parityChunks.length >= dataShards) return;375    const map = new Map<number, Uint8Array>();376    parityChunks.forEach((c, j) => map.set(dataShards + j, c));377    await expect(378      decodeWithErasure(map, dataShards, totalChunks)379    ).rejects.toThrow(CodexError);380  });381382  it("dropping too many data chunks (more than parity) fails cleanly", async () => {383    const data = crypto.getRandomValues(new Uint8Array(20000));384    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =385      await encodeWithErasure(data);386    if (dataShards <= parityShards + 1) return; // need enough shards to test.387    const map = new Map<number, Uint8Array>();388    // Drop parityShards + 1 data chunks (one more than the budget allows).389    for (let i = parityShards + 1; i < dataShards; i++) {390      map.set(i, dataChunks[i]!);391    }392    parityChunks.forEach((c, j) => map.set(dataShards + j, c));393    await expect(394      decodeWithErasure(map, dataShards, totalChunks)395    ).rejects.toThrow(CodexError);396  });397});398399// ── reconstructAndVerify with full pipeline ────────────────────────────────400401describe("reconstructAndVerify adversarial", () => {402  it("round-trips a payload via ChunkBuffer + reconstructAndVerify", async () => {403    const payload = await makePayload(2000);404    const envs = await buildEnvelopesForPayload(payload);405    const buf = new ChunkBuffer();406    for (const e of envs) await buf.add(e);407    const reconstructed = await reconstructAndVerify(buf);408    expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);409    expect(reconstructed.entryCount).toBe(payload.entryCount);410  });411412  it("missing chunks but enough parity still reconstructs", async () => {413    const payload = await makePayload(5000);414    const envs = await buildEnvelopesForPayload(payload);415    if (envs.length <= 3) return; // need room to drop one and still have parity416    const buf = new ChunkBuffer();417    // Skip the first data chunk (index 0). Fill the rest.418    for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);419    const reconstructed = await reconstructAndVerify(buf);420    expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);421  });422423  it("tampered chunk data (after acceptance) does not round-trip", async () => {424    const payload = await makePayload(3000);425    const envs = await buildEnvelopesForPayload(payload);426    const buf = new ChunkBuffer();427    // Tamper one envelope's `d` field by replacing with random bytes plus428    // recomputed hash so the integrity check passes.429    const evil = envs[0]!;430    const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE));431    const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 16);432    const fakeEnv: ChunkEnvelope = {433      ...evil,434      d: bytesToBase64Url(fakeBytes),435      h: fakeHash,436    };437    await buf.add(fakeEnv);438    for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);439    // Reconstruction should fail fingerprint check.440    await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);441  });442});443444// ── Aztec-decoded raw payload adversarial ──────────────────────────────────445446describe("parseRawScan with adversarial raw strings (no-crash)", () => {447  const inputs = [448    "{}",449    '{"a":1,"b":2}',450    '{"v":2}',451    '{"v":2,"c":""}',452    '{"v":2,"c":"' + "A".repeat(10000) + '"}',453    '{"v":2,"c":"PX-XXXX-XXXX","i":-1,"t":5,"p":false,"d":"","h":""}',454    '{"v":2,"c":"PX-XXXX-XXXX","i":99,"t":5,"p":false,"d":"","h":""}',455    '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":-5,"p":false,"d":"","h":""}',456    '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":"yes","d":"","h":""}',457    '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":false,"d":null,"h":null}',458  ];459  for (const raw of inputs) {460    it(`parseRawScan does not crash on ${raw.slice(0, 50)}`, () => {461      const result = parseRawScan(raw);462      expect(result).toBeDefined();463    });464  }465});466467// ── Verify code matching ────────────────────────────────────────────────────468469describe("ChunkBuffer verify code adversarial", () => {470  const foreignCodes = [471    "PX-OTHR-1234",472    "PX-AAAA-AAAA",473    "PX-1111-1111",474    "PX-9999-9999",475    "PX-ABCD-EFGH",476  ];477  for (const code of foreignCodes) {478    it(`buffer rejects foreign code ${code}`, async () => {479      const buf = new ChunkBuffer();480      await buf.add(await makeEnv({ index: 0, total: 5 }));481      const foreign = await makeEnv({ index: 1, total: 5, verifyCode: code });482      const res = await buf.add(foreign);483      expect(res.accepted).toBe(false);484      expect(res.reason).toBe("foreign_codex");485    });486  }487});488489// ── Round-trip fuzz across data sizes ───────────────────────────────────────490491describe("erasure round-trip size sweep", () => {492  for (const size of [493    0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1023, 1024, 1025, 1500, 2048,494    3000, 4096, 5000, 7500, 10000,495  ]) {496    it(`size ${size} round-trips correctly`, async () => {497      const data = crypto.getRandomValues(new Uint8Array(size));498      const { dataChunks, parityChunks, dataShards, totalChunks } =499        await encodeWithErasure(data);500      const map = new Map<number, Uint8Array>();501      [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));502      const decoded = await decodeWithErasure(map, dataShards, totalChunks);503      for (let i = 0; i < size; i++) {504        expect(decoded[i]).toBe(data[i]);505      }506    });507508    it(`size ${size} drop-first-parity still decodes`, async () => {509      const data = crypto.getRandomValues(new Uint8Array(size));510      const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(511        data512      );513      const map = new Map<number, Uint8Array>();514      dataChunks.forEach((c, i) => map.set(i, c));515      // No parity present, but all data; decode succeeds.516      const decoded = await decodeWithErasure(map, dataShards, totalChunks);517      for (let i = 0; i < size; i++) {518        expect(decoded[i]).toBe(data[i]);519      }520    });521  }522});523524// ── Boundary: many envelopes ────────────────────────────────────────────────525526describe("ChunkBuffer scale", () => {527  for (const total of [3, 5, 10]) {528    const dataShards = deriveShardCounts(total).dataShards;529    it(`buffer accepts ${dataShards} data chunks and reports ready`, async () => {530      const buf = new ChunkBuffer();531      for (let i = 0; i < dataShards; i++) {532        const res = await buf.add(await makeEnv({ index: i, total }));533        expect(res.accepted).toBe(true);534      }535      expect(buf.isReady()).toBe(true);536    });537  }538});539540// ── AUD-004: 128-bit integrity prefixes ────────────────────────────────────541542describe("Codex integrity prefix length (AUD-004)", () => {543  it("chunk hash is 32 hex chars (128 bits)", async () => {544    const bytes = new Uint8Array([1, 2, 3, 4, 5]);545    const env = await buildChunkEnvelope(bytes, 0, 10, false, "PX-TEST-1234");546    expect(env.h).toHaveLength(32);547    expect(env.h).toMatch(/^[0-9A-F]{32}$/);548  });549550  it("verifyChunkIntegrity passes for current 32-char hash", async () => {551    const bytes = new Uint8Array([1, 2, 3]);552    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");553    expect(env.h).toHaveLength(32);554    expect(await verifyChunkIntegrity(env)).toBe(true);555  });556557  it("verifyChunkIntegrity still passes for legacy 16-char hash", async () => {558    const bytes = new Uint8Array([1, 2, 3]);559    const fullHash = await sha256Hex(bytes);560    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");561    const legacy = { ...env, h: fullHash.slice(0, 16) };562    expect(await verifyChunkIntegrity(legacy)).toBe(true);563  });564565  it("verifyChunkIntegrity rejects tampered 32-char hash", async () => {566    const bytes = new Uint8Array([1, 2, 3]);567    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");568    const tampered = { ...env, h: "X".repeat(32) };569    expect(await verifyChunkIntegrity(tampered)).toBe(false);570  });571572  it("verifyChunkIntegrity rejects tampered 16-char legacy hash", async () => {573    const bytes = new Uint8Array([1, 2, 3]);574    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");575    const tampered = { ...env, h: "X".repeat(16) };576    expect(await verifyChunkIntegrity(tampered)).toBe(false);577  });578});