peachPayload.bomb.test.ts

10.3 KB · raw

1// T-005: peachPayload declared-length decompression bomb (MV-4)2//3// Why this test exists: MV-4 added two guards in peachPayload.ts's section4// parsing loop:5//   1. length > MAX_DECLARED_LENGTH → throw BEFORE calling decompressZstd6//   2. totalDecompressed > MAX_DECOMPRESSED_BYTES → throw BEFORE calling7//      decompressZstd on the next section8//9// The existing decompression-bomb.test.ts only exercises the zstd output cap10// (MAX_DECOMPRESSED_BYTES applied inside decompressZstd itself). It does NOT11// test that the *declared-length* field check fires before decompression is12// attempted. Without this test, the declared-length guard could be removed13// (allowing a 100-byte section to claim a 100 GiB decompressed size) and no14// existing test would fail — because if the zstd frame is honest the output15// cap catches it, but the declared-length check is the FIRST line of defense16// and exists to reject before even calling the decompressor.1718import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";19import { argon2id, blake3 } from "hash-wasm";2021// Spy on decompressZstd before it is imported by peachPayload.22// vi.mock is hoisted to the top of the file so the spy is in place23// before any module that imports decompressZstd is evaluated.24vi.mock("./zstd", async (importOriginal) => {25  const mod = await importOriginal<typeof import("./zstd")>();26  return {27    ...mod,28    decompressZstd: vi.fn(mod.decompressZstd),29  };30});3132import { parsePayload } from "./peachPayload";33import { decompressZstd } from "./zstd";34import { IMPORT_LIMITS } from "../shared/import-limits";3536// ---------------------------------------------------------------------------37// Helpers38// ---------------------------------------------------------------------------3940const PASSPHRASE = "test-bomb-passphrase";4142// Argon2id parameters must match peachPayload.ts exactly or decryption fails.43const ARGON2_PARAMS = {44  parallelism: 4,45  iterations: 3,46  memorySize: 65536, // 64 MiB47  hashLength: 32,48} as const;4950function concat(arrays: Uint8Array[]): Uint8Array {51  const total = arrays.reduce((s, a) => s + a.length, 0);52  const out = new Uint8Array(total);53  let cursor = 0;54  for (const a of arrays) { out.set(a, cursor); cursor += a.length; }55  return out;56}5758function hexToBytes(hex: string): Uint8Array {59  const out = new Uint8Array(hex.length / 2);60  for (let i = 0; i < hex.length; i += 2) {61    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);62  }63  return out;64}6566/**67 * Build a fully-encrypted peachPayload envelope whose section header68 * declares `declaredLength` for the decompressed size. The compressed69 * data is a minimal zstd frame so the structure parses, but the70 * declared-length check fires first.71 *72 * By constructing the payload ourselves (using the same Argon2id KDF and73 * AES-GCM as peachPayload.ts), we avoid mocking the crypto layer while74 * still controlling the section header bytes.75 */76async function buildBombPayload(77  passphrase: string,78  declaredLength: number,79  sectionCount = 1,80): Promise<Uint8Array> {81  const MAGIC = 0x50434853; // "PCHS"82  const VERSION = 0x01;83  const FORMAT_V3 = 0x03;84  const SECTION_ENTRIES = 0x01;85  const CHECKSUM_LEN = 4;8687  // Valid zstd frame for compress(new Uint8Array([0x00]), 1).88  // Generated via @bokuweb/zstd-wasm compress([0x00], 1) → decompresses to 1 zero byte.89  const minimalCompressed = new Uint8Array([90    0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x01, 0x09, 0x00, 0x00, 0x00,91  ]);9293  // Build section: type(1) + declared_length(4 LE) + compressed_length(4 LE) + data94  const sections: Uint8Array[] = [];95  for (let i = 0; i < sectionCount; i++) {96    const sec = new Uint8Array(9 + minimalCompressed.length);97    sec[0] = SECTION_ENTRIES;98    new DataView(sec.buffer).setUint32(1, declaredLength, true);99    new DataView(sec.buffer).setUint32(5, minimalCompressed.length, true);100    sec.set(minimalCompressed, 9);101    sections.push(sec);102  }103104  // Build body: header(8) + sections105  const header = new Uint8Array(8);106  new DataView(header.buffer).setUint32(0, MAGIC, false);107  header[4] = VERSION;108  header[5] = sections.length;109  const body = concat([header, ...sections]);110111  // BLAKE3 checksum (first 4 bytes of the 32-byte hash)112  const checksumHex = await blake3(body, 32);113  const checksum = hexToBytes(checksumHex).slice(0, CHECKSUM_LEN);114115  const inner = concat([body, checksum]);116117  // v3 format wrapper: [FORMAT_V3(1)][inner_len(4 LE)][inner]118  const wrapper = new Uint8Array(5);119  wrapper[0] = FORMAT_V3;120  new DataView(wrapper.buffer).setUint32(1, inner.length, true);121  const plaintext = concat([wrapper, inner]);122123  // Derive the encryption key (same Argon2id params as peachPayload.ts)124  const salt = new Uint8Array(16).fill(0x42); // deterministic for tests125  const nonce = new Uint8Array(12).fill(0x13);126  const keyBytes = await argon2id({127    password: passphrase.normalize("NFC"),128    salt,129    ...ARGON2_PARAMS,130    outputType: "binary",131  });132133  // AES-256-GCM encrypt134  const cryptoKey = await crypto.subtle.importKey(135    "raw",136    keyBytes.buffer as ArrayBuffer,137    { name: "AES-GCM", length: 256 },138    false,139    ["encrypt"],140  );141  const rawCt = new Uint8Array(142    await crypto.subtle.encrypt(143      { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },144      cryptoKey,145      plaintext.buffer.slice(146        plaintext.byteOffset,147        plaintext.byteOffset + plaintext.byteLength,148      ) as ArrayBuffer,149    ),150  );151  // WebCrypto appends the 16-byte tag to the ciphertext.152  // peachPayload's format: salt(16) + nonce(12) + ciphertext + tag(16)153  const ciphertext = rawCt.slice(0, rawCt.length - 16);154  const tag = rawCt.slice(rawCt.length - 16);155  return concat([salt, nonce, ciphertext, tag]);156}157158// ---------------------------------------------------------------------------159// Tests160// ---------------------------------------------------------------------------161162describe("T-005: peachPayload declared-length decompression bomb (MV-4)", () => {163  beforeEach(() => {164    vi.clearAllMocks();165  });166167  afterEach(() => {168    vi.clearAllMocks();169  });170171  // T-005a: a single section declaring length > MAX_DECLARED_LENGTH must throw172  // BEFORE decompressZstd is called. The spy on decompressZstd must not be173  // invoked — the guard fires at the declared-length check, not at inflate time.174  it("T-005a: declared length > MAX_DECLARED_LENGTH throws before decompressZstd", async () => {175    const bombPayload = await buildBombPayload(176      PASSPHRASE,177      IMPORT_LIMITS.MAX_DECLARED_LENGTH + 1,178    );179180    const decompress = decompressZstd as ReturnType<typeof vi.fn>;181    decompress.mockClear();182183    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(184      /IMPORT_TOO_LARGE/,185    );186187    // Bug this catches: if the declared-length check is removed, decompressZstd188    // would be called and this assertion would fail.189    expect(decompress).not.toHaveBeenCalled();190  }, 30_000); // Argon2id is slow191192  // T-005b: multiple sections summing to > MAX_DECOMPRESSED_BYTES must reject193  // even if each individual section is under MAX_DECLARED_LENGTH.194  // We use 2 sections each declaring half + 1 of MAX so the running total195  // exceeds MAX_DECOMPRESSED_BYTES on the second section.196  //197  // Section 0's decompressZstd call is mocked to return exactly `half` bytes198  // so the declared/actual check passes and section 0 completes cleanly.199  // The cumulative check then fires for section 1 BEFORE its decompressZstd200  // call — verifiable by asserting the spy was called exactly once.201  it("T-005b: cumulative section sum > MAX_DECOMPRESSED_BYTES throws before decompress", async () => {202    const half = Math.floor(IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES / 2) + 1;203    const bombPayload = await buildBombPayload(PASSPHRASE, half, 2);204205    const decompress = decompressZstd as ReturnType<typeof vi.fn>;206    decompress.mockClear();207    // Let section 0 decompress "successfully" to the declared length so the208    // SECTION_LENGTH_MISMATCH guard doesn't fire before the cumulative check.209    decompress.mockImplementationOnce(async () => new Uint8Array(half));210211    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(212      /IMPORT_TOO_LARGE/,213    );214    // The cumulative check fired for section 1 before its decompressZstd call.215    // If this were 2, the guard failed to stop the second decompress.216    expect(decompress).toHaveBeenCalledTimes(1);217  }, 30_000);218219  // T-005c: a section declaring exactly MAX_DECLARED_LENGTH - 1 is at the220  // boundary and must NOT throw on the declared-length check alone.221  // (The actual decompression may fail for other reasons — zstd output cap,222  // or decompressed size mismatch — but not on the declared-length guard.)223  it("T-005c: declared length at MAX_DECLARED_LENGTH - 1 passes the declared-length check", async () => {224    const justUnder = IMPORT_LIMITS.MAX_DECLARED_LENGTH - 1;225    const bombPayload = await buildBombPayload(PASSPHRASE, justUnder);226227    // Should NOT throw IMPORT_TOO_LARGE for the declared-length reason.228    // It may fail with SECTION_LENGTH_MISMATCH, a zstd error, or PAYLOAD_TOO_SHORT —229    // any proves the declared-length guard (which throws IMPORT_TOO_LARGE) did NOT fire.230    const err = await parsePayload(bombPayload, PASSPHRASE).catch((e: Error) => e);231    expect(err).toBeInstanceOf(Error);232    expect((err as Error).message).not.toMatch(/IMPORT_TOO_LARGE/);233  }, 30_000);234235  // T-005d: the size check fires before AES-GCM authentication failure.236  // (This is implicitly tested by T-005a: we decrypt successfully and THEN237  // the size check fires. If the order were reversed, the AES-GCM tag would238  // need to match before we could tamper with the payload, which is a239  // contradiction. The actual order is: AES-GCM decrypt → declared-length check.)240  // This test documents the ordering explicitly.241  it("T-005d: AES-GCM decryption succeeds; bomb is rejected at the section-size guard", async () => {242    const bombPayload = await buildBombPayload(243      PASSPHRASE,244      IMPORT_LIMITS.MAX_DECLARED_LENGTH + 1,245    );246247    // The error must be IMPORT_TOO_LARGE (size guard), not INVALID_PASSPHRASE248    // (auth tag failure). This confirms the payload decrypts successfully before249    // the bomb is detected.250    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(251      /IMPORT_TOO_LARGE/,252    );253    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.not.toThrow(254      /INVALID_PASSPHRASE/,255    );256  }, 30_000);257});