@peac/audit 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,838 @@
1
+ "use strict";
2
+ /**
3
+ * Dispute Bundle (v0.9.30+)
4
+ *
5
+ * DisputeBundle is a ZIP archive containing receipts, keys, and policy
6
+ * for offline verification and audit.
7
+ *
8
+ * Key design principles:
9
+ * 1. ZIP is transport container, not what we hash - deterministic integrity at content layer
10
+ * 2. bundle.sig provides authenticity (JWS over content_hash)
11
+ * 3. receipts.ndjson format for determinism + streaming
12
+ * 4. Real Ed25519 signature verification
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.BUNDLE_ERROR_CODES = exports.BundleErrorCodes = void 0;
49
+ exports.createDisputeBundle = createDisputeBundle;
50
+ exports.readDisputeBundle = readDisputeBundle;
51
+ exports.verifyBundleIntegrity = verifyBundleIntegrity;
52
+ exports.getBundleContentHash = getBundleContentHash;
53
+ const node_crypto_1 = require("node:crypto");
54
+ const node_path_1 = require("node:path");
55
+ const crypto_1 = require("@peac/crypto");
56
+ const kernel_1 = require("@peac/kernel");
57
+ const yazl = __importStar(require("yazl"));
58
+ const yauzl = __importStar(require("yauzl"));
59
+ const dispute_bundle_types_js_1 = require("./dispute-bundle-types.js");
60
+ // ============================================================================
61
+ // Constants and Limits (DoS protection)
62
+ // ============================================================================
63
+ /** Maximum number of entries in a bundle ZIP */
64
+ const MAX_ZIP_ENTRIES = 10000;
65
+ /** Maximum uncompressed size per entry (64MB) */
66
+ const MAX_ENTRY_SIZE = 64 * 1024 * 1024;
67
+ /** Maximum total uncompressed size (512MB) */
68
+ const MAX_TOTAL_SIZE = 512 * 1024 * 1024;
69
+ /** Maximum receipts in a bundle */
70
+ const MAX_RECEIPTS = 10000;
71
+ /** Allowed path prefixes in bundle */
72
+ const ALLOWED_PATHS = ['manifest.json', 'bundle.sig', 'receipts.ndjson', 'keys/', 'policy/'];
73
+ // ============================================================================
74
+ // Error Codes (from @peac/kernel - generated from specs/kernel/errors.json)
75
+ // ============================================================================
76
+ /**
77
+ * Re-export BUNDLE_ERRORS as BundleErrorCodes for backwards compatibility
78
+ * @deprecated Use BUNDLE_ERRORS from @peac/kernel directly
79
+ */
80
+ exports.BundleErrorCodes = kernel_1.BUNDLE_ERRORS;
81
+ exports.BUNDLE_ERROR_CODES = exports.BundleErrorCodes;
82
+ // ============================================================================
83
+ // Utilities
84
+ // ============================================================================
85
+ /** Crockford's Base32 alphabet for ULID */
86
+ const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
87
+ /**
88
+ * Generate a spec-compliant ULID (Universally Unique Lexicographically Sortable Identifier)
89
+ *
90
+ * ULID format: 26 characters using Crockford's Base32
91
+ * - 10 chars timestamp (48 bits, ms since Unix epoch) - lexicographically sortable
92
+ * - 16 chars randomness (80 bits from crypto.randomBytes)
93
+ *
94
+ * @see https://github.com/ulid/spec
95
+ */
96
+ function generateBundleId() {
97
+ const timestamp = Date.now();
98
+ // Encode 48-bit timestamp as 10 Crockford Base32 characters
99
+ // Each character encodes 5 bits, but we encode from most significant
100
+ const timestampChars = [];
101
+ let ts = timestamp;
102
+ for (let i = 9; i >= 0; i--) {
103
+ timestampChars[i] = ULID_ALPHABET[ts % 32];
104
+ ts = Math.floor(ts / 32);
105
+ }
106
+ // Generate 80 bits of randomness (16 Crockford Base32 characters)
107
+ // Use crypto.randomBytes for cryptographic randomness
108
+ const { randomBytes: cryptoRandomBytes } = require('node:crypto');
109
+ const randBytes = cryptoRandomBytes(10); // 80 bits = 10 bytes
110
+ const randomChars = [];
111
+ // Encode 10 bytes as 16 base32 characters
112
+ // Each base32 char = 5 bits, so we need to carefully extract 5-bit groups
113
+ // We'll use BigInt for clean 80-bit handling
114
+ let randomValue = BigInt(0);
115
+ for (let i = 0; i < 10; i++) {
116
+ randomValue = (randomValue << BigInt(8)) | BigInt(randBytes[i]);
117
+ }
118
+ // Extract 16 characters (5 bits each) from the 80-bit value
119
+ for (let i = 15; i >= 0; i--) {
120
+ randomChars[i] = ULID_ALPHABET[Number(randomValue & BigInt(0x1f))];
121
+ randomValue = randomValue >> BigInt(5);
122
+ }
123
+ return timestampChars.join('') + randomChars.join('');
124
+ }
125
+ /**
126
+ * Compute SHA-256 hash of data with self-describing format.
127
+ * Returns `sha256:<64 lowercase hex chars>` format.
128
+ */
129
+ function sha256Hex(data) {
130
+ const hash = (0, node_crypto_1.createHash)('sha256');
131
+ hash.update(data);
132
+ return `sha256:${hash.digest('hex')}`;
133
+ }
134
+ /** Decode base64url to Buffer */
135
+ function base64urlDecode(str) {
136
+ const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
137
+ const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
138
+ return Buffer.from(base64, 'base64');
139
+ }
140
+ /** Parse JWS compact serialization to extract header and payload */
141
+ function parseJws(jws) {
142
+ const parts = jws.split('.');
143
+ if (parts.length !== 3) {
144
+ return null;
145
+ }
146
+ try {
147
+ const headerJson = base64urlDecode(parts[0]).toString('utf8');
148
+ const payloadJson = base64urlDecode(parts[1]).toString('utf8');
149
+ return {
150
+ header: JSON.parse(headerJson),
151
+ payload: JSON.parse(payloadJson),
152
+ signature: base64urlDecode(parts[2]),
153
+ signingInput: `${parts[0]}.${parts[1]}`,
154
+ };
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ /** Create a bundle error */
161
+ function bundleError(code, message, details) {
162
+ return { code, message, details };
163
+ }
164
+ /**
165
+ * Convert a yauzl ZIP error to the appropriate bundle error.
166
+ * Detects path traversal attempts that yauzl catches and maps them to PATH_TRAVERSAL.
167
+ */
168
+ function handleZipError(zipErr) {
169
+ // Detect yauzl path validation errors and map to PATH_TRAVERSAL
170
+ // yauzl throws "invalid relative path" for zip-slip attempts
171
+ const isPathError = zipErr.message.includes('invalid relative path') ||
172
+ zipErr.message.includes('absolute path') ||
173
+ zipErr.message.includes('..') ||
174
+ zipErr.message.includes('\\');
175
+ if (isPathError) {
176
+ return bundleError(exports.BundleErrorCodes.PATH_TRAVERSAL, `Unsafe path in bundle: ${zipErr.message}`);
177
+ }
178
+ return bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `ZIP error: ${zipErr.message}`);
179
+ }
180
+ /**
181
+ * Virtual root for path containment checks.
182
+ * Using a fixed virtual root allows resolve-based containment verification.
183
+ */
184
+ const VIRTUAL_ROOT = '/bundle';
185
+ /**
186
+ * Validate path for zip-slip and path traversal attacks.
187
+ *
188
+ * Security measures:
189
+ * 1. Reject backslashes (Windows path separators can bypass Unix checks)
190
+ * 2. Reject null bytes (can bypass string-based checks)
191
+ * 3. Normalize with posix.normalize to handle . and .. components
192
+ * 4. Reject absolute paths (starting with /)
193
+ * 5. Reject paths that escape via .. after normalization
194
+ * 6. Resolve-based containment check (defense in depth)
195
+ * 7. Only allow explicitly whitelisted path prefixes
196
+ */
197
+ function isPathSafe(entryPath) {
198
+ // Reject backslashes - often used for zip-slip on Unix systems
199
+ // since many ZIP tools will accept both separators
200
+ if (entryPath.includes('\\'))
201
+ return false;
202
+ // Reject null bytes (can be used to bypass checks)
203
+ if (entryPath.includes('\0'))
204
+ return false;
205
+ // Normalize the path to resolve . and .. components
206
+ const normalized = node_path_1.posix.normalize(entryPath);
207
+ // After normalization, reject if:
208
+ // - Starts with / (absolute path)
209
+ // - Starts with .. (escapes bundle root)
210
+ // - Is exactly . (current dir, not a valid file)
211
+ if (normalized.startsWith('/'))
212
+ return false;
213
+ if (normalized.startsWith('..'))
214
+ return false;
215
+ if (normalized === '.')
216
+ return false;
217
+ // Defense in depth: resolve-based containment check
218
+ // Resolve the path relative to a virtual root and verify it stays contained
219
+ const resolved = node_path_1.posix.resolve(VIRTUAL_ROOT, normalized);
220
+ if (!resolved.startsWith(VIRTUAL_ROOT + '/') && resolved !== VIRTUAL_ROOT) {
221
+ return false;
222
+ }
223
+ // Only allow explicitly whitelisted paths
224
+ return ALLOWED_PATHS.some((prefix) => normalized === prefix || normalized.startsWith(prefix));
225
+ }
226
+ /** Convert JWK to raw Ed25519 public key bytes */
227
+ function jwkToEd25519PublicKey(jwk) {
228
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !jwk.x) {
229
+ return null;
230
+ }
231
+ return base64urlDecode(jwk.x);
232
+ }
233
+ // ============================================================================
234
+ // Bundle Creation
235
+ // ============================================================================
236
+ /**
237
+ * Create a dispute bundle from receipts, keys, and optional policy.
238
+ */
239
+ async function createDisputeBundle(options) {
240
+ const { kind = 'dispute', refs, dispute_ref, created_by, receipts, keys, policy, bundle_id, created_at, signing_key, signing_kid, } = options;
241
+ // Build refs from either new refs or deprecated dispute_ref
242
+ const bundleRefs = refs ?? (dispute_ref ? [{ type: 'dispute', id: dispute_ref }] : []);
243
+ // Validate receipts
244
+ if (receipts.length === 0) {
245
+ return {
246
+ ok: false,
247
+ error: bundleError(exports.BundleErrorCodes.MISSING_RECEIPTS, 'No receipts provided'),
248
+ };
249
+ }
250
+ if (receipts.length > MAX_RECEIPTS) {
251
+ return {
252
+ ok: false,
253
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Too many receipts: ${receipts.length} > ${MAX_RECEIPTS}`),
254
+ };
255
+ }
256
+ // Validate keys
257
+ if (keys.keys.length === 0) {
258
+ return {
259
+ ok: false,
260
+ error: bundleError(exports.BundleErrorCodes.MISSING_KEYS, 'No keys provided in JWKS'),
261
+ };
262
+ }
263
+ // Parse receipts and detect duplicates
264
+ const receiptEntries = [];
265
+ const seenReceiptIds = new Set();
266
+ const ndjsonLines = [];
267
+ let minIssuedAt;
268
+ let maxIssuedAt;
269
+ for (let i = 0; i < receipts.length; i++) {
270
+ const jws = receipts[i];
271
+ const parsed = parseJws(jws);
272
+ if (!parsed) {
273
+ return {
274
+ ok: false,
275
+ error: bundleError(exports.BundleErrorCodes.RECEIPT_INVALID, `Invalid JWS at index ${i}`),
276
+ };
277
+ }
278
+ const claims = parsed.payload;
279
+ const receiptId = claims.jti;
280
+ const issuedAtRaw = claims.iat;
281
+ if (!receiptId) {
282
+ return {
283
+ ok: false,
284
+ error: bundleError(exports.BundleErrorCodes.RECEIPT_INVALID, `Receipt at index ${i} missing jti claim`),
285
+ };
286
+ }
287
+ // Detect duplicates
288
+ if (seenReceiptIds.has(receiptId)) {
289
+ return {
290
+ ok: false,
291
+ error: bundleError(exports.BundleErrorCodes.DUPLICATE_RECEIPT, `Duplicate receipt ID: ${receiptId}`),
292
+ };
293
+ }
294
+ seenReceiptIds.add(receiptId);
295
+ // Convert iat to ISO 8601 string
296
+ let issuedAt;
297
+ if (typeof issuedAtRaw === 'number') {
298
+ issuedAt = new Date(issuedAtRaw * 1000).toISOString();
299
+ }
300
+ else if (typeof issuedAtRaw === 'string') {
301
+ issuedAt = issuedAtRaw;
302
+ }
303
+ else {
304
+ return {
305
+ ok: false,
306
+ error: bundleError(exports.BundleErrorCodes.RECEIPT_INVALID, `Receipt ${receiptId} missing or invalid iat claim`),
307
+ };
308
+ }
309
+ // Track time range
310
+ if (!minIssuedAt || issuedAt < minIssuedAt)
311
+ minIssuedAt = issuedAt;
312
+ if (!maxIssuedAt || issuedAt > maxIssuedAt)
313
+ maxIssuedAt = issuedAt;
314
+ // Compute receipt hash (SHA-256 of JWS bytes)
315
+ const receiptHash = sha256Hex(Buffer.from(jws, 'utf8'));
316
+ receiptEntries.push({
317
+ receipt_id: receiptId,
318
+ issued_at: issuedAt,
319
+ receipt_hash: receiptHash,
320
+ });
321
+ ndjsonLines.push(jws);
322
+ }
323
+ // Sort receipts by (issued_at, receipt_id, receipt_hash) for determinism
324
+ const sortedIndices = receiptEntries
325
+ .map((entry, i) => ({ entry, i }))
326
+ .sort((a, b) => {
327
+ if (a.entry.issued_at !== b.entry.issued_at) {
328
+ return a.entry.issued_at.localeCompare(b.entry.issued_at);
329
+ }
330
+ if (a.entry.receipt_id !== b.entry.receipt_id) {
331
+ return a.entry.receipt_id.localeCompare(b.entry.receipt_id);
332
+ }
333
+ return a.entry.receipt_hash.localeCompare(b.entry.receipt_hash);
334
+ });
335
+ const sortedReceiptEntries = sortedIndices.map((x) => x.entry);
336
+ const sortedNdjsonLines = sortedIndices.map((x) => ndjsonLines[x.i]);
337
+ // Create receipts.ndjson content
338
+ const receiptsNdjson = sortedNdjsonLines.join('\n') + '\n';
339
+ const receiptsNdjsonBytes = Buffer.from(receiptsNdjson, 'utf8');
340
+ // Process keys
341
+ const keyEntries = keys.keys.map((key) => ({
342
+ kid: key.kid,
343
+ alg: key.alg ?? 'EdDSA',
344
+ }));
345
+ keyEntries.sort((a, b) => a.kid.localeCompare(b.kid));
346
+ const keysJson = JSON.stringify(keys, null, 2);
347
+ const keysBytes = Buffer.from(keysJson, 'utf8');
348
+ // Build file entries
349
+ const fileEntries = [
350
+ {
351
+ path: 'receipts.ndjson',
352
+ sha256: sha256Hex(receiptsNdjsonBytes),
353
+ size: receiptsNdjsonBytes.length,
354
+ },
355
+ {
356
+ path: 'keys/keys.json',
357
+ sha256: sha256Hex(keysBytes),
358
+ size: keysBytes.length,
359
+ },
360
+ ];
361
+ // Process policy if present
362
+ let policyHash;
363
+ let policyBytes;
364
+ if (policy) {
365
+ policyBytes = Buffer.from(policy, 'utf8');
366
+ policyHash = sha256Hex(policyBytes);
367
+ fileEntries.push({
368
+ path: 'policy/policy.yaml',
369
+ sha256: policyHash,
370
+ size: policyBytes.length,
371
+ });
372
+ }
373
+ // Sort files by path
374
+ fileEntries.sort((a, b) => a.path.localeCompare(b.path));
375
+ // Build manifest (without content_hash first)
376
+ const timeRange = {
377
+ start: minIssuedAt,
378
+ end: maxIssuedAt,
379
+ };
380
+ const manifestWithoutHash = {
381
+ version: dispute_bundle_types_js_1.BUNDLE_VERSION,
382
+ kind,
383
+ bundle_id: bundle_id ?? generateBundleId(),
384
+ refs: bundleRefs,
385
+ // Include deprecated dispute_ref for backwards compatibility
386
+ dispute_ref: dispute_ref,
387
+ created_by,
388
+ created_at: created_at ?? new Date().toISOString(),
389
+ time_range: timeRange,
390
+ receipts: sortedReceiptEntries,
391
+ keys: keyEntries,
392
+ files: fileEntries,
393
+ };
394
+ if (policyHash) {
395
+ manifestWithoutHash.policy_hash = policyHash;
396
+ }
397
+ // Compute content_hash = SHA-256 of JCS(manifest without content_hash)
398
+ const contentHash = sha256Hex((0, crypto_1.canonicalize)(manifestWithoutHash));
399
+ const manifest = {
400
+ ...manifestWithoutHash,
401
+ content_hash: contentHash,
402
+ };
403
+ // Create ZIP archive
404
+ const zipfile = new yazl.ZipFile();
405
+ // Use manifest.created_at as the mtime for all entries to ensure deterministic ZIP output
406
+ // Disable compression to ensure byte-identical output across platforms (zlib implementations vary)
407
+ const mtime = new Date(manifest.created_at);
408
+ const zipOptions = { mtime, compress: false };
409
+ // Add manifest.json
410
+ const manifestJson = JSON.stringify(manifest, null, 2);
411
+ zipfile.addBuffer(Buffer.from(manifestJson), 'manifest.json', zipOptions);
412
+ // Add receipts.ndjson
413
+ zipfile.addBuffer(receiptsNdjsonBytes, 'receipts.ndjson', zipOptions);
414
+ // Add keys
415
+ zipfile.addBuffer(keysBytes, 'keys/keys.json', zipOptions);
416
+ // Add policy if present
417
+ if (policyBytes) {
418
+ zipfile.addBuffer(policyBytes, 'policy/policy.yaml', zipOptions);
419
+ }
420
+ // Add bundle.sig if signing key is provided
421
+ if (signing_key && signing_kid) {
422
+ const sigResult = await createBundleSignature(contentHash, signing_key, signing_kid);
423
+ if (!sigResult.ok) {
424
+ return sigResult;
425
+ }
426
+ zipfile.addBuffer(Buffer.from(sigResult.value), 'bundle.sig', zipOptions);
427
+ }
428
+ // Finalize and collect the ZIP buffer
429
+ return new Promise((resolve) => {
430
+ const chunks = [];
431
+ zipfile.outputStream
432
+ .on('data', (chunk) => chunks.push(chunk))
433
+ .on('end', () => {
434
+ resolve({ ok: true, value: Buffer.concat(chunks) });
435
+ })
436
+ .on('error', (err) => {
437
+ resolve({
438
+ ok: false,
439
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `Failed to create ZIP: ${err.message}`),
440
+ });
441
+ });
442
+ zipfile.end();
443
+ });
444
+ }
445
+ /**
446
+ * Create bundle.sig JWS over the content_hash
447
+ */
448
+ async function createBundleSignature(contentHash, privateKey, kid) {
449
+ try {
450
+ const { sign } = await Promise.resolve().then(() => __importStar(require('@peac/crypto')));
451
+ const jws = await sign({ content_hash: contentHash }, privateKey, kid);
452
+ return { ok: true, value: jws };
453
+ }
454
+ catch (err) {
455
+ return {
456
+ ok: false,
457
+ error: bundleError(exports.BundleErrorCodes.SIGNATURE_INVALID, `Failed to create bundle signature: ${err.message}`),
458
+ };
459
+ }
460
+ }
461
+ // ============================================================================
462
+ // Bundle Reading
463
+ // ============================================================================
464
+ /**
465
+ * Read and parse a dispute bundle from a ZIP buffer.
466
+ */
467
+ async function readDisputeBundle(zipBuffer) {
468
+ return new Promise((resolve) => {
469
+ yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => {
470
+ if (err || !zipfile) {
471
+ resolve({
472
+ ok: false,
473
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `Failed to open ZIP: ${err?.message ?? 'unknown error'}`),
474
+ });
475
+ return;
476
+ }
477
+ const files = new Map();
478
+ let entryCount = 0;
479
+ let totalSize = 0; // Claimed total from ZIP metadata
480
+ let actualTotalBytes = 0; // Actual decompressed bytes (defense-in-depth)
481
+ zipfile.on('entry', (entry) => {
482
+ if (/\/$/.test(entry.fileName)) {
483
+ zipfile.readEntry();
484
+ return;
485
+ }
486
+ entryCount++;
487
+ // DoS protection: entry count
488
+ if (entryCount > MAX_ZIP_ENTRIES) {
489
+ zipfile.close();
490
+ resolve({
491
+ ok: false,
492
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Too many ZIP entries: > ${MAX_ZIP_ENTRIES}`),
493
+ });
494
+ return;
495
+ }
496
+ // Security: path validation
497
+ if (!isPathSafe(entry.fileName)) {
498
+ zipfile.close();
499
+ resolve({
500
+ ok: false,
501
+ error: bundleError(exports.BundleErrorCodes.PATH_TRAVERSAL, `Unsafe path in bundle: ${entry.fileName}`),
502
+ });
503
+ return;
504
+ }
505
+ // DoS protection: entry size
506
+ if (entry.uncompressedSize > MAX_ENTRY_SIZE) {
507
+ zipfile.close();
508
+ resolve({
509
+ ok: false,
510
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Entry too large: ${entry.fileName}`),
511
+ });
512
+ return;
513
+ }
514
+ totalSize += entry.uncompressedSize;
515
+ if (totalSize > MAX_TOTAL_SIZE) {
516
+ zipfile.close();
517
+ resolve({
518
+ ok: false,
519
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Total size exceeded: > ${MAX_TOTAL_SIZE} bytes`),
520
+ });
521
+ return;
522
+ }
523
+ zipfile.openReadStream(entry, (readErr, readStream) => {
524
+ if (readErr || !readStream) {
525
+ zipfile.close();
526
+ resolve({
527
+ ok: false,
528
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `Failed to read ${entry.fileName}`),
529
+ });
530
+ return;
531
+ }
532
+ const chunks = [];
533
+ let actualBytes = 0;
534
+ const entryBudget = entry.uncompressedSize > 0 ? entry.uncompressedSize : MAX_ENTRY_SIZE;
535
+ readStream.on('data', (chunk) => {
536
+ actualBytes += chunk.length;
537
+ actualTotalBytes += chunk.length;
538
+ // Defense-in-depth: Track actual decompressed bytes, not just ZIP metadata.
539
+ // A malicious ZIP can claim small uncompressedSize but decompress to much more.
540
+ if (actualBytes > MAX_ENTRY_SIZE) {
541
+ readStream.destroy();
542
+ zipfile.close();
543
+ resolve({
544
+ ok: false,
545
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Entry exceeds size limit during decompression: ${entry.fileName}`, { claimed: entry.uncompressedSize, actual: actualBytes, limit: MAX_ENTRY_SIZE }),
546
+ });
547
+ return;
548
+ }
549
+ // Defense-in-depth: Track actual total decompressed bytes across all entries
550
+ if (actualTotalBytes > MAX_TOTAL_SIZE) {
551
+ readStream.destroy();
552
+ zipfile.close();
553
+ resolve({
554
+ ok: false,
555
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Total decompressed size exceeds limit: ${actualTotalBytes} > ${MAX_TOTAL_SIZE}`, { actual: actualTotalBytes, limit: MAX_TOTAL_SIZE }),
556
+ });
557
+ return;
558
+ }
559
+ // Also check against claimed size (detect zip bombs with false metadata)
560
+ if (entry.uncompressedSize > 0 && actualBytes > entry.uncompressedSize * 2) {
561
+ // Allow 2x tolerance for edge cases, but catch gross violations
562
+ readStream.destroy();
563
+ zipfile.close();
564
+ resolve({
565
+ ok: false,
566
+ error: bundleError(exports.BundleErrorCodes.SIZE_EXCEEDED, `Entry decompressed size exceeds claimed size: ${entry.fileName}`, { claimed: entry.uncompressedSize, actual: actualBytes }),
567
+ });
568
+ return;
569
+ }
570
+ chunks.push(chunk);
571
+ });
572
+ readStream.on('end', () => {
573
+ files.set(entry.fileName, Buffer.concat(chunks));
574
+ zipfile.readEntry();
575
+ });
576
+ readStream.on('error', (streamErr) => {
577
+ zipfile.close();
578
+ resolve({
579
+ ok: false,
580
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `Stream error: ${streamErr.message}`),
581
+ });
582
+ });
583
+ });
584
+ });
585
+ zipfile.on('end', () => {
586
+ processExtractedFiles(files, resolve);
587
+ });
588
+ zipfile.on('error', (zipErr) => {
589
+ resolve({
590
+ ok: false,
591
+ error: handleZipError(zipErr),
592
+ });
593
+ });
594
+ zipfile.readEntry();
595
+ });
596
+ });
597
+ }
598
+ /**
599
+ * Process extracted files and validate bundle integrity
600
+ */
601
+ function processExtractedFiles(files, resolve) {
602
+ // Parse manifest
603
+ const manifestBuffer = files.get('manifest.json');
604
+ if (!manifestBuffer) {
605
+ resolve({
606
+ ok: false,
607
+ error: bundleError(exports.BundleErrorCodes.MANIFEST_MISSING, 'manifest.json not found in bundle'),
608
+ });
609
+ return;
610
+ }
611
+ let manifest;
612
+ try {
613
+ manifest = JSON.parse(manifestBuffer.toString('utf8'));
614
+ }
615
+ catch (parseErr) {
616
+ resolve({
617
+ ok: false,
618
+ error: bundleError(exports.BundleErrorCodes.MANIFEST_INVALID, `Failed to parse manifest.json: ${parseErr.message}`),
619
+ });
620
+ return;
621
+ }
622
+ // Validate version
623
+ if (manifest.version !== dispute_bundle_types_js_1.BUNDLE_VERSION) {
624
+ resolve({
625
+ ok: false,
626
+ error: bundleError(exports.BundleErrorCodes.MANIFEST_INVALID, `Unsupported bundle version: ${manifest.version}`, { expected: dispute_bundle_types_js_1.BUNDLE_VERSION, actual: manifest.version }),
627
+ });
628
+ return;
629
+ }
630
+ // Verify content_hash
631
+ const { content_hash, ...manifestWithoutHash } = manifest;
632
+ const computedHash = sha256Hex((0, crypto_1.canonicalize)(manifestWithoutHash));
633
+ if (computedHash !== content_hash) {
634
+ resolve({
635
+ ok: false,
636
+ error: bundleError(exports.BundleErrorCodes.HASH_MISMATCH, 'Bundle content_hash verification failed', { expected: content_hash, computed: computedHash }),
637
+ });
638
+ return;
639
+ }
640
+ // Verify file hashes
641
+ for (const fileEntry of manifest.files) {
642
+ const fileBuffer = files.get(fileEntry.path);
643
+ if (!fileBuffer) {
644
+ resolve({
645
+ ok: false,
646
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `File not found: ${fileEntry.path}`),
647
+ });
648
+ return;
649
+ }
650
+ const computedFileHash = sha256Hex(fileBuffer);
651
+ if (computedFileHash !== fileEntry.sha256) {
652
+ resolve({
653
+ ok: false,
654
+ error: bundleError(exports.BundleErrorCodes.HASH_MISMATCH, `File hash mismatch: ${fileEntry.path}`, { expected: fileEntry.sha256, computed: computedFileHash }),
655
+ });
656
+ return;
657
+ }
658
+ if (fileBuffer.length !== fileEntry.size) {
659
+ resolve({
660
+ ok: false,
661
+ error: bundleError(exports.BundleErrorCodes.HASH_MISMATCH, `File size mismatch: ${fileEntry.path}`, { expected: fileEntry.size, actual: fileBuffer.length }),
662
+ });
663
+ return;
664
+ }
665
+ }
666
+ // Parse receipts.ndjson
667
+ const receiptsBuffer = files.get('receipts.ndjson');
668
+ const receipts = new Map();
669
+ if (receiptsBuffer) {
670
+ const lines = receiptsBuffer.toString('utf8').trim().split('\n');
671
+ // Verify receipts are in deterministic order
672
+ let lastKey = '';
673
+ for (let i = 0; i < lines.length; i++) {
674
+ const jws = lines[i].trim();
675
+ if (!jws)
676
+ continue;
677
+ const parsed = parseJws(jws);
678
+ if (!parsed) {
679
+ resolve({
680
+ ok: false,
681
+ error: bundleError(exports.BundleErrorCodes.RECEIPT_INVALID, `Invalid JWS at line ${i + 1}`),
682
+ });
683
+ return;
684
+ }
685
+ const receiptId = parsed.payload.jti;
686
+ // Detect duplicate receipt IDs
687
+ if (receipts.has(receiptId)) {
688
+ resolve({
689
+ ok: false,
690
+ error: bundleError(exports.BundleErrorCodes.DUPLICATE_RECEIPT, `Duplicate receipt ID in bundle: ${receiptId}`, { receipt_id: receiptId, line: i + 1 }),
691
+ });
692
+ return;
693
+ }
694
+ const issuedAt = typeof parsed.payload.iat === 'number'
695
+ ? new Date(parsed.payload.iat * 1000).toISOString()
696
+ : String(parsed.payload.iat);
697
+ const receiptHash = sha256Hex(Buffer.from(jws, 'utf8'));
698
+ // Check ordering
699
+ const currentKey = `${issuedAt}|${receiptId}|${receiptHash}`;
700
+ if (currentKey < lastKey) {
701
+ resolve({
702
+ ok: false,
703
+ error: bundleError(exports.BundleErrorCodes.RECEIPTS_UNORDERED, 'receipts.ndjson is not in deterministic order'),
704
+ });
705
+ return;
706
+ }
707
+ lastKey = currentKey;
708
+ receipts.set(receiptId, jws);
709
+ }
710
+ }
711
+ // Extract keys
712
+ let keys = { keys: [] };
713
+ const keysBuffer = files.get('keys/keys.json');
714
+ if (keysBuffer) {
715
+ try {
716
+ keys = JSON.parse(keysBuffer.toString('utf8'));
717
+ }
718
+ catch {
719
+ resolve({
720
+ ok: false,
721
+ error: bundleError(exports.BundleErrorCodes.MANIFEST_INVALID, 'Failed to parse keys/keys.json'),
722
+ });
723
+ return;
724
+ }
725
+ }
726
+ // Extract policy if present
727
+ let policyContent;
728
+ const policyBuffer = files.get('policy/policy.yaml');
729
+ if (policyBuffer) {
730
+ policyContent = policyBuffer.toString('utf8');
731
+ // Verify policy hash if declared
732
+ if (manifest.policy_hash) {
733
+ const computedPolicyHash = sha256Hex(policyBuffer);
734
+ if (computedPolicyHash !== manifest.policy_hash) {
735
+ resolve({
736
+ ok: false,
737
+ error: bundleError(exports.BundleErrorCodes.POLICY_HASH_MISMATCH, 'Policy hash mismatch', {
738
+ expected: manifest.policy_hash,
739
+ computed: computedPolicyHash,
740
+ }),
741
+ });
742
+ return;
743
+ }
744
+ }
745
+ }
746
+ // Extract bundle.sig if present
747
+ let bundleSig;
748
+ const sigBuffer = files.get('bundle.sig');
749
+ if (sigBuffer) {
750
+ bundleSig = sigBuffer.toString('utf8');
751
+ }
752
+ resolve({
753
+ ok: true,
754
+ value: {
755
+ manifest,
756
+ receipts,
757
+ keys,
758
+ policy: policyContent,
759
+ bundle_sig: bundleSig,
760
+ },
761
+ });
762
+ }
763
+ /**
764
+ * Verify bundle integrity without verifying receipt signatures.
765
+ */
766
+ async function verifyBundleIntegrity(zipBuffer) {
767
+ const result = await readDisputeBundle(zipBuffer);
768
+ if (!result.ok) {
769
+ return result;
770
+ }
771
+ return { ok: true, value: { manifest: result.value.manifest } };
772
+ }
773
+ /**
774
+ * Get the content hash of a bundle without fully parsing it.
775
+ */
776
+ async function getBundleContentHash(zipBuffer) {
777
+ return new Promise((resolve) => {
778
+ yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => {
779
+ if (err || !zipfile) {
780
+ resolve({
781
+ ok: false,
782
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `Failed to open ZIP: ${err?.message ?? 'unknown'}`),
783
+ });
784
+ return;
785
+ }
786
+ let found = false;
787
+ zipfile.on('entry', (entry) => {
788
+ if (entry.fileName === 'manifest.json') {
789
+ found = true;
790
+ zipfile.openReadStream(entry, (readErr, readStream) => {
791
+ if (readErr || !readStream) {
792
+ zipfile.close();
793
+ resolve({
794
+ ok: false,
795
+ error: bundleError(exports.BundleErrorCodes.INVALID_FORMAT, `Failed to read manifest.json`),
796
+ });
797
+ return;
798
+ }
799
+ const chunks = [];
800
+ readStream.on('data', (chunk) => chunks.push(chunk));
801
+ readStream.on('end', () => {
802
+ zipfile.close();
803
+ try {
804
+ const manifest = JSON.parse(Buffer.concat(chunks).toString('utf8'));
805
+ resolve({ ok: true, value: manifest.content_hash });
806
+ }
807
+ catch (parseErr) {
808
+ resolve({
809
+ ok: false,
810
+ error: bundleError(exports.BundleErrorCodes.MANIFEST_INVALID, `Failed to parse manifest.json`),
811
+ });
812
+ }
813
+ });
814
+ });
815
+ }
816
+ else {
817
+ zipfile.readEntry();
818
+ }
819
+ });
820
+ zipfile.on('end', () => {
821
+ if (!found) {
822
+ resolve({
823
+ ok: false,
824
+ error: bundleError(exports.BundleErrorCodes.MANIFEST_MISSING, 'manifest.json not found'),
825
+ });
826
+ }
827
+ });
828
+ zipfile.on('error', (zipErr) => {
829
+ resolve({
830
+ ok: false,
831
+ error: handleZipError(zipErr),
832
+ });
833
+ });
834
+ zipfile.readEntry();
835
+ });
836
+ });
837
+ }
838
+ //# sourceMappingURL=dispute-bundle.js.map