peachPayload.bomb.test.ts

10.2 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("../../crypto/zstd", async (importOriginal) => {25  const mod = await importOriginal<typeof import("../../crypto/zstd")>();26  return {27    ...mod,28    decompressZstd: vi.fn(mod.decompressZstd),29  };30});3132import { parsePayload } from "../../crypto/peachPayload";33import { decompressZstd } from "../../crypto/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  // A minimal zstd frame for a 1-byte input — real but tiny.88  // This is what would be decompressed if the declared-length check didn't fire.89  // zstd magic (4 bytes) + frame header + literal block.90  // We use a known-valid zstd frame (from the zstd.test.ts pattern):91  // compress(new Uint8Array([0x00])) produces a short frame.92  // For test purposes, use a raw zstd frame for 1 zero byte.93  const minimalCompressed = new Uint8Array([94    0x28, 0xb5, 0x2f, 0xfd, // zstd magic95    0x04, 0x00, 0x31, 0x00,  // frame header (content_size=1, single segment)96    0x00, 0x01, 0x00, 0x00,  // block header (last block, raw, size 1)97    0x00,                    // literal: one zero byte98    0x3e, 0x4a, 0xa9, 0x83,  // content checksum99  ]);100101  // Build section: type(1) + declared_length(4 LE) + compressed_length(4 LE) + data102  const sections: Uint8Array[] = [];103  for (let i = 0; i < sectionCount; i++) {104    const sec = new Uint8Array(9 + minimalCompressed.length);105    sec[0] = SECTION_ENTRIES;106    new DataView(sec.buffer).setUint32(1, declaredLength, true);107    new DataView(sec.buffer).setUint32(5, minimalCompressed.length, true);108    sec.set(minimalCompressed, 9);109    sections.push(sec);110  }111112  // Build body: header(8) + sections113  const header = new Uint8Array(8);114  new DataView(header.buffer).setUint32(0, MAGIC, false);115  header[4] = VERSION;116  header[5] = sections.length;117  const body = concat([header, ...sections]);118119  // BLAKE3 checksum (first 4 bytes of the 32-byte hash)120  const checksumHex = await blake3(body, 32);121  const checksum = hexToBytes(checksumHex).slice(0, CHECKSUM_LEN);122123  const inner = concat([body, checksum]);124125  // v3 format wrapper: [FORMAT_V3(1)][inner_len(4 LE)][inner]126  const wrapper = new Uint8Array(5);127  wrapper[0] = FORMAT_V3;128  new DataView(wrapper.buffer).setUint32(1, inner.length, true);129  const plaintext = concat([wrapper, inner]);130131  // Derive the encryption key (same Argon2id params as peachPayload.ts)132  const salt = new Uint8Array(16).fill(0x42); // deterministic for tests133  const nonce = new Uint8Array(12).fill(0x13);134  const keyBytes = await argon2id({135    password: passphrase.normalize("NFC"),136    salt,137    ...ARGON2_PARAMS,138    outputType: "binary",139  });140141  // AES-256-GCM encrypt142  const cryptoKey = await crypto.subtle.importKey(143    "raw",144    keyBytes.buffer as ArrayBuffer,145    { name: "AES-GCM", length: 256 },146    false,147    ["encrypt"],148  );149  const rawCt = new Uint8Array(150    await crypto.subtle.encrypt(151      { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },152      cryptoKey,153      plaintext.buffer.slice(154        plaintext.byteOffset,155        plaintext.byteOffset + plaintext.byteLength,156      ) as ArrayBuffer,157    ),158  );159  // WebCrypto appends the 16-byte tag to the ciphertext.160  // peachPayload's format: salt(16) + nonce(12) + ciphertext + tag(16)161  const ciphertext = rawCt.slice(0, rawCt.length - 16);162  const tag = rawCt.slice(rawCt.length - 16);163  return concat([salt, nonce, ciphertext, tag]);164}165166// ---------------------------------------------------------------------------167// Tests168// ---------------------------------------------------------------------------169170describe("T-005: peachPayload declared-length decompression bomb (MV-4)", () => {171  beforeEach(() => {172    vi.clearAllMocks();173  });174175  afterEach(() => {176    vi.clearAllMocks();177  });178179  // T-005a: a single section declaring length > MAX_DECLARED_LENGTH must throw180  // BEFORE decompressZstd is called. The spy on decompressZstd must not be181  // invoked — the guard fires at the declared-length check, not at inflate time.182  it("T-005a: declared length > MAX_DECLARED_LENGTH throws before decompressZstd", async () => {183    const bombPayload = await buildBombPayload(184      PASSPHRASE,185      IMPORT_LIMITS.MAX_DECLARED_LENGTH + 1,186    );187188    const decompress = decompressZstd as ReturnType<typeof vi.fn>;189    decompress.mockClear();190191    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(192      /IMPORT_TOO_LARGE/,193    );194195    // Bug this catches: if the declared-length check is removed, decompressZstd196    // would be called and this assertion would fail.197    expect(decompress).not.toHaveBeenCalled();198  }, 30_000); // Argon2id is slow199200  // T-005b: multiple sections summing to > MAX_DECOMPRESSED_BYTES must reject201  // even if each individual section is under MAX_DECLARED_LENGTH.202  // We use 2 sections each declaring MAX_DECLARED_LENGTH/2 + 1 so the203  // running total exceeds MAX_DECOMPRESSED_BYTES on the second section.204  it("T-005b: cumulative section sum > MAX_DECOMPRESSED_BYTES throws before decompress", async () => {205    const half = Math.floor(IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES / 2) + 1;206    const bombPayload = await buildBombPayload(PASSPHRASE, half, 2);207208    const decompress = decompressZstd as ReturnType<typeof vi.fn>;209    decompress.mockClear();210211    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(212      /IMPORT_TOO_LARGE/,213    );214    // The first section is under the limit so decompressZstd MAY be called215    // for section 0; the cumulative check fires on section 1.216    // Key assertion: the throw comes from the cumulative guard, not zstd output.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 throw for SECTION_LENGTH_MISMATCH (the actual decompressed size229    // differs from the claimed size), but that's a different guard.230    await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(231      /SECTION_LENGTH_MISMATCH|CHECKSUM|PAYLOAD_TOO_SHORT/,232    );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});