@mcpose/audit 2.0.2 → 3.0.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -28
  3. package/dist/chain.d.ts +55 -0
  4. package/dist/chain.d.ts.map +1 -0
  5. package/dist/chain.js +265 -0
  6. package/dist/chain.js.map +1 -0
  7. package/dist/index.d.ts +9 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +7 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware.d.ts +5 -0
  12. package/dist/middleware.d.ts.map +1 -0
  13. package/dist/middleware.js +488 -0
  14. package/dist/middleware.js.map +1 -0
  15. package/dist/sensitivity.d.ts +12 -0
  16. package/dist/sensitivity.d.ts.map +1 -0
  17. package/dist/sensitivity.js +24 -0
  18. package/dist/sensitivity.js.map +1 -0
  19. package/dist/signingKey.d.ts +11 -0
  20. package/dist/signingKey.d.ts.map +1 -0
  21. package/dist/signingKey.js +30 -0
  22. package/dist/signingKey.js.map +1 -0
  23. package/dist/subjectKeyStore.d.ts +17 -0
  24. package/dist/subjectKeyStore.d.ts.map +1 -0
  25. package/dist/subjectKeyStore.js +37 -0
  26. package/dist/subjectKeyStore.js.map +1 -0
  27. package/dist/types.d.ts +244 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +2 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/verify.d.ts +24 -0
  32. package/dist/verify.d.ts.map +1 -0
  33. package/dist/verify.js +50 -0
  34. package/dist/verify.js.map +1 -0
  35. package/package.json +43 -14
  36. package/src/__tests__/chain.batch.test.ts +30 -0
  37. package/src/__tests__/chain.test.ts +399 -0
  38. package/src/__tests__/erasable.test.ts +499 -0
  39. package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
  40. package/src/__tests__/middleware.http.test.ts +172 -0
  41. package/src/__tests__/middleware.robustness.test.ts +737 -0
  42. package/src/__tests__/middleware.test.ts +1121 -0
  43. package/src/__tests__/properties.test.ts +737 -0
  44. package/src/__tests__/sensitivity.test.ts +72 -0
  45. package/src/__tests__/signingKey.test.ts +76 -0
  46. package/src/chain.ts +317 -0
  47. package/src/index.ts +31 -0
  48. package/src/middleware.ts +690 -0
  49. package/src/sensitivity.ts +33 -0
  50. package/src/signingKey.ts +35 -0
  51. package/src/subjectKeyStore.ts +38 -0
  52. package/src/types.ts +281 -0
  53. package/src/verify.ts +76 -0
@@ -0,0 +1,499 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createHmac, createDecipheriv } from 'node:crypto';
3
+ import { createAuditMiddleware } from '../middleware.js';
4
+ import { createDefaultSigningKeyProvider } from '../signingKey.js';
5
+ import { createInMemorySubjectKeyStore } from '../subjectKeyStore.js';
6
+ import { verifyAuditChain, verifyManifestSignature } from '../verify.js';
7
+ import { chainPreimageFields, sha256hex, stableStringify } from '../chain.js';
8
+ import type {
9
+ AuditEvent,
10
+ AuditOptions,
11
+ HighAuditEvent,
12
+ SubjectKeyStore,
13
+ } from '../types.js';
14
+ import type { Identity } from 'mcpose';
15
+ import { createProxyContext } from 'mcpose';
16
+
17
+ // ── Fixtures ─────────────────────────────────────────────────────────────────
18
+
19
+ const proxy = { name: 'test-proxy', version: '0.0.0' };
20
+
21
+ function makeIdentity(sub: string): Identity {
22
+ return {
23
+ sub,
24
+ type: 'human',
25
+ roles: ['analyst'],
26
+ claims: {},
27
+ resolvedAt: '2026-06-01T00:00:00.000Z',
28
+ source: 'jwt',
29
+ };
30
+ }
31
+
32
+ function makeCtx(identity: Identity | undefined, sessionId?: string) {
33
+ return createProxyContext({
34
+ transport: 'http',
35
+ ...(identity === undefined ? {} : { identity }),
36
+ sessionId,
37
+ proxy,
38
+ });
39
+ }
40
+
41
+ function makeReq(tool: string, args: Record<string, unknown> = {}) {
42
+ return {
43
+ method: 'tools/call' as const,
44
+ params: { name: tool, arguments: args },
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Collects events, and always encrypts, so every test here exercises the
50
+ * high-tier path where erasure has something to destroy.
51
+ */
52
+ function makeHarness(overrides: Partial<AuditOptions> = {}) {
53
+ const events: AuditEvent[] = [];
54
+ const signingKey = createDefaultSigningKeyProvider('test-secret');
55
+ const handle = createAuditMiddleware({
56
+ signingKey,
57
+ sensitivityResolver: () => 'high',
58
+ onEvent: (e) => {
59
+ events.push(e);
60
+ },
61
+ ...overrides,
62
+ });
63
+ return { events, signingKey, ...handle };
64
+ }
65
+
66
+ function aesGcmDecrypt(b64: string, key: Buffer, aad: string): string {
67
+ const buf = Buffer.from(b64, 'base64');
68
+ const decipher = createDecipheriv(
69
+ 'aes-256-gcm',
70
+ key,
71
+ buf.subarray(0, 12), // iv
72
+ );
73
+ decipher.setAAD(Buffer.from(aad));
74
+ decipher.setAuthTag(buf.subarray(12, 28)); // tag
75
+ return Buffer.concat([
76
+ decipher.update(buf.subarray(28)),
77
+ decipher.final(),
78
+ ]).toString('utf8');
79
+ }
80
+
81
+ /**
82
+ * Reproduces the documented event-key derivation. Erasable mode swaps only
83
+ * the root: the label and the session/position/id inputs are unchanged.
84
+ */
85
+ function deriveEventKey(
86
+ root: Buffer,
87
+ sessionId: string | undefined,
88
+ position: number,
89
+ eventId: string,
90
+ ): Buffer {
91
+ return createHmac('sha256', root)
92
+ .update(`mcpose/v2/eventkey\0${sessionId ?? ''}\0${position}\0${eventId}`)
93
+ .digest();
94
+ }
95
+
96
+ /** Reproduces the erasable-mode hash subkey derivation (ADR-0018). */
97
+ function deriveHashSubkey(subjectKey: Buffer): Buffer {
98
+ return createHmac('sha256', subjectKey).update('mcpose/v2/hashkey').digest();
99
+ }
100
+
101
+ function decryptInput(
102
+ event: HighAuditEvent,
103
+ subjectKey: Buffer,
104
+ sessionId?: string,
105
+ ): unknown {
106
+ const key = deriveEventKey(
107
+ subjectKey,
108
+ sessionId,
109
+ event.replayManifestPosition,
110
+ event.id,
111
+ );
112
+ return JSON.parse(
113
+ aesGcmDecrypt(
114
+ event.inputEncrypted,
115
+ key,
116
+ `mcpose/v2/aad\0${event.id}\0input`,
117
+ ),
118
+ );
119
+ }
120
+
121
+ function asHigh(event: AuditEvent | undefined): HighAuditEvent {
122
+ if (event?.sensitivityTier !== 'high') throw new Error('expected high tier');
123
+ return event;
124
+ }
125
+
126
+ // ── Chain and manifest still verify ──────────────────────────────────────────
127
+
128
+ describe('erasable mode — the chain is unaffected', () => {
129
+ it('an erasable-mode chain and its manifest verify under the signing key', async () => {
130
+ const keyStore = createInMemorySubjectKeyStore();
131
+ const { events, signingKey, middleware, closeSession } = makeHarness({
132
+ keyStore,
133
+ });
134
+ const ctx = makeCtx(makeIdentity('user-1'), 'sess-verify');
135
+
136
+ await middleware(
137
+ makeReq('transfer', { n: 1 }),
138
+ async () => ({ content: [] }),
139
+ ctx,
140
+ );
141
+ await middleware(
142
+ makeReq('transfer', { n: 2 }),
143
+ async () => ({ content: [] }),
144
+ ctx,
145
+ );
146
+
147
+ expect(events.map((e) => e.erasable)).toEqual([true, true]);
148
+ await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
149
+ valid: true,
150
+ });
151
+
152
+ const manifest = await closeSession('sess-verify');
153
+ expect(manifest).toBeDefined();
154
+ await expect(verifyManifestSignature(manifest!, signingKey)).resolves.toBe(
155
+ true,
156
+ );
157
+ });
158
+
159
+ it('erasure destroys payload access and leaves the chain verifying', async () => {
160
+ const keyStore = createInMemorySubjectKeyStore();
161
+ const { events, signingKey, middleware } = makeHarness({ keyStore });
162
+ const ctx = makeCtx(makeIdentity('user-1'), 'sess-erase');
163
+
164
+ await middleware(
165
+ makeReq('transfer', { acct: 'secret-acct' }),
166
+ async () => ({ content: [] }),
167
+ ctx,
168
+ );
169
+ const event = asHigh(events[0]);
170
+
171
+ // Readable while the key exists.
172
+ const before = await keyStore.getOrCreate('user-1');
173
+ expect(decryptInput(event, before, 'sess-erase')).toEqual({
174
+ acct: 'secret-acct',
175
+ });
176
+
177
+ const tombstone = await keyStore.destroy('user-1');
178
+ expect(tombstone.destroyedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
179
+
180
+ // The key that comes back now is a fresh one, and it opens nothing.
181
+ const after = await keyStore.getOrCreate('user-1');
182
+ expect(after.equals(before)).toBe(false);
183
+ expect(() => decryptInput(event, after, 'sess-erase')).toThrow();
184
+
185
+ // The record itself is untouched and still verifies.
186
+ await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
187
+ valid: true,
188
+ });
189
+ });
190
+
191
+ it('erasing one subject leaves another subject decryptable', async () => {
192
+ const keyStore = createInMemorySubjectKeyStore();
193
+ const { events, middleware } = makeHarness({ keyStore });
194
+
195
+ await middleware(
196
+ makeReq('transfer', { who: 'one' }),
197
+ async () => ({ content: [] }),
198
+ makeCtx(makeIdentity('user-1'), 's1'),
199
+ );
200
+ await middleware(
201
+ makeReq('transfer', { who: 'two' }),
202
+ async () => ({ content: [] }),
203
+ makeCtx(makeIdentity('user-2'), 's2'),
204
+ );
205
+
206
+ const keyTwo = await keyStore.getOrCreate('user-2');
207
+ await keyStore.destroy('user-1');
208
+ const keyOneAfter = await keyStore.getOrCreate('user-1');
209
+
210
+ expect(() => decryptInput(asHigh(events[0]), keyOneAfter, 's1')).toThrow();
211
+ expect(decryptInput(asHigh(events[1]), keyTwo, 's2')).toEqual({
212
+ who: 'two',
213
+ });
214
+ });
215
+
216
+ it('a subject that reappears after erasure gets a fresh key and the old events stay dead', async () => {
217
+ const keyStore = createInMemorySubjectKeyStore();
218
+ const { events, middleware } = makeHarness({ keyStore });
219
+
220
+ await middleware(
221
+ makeReq('transfer', { era: 'before' }),
222
+ async () => ({ content: [] }),
223
+ makeCtx(makeIdentity('user-1'), 's-before'),
224
+ );
225
+ await keyStore.destroy('user-1');
226
+ await middleware(
227
+ makeReq('transfer', { era: 'after' }),
228
+ async () => ({ content: [] }),
229
+ makeCtx(makeIdentity('user-1'), 's-after'),
230
+ );
231
+
232
+ const current = await keyStore.getOrCreate('user-1');
233
+ // The post-erasure event reads under the new key; the pre-erasure one
234
+ // never will again, under this key or any other.
235
+ expect(decryptInput(asHigh(events[1]), current, 's-after')).toEqual({
236
+ era: 'after',
237
+ });
238
+ expect(() =>
239
+ decryptInput(asHigh(events[0]), current, 's-before'),
240
+ ).toThrow();
241
+ });
242
+ });
243
+
244
+ // ── The marker is covered ────────────────────────────────────────────────────
245
+
246
+ describe('erasable mode — the marker is covered by the chain', () => {
247
+ it('stripping `erasable` from a recorded event breaks verification at its index', async () => {
248
+ const { events, signingKey, middleware } = makeHarness({
249
+ keyStore: createInMemorySubjectKeyStore(),
250
+ });
251
+ const ctx = makeCtx(makeIdentity('user-1'), 'sess-strip');
252
+ await middleware(
253
+ makeReq('transfer', {}),
254
+ async () => ({ content: [] }),
255
+ ctx,
256
+ );
257
+ await middleware(
258
+ makeReq('transfer', {}),
259
+ async () => ({ content: [] }),
260
+ ctx,
261
+ );
262
+
263
+ const { erasable: _dropped, ...stripped } = events[1]!;
264
+ const tampered = [events[0]!, stripped as AuditEvent];
265
+
266
+ await expect(verifyAuditChain(tampered, signingKey)).resolves.toEqual({
267
+ valid: false,
268
+ index: 1,
269
+ reason: 'chainHash mismatch',
270
+ });
271
+ });
272
+
273
+ it('adding `erasable` to a default-mode event breaks verification at its index', async () => {
274
+ const { events, signingKey, middleware } = makeHarness();
275
+ const ctx = makeCtx(makeIdentity('user-1'), 'sess-add');
276
+ await middleware(
277
+ makeReq('transfer', {}),
278
+ async () => ({ content: [] }),
279
+ ctx,
280
+ );
281
+ await middleware(
282
+ makeReq('transfer', {}),
283
+ async () => ({ content: [] }),
284
+ ctx,
285
+ );
286
+
287
+ const tampered = [events[0]!, { ...events[1]!, erasable: true as const }];
288
+
289
+ await expect(verifyAuditChain(tampered, signingKey)).resolves.toEqual({
290
+ valid: false,
291
+ index: 1,
292
+ reason: 'chainHash mismatch',
293
+ });
294
+ });
295
+ });
296
+
297
+ // ── Keyed hashes close the confirmation attack ───────────────────────────────
298
+
299
+ describe('erasable mode — keyed payload hashes', () => {
300
+ it('hashes are HMAC under the subject hash subkey, never the plain sha256', async () => {
301
+ const keyStore = createInMemorySubjectKeyStore();
302
+ const { events, middleware } = makeHarness({ keyStore });
303
+ const args = { acct: 'secret-acct' };
304
+
305
+ await middleware(
306
+ makeReq('transfer', args),
307
+ async () => ({ content: [] }),
308
+ makeCtx(makeIdentity('user-1'), 'sess-hash'),
309
+ );
310
+ const event = events[0]!;
311
+
312
+ // The confirmation attack: an adversary holding a candidate payload
313
+ // recomputes the plain hash and matches it against the record. In
314
+ // erasable mode that hash is not what the event carries.
315
+ const plain = sha256hex(stableStringify(args));
316
+ expect(event.inputHash).not.toBe(plain);
317
+ expect(event.outputHash).not.toBe(
318
+ sha256hex(stableStringify({ content: [] })),
319
+ );
320
+
321
+ // Pins the new domain label: it is part of the on-disk format.
322
+ const hashSubkey = deriveHashSubkey(await keyStore.getOrCreate('user-1'));
323
+ expect(event.inputHash).toBe(
324
+ createHmac('sha256', hashSubkey)
325
+ .update(stableStringify(args))
326
+ .digest('hex'),
327
+ );
328
+ expect(event.inputHash).not.toBe(
329
+ createHmac('sha256', hashSubkey)
330
+ .update(stableStringify({ acct: 'other-acct' }))
331
+ .digest('hex'),
332
+ );
333
+ });
334
+
335
+ it('two subjects hash the same payload differently', async () => {
336
+ const { events, middleware } = makeHarness({
337
+ keyStore: createInMemorySubjectKeyStore(),
338
+ });
339
+ const args = { acct: 'same' };
340
+
341
+ await middleware(
342
+ makeReq('transfer', args),
343
+ async () => ({ content: [] }),
344
+ makeCtx(makeIdentity('user-1'), 's1'),
345
+ );
346
+ await middleware(
347
+ makeReq('transfer', args),
348
+ async () => ({ content: [] }),
349
+ makeCtx(makeIdentity('user-2'), 's2'),
350
+ );
351
+
352
+ expect(events[0]!.inputHash).not.toBe(events[1]!.inputHash);
353
+ });
354
+ });
355
+
356
+ // ── Default mode is untouched ────────────────────────────────────────────────
357
+
358
+ describe('erasable mode — default mode is byte-identical', () => {
359
+ it('the same call recorded with and without a keyStore differs only in the marker and the hashes', async () => {
360
+ const args = { acct: 'fixed' };
361
+ const result = { content: [{ type: 'text' as const, text: 'fixed-out' }] };
362
+ // ONE context, so requestId, session and identity are literally the same
363
+ // inputs on both runs.
364
+ const ctx = makeCtx(makeIdentity('user-1'), 'sess-identical');
365
+
366
+ const plainRun = makeHarness();
367
+ await plainRun.middleware(
368
+ makeReq('transfer', args),
369
+ async () => result,
370
+ ctx,
371
+ );
372
+ const erasableRun = makeHarness({
373
+ keyStore: createInMemorySubjectKeyStore(),
374
+ });
375
+ await erasableRun.middleware(
376
+ makeReq('transfer', args),
377
+ async () => result,
378
+ ctx,
379
+ );
380
+
381
+ const plainEvent = plainRun.events[0]!;
382
+ const erasableEvent = erasableRun.events[0]!;
383
+
384
+ // Default mode carries no marker and the original plain hashes.
385
+ expect('erasable' in plainEvent).toBe(false);
386
+ expect(plainEvent.inputHash).toBe(sha256hex(stableStringify(args)));
387
+ expect(plainEvent.outputHash).toBe(sha256hex(stableStringify(result)));
388
+
389
+ // Everything the chain covers is identical apart from the three fields
390
+ // erasable mode is defined to change, once wall-clock fields are set
391
+ // aside.
392
+ const strip = (event: AuditEvent) => {
393
+ const {
394
+ startedAt: _s,
395
+ endedAt: _e,
396
+ duration_ms: _d,
397
+ inputHash: _i,
398
+ outputHash: _o,
399
+ erasable: _r,
400
+ ...rest
401
+ } = chainPreimageFields(event) as Record<string, unknown> &
402
+ Partial<AuditEvent>;
403
+ return rest;
404
+ };
405
+ expect(strip(erasableEvent)).toEqual(strip(plainEvent));
406
+ expect(erasableEvent.erasable).toBe(true);
407
+ });
408
+ });
409
+
410
+ // ── The store is a pre-call failure ──────────────────────────────────────────
411
+
412
+ describe('erasable mode — a failing key store', () => {
413
+ it('fails the call before it runs, audits nothing, and stays retryable', async () => {
414
+ let fail = true;
415
+ const inner = createInMemorySubjectKeyStore();
416
+ const keyStore: SubjectKeyStore = {
417
+ getOrCreate: async (subjectId) => {
418
+ if (fail) throw new Error('key store unavailable');
419
+ return inner.getOrCreate(subjectId);
420
+ },
421
+ destroy: (subjectId) => inner.destroy(subjectId),
422
+ };
423
+ const onAuditError = vi.fn();
424
+ const { events, middleware } = makeHarness({ keyStore, onAuditError });
425
+ const next = vi.fn(async () => ({ content: [] }));
426
+ const ctx = makeCtx(makeIdentity('user-1'), 'sess-store-fail');
427
+
428
+ await expect(
429
+ middleware(makeReq('transfer', {}), next, ctx),
430
+ ).rejects.toThrow('key store unavailable');
431
+
432
+ // No unaudited call, and no audit-error swallowing either: a pre-call
433
+ // failure fails the call, it does not degrade into a recorded event.
434
+ expect(next).not.toHaveBeenCalled();
435
+ expect(events).toEqual([]);
436
+ expect(onAuditError).not.toHaveBeenCalled();
437
+
438
+ // Nothing is cached, so a transient outage recovers on the next call.
439
+ fail = false;
440
+ await expect(
441
+ middleware(makeReq('transfer', {}), next, ctx),
442
+ ).resolves.toEqual({ content: [] });
443
+ expect(events).toHaveLength(1);
444
+ });
445
+ });
446
+
447
+ // ── The anonymous bucket ─────────────────────────────────────────────────────
448
+
449
+ describe('erasable mode — anonymous events', () => {
450
+ it('events with no resolved identity share the anonymous subject bucket', async () => {
451
+ const keyStore = createInMemorySubjectKeyStore();
452
+ const seen: string[] = [];
453
+ const spy: SubjectKeyStore = {
454
+ getOrCreate: (subjectId) => {
455
+ seen.push(subjectId);
456
+ return keyStore.getOrCreate(subjectId);
457
+ },
458
+ destroy: (subjectId) => keyStore.destroy(subjectId),
459
+ };
460
+ const { events, middleware } = makeHarness({ keyStore: spy });
461
+
462
+ await middleware(
463
+ makeReq('transfer', { n: 1 }),
464
+ async () => ({ content: [] }),
465
+ makeCtx(undefined, 's-anon'),
466
+ );
467
+ await middleware(
468
+ makeReq('transfer', { n: 2 }),
469
+ async () => ({ content: [] }),
470
+ makeCtx(undefined, 's-anon'),
471
+ );
472
+
473
+ expect(seen).toEqual(['anonymous', 'anonymous']);
474
+ // One destroy erases the whole bucket.
475
+ const key = await keyStore.getOrCreate('anonymous');
476
+ expect(decryptInput(asHigh(events[0]), key, 's-anon')).toEqual({ n: 1 });
477
+ await keyStore.destroy('anonymous');
478
+ const fresh = await keyStore.getOrCreate('anonymous');
479
+ expect(() => decryptInput(asHigh(events[0]), fresh, 's-anon')).toThrow();
480
+ });
481
+ });
482
+
483
+ // ── The reference store ──────────────────────────────────────────────────────
484
+
485
+ describe('createInMemorySubjectKeyStore', () => {
486
+ it('creates a 256-bit key once per subject and returns it stably', async () => {
487
+ const store = createInMemorySubjectKeyStore();
488
+ const first = await store.getOrCreate('a');
489
+ expect(first).toHaveLength(32);
490
+ expect((await store.getOrCreate('a')).equals(first)).toBe(true);
491
+ expect((await store.getOrCreate('b')).equals(first)).toBe(false);
492
+ });
493
+
494
+ it('destroying an unknown subject still returns a tombstone', async () => {
495
+ const store = createInMemorySubjectKeyStore();
496
+ const tombstone = await store.destroy('never-seen');
497
+ expect(Number.isNaN(Date.parse(tombstone.destroyedAt))).toBe(false);
498
+ });
499
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ const sha256Calls = vi.hoisted(() => ({ count: 0 }));
4
+
5
+ vi.mock('node:crypto', async (importOriginal) => {
6
+ const original = await importOriginal<typeof import('node:crypto')>();
7
+ return {
8
+ ...original,
9
+ createHash: (...args: Parameters<typeof original.createHash>) => {
10
+ if (args[0] === 'sha256') sha256Calls.count += 1;
11
+ return original.createHash(...args);
12
+ },
13
+ };
14
+ });
15
+
16
+ import { createProxyContext } from 'mcpose';
17
+ import type { Identity } from 'mcpose';
18
+ import { createAuditMiddleware } from '../middleware.js';
19
+ import { createDefaultSigningKeyProvider } from '../signingKey.js';
20
+ import { createSensitivityResolver } from '../sensitivity.js';
21
+ import type { AuditEvent } from '../types.js';
22
+ import { computeMerkleProof, computeMerkleRoot } from '../chain.js';
23
+
24
+ const identity: Identity = {
25
+ sub: 'user-1',
26
+ type: 'human',
27
+ roles: ['analyst'],
28
+ claims: {},
29
+ resolvedAt: '2026-06-01T00:00:00.000Z',
30
+ source: 'jwt',
31
+ };
32
+
33
+ async function closeSessionWithEvents(sessionId: string, count: number) {
34
+ const events: AuditEvent[] = [];
35
+ const { middleware, closeSession } = createAuditMiddleware({
36
+ signingKey: createDefaultSigningKeyProvider('test-secret'),
37
+ sensitivityResolver: createSensitivityResolver({ search: 'low' }),
38
+ onEvent: (event) => {
39
+ events.push(event);
40
+ },
41
+ });
42
+ const context = () =>
43
+ createProxyContext({
44
+ transport: 'http',
45
+ identity,
46
+ sessionId,
47
+ proxy: { name: 'test-proxy', version: '0.0.0' },
48
+ });
49
+
50
+ for (let index = 0; index < count; index++) {
51
+ await middleware(
52
+ {
53
+ method: 'tools/call' as const,
54
+ params: { name: 'search', arguments: { index } },
55
+ },
56
+ async () => ({ content: [] }),
57
+ context(),
58
+ );
59
+ }
60
+
61
+ sha256Calls.count = 0;
62
+ const manifest = await closeSession(sessionId);
63
+ return {
64
+ events,
65
+ manifest: manifest!,
66
+ sha256Count: sha256Calls.count,
67
+ };
68
+ }
69
+
70
+ describe('batched proofs in the signed manifest', () => {
71
+ it('keeps the same root and proofs across singleton, odd, and even event counts', async () => {
72
+ for (const count of [1, 2, 3, 4, 5, 8, 9, 17]) {
73
+ const { events, manifest } = await closeSessionWithEvents(
74
+ `batch-${count}`,
75
+ count,
76
+ );
77
+ const hashes = events.map((event) => event.chainHash);
78
+
79
+ expect(manifest.merkleRoot).toBe(computeMerkleRoot(hashes));
80
+ expect(manifest.merkleProofs).toEqual(
81
+ hashes.map((_, index) => computeMerkleProof(hashes, index)),
82
+ );
83
+ }
84
+ });
85
+
86
+ it('hashes each tree node once while closing a large session', async () => {
87
+ const count = 256;
88
+ const { sha256Count, manifest } = await closeSessionWithEvents(
89
+ 'batch-large',
90
+ count,
91
+ );
92
+
93
+ expect(manifest.merkleProofs).toHaveLength(count);
94
+ expect(sha256Count).toBe(2 * count - 1);
95
+ });
96
+ });