@eddacraft/anvil-policy 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE +14 -0
  2. package/dist/bundle-manager.d.ts +183 -0
  3. package/dist/bundle-manager.d.ts.map +1 -0
  4. package/dist/bundle-manager.js +498 -0
  5. package/dist/bundle-verifier.d.ts +162 -0
  6. package/dist/bundle-verifier.d.ts.map +1 -0
  7. package/dist/bundle-verifier.js +401 -0
  8. package/dist/index.d.ts +16 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +10 -0
  11. package/dist/opa-binary-manager.d.ts +76 -0
  12. package/dist/opa-binary-manager.d.ts.map +1 -0
  13. package/dist/opa-binary-manager.js +341 -0
  14. package/dist/opa-executor.d.ts +225 -0
  15. package/dist/opa-executor.d.ts.map +1 -0
  16. package/dist/opa-executor.js +427 -0
  17. package/dist/policy-loader.d.ts +90 -0
  18. package/dist/policy-loader.d.ts.map +1 -0
  19. package/dist/policy-loader.js +180 -0
  20. package/dist/types.d.ts +40 -0
  21. package/dist/types.d.ts.map +1 -0
  22. package/dist/types.js +6 -0
  23. package/dist/utils/debug.d.ts +9 -0
  24. package/dist/utils/debug.d.ts.map +1 -0
  25. package/dist/utils/debug.js +44 -0
  26. package/package.json +33 -0
  27. package/project.json +8 -0
  28. package/src/bundle-manager.test.ts +588 -0
  29. package/src/bundle-manager.ts +710 -0
  30. package/src/bundle-verifier.test.ts +903 -0
  31. package/src/bundle-verifier.ts +568 -0
  32. package/src/index.ts +38 -0
  33. package/src/opa-binary-manager.test.ts +208 -0
  34. package/src/opa-binary-manager.ts +417 -0
  35. package/src/opa-executor.test.ts +1802 -0
  36. package/src/opa-executor.ts +681 -0
  37. package/src/policy-loader.test.ts +469 -0
  38. package/src/policy-loader.ts +262 -0
  39. package/src/types.ts +43 -0
  40. package/src/utils/debug.ts +54 -0
  41. package/tsconfig.json +12 -0
  42. package/tsconfig.lib.json +9 -0
  43. package/tsconfig.lib.tsbuildinfo +1 -0
  44. package/tsconfig.tsbuildinfo +1 -0
  45. package/vitest.config.ts +8 -0
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Unit Tests for Bundle Verifier
3
+ *
4
+ * Tests signature verification for OPA policy bundles
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import {
9
+ BundleVerifier,
10
+ loadKeyFromFile,
11
+ type BundleVerifierConfig,
12
+ type PublicKeyConfig,
13
+ type SignatureManifest,
14
+ } from './bundle-verifier.js';
15
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { tmpdir } from 'node:os';
18
+ import { createHash, generateKeyPairSync, createSign } from 'node:crypto';
19
+
20
+ describe('BundleVerifier', () => {
21
+ let tempDir: string;
22
+ let bundleDir: string;
23
+ let verifier: BundleVerifier;
24
+ let rsaKeyPair: { publicKey: string; privateKey: string };
25
+ let ecKeyPair: { publicKey: string; privateKey: string };
26
+
27
+ beforeEach(() => {
28
+ tempDir = join(tmpdir(), 'anvil-bundle-verifier-test', Math.random().toString(36));
29
+ bundleDir = join(tempDir, 'bundle');
30
+ mkdirSync(bundleDir, { recursive: true });
31
+
32
+ // Generate test RSA key pair
33
+ rsaKeyPair = generateKeyPairSync('rsa', {
34
+ modulusLength: 2048,
35
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
36
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
37
+ });
38
+
39
+ // Generate test EC key pair
40
+ ecKeyPair = generateKeyPairSync('ec', {
41
+ namedCurve: 'P-256',
42
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
43
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
44
+ });
45
+
46
+ const config: BundleVerifierConfig = {
47
+ keys: [
48
+ {
49
+ id: 'test-rsa-key',
50
+ algorithm: 'RS256',
51
+ key: rsaKeyPair.publicKey,
52
+ source: 'inline',
53
+ },
54
+ {
55
+ id: 'test-ec-key',
56
+ algorithm: 'ES256',
57
+ key: ecKeyPair.publicKey,
58
+ source: 'inline',
59
+ },
60
+ ],
61
+ require_signature: false,
62
+ };
63
+
64
+ verifier = new BundleVerifier(config);
65
+ });
66
+
67
+ afterEach(() => {
68
+ if (existsSync(tempDir)) {
69
+ rmSync(tempDir, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ describe('initialization', () => {
74
+ it('should reject unsigned bundles when require_signature is true', async () => {
75
+ const strictVerifier = new BundleVerifier({
76
+ keys: [],
77
+ require_signature: true,
78
+ });
79
+
80
+ // Create an unsigned bundle directory
81
+ const unsignedBundle = join(tempDir, 'unsigned-bundle');
82
+ mkdirSync(unsignedBundle, { recursive: true });
83
+ writeFileSync(join(unsignedBundle, 'policy.rego'), 'package test');
84
+
85
+ const result = await strictVerifier.verifyBundle(unsignedBundle);
86
+ expect(result.verified).toBe(false);
87
+ expect(result.errors.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ it('should accept custom allowed algorithms and reject disallowed ones', async () => {
91
+ const restrictedVerifier = new BundleVerifier({
92
+ keys: [],
93
+ require_signature: true,
94
+ allowed_algorithms: ['RS256'],
95
+ });
96
+
97
+ // Verifier should be created with restricted algorithms
98
+ // The restriction is tested via the actual verification flow in crypto tests below
99
+ expect(restrictedVerifier).toBeDefined();
100
+ });
101
+ });
102
+
103
+ describe('key management', () => {
104
+ it('should add keys and use them for verification', () => {
105
+ const newKey: PublicKeyConfig = {
106
+ id: 'new-key',
107
+ algorithm: 'RS256',
108
+ key: rsaKeyPair.publicKey,
109
+ source: 'inline',
110
+ };
111
+
112
+ verifier.addKey(newKey);
113
+
114
+ // Verify key was added by confirming removeKey succeeds
115
+ const removed = verifier.removeKey('new-key');
116
+ expect(removed).toBe(true);
117
+ });
118
+
119
+ it('should remove keys', () => {
120
+ const removed = verifier.removeKey('test-rsa-key');
121
+ expect(removed).toBe(true);
122
+ });
123
+
124
+ it('should return false when removing non-existent key', () => {
125
+ const removed = verifier.removeKey('non-existent-key');
126
+ expect(removed).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe('file verification', () => {
131
+ it('should verify file with correct hash', async () => {
132
+ const filePath = join(bundleDir, 'policy.rego');
133
+ const content = 'package test\nallow = true';
134
+ writeFileSync(filePath, content);
135
+
136
+ const hash = createHash('sha256').update(content).digest('hex');
137
+ const result = await verifier.verifyFile(filePath, hash);
138
+
139
+ expect(result).toBe(true);
140
+ });
141
+
142
+ it('should verify file with sha256 prefix', async () => {
143
+ const filePath = join(bundleDir, 'policy.rego');
144
+ const content = 'package test\nallow = true';
145
+ writeFileSync(filePath, content);
146
+
147
+ const hash = createHash('sha256').update(content).digest('hex');
148
+ const result = await verifier.verifyFile(filePath, `sha256:${hash}`);
149
+
150
+ expect(result).toBe(true);
151
+ });
152
+
153
+ it('should reject file with incorrect hash', async () => {
154
+ const filePath = join(bundleDir, 'policy.rego');
155
+ writeFileSync(filePath, 'package test\nallow = true');
156
+
157
+ const result = await verifier.verifyFile(filePath, 'incorrect-hash-value'.padEnd(64, '0'));
158
+
159
+ expect(result).toBe(false);
160
+ });
161
+
162
+ it('should return false for non-existent file', async () => {
163
+ const result = await verifier.verifyFile('/non/existent/file.rego', 'somehash');
164
+
165
+ expect(result).toBe(false);
166
+ });
167
+ });
168
+
169
+ describe('signature extraction', () => {
170
+ it('should extract valid signature manifest', async () => {
171
+ const manifest: SignatureManifest = {
172
+ signatures: [
173
+ {
174
+ files: [{ name: 'policy.rego', hash: 'sha256:abc123' }],
175
+ algorithm: 'RS256',
176
+ keyid: 'test-key',
177
+ signatures: ['base64sig'],
178
+ },
179
+ ],
180
+ };
181
+
182
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
183
+
184
+ const result = await verifier.extractSignatures(bundleDir);
185
+
186
+ expect(result).not.toBeNull();
187
+ expect(result?.signatures).toHaveLength(1);
188
+ expect(result?.signatures[0].keyid).toBe('test-key');
189
+ });
190
+
191
+ it('should return null for missing signature file', async () => {
192
+ const result = await verifier.extractSignatures(bundleDir);
193
+
194
+ expect(result).toBeNull();
195
+ });
196
+
197
+ it('should return null for invalid JSON', async () => {
198
+ writeFileSync(join(bundleDir, '.signatures.json'), 'not valid json');
199
+
200
+ const result = await verifier.extractSignatures(bundleDir);
201
+
202
+ expect(result).toBeNull();
203
+ });
204
+
205
+ it('should return null for invalid manifest structure', async () => {
206
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify({ invalid: true }));
207
+
208
+ const result = await verifier.extractSignatures(bundleDir);
209
+
210
+ expect(result).toBeNull();
211
+ });
212
+
213
+ it('should validate file entries in manifest', async () => {
214
+ const invalidManifest = {
215
+ signatures: [
216
+ {
217
+ files: [{ name: 'policy.rego' }], // Missing hash
218
+ algorithm: 'RS256',
219
+ keyid: 'test-key',
220
+ signatures: ['base64sig'],
221
+ },
222
+ ],
223
+ };
224
+
225
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(invalidManifest));
226
+
227
+ const result = await verifier.extractSignatures(bundleDir);
228
+
229
+ expect(result).toBeNull();
230
+ });
231
+ });
232
+
233
+ describe('bundle verification', () => {
234
+ it('should return verified=true for bundle without signature when not required', async () => {
235
+ // Create a policy file but no signatures
236
+ writeFileSync(join(bundleDir, 'policy.rego'), 'package test\nallow = true');
237
+
238
+ const result = await verifier.verifyBundle(bundleDir);
239
+
240
+ expect(result.verified).toBe(true);
241
+ expect(result.errors).toHaveLength(0);
242
+ });
243
+
244
+ it('should return verified=false for bundle without signature when required', async () => {
245
+ const strictVerifier = new BundleVerifier({
246
+ keys: [],
247
+ require_signature: true,
248
+ });
249
+
250
+ writeFileSync(join(bundleDir, 'policy.rego'), 'package test\nallow = true');
251
+
252
+ const result = await strictVerifier.verifyBundle(bundleDir);
253
+
254
+ expect(result.verified).toBe(false);
255
+ expect(result.errors).toContain('No signature manifest found and signatures are required');
256
+ });
257
+
258
+ it('should return error for non-existent bundle path', async () => {
259
+ const result = await verifier.verifyBundle('/non/existent/path');
260
+
261
+ expect(result.verified).toBe(false);
262
+ expect(result.errors[0]).toContain('Bundle path does not exist');
263
+ });
264
+
265
+ it('should reject bundle with unknown key ID', async () => {
266
+ const policyContent = 'package test\nallow = true';
267
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
268
+
269
+ const hash = createHash('sha256').update(policyContent).digest('hex');
270
+ const manifest: SignatureManifest = {
271
+ signatures: [
272
+ {
273
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
274
+ algorithm: 'RS256',
275
+ keyid: 'unknown-key',
276
+ signatures: ['fakesig'],
277
+ },
278
+ ],
279
+ };
280
+
281
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
282
+
283
+ const result = await verifier.verifyBundle(bundleDir);
284
+
285
+ expect(result.verified).toBe(false);
286
+ expect(result.errors).toContain('Unknown key ID: unknown-key');
287
+ });
288
+
289
+ it('should reject bundle with disallowed algorithm', async () => {
290
+ const restrictedVerifier = new BundleVerifier({
291
+ keys: [
292
+ {
293
+ id: 'test-rsa-key',
294
+ algorithm: 'RS256',
295
+ key: rsaKeyPair.publicKey,
296
+ source: 'inline',
297
+ },
298
+ ],
299
+ require_signature: true,
300
+ allowed_algorithms: ['ES256'], // Only allow ES256
301
+ });
302
+
303
+ const policyContent = 'package test\nallow = true';
304
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
305
+
306
+ const hash = createHash('sha256').update(policyContent).digest('hex');
307
+ const manifest: SignatureManifest = {
308
+ signatures: [
309
+ {
310
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
311
+ algorithm: 'RS256', // Not allowed
312
+ keyid: 'test-rsa-key',
313
+ signatures: ['fakesig'],
314
+ },
315
+ ],
316
+ };
317
+
318
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
319
+
320
+ const result = await restrictedVerifier.verifyBundle(bundleDir);
321
+
322
+ expect(result.verified).toBe(false);
323
+ expect(result.errors).toContain('Algorithm not allowed: RS256');
324
+ });
325
+
326
+ it('should reject bundle with algorithm mismatch', async () => {
327
+ const policyContent = 'package test\nallow = true';
328
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
329
+
330
+ const hash = createHash('sha256').update(policyContent).digest('hex');
331
+ const manifest: SignatureManifest = {
332
+ signatures: [
333
+ {
334
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
335
+ algorithm: 'ES256', // Mismatch: key is RS256
336
+ keyid: 'test-rsa-key',
337
+ signatures: ['fakesig'],
338
+ },
339
+ ],
340
+ };
341
+
342
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
343
+
344
+ const result = await verifier.verifyBundle(bundleDir);
345
+
346
+ expect(result.verified).toBe(false);
347
+ expect(result.errors.some((e) => e.includes('Algorithm mismatch'))).toBe(true);
348
+ });
349
+
350
+ it('should reject bundle with incorrect file hash', async () => {
351
+ const policyContent = 'package test\nallow = true';
352
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
353
+
354
+ // Use wrong hash
355
+ const manifest: SignatureManifest = {
356
+ signatures: [
357
+ {
358
+ files: [{ name: 'policy.rego', hash: 'sha256:' + '0'.repeat(64) }],
359
+ algorithm: 'RS256',
360
+ keyid: 'test-rsa-key',
361
+ signatures: ['fakesig'],
362
+ },
363
+ ],
364
+ };
365
+
366
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
367
+
368
+ const result = await verifier.verifyBundle(bundleDir);
369
+
370
+ expect(result.verified).toBe(false);
371
+ expect(result.fileResults[0].verified).toBe(false);
372
+ expect(result.errors.some((e) => e.includes('File hash verification failed'))).toBe(true);
373
+ });
374
+
375
+ it('should verify bundle with valid RS256 signature', async () => {
376
+ const policyContent = 'package test\nallow = true';
377
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
378
+
379
+ const hash = createHash('sha256').update(policyContent).digest('hex');
380
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
381
+
382
+ // Create the signed data (canonical JSON of files array)
383
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
384
+
385
+ // Sign with RSA
386
+ const signer = createSign('RSA-SHA256');
387
+ signer.update(signedData);
388
+ const signature = signer.sign(rsaKeyPair.privateKey, 'base64');
389
+
390
+ const manifest: SignatureManifest = {
391
+ signatures: [
392
+ {
393
+ files,
394
+ algorithm: 'RS256',
395
+ keyid: 'test-rsa-key',
396
+ signatures: [signature],
397
+ },
398
+ ],
399
+ };
400
+
401
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
402
+
403
+ const result = await verifier.verifyBundle(bundleDir);
404
+
405
+ expect(result.verified).toBe(true);
406
+ expect(result.keyId).toBe('test-rsa-key');
407
+ expect(result.errors).toHaveLength(0);
408
+ expect(result.fileResults[0].verified).toBe(true);
409
+ });
410
+
411
+ it('should verify bundle with valid ES256 signature', async () => {
412
+ const policyContent = 'package test\ndeny = false';
413
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
414
+
415
+ const hash = createHash('sha256').update(policyContent).digest('hex');
416
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
417
+
418
+ // Create the signed data
419
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
420
+
421
+ // Sign with EC
422
+ const signer = createSign('SHA256');
423
+ signer.update(signedData);
424
+ const signature = signer.sign(
425
+ { key: ecKeyPair.privateKey, dsaEncoding: 'ieee-p1363' },
426
+ 'base64'
427
+ );
428
+
429
+ const manifest: SignatureManifest = {
430
+ signatures: [
431
+ {
432
+ files,
433
+ algorithm: 'ES256',
434
+ keyid: 'test-ec-key',
435
+ signatures: [signature],
436
+ },
437
+ ],
438
+ };
439
+
440
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
441
+
442
+ const result = await verifier.verifyBundle(bundleDir);
443
+
444
+ expect(result.verified).toBe(true);
445
+ expect(result.keyId).toBe('test-ec-key');
446
+ expect(result.errors).toHaveLength(0);
447
+ });
448
+
449
+ it('should verify bundle with multiple files', async () => {
450
+ const policy1Content = 'package test.one\nallow = true';
451
+ const policy2Content = 'package test.two\ndeny = false';
452
+
453
+ writeFileSync(join(bundleDir, 'policy1.rego'), policy1Content);
454
+ writeFileSync(join(bundleDir, 'policy2.rego'), policy2Content);
455
+
456
+ const hash1 = createHash('sha256').update(policy1Content).digest('hex');
457
+ const hash2 = createHash('sha256').update(policy2Content).digest('hex');
458
+
459
+ const files = [
460
+ { name: 'policy1.rego', hash: `sha256:${hash1}` },
461
+ { name: 'policy2.rego', hash: `sha256:${hash2}` },
462
+ ];
463
+
464
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
465
+
466
+ const signer = createSign('RSA-SHA256');
467
+ signer.update(signedData);
468
+ const signature = signer.sign(rsaKeyPair.privateKey, 'base64');
469
+
470
+ const manifest: SignatureManifest = {
471
+ signatures: [
472
+ {
473
+ files,
474
+ algorithm: 'RS256',
475
+ keyid: 'test-rsa-key',
476
+ signatures: [signature],
477
+ },
478
+ ],
479
+ };
480
+
481
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
482
+
483
+ const result = await verifier.verifyBundle(bundleDir);
484
+
485
+ expect(result.verified).toBe(true);
486
+ expect(result.fileResults).toHaveLength(2);
487
+ expect(result.fileResults.every((r) => r.verified)).toBe(true);
488
+ });
489
+
490
+ it('should reject bundle with tampered file', async () => {
491
+ const originalContent = 'package test\nallow = true';
492
+ const hash = createHash('sha256').update(originalContent).digest('hex');
493
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
494
+
495
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
496
+
497
+ const signer = createSign('RSA-SHA256');
498
+ signer.update(signedData);
499
+ const signature = signer.sign(rsaKeyPair.privateKey, 'base64');
500
+
501
+ const manifest: SignatureManifest = {
502
+ signatures: [
503
+ {
504
+ files,
505
+ algorithm: 'RS256',
506
+ keyid: 'test-rsa-key',
507
+ signatures: [signature],
508
+ },
509
+ ],
510
+ };
511
+
512
+ // Write TAMPERED content
513
+ writeFileSync(join(bundleDir, 'policy.rego'), 'package test\nallow = false');
514
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
515
+
516
+ const result = await verifier.verifyBundle(bundleDir);
517
+
518
+ expect(result.verified).toBe(false);
519
+ expect(result.fileResults[0].verified).toBe(false);
520
+ });
521
+
522
+ it('should reject bundle with invalid signature', async () => {
523
+ const policyContent = 'package test\nallow = true';
524
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
525
+
526
+ const hash = createHash('sha256').update(policyContent).digest('hex');
527
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
528
+
529
+ const manifest: SignatureManifest = {
530
+ signatures: [
531
+ {
532
+ files,
533
+ algorithm: 'RS256',
534
+ keyid: 'test-rsa-key',
535
+ signatures: ['aW52YWxpZC1zaWduYXR1cmU='], // Invalid signature
536
+ },
537
+ ],
538
+ };
539
+
540
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
541
+
542
+ const result = await verifier.verifyBundle(bundleDir);
543
+
544
+ expect(result.verified).toBe(false);
545
+ });
546
+
547
+ it('should try multiple signature blocks until one succeeds', async () => {
548
+ // Generate a second RSA key pair
549
+ const rsaKeyPair2 = generateKeyPairSync('rsa', {
550
+ modulusLength: 2048,
551
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
552
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
553
+ });
554
+
555
+ verifier.addKey({
556
+ id: 'test-rsa-key-2',
557
+ algorithm: 'RS256',
558
+ key: rsaKeyPair2.publicKey,
559
+ source: 'inline',
560
+ });
561
+
562
+ const policyContent = 'package test\nallow = true';
563
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
564
+
565
+ const hash = createHash('sha256').update(policyContent).digest('hex');
566
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
567
+
568
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
569
+
570
+ // Sign with second key
571
+ const signer = createSign('RSA-SHA256');
572
+ signer.update(signedData);
573
+ const signature = signer.sign(rsaKeyPair2.privateKey, 'base64');
574
+
575
+ const manifest: SignatureManifest = {
576
+ signatures: [
577
+ {
578
+ // First block with first key - invalid signature
579
+ files,
580
+ algorithm: 'RS256',
581
+ keyid: 'test-rsa-key',
582
+ signatures: ['aW52YWxpZA=='],
583
+ },
584
+ {
585
+ // Second block with second key - valid signature
586
+ files,
587
+ algorithm: 'RS256',
588
+ keyid: 'test-rsa-key-2',
589
+ signatures: [signature],
590
+ },
591
+ ],
592
+ };
593
+
594
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
595
+
596
+ const result = await verifier.verifyBundle(bundleDir);
597
+
598
+ expect(result.verified).toBe(true);
599
+ expect(result.keyId).toBe('test-rsa-key-2');
600
+ });
601
+ });
602
+
603
+ describe('key sources', () => {
604
+ it('should load key from environment variable', async () => {
605
+ const envKeyName = 'ANVIL_VERIFY_KEY_' + Math.random().toString(36).slice(2);
606
+ process.env[envKeyName] = rsaKeyPair.publicKey;
607
+
608
+ try {
609
+ const envVerifier = new BundleVerifier({
610
+ keys: [
611
+ {
612
+ id: 'env-key',
613
+ algorithm: 'RS256',
614
+ key: envKeyName,
615
+ source: 'env',
616
+ },
617
+ ],
618
+ require_signature: true,
619
+ });
620
+
621
+ const policyContent = 'package test\nallow = true';
622
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
623
+
624
+ const hash = createHash('sha256').update(policyContent).digest('hex');
625
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
626
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
627
+
628
+ const signer = createSign('RSA-SHA256');
629
+ signer.update(signedData);
630
+ const signature = signer.sign(rsaKeyPair.privateKey, 'base64');
631
+
632
+ const manifest: SignatureManifest = {
633
+ signatures: [
634
+ {
635
+ files,
636
+ algorithm: 'RS256',
637
+ keyid: 'env-key',
638
+ signatures: [signature],
639
+ },
640
+ ],
641
+ };
642
+
643
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
644
+
645
+ const result = await envVerifier.verifyBundle(bundleDir);
646
+
647
+ expect(result.verified).toBe(true);
648
+ } finally {
649
+ delete process.env[envKeyName];
650
+ }
651
+ });
652
+
653
+ it('should fail when environment variable is not set', async () => {
654
+ const envVerifier = new BundleVerifier({
655
+ keys: [
656
+ {
657
+ id: 'missing-env-key',
658
+ algorithm: 'RS256',
659
+ key: 'ANVIL_VERIFY_MISSING_' + Math.random().toString(36).slice(2),
660
+ source: 'env',
661
+ },
662
+ ],
663
+ require_signature: true,
664
+ });
665
+
666
+ const policyContent = 'package test\nallow = true';
667
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
668
+
669
+ const hash = createHash('sha256').update(policyContent).digest('hex');
670
+ const manifest: SignatureManifest = {
671
+ signatures: [
672
+ {
673
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
674
+ algorithm: 'RS256',
675
+ keyid: 'missing-env-key',
676
+ signatures: ['fakesig'],
677
+ },
678
+ ],
679
+ };
680
+
681
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
682
+
683
+ const result = await envVerifier.verifyBundle(bundleDir);
684
+
685
+ expect(result.verified).toBe(false);
686
+ expect(result.errors.some((e) => e.includes('Environment variable not found'))).toBe(true);
687
+ });
688
+ });
689
+
690
+ describe('env var allowlist', () => {
691
+ it('should reject env vars with disallowed prefixes', async () => {
692
+ const envVerifier = new BundleVerifier({
693
+ keys: [
694
+ {
695
+ id: 'blocked-key',
696
+ algorithm: 'RS256',
697
+ key: 'SECRET_KEY',
698
+ source: 'env',
699
+ },
700
+ ],
701
+ require_signature: true,
702
+ });
703
+
704
+ const policyContent = 'package test\nallow = true';
705
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
706
+
707
+ const hash = createHash('sha256').update(policyContent).digest('hex');
708
+ const manifest: SignatureManifest = {
709
+ signatures: [
710
+ {
711
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
712
+ algorithm: 'RS256',
713
+ keyid: 'blocked-key',
714
+ signatures: ['fakesig'],
715
+ },
716
+ ],
717
+ };
718
+
719
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
720
+
721
+ const result = await envVerifier.verifyBundle(bundleDir);
722
+
723
+ expect(result.verified).toBe(false);
724
+ expect(result.errors.some((e) => e.includes('not in allowlist'))).toBe(true);
725
+ });
726
+
727
+ it('should reject AWS-style env vars', async () => {
728
+ const envVerifier = new BundleVerifier({
729
+ keys: [
730
+ {
731
+ id: 'aws-key',
732
+ algorithm: 'RS256',
733
+ key: 'AWS_SECRET_ACCESS_KEY',
734
+ source: 'env',
735
+ },
736
+ ],
737
+ require_signature: true,
738
+ });
739
+
740
+ const policyContent = 'package test\nallow = true';
741
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
742
+
743
+ const hash = createHash('sha256').update(policyContent).digest('hex');
744
+ const manifest: SignatureManifest = {
745
+ signatures: [
746
+ {
747
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
748
+ algorithm: 'RS256',
749
+ keyid: 'aws-key',
750
+ signatures: ['fakesig'],
751
+ },
752
+ ],
753
+ };
754
+
755
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
756
+
757
+ const result = await envVerifier.verifyBundle(bundleDir);
758
+
759
+ expect(result.verified).toBe(false);
760
+ expect(result.errors.some((e) => e.includes('not in allowlist'))).toBe(true);
761
+ });
762
+
763
+ it('should accept env vars with allowed prefixes', async () => {
764
+ const envKeyName = 'ANVIL_BUNDLE_PUBLIC_KEY_' + Math.random().toString(36).slice(2);
765
+ process.env[envKeyName] = rsaKeyPair.publicKey;
766
+
767
+ try {
768
+ const envVerifier = new BundleVerifier({
769
+ keys: [
770
+ {
771
+ id: 'allowed-key',
772
+ algorithm: 'RS256',
773
+ key: envKeyName,
774
+ source: 'env',
775
+ },
776
+ ],
777
+ require_signature: true,
778
+ });
779
+
780
+ const policyContent = 'package test\nallow = true';
781
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
782
+
783
+ const hash = createHash('sha256').update(policyContent).digest('hex');
784
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
785
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
786
+
787
+ const signer = createSign('RSA-SHA256');
788
+ signer.update(signedData);
789
+ const signature = signer.sign(rsaKeyPair.privateKey, 'base64');
790
+
791
+ const manifest: SignatureManifest = {
792
+ signatures: [
793
+ {
794
+ files,
795
+ algorithm: 'RS256',
796
+ keyid: 'allowed-key',
797
+ signatures: [signature],
798
+ },
799
+ ],
800
+ };
801
+
802
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
803
+
804
+ const result = await envVerifier.verifyBundle(bundleDir);
805
+
806
+ expect(result.verified).toBe(true);
807
+ } finally {
808
+ delete process.env[envKeyName];
809
+ }
810
+ });
811
+ });
812
+
813
+ describe('loadKeyFromFile', () => {
814
+ it('should load key from file', async () => {
815
+ const keyPath = join(tempDir, 'public-key.pem');
816
+ writeFileSync(keyPath, rsaKeyPair.publicKey);
817
+
818
+ const keyConfig = await loadKeyFromFile(keyPath, 'file-key', 'RS256');
819
+
820
+ expect(keyConfig.id).toBe('file-key');
821
+ expect(keyConfig.algorithm).toBe('RS256');
822
+ // loadKeyFromFile trims whitespace from the key
823
+ expect(keyConfig.key).toBe(rsaKeyPair.publicKey.trim());
824
+ expect(keyConfig.source).toBe('file');
825
+ });
826
+
827
+ it('should use loaded key for verification', async () => {
828
+ const keyPath = join(tempDir, 'public-key.pem');
829
+ writeFileSync(keyPath, rsaKeyPair.publicKey);
830
+
831
+ const keyConfig = await loadKeyFromFile(keyPath, 'loaded-key', 'RS256');
832
+
833
+ const fileVerifier = new BundleVerifier({
834
+ keys: [keyConfig],
835
+ require_signature: true,
836
+ });
837
+
838
+ const policyContent = 'package test\nallow = true';
839
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
840
+
841
+ const hash = createHash('sha256').update(policyContent).digest('hex');
842
+ const files = [{ name: 'policy.rego', hash: `sha256:${hash}` }];
843
+ const signedData = JSON.stringify([...files].sort((a, b) => a.name.localeCompare(b.name)));
844
+
845
+ const signer = createSign('RSA-SHA256');
846
+ signer.update(signedData);
847
+ const signature = signer.sign(rsaKeyPair.privateKey, 'base64');
848
+
849
+ const manifest: SignatureManifest = {
850
+ signatures: [
851
+ {
852
+ files,
853
+ algorithm: 'RS256',
854
+ keyid: 'loaded-key',
855
+ signatures: [signature],
856
+ },
857
+ ],
858
+ };
859
+
860
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
861
+
862
+ const result = await fileVerifier.verifyBundle(bundleDir);
863
+
864
+ expect(result.verified).toBe(true);
865
+ });
866
+ });
867
+
868
+ describe('error handling', () => {
869
+ it('should handle file read errors gracefully', async () => {
870
+ // Create a directory where a file is expected
871
+ const dirAsFile = join(bundleDir, 'policy.rego');
872
+ mkdirSync(dirAsFile, { recursive: true });
873
+
874
+ const result = await verifier.verifyFile(dirAsFile, 'somehash');
875
+
876
+ expect(result).toBe(false);
877
+ });
878
+
879
+ it('should provide detailed error messages', async () => {
880
+ const policyContent = 'package test\nallow = true';
881
+ writeFileSync(join(bundleDir, 'policy.rego'), policyContent);
882
+
883
+ const hash = createHash('sha256').update(policyContent).digest('hex');
884
+ const manifest: SignatureManifest = {
885
+ signatures: [
886
+ {
887
+ files: [{ name: 'policy.rego', hash: `sha256:${hash}` }],
888
+ algorithm: 'RS256',
889
+ keyid: 'test-rsa-key',
890
+ signatures: ['invalid-base64!@#'],
891
+ },
892
+ ],
893
+ };
894
+
895
+ writeFileSync(join(bundleDir, '.signatures.json'), JSON.stringify(manifest));
896
+
897
+ const result = await verifier.verifyBundle(bundleDir);
898
+
899
+ expect(result.verified).toBe(false);
900
+ expect(result.errors.length).toBeGreaterThan(0);
901
+ });
902
+ });
903
+ });