/** * codex.adversarial.test.ts — Layer 3 §3.4 adversarial Codex tests. * * Exercises ChunkBuffer, parseRawScan, encodeWithErasure/decodeWithErasure, * and the reconstruction pipeline with adversarial inputs: tampered chunks, * wrong verify codes, parity-only sets, out-of-range indices, malformed * envelopes. Parameterized for >=200 tests. */ import { beforeEach, describe, expect, it } from "vitest"; import { deflate } from "pako"; import { CodexError, CHUNK_SIZE, computeParityShards, deriveShardCounts, type ChunkEnvelope, type CodexPayload, } from "../../shared/codex-types"; import { bytesToBase64Url, buildChunkEnvelope, sha256Hex, verifyChunkIntegrity, } from "./helpers"; import { encodeWithErasure, decodeWithErasure } from "./erasure"; import { _resetReedSolomonCacheForTest } from "./rs-loader"; import { ChunkBuffer, parseRawScan, reconstructAndVerify } from "./restore"; beforeEach(() => { _resetReedSolomonCacheForTest(); }); const VERIFY_CODE = "PX-TEST-1234"; async function makeEnv(opts: { index: number; total: number; isParity?: boolean; verifyCode?: string; bytes?: Uint8Array; }): Promise { return buildChunkEnvelope( opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]), opts.index, opts.total, opts.isParity ?? false, opts.verifyCode ?? VERIFY_CODE ); } async function makePayload(seedLength = 12_000): Promise { const blobBytes = new Uint8Array(seedLength); let s = 0x9e3779b9; for (let i = 0; i < seedLength; i++) { s = (s ^ (s << 13)) >>> 0; s = (s ^ (s >>> 17)) >>> 0; s = (s ^ (s << 5)) >>> 0; blobBytes[i] = s & 0xff; } const vaultBlob = bytesToBase64Url(blobBytes); const vaultFingerprint = ( await sha256Hex(new TextEncoder().encode(vaultBlob)) ) .slice(0, 32) .toUpperCase(); return { version: 2, verifyCode: VERIFY_CODE, vaultBlob, timestamp: 1_700_000_000_000, entryCount: 42, vaultFingerprint, generatedAt: 1_700_000_001_000, }; } async function buildEnvelopesForPayload( payload: CodexPayload ): Promise { const json = JSON.stringify(payload); const compressed = deflate(new TextEncoder().encode(json), { level: 9 }); const { dataChunks, parityChunks, totalChunks } = await encodeWithErasure(compressed); const all = [...dataChunks, ...parityChunks]; return Promise.all( all.map((bytes, i) => buildChunkEnvelope( bytes, i, totalChunks, i >= dataChunks.length, payload.verifyCode ) ) ); } // ── parseRawScan adversarial ──────────────────────────────────────────────── describe("parseRawScan adversarial", () => { const garbageInputs = [ "", " ", "\n", "not json at all", "{not_valid_json", "null", "true", "42", '"a string"', "[]", "[1,2,3]", ]; for (const raw of garbageInputs) { it(`returns parse_error for ${JSON.stringify(raw)}`, () => { const result = parseRawScan(raw); expect(result).toHaveProperty("error"); }); } const wrongVersionInputs = [ JSON.stringify({ v: 1, c: VERIFY_CODE, i: 0, t: 5 }), JSON.stringify({ v: 4, c: VERIFY_CODE, i: 0, t: 5 }), JSON.stringify({ v: 0, c: VERIFY_CODE, i: 0, t: 5 }), JSON.stringify({ v: "2", c: VERIFY_CODE, i: 0, t: 5 }), JSON.stringify({ v: -1 }), JSON.stringify({ v: 99 }), ]; for (const raw of wrongVersionInputs) { it(`reports wrong_version for ${JSON.stringify(raw).slice(0, 40)}`, () => { const result = parseRawScan(raw); expect(result).toMatchObject({ error: "wrong_version" }); }); } const missingFieldInputs = [ JSON.stringify({ v: 3 }), JSON.stringify({ v: 3, c: VERIFY_CODE }), JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0 }), JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5 }), JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5, p: false }), JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }), ]; for (const raw of missingFieldInputs) { it(`parse_error for missing fields: ${raw.slice(0, 60)}`, () => { const result = parseRawScan(raw); expect(result).toMatchObject({ error: "parse_error" }); }); } it("returns envelope for a valid envelope JSON", async () => { const env = await makeEnv({ index: 0, total: 5 }); const result = parseRawScan(JSON.stringify(env)); expect(result).toMatchObject({ v: 3, c: VERIFY_CODE, i: 0, t: 5 }); }); it("extra unknown fields do not break parseRawScan", async () => { const env = await makeEnv({ index: 0, total: 5 }); const raw = JSON.stringify({ ...env, extra: "field", more: 42 }); const result = parseRawScan(raw); expect(result).toMatchObject({ v: 3, c: VERIFY_CODE }); }); }); // ── ChunkBuffer adversarial ──────────────────────────────────────────────── describe("ChunkBuffer adversarial", () => { it("foreign verifyCode is rejected as foreign_codex", async () => { const buf = new ChunkBuffer(); await buf.add(await makeEnv({ index: 0, total: 5 })); const res = await buf.add( await makeEnv({ index: 1, total: 5, verifyCode: "PX-OTHR-XXXX" }) ); expect(res).toEqual({ accepted: false, reason: "foreign_codex" }); }); it("malformed hash length is rejected as parse_error before hashing", async () => { // PB-26-207: `h` must be exactly 32 chars; a short hash is malformed, so // ChunkBuffer.add rejects it before base64 conversion or hashing. const buf = new ChunkBuffer(); const env = await makeEnv({ index: 0, total: 5 }); const tampered = { ...env, h: "00000000" }; const res = await buf.add(tampered); expect(res).toEqual({ accepted: false, reason: "parse_error" }); }); it("data tampered without rehashing is rejected", async () => { const buf = new ChunkBuffer(); const env = await makeEnv({ index: 0, total: 5 }); const tampered: ChunkEnvelope = { ...env, d: bytesToBase64Url(new Uint8Array([99, 99, 99])), }; const res = await buf.add(tampered); expect(res).toEqual({ accepted: false, reason: "bad_integrity" }); }); it("wrong envelope version is rejected as parse_error", async () => { const buf = new ChunkBuffer(); const env = await makeEnv({ index: 0, total: 5 }); const wrong = { ...env, v: 1 } as unknown as ChunkEnvelope; const res = await buf.add(wrong); expect(res).toEqual({ accepted: false, reason: "parse_error" }); }); it("duplicate chunk index is dedup'd", async () => { const buf = new ChunkBuffer(); const env = await makeEnv({ index: 2, total: 5 }); expect((await buf.add(env)).accepted).toBe(true); const second = await buf.add(env); expect(second).toEqual({ accepted: false, reason: "duplicate" }); expect(buf.size()).toBe(1); }); for (const idx of [0, 1, 2, 3, 4]) { it(`accepts chunk at index ${idx}`, async () => { const buf = new ChunkBuffer(); const env = await makeEnv({ index: idx, total: 5 }); const res = await buf.add(env); expect(res.accepted).toBe(true); }); } for (const total of [3, 5, 7, 10, 20]) { it(`context dataShards matches deriveShardCounts at total=${total}`, async () => { const buf = new ChunkBuffer(); await buf.add(await makeEnv({ index: 0, total })); const ctx = buf.getContext(); expect(ctx?.dataShards).toBe(deriveShardCounts(total).dataShards); }); } it("reconstructAndVerify on empty buffer throws CodexError", async () => { const buf = new ChunkBuffer(); await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError); }); // PCH-01-010: computeParityShards saturates at 35, so without an upper bound on // dataShards the inversion accepts absurd totals and wedges a stripe. it("derive_shard_counts_rejects_absurd_total", () => { expect(() => deriveShardCounts(1_000_000_000)).toThrow(CodexError); }); it("derive_shard_counts_accepts_real_max", () => { // dataShards = 200 (MAX_DATA_SHARDS_PER_STRIPE) with its saturated parity (35). const total = 200 + computeParityShards(200); const counts = deriveShardCounts(total); expect(counts.dataShards).toBe(200); }); // PCH-01-011: a crafted stripe count S must be rejected before it pins // this.stripeCount and drives O(S) allocation on the popup's render path. it("chunk_buffer_rejects_oversized_stripe_count", async () => { const buf = new ChunkBuffer(); const env = await buildChunkEnvelope(new Uint8Array([1, 2, 3, 4, 5]), 0, 5, false, VERIFY_CODE, 0, 2 ** 31); const res = await buf.add(env); expect(res).toEqual({ accepted: false, reason: "parse_error" }); expect(buf.getContext()).toBeNull(); }); it("chunk_buffer_accepts_max_stripe_count", async () => { const buf = new ChunkBuffer(); const env = await buildChunkEnvelope(new Uint8Array([1, 2, 3, 4, 5]), 0, 5, false, VERIFY_CODE, 0, 64); const res = await buf.add(env); expect(res.accepted).toBe(true); }); // PCH-01-012: fractional i/t/s/S must be rejected by parseRawScan (NaN/Infinity // are not JSON-representable, so fractional is the reachable hostile case). it("parse_raw_scan_rejects_non_integer_index", async () => { const env = await makeEnv({ index: 0, total: 5 }); expect(parseRawScan(JSON.stringify({ ...env, i: 1.5 }))).toEqual({ error: "parse_error" }); expect(parseRawScan(JSON.stringify({ ...env, t: 5.5 }))).toEqual({ error: "parse_error" }); expect(parseRawScan(JSON.stringify({ ...env, s: 0.5 }))).toEqual({ error: "parse_error" }); expect(parseRawScan(JSON.stringify({ ...env, S: 1.5 }))).toEqual({ error: "parse_error" }); }); // PCH-01-026 #2: ChunkBuffer.add must never throw — an out-of-alphabet base64 // payload becomes a parse_error rejection. it("chunk_buffer_add_does_not_throw_on_invalid_base64", async () => { const buf = new ChunkBuffer(); const env = await makeEnv({ index: 0, total: 5 }); const res = await buf.add({ ...env, d: "!!!@@" }); expect(res).toEqual({ accepted: false, reason: "parse_error" }); expect(buf.getContext()).toBeNull(); }); // PCH-01-026 #7: an out-of-range index is malformed (parse_error), never // mis-reported as a duplicate. it("chunk_buffer_out_of_range_index_reports_parse_error", async () => { const buf = new ChunkBuffer(); const env = await makeEnv({ index: 5, total: 5 }); // i === t, one past the end const res = await buf.add(env); expect(res).toEqual({ accepted: false, reason: "parse_error" }); }); }); // ── verifyChunkIntegrity adversarial ──────────────────────────────────────── describe("verifyChunkIntegrity adversarial", () => { it("correct envelope verifies true", async () => { const env = await makeEnv({ index: 0, total: 5 }); expect(await verifyChunkIntegrity(env)).toBe(true); }); const corruptHashes = [ "00000000", "FFFFFFFF", "ZZZZZZZZ", "abcdef12", "", ]; for (const h of corruptHashes) { it(`tampered hash ${JSON.stringify(h)} verifies false`, async () => { const env = await makeEnv({ index: 0, total: 5 }); const tampered = { ...env, h }; expect(await verifyChunkIntegrity(tampered)).toBe(false); }); } for (let i = 0; i < 5; i++) { it(`random data tamper #${i} verifies false`, async () => { const env = await makeEnv({ index: 0, total: 5 }); const bytes = new Uint8Array([ Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), ]); const tampered = { ...env, d: bytesToBase64Url(bytes) }; // The new data hash will almost certainly differ from env.h. const recomputed = (await sha256Hex(bytes)).slice(0, 16); if (recomputed === env.h) return; // 1-in-2^64 lottery; skip. expect(await verifyChunkIntegrity(tampered)).toBe(false); }); } }); // ── encodeWithErasure / decodeWithErasure adversarial ─────────────────────── describe("encodeWithErasure / decodeWithErasure adversarial", () => { it("encoding empty input still produces at least 1 data shard", async () => { const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } = await encodeWithErasure(new Uint8Array()); expect(dataShards).toBeGreaterThanOrEqual(1); expect(parityShards).toBe(computeParityShards(dataShards)); expect(totalChunks).toBe(dataShards + parityShards); expect(dataChunks.length).toBe(dataShards); expect(parityChunks.length).toBe(parityShards); }); for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) { it(`encode/decode round-trips data of size ${size}`, async () => { const data = crypto.getRandomValues(new Uint8Array(size)); const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(data); const map = new Map(); [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c)); const decoded = await decodeWithErasure(map, dataShards, totalChunks); expect(decoded.length).toBe(dataShards * CHUNK_SIZE); // First `size` bytes of decoded should equal original. for (let i = 0; i < size; i++) { expect(decoded[i]).toBe(data[i]); } }); } it("decoding with empty chunk map throws CodexError", async () => { await expect( decodeWithErasure(new Map(), 5, 7) ).rejects.toThrow(CodexError); }); it("decoding with fewer chunks than dataShards throws CodexError", async () => { const data = crypto.getRandomValues(new Uint8Array(5000)); const { dataChunks, dataShards, totalChunks } = await encodeWithErasure( data ); // Use only one data chunk — less than dataShards. const map = new Map(); map.set(0, dataChunks[0]!); await expect( decodeWithErasure(map, dataShards, totalChunks) ).rejects.toThrow(CodexError); }); it("chunk index out of range throws CodexError", async () => { const data = crypto.getRandomValues(new Uint8Array(2048)); const { dataChunks, dataShards, totalChunks } = await encodeWithErasure( data ); const map = new Map(); map.set(999, dataChunks[0]!); await expect( decodeWithErasure(map, dataShards, totalChunks) ).rejects.toThrow(CodexError); }); it("chunk with wrong size throws CodexError", async () => { const data = crypto.getRandomValues(new Uint8Array(2048)); const { dataChunks, dataShards, totalChunks } = await encodeWithErasure( data ); const map = new Map(); // Stuff a wrong-size chunk at index 0. map.set(0, new Uint8Array(100)); for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!); await expect( decodeWithErasure(map, dataShards, totalChunks) ).rejects.toThrow(CodexError); }); it("dropping 1 data chunk (within parity budget) reconstructs", async () => { const data = crypto.getRandomValues(new Uint8Array(8000)); const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(data); const map = new Map(); // Drop dataChunks[0]; rely on parity. for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!); parityChunks.forEach((c, j) => map.set(dataShards + j, c)); const decoded = await decodeWithErasure(map, dataShards, totalChunks); expect(decoded.length).toBe(dataShards * CHUNK_SIZE); for (let i = 0; i < data.length; i++) { expect(decoded[i]).toBe(data[i]); } }); it("dropping all data chunks (only parity present) fails when k < dataShards", async () => { const data = crypto.getRandomValues(new Uint8Array(8000)); const { parityChunks, dataShards, totalChunks } = await encodeWithErasure( data ); // Only parity present; with parity < dataShards the decoder must refuse. if (parityChunks.length >= dataShards) return; const map = new Map(); parityChunks.forEach((c, j) => map.set(dataShards + j, c)); await expect( decodeWithErasure(map, dataShards, totalChunks) ).rejects.toThrow(CodexError); }); it("dropping too many data chunks (more than parity) fails cleanly", async () => { const data = crypto.getRandomValues(new Uint8Array(20000)); const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } = await encodeWithErasure(data); if (dataShards <= parityShards + 1) return; // need enough shards to test. const map = new Map(); // Drop parityShards + 1 data chunks (one more than the budget allows). for (let i = parityShards + 1; i < dataShards; i++) { map.set(i, dataChunks[i]!); } parityChunks.forEach((c, j) => map.set(dataShards + j, c)); await expect( decodeWithErasure(map, dataShards, totalChunks) ).rejects.toThrow(CodexError); }); }); // ── reconstructAndVerify with full pipeline ──────────────────────────────── describe("reconstructAndVerify adversarial", () => { it("round-trips a payload via ChunkBuffer + reconstructAndVerify", async () => { const payload = await makePayload(2000); const envs = await buildEnvelopesForPayload(payload); const buf = new ChunkBuffer(); for (const e of envs) await buf.add(e); const reconstructed = await reconstructAndVerify(buf); expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint); expect(reconstructed.entryCount).toBe(payload.entryCount); }); // PCH-01-013: the declared fingerprint length is attacker-controlled. An // empty fingerprint must not pass via slice(0,0) === "". it("reconstruct_rejects_empty_fingerprint", async () => { const base = await makePayload(2000); const payload = { ...base, vaultFingerprint: "" }; const envs = await buildEnvelopesForPayload(payload); const buf = new ChunkBuffer(); for (const e of envs) await buf.add(e); await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError); }); it("reconstruct_accepts_current_fingerprint_length", async () => { const base = await makePayload(2500); const fp32 = (await sha256Hex(new TextEncoder().encode(base.vaultBlob))).slice(0, 32).toUpperCase(); const payload = { ...base, vaultFingerprint: fp32 }; const envs = await buildEnvelopesForPayload(payload); const buf = new ChunkBuffer(); for (const e of envs) await buf.add(e); const reconstructed = await reconstructAndVerify(buf); expect(reconstructed.vaultFingerprint).toBe(fp32); }); it("missing chunks but enough parity still reconstructs", async () => { const payload = await makePayload(5000); const envs = await buildEnvelopesForPayload(payload); if (envs.length <= 3) return; // need room to drop one and still have parity const buf = new ChunkBuffer(); // Skip the first data chunk (index 0). Fill the rest. for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!); const reconstructed = await reconstructAndVerify(buf); expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint); }); it("tampered chunk data (after acceptance) does not round-trip", async () => { const payload = await makePayload(3000); const envs = await buildEnvelopesForPayload(payload); const buf = new ChunkBuffer(); // Tamper one envelope's `d` field by replacing with random bytes plus // recomputed hash so the integrity check passes. const evil = envs[0]!; const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE)); const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 32); const fakeEnv: ChunkEnvelope = { ...evil, d: bytesToBase64Url(fakeBytes), h: fakeHash, }; await buf.add(fakeEnv); for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!); // Reconstruction should fail fingerprint check. await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError); }); }); // ── Aztec-decoded raw payload adversarial ────────────────────────────────── describe("parseRawScan with adversarial raw strings (no-crash)", () => { const inputs = [ "{}", '{"a":1,"b":2}', '{"v":2}', '{"v":2,"c":""}', '{"v":2,"c":"' + "A".repeat(10000) + '"}', '{"v":2,"c":"PX-XXXX-XXXX","i":-1,"t":5,"p":false,"d":"","h":""}', '{"v":2,"c":"PX-XXXX-XXXX","i":99,"t":5,"p":false,"d":"","h":""}', '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":-5,"p":false,"d":"","h":""}', '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":"yes","d":"","h":""}', '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":false,"d":null,"h":null}', ]; for (const raw of inputs) { it(`parseRawScan does not crash on ${raw.slice(0, 50)}`, () => { const result = parseRawScan(raw); expect(result).toBeDefined(); }); } }); // ── Verify code matching ──────────────────────────────────────────────────── describe("ChunkBuffer verify code adversarial", () => { const foreignCodes = [ "PX-OTHR-1234", "PX-AAAA-AAAA", "PX-1111-1111", "PX-9999-9999", "PX-ABCD-EFGH", ]; for (const code of foreignCodes) { it(`buffer rejects foreign code ${code}`, async () => { const buf = new ChunkBuffer(); await buf.add(await makeEnv({ index: 0, total: 5 })); const foreign = await makeEnv({ index: 1, total: 5, verifyCode: code }); const res = await buf.add(foreign); expect(res.accepted).toBe(false); expect(res.reason).toBe("foreign_codex"); }); } }); // ── Round-trip fuzz across data sizes ─────────────────────────────────────── describe("erasure round-trip size sweep", () => { for (const size of [ 0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1023, 1024, 1025, 1500, 2048, 3000, 4096, 5000, 7500, 10000, ]) { it(`size ${size} round-trips correctly`, async () => { const data = crypto.getRandomValues(new Uint8Array(size)); const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(data); const map = new Map(); [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c)); const decoded = await decodeWithErasure(map, dataShards, totalChunks); for (let i = 0; i < size; i++) { expect(decoded[i]).toBe(data[i]); } }); it(`size ${size} drop-first-parity still decodes`, async () => { const data = crypto.getRandomValues(new Uint8Array(size)); const { dataChunks, dataShards, totalChunks } = await encodeWithErasure( data ); const map = new Map(); dataChunks.forEach((c, i) => map.set(i, c)); // No parity present, but all data; decode succeeds. const decoded = await decodeWithErasure(map, dataShards, totalChunks); for (let i = 0; i < size; i++) { expect(decoded[i]).toBe(data[i]); } }); } }); // ── Boundary: many envelopes ──────────────────────────────────────────────── describe("ChunkBuffer scale", () => { for (const total of [3, 5, 10]) { const dataShards = deriveShardCounts(total).dataShards; it(`buffer accepts ${dataShards} data chunks and reports ready`, async () => { const buf = new ChunkBuffer(); for (let i = 0; i < dataShards; i++) { const res = await buf.add(await makeEnv({ index: i, total })); expect(res.accepted).toBe(true); } expect(buf.isReady()).toBe(true); }); } }); // ── AUD-004: 128-bit integrity prefixes ──────────────────────────────────── describe("Codex integrity prefix length (AUD-004)", () => { it("chunk hash is 32 hex chars (128 bits)", async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); const env = await buildChunkEnvelope(bytes, 0, 10, false, "PX-TEST-1234"); expect(env.h).toHaveLength(32); expect(env.h).toMatch(/^[0-9A-F]{32}$/); }); it("verifyChunkIntegrity passes for current 32-char hash", async () => { const bytes = new Uint8Array([1, 2, 3]); const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234"); expect(env.h).toHaveLength(32); expect(await verifyChunkIntegrity(env)).toBe(true); }); it("verifyChunkIntegrity rejects tampered 32-char hash", async () => { const bytes = new Uint8Array([1, 2, 3]); const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234"); const tampered = { ...env, h: "X".repeat(32) }; expect(await verifyChunkIntegrity(tampered)).toBe(false); }); });