123456789101112131415161718import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";19import { argon2id, blake3 } from "hash-wasm";2021222324vi.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";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 const minimalCompressed = new Uint8Array([90 0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x01, 0x09, 0x00, 0x00, 0x00,91 ]);9293 94 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 105 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 112 const checksumHex = await blake3(body, 32);113 const checksum = hexToBytes(checksumHex).slice(0, CHECKSUM_LEN);114115 const inner = concat([body, checksum]);116117 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 124 const salt = new Uint8Array(16).fill(0x42); 125 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 134 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 152 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}157158159160161162describe("T-005: peachPayload declared-length decompression bomb (MV-4)", () => {163 beforeEach(() => {164 vi.clearAllMocks();165 });166167 afterEach(() => {168 vi.clearAllMocks();169 });170171 172 173 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 188 189 expect(decompress).not.toHaveBeenCalled();190 }, 30_000); 191192 193 194 195 196 197 198 199 200 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 208 209 decompress.mockImplementationOnce(async () => new Uint8Array(half));210211 await expect(parsePayload(bombPayload, PASSPHRASE)).rejects.toThrow(212 /IMPORT_TOO_LARGE/,213 );214 215 216 expect(decompress).toHaveBeenCalledTimes(1);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 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 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});