123456789101112131415161718import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";19import { argon2id, blake3 } from "hash-wasm";2021222324vi.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";353637383940const PASSPHRASE = "test-bomb-passphrase";414243const ARGON2_PARAMS = {44 parallelism: 4,45 iterations: 3,46 memorySize: 65536, 47 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}656667686970717273747576async function buildBombPayload(77 passphrase: string,78 declaredLength: number,79 sectionCount = 1,80): Promise<Uint8Array> {81 const MAGIC = 0x50434853; 82 const VERSION = 0x01;83 const FORMAT_V3 = 0x03;84 const SECTION_ENTRIES = 0x01;85 const CHECKSUM_LEN = 4;8687 88 89 90 91 92 93 const minimalCompressed = new Uint8Array([94 0x28, 0xb5, 0x2f, 0xfd, 95 0x04, 0x00, 0x31, 0x00, 96 0x00, 0x01, 0x00, 0x00, 97 0x00, 98 0x3e, 0x4a, 0xa9, 0x83, 99 ]);100101 102 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 113 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 120 const checksumHex = await blake3(body, 32);121 const checksum = hexToBytes(checksumHex).slice(0, CHECKSUM_LEN);122123 const inner = concat([body, checksum]);124125 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 132 const salt = new Uint8Array(16).fill(0x42); 133 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 142 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 160 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}165166167168169170describe("T-005: peachPayload declared-length decompression bomb (MV-4)", () => {171 beforeEach(() => {172 vi.clearAllMocks();173 });174175 afterEach(() => {176 vi.clearAllMocks();177 });178179 180 181 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 196 197 expect(decompress).not.toHaveBeenCalled();198 }, 30_000); 199200 201 202 203 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 215 216 217 }, 30_000);218219 220 221 222 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 228 229 230 await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(231 /SECTION_LENGTH_MISMATCH|CHECKSUM|PAYLOAD_TOO_SHORT/,232 );233 }, 30_000);234235 236 237 238 239 240 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 248 249 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});