// T-005: peachPayload declared-length decompression bomb (MV-4) // // Why this test exists: MV-4 added two guards in peachPayload.ts's section // parsing loop: // 1. length > MAX_DECLARED_LENGTH → throw BEFORE calling decompressZstd // 2. totalDecompressed > MAX_DECOMPRESSED_BYTES → throw BEFORE calling // decompressZstd on the next section // // The existing decompression-bomb.test.ts only exercises the zstd output cap // (MAX_DECOMPRESSED_BYTES applied inside decompressZstd itself). It does NOT // test that the *declared-length* field check fires before decompression is // attempted. Without this test, the declared-length guard could be removed // (allowing a 100-byte section to claim a 100 GiB decompressed size) and no // existing test would fail — because if the zstd frame is honest the output // cap catches it, but the declared-length check is the FIRST line of defense // and exists to reject before even calling the decompressor. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { argon2id, blake3 } from "hash-wasm"; // Spy on decompressZstd before it is imported by peachPayload. // vi.mock is hoisted to the top of the file so the spy is in place // before any module that imports decompressZstd is evaluated. vi.mock("./zstd", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, decompressZstd: vi.fn(mod.decompressZstd), }; }); import { parsePayload } from "./peachPayload"; import { decompressZstd } from "./zstd"; import { IMPORT_LIMITS } from "../shared/import-limits"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const PASSPHRASE = "test-bomb-passphrase"; // Argon2id parameters must match peachPayload.ts exactly or decryption fails. const ARGON2_PARAMS = { parallelism: 4, iterations: 3, memorySize: 65536, // 64 MiB hashLength: 32, } as const; function concat(arrays: Uint8Array[]): Uint8Array { const total = arrays.reduce((s, a) => s + a.length, 0); const out = new Uint8Array(total); let cursor = 0; for (const a of arrays) { out.set(a, cursor); cursor += a.length; } return out; } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { out[i / 2] = parseInt(hex.slice(i, i + 2), 16); } return out; } /** * Build a fully-encrypted peachPayload envelope whose section header * declares `declaredLength` for the decompressed size. The compressed * data is a minimal zstd frame so the structure parses, but the * declared-length check fires first. * * By constructing the payload ourselves (using the same Argon2id KDF and * AES-GCM as peachPayload.ts), we avoid mocking the crypto layer while * still controlling the section header bytes. */ async function buildBombPayload( passphrase: string, declaredLength: number, sectionCount = 1, ): Promise { const MAGIC = 0x50434853; // "PCHS" const VERSION = 0x01; const FORMAT_V3 = 0x03; const SECTION_ENTRIES = 0x01; const CHECKSUM_LEN = 4; // Valid zstd frame for compress(new Uint8Array([0x00]), 1). // Generated via @bokuweb/zstd-wasm compress([0x00], 1) → decompresses to 1 zero byte. const minimalCompressed = new Uint8Array([ 0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x01, 0x09, 0x00, 0x00, 0x00, ]); // Build section: type(1) + declared_length(4 LE) + compressed_length(4 LE) + data const sections: Uint8Array[] = []; for (let i = 0; i < sectionCount; i++) { const sec = new Uint8Array(9 + minimalCompressed.length); sec[0] = SECTION_ENTRIES; new DataView(sec.buffer).setUint32(1, declaredLength, true); new DataView(sec.buffer).setUint32(5, minimalCompressed.length, true); sec.set(minimalCompressed, 9); sections.push(sec); } // Build body: header(8) + sections const header = new Uint8Array(8); new DataView(header.buffer).setUint32(0, MAGIC, false); header[4] = VERSION; header[5] = sections.length; const body = concat([header, ...sections]); // BLAKE3 checksum (first 4 bytes of the 32-byte hash) const checksumHex = await blake3(body, 32); const checksum = hexToBytes(checksumHex).slice(0, CHECKSUM_LEN); const inner = concat([body, checksum]); // v3 format wrapper: [FORMAT_V3(1)][inner_len(4 LE)][inner] const wrapper = new Uint8Array(5); wrapper[0] = FORMAT_V3; new DataView(wrapper.buffer).setUint32(1, inner.length, true); const plaintext = concat([wrapper, inner]); // Derive the encryption key (same Argon2id params as peachPayload.ts) const salt = new Uint8Array(16).fill(0x42); // deterministic for tests const nonce = new Uint8Array(12).fill(0x13); const keyBytes = await argon2id({ password: passphrase.normalize("NFC"), salt, ...ARGON2_PARAMS, outputType: "binary", }); // AES-256-GCM encrypt const cryptoKey = await crypto.subtle.importKey( "raw", keyBytes.buffer as ArrayBuffer, { name: "AES-GCM", length: 256 }, false, ["encrypt"], ); const rawCt = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 }, cryptoKey, plaintext.buffer.slice( plaintext.byteOffset, plaintext.byteOffset + plaintext.byteLength, ) as ArrayBuffer, ), ); // WebCrypto appends the 16-byte tag to the ciphertext. // peachPayload's format: salt(16) + nonce(12) + ciphertext + tag(16) const ciphertext = rawCt.slice(0, rawCt.length - 16); const tag = rawCt.slice(rawCt.length - 16); return concat([salt, nonce, ciphertext, tag]); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-005: peachPayload declared-length decompression bomb (MV-4)", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.clearAllMocks(); }); // T-005a: a single section declaring length > MAX_DECLARED_LENGTH must throw // BEFORE decompressZstd is called. The spy on decompressZstd must not be // invoked — the guard fires at the declared-length check, not at inflate time. it("T-005a: declared length > MAX_DECLARED_LENGTH throws before decompressZstd", async () => { const bombPayload = await buildBombPayload( PASSPHRASE, IMPORT_LIMITS.MAX_DECLARED_LENGTH + 1, ); const decompress = decompressZstd as ReturnType; decompress.mockClear(); await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow( /IMPORT_TOO_LARGE/, ); // Bug this catches: if the declared-length check is removed, decompressZstd // would be called and this assertion would fail. expect(decompress).not.toHaveBeenCalled(); }, 30_000); // Argon2id is slow // T-005b: multiple sections summing to > MAX_DECOMPRESSED_BYTES must reject // even if each individual section is under MAX_DECLARED_LENGTH. // We use 2 sections each declaring half + 1 of MAX so the running total // exceeds MAX_DECOMPRESSED_BYTES on the second section. // // Section 0's decompressZstd call is mocked to return exactly `half` bytes // so the declared/actual check passes and section 0 completes cleanly. // The cumulative check then fires for section 1 BEFORE its decompressZstd // call — verifiable by asserting the spy was called exactly once. it("T-005b: cumulative section sum > MAX_DECOMPRESSED_BYTES throws before decompress", async () => { const half = Math.floor(IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES / 2) + 1; const bombPayload = await buildBombPayload(PASSPHRASE, half, 2); const decompress = decompressZstd as ReturnType; decompress.mockClear(); // Let section 0 decompress "successfully" to the declared length so the // SECTION_LENGTH_MISMATCH guard doesn't fire before the cumulative check. decompress.mockImplementationOnce(async () => new Uint8Array(half)); await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow( /IMPORT_TOO_LARGE/, ); // The cumulative check fired for section 1 before its decompressZstd call. // If this were 2, the guard failed to stop the second decompress. expect(decompress).toHaveBeenCalledTimes(1); }, 30_000); // T-005c: a section declaring exactly MAX_DECLARED_LENGTH - 1 is at the // boundary and must NOT throw on the declared-length check alone. // (The actual decompression may fail for other reasons — zstd output cap, // or decompressed size mismatch — but not on the declared-length guard.) it("T-005c: declared length at MAX_DECLARED_LENGTH - 1 passes the declared-length check", async () => { const justUnder = IMPORT_LIMITS.MAX_DECLARED_LENGTH - 1; const bombPayload = await buildBombPayload(PASSPHRASE, justUnder); // Should NOT throw IMPORT_TOO_LARGE for the declared-length reason. // It may fail with SECTION_LENGTH_MISMATCH, a zstd error, or PAYLOAD_TOO_SHORT — // any proves the declared-length guard (which throws IMPORT_TOO_LARGE) did NOT fire. const err = await parsePayload(bombPayload, PASSPHRASE).catch((e: Error) => e); expect(err).toBeInstanceOf(Error); expect((err as Error).message).not.toMatch(/IMPORT_TOO_LARGE/); }, 30_000); // T-005d: the size check fires before AES-GCM authentication failure. // (This is implicitly tested by T-005a: we decrypt successfully and THEN // the size check fires. If the order were reversed, the AES-GCM tag would // need to match before we could tamper with the payload, which is a // contradiction. The actual order is: AES-GCM decrypt → declared-length check.) // This test documents the ordering explicitly. it("T-005d: AES-GCM decryption succeeds; bomb is rejected at the section-size guard", async () => { const bombPayload = await buildBombPayload( PASSPHRASE, IMPORT_LIMITS.MAX_DECLARED_LENGTH + 1, ); // The error must be IMPORT_TOO_LARGE (size guard), not INVALID_PASSPHRASE // (auth tag failure). This confirms the payload decrypts successfully before // the bomb is detected. await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow( /IMPORT_TOO_LARGE/, ); await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.not.toThrow( /INVALID_PASSPHRASE/, ); }, 30_000); });