@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,737 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import fc from 'fast-check';
3
+ import { createProxyContext } from 'mcpose';
4
+ import type { Identity, ProxyIdentity, RejectionReason } from 'mcpose';
5
+ import {
6
+ canonicalJson,
7
+ chainPreimageFields,
8
+ computeChainHash,
9
+ computeMerkleProof,
10
+ computeMerkleRoot,
11
+ sha256hex,
12
+ verifyMerkleProof,
13
+ } from '../chain.js';
14
+ import { createAuditMiddleware } from '../middleware.js';
15
+ import { createSensitivityResolver } from '../sensitivity.js';
16
+ import { createDefaultSigningKeyProvider } from '../signingKey.js';
17
+ import type { AuditEvent, ReplayManifest } from '../types.js';
18
+ import { verifyAuditChain, verifyManifestSignature } from '../verify.js';
19
+
20
+ // Property-based companions to the example-based tests in chain.test.ts.
21
+ // Each block states an invariant from ADR-0004 that must hold for EVERY
22
+ // input, not just the hand-picked ones: canonical key ordering, Merkle
23
+ // domain separation and proof well-formedness, chain coverage of every
24
+ // preimage field, and key derivation.
25
+
26
+ // The v2 domain labels are part of the on-disk format. Restating them here
27
+ // pins them: changing one is a v2 → v3 break and must follow the ritual in
28
+ // .claude/skills/mcpose-audit-invariants (new ADR, major bump, new labels).
29
+ const LEAF_TAG = 'mcpose/v2/leaf\0';
30
+ const NODE_TAG = 'mcpose/v2/node\0';
31
+ const DOMAIN_CHAIN = 'mcpose/v2/chain';
32
+
33
+ // ── Arbitraries ───────────────────────────────────────────────────────────────
34
+
35
+ const hexHash = fc
36
+ .uint8Array({ minLength: 32, maxLength: 32 })
37
+ .map((bytes) => Buffer.from(bytes).toString('hex'));
38
+
39
+ /** Hex of an arbitrary byte length, including the empty string. */
40
+ const anyLengthHex = fc
41
+ .uint8Array({ maxLength: 40 })
42
+ .map((bytes) => Buffer.from(bytes).toString('hex'));
43
+
44
+ const keyArb = fc.string({ minLength: 1, maxLength: 5 });
45
+ const scalarArb = fc.oneof(
46
+ fc.string({ maxLength: 6 }),
47
+ fc.integer(),
48
+ fc.boolean(),
49
+ fc.constant(null),
50
+ );
51
+ const flatObjectArb = fc.dictionary(keyArb, scalarArb, { maxKeys: 4 });
52
+ const nestedObjectArb = fc.dictionary(
53
+ keyArb,
54
+ fc.oneof(
55
+ scalarArb,
56
+ flatObjectArb,
57
+ fc.array(fc.oneof(scalarArb, flatObjectArb), { maxLength: 3 }),
58
+ ),
59
+ { maxKeys: 4 },
60
+ );
61
+
62
+ const isoArb = fc
63
+ .integer({ min: 0, max: 4_000_000_000_000 })
64
+ .map((ms) => new Date(ms).toISOString());
65
+
66
+ const identityArb: fc.Arbitrary<Identity> = fc.record(
67
+ {
68
+ sub: fc.string({ minLength: 1, maxLength: 10 }),
69
+ type: fc.constantFrom('human', 'agent', 'service'),
70
+ displayName: fc.string({ maxLength: 8 }),
71
+ roles: fc.array(fc.string({ maxLength: 6 }), { maxLength: 3 }),
72
+ claims: fc.dictionary(keyArb, scalarArb, { maxKeys: 3 }),
73
+ resolvedAt: isoArb,
74
+ source: fc.constantFrom('jwt', 'mtls', 'apikey', 'custom'),
75
+ },
76
+ {
77
+ requiredKeys: ['sub', 'type', 'roles', 'claims', 'resolvedAt', 'source'],
78
+ },
79
+ );
80
+
81
+ const proxyArb: fc.Arbitrary<ProxyIdentity> = fc.record({
82
+ name: fc.string({ minLength: 1, maxLength: 10 }),
83
+ version: fc.string({ minLength: 1, maxLength: 8 }),
84
+ });
85
+
86
+ /**
87
+ * Every field the chain preimage covers (ADR-0004), minus
88
+ * `replayManifestPosition`, which the verifier checks separately, and the
89
+ * optional `kind`, covered by the middleware tests. `proxy` is required and
90
+ * always covered (ADR-0019), so it is generated and tampered like any other
91
+ * always-present field.
92
+ */
93
+ interface PreimageFields {
94
+ id: string;
95
+ startedAt: string;
96
+ endedAt: string;
97
+ sessionId?: string | undefined;
98
+ delegatedFrom?: Identity[] | undefined;
99
+ proxy: ProxyIdentity;
100
+ identity: Identity;
101
+ tool: string;
102
+ duration_ms: number;
103
+ outcome: AuditEvent['outcome'];
104
+ sensitivityTier: AuditEvent['sensitivityTier'];
105
+ rejectionReason?: RejectionReason | undefined;
106
+ error?: { name: string; message: string } | undefined;
107
+ inputHash: string;
108
+ outputHash: string;
109
+ }
110
+
111
+ const PREIMAGE_FIELDS = [
112
+ 'id',
113
+ 'startedAt',
114
+ 'endedAt',
115
+ 'sessionId',
116
+ 'delegatedFrom',
117
+ 'proxy',
118
+ 'identity',
119
+ 'tool',
120
+ 'duration_ms',
121
+ 'outcome',
122
+ 'sensitivityTier',
123
+ 'rejectionReason',
124
+ 'error',
125
+ 'inputHash',
126
+ 'outputHash',
127
+ ] as const satisfies readonly (keyof PreimageFields)[];
128
+
129
+ const eventFieldsArb: fc.Arbitrary<PreimageFields> = fc.record(
130
+ {
131
+ id: fc.string({ minLength: 1, maxLength: 12 }),
132
+ startedAt: isoArb,
133
+ endedAt: isoArb,
134
+ sessionId: fc.string({ minLength: 1, maxLength: 8 }),
135
+ delegatedFrom: fc.array(identityArb, { minLength: 1, maxLength: 2 }),
136
+ proxy: proxyArb,
137
+ identity: identityArb,
138
+ tool: fc.string({ minLength: 1, maxLength: 10 }),
139
+ duration_ms: fc.nat({ max: 100_000 }),
140
+ outcome: fc.constantFrom('success', 'rejected', 'error'),
141
+ sensitivityTier: fc.constantFrom('low', 'medium', 'high'),
142
+ rejectionReason: fc.constantFrom<RejectionReason[]>(
143
+ 'TOOL_HIDDEN',
144
+ 'POLICY_DENIED',
145
+ 'BUDGET_EXCEEDED',
146
+ ),
147
+ error: fc.record({
148
+ name: fc.string({ maxLength: 8 }),
149
+ message: fc.string({ maxLength: 20 }),
150
+ }),
151
+ inputHash: hexHash,
152
+ outputHash: hexHash,
153
+ },
154
+ {
155
+ requiredKeys: [
156
+ 'id',
157
+ 'startedAt',
158
+ 'endedAt',
159
+ 'proxy',
160
+ 'identity',
161
+ 'tool',
162
+ 'duration_ms',
163
+ 'outcome',
164
+ 'sensitivityTier',
165
+ 'inputHash',
166
+ 'outputHash',
167
+ ],
168
+ },
169
+ );
170
+
171
+ const chainFieldsArb = fc.array(eventFieldsArb, {
172
+ minLength: 1,
173
+ maxLength: 5,
174
+ });
175
+
176
+ // ── Helpers ───────────────────────────────────────────────────────────────────
177
+
178
+ const SECRET = 'property-test-secret';
179
+ const signingKey = createDefaultSigningKeyProvider(SECRET);
180
+ let chainKey: Buffer;
181
+
182
+ const baseIdentity: Identity = {
183
+ sub: 'user-1',
184
+ type: 'human',
185
+ roles: ['analyst'],
186
+ claims: {},
187
+ resolvedAt: '2026-06-01T00:00:00.000Z',
188
+ source: 'jwt',
189
+ };
190
+
191
+ /**
192
+ * Builds an honest chain straight from the primitives, so the generated
193
+ * events can carry field values the middleware would never produce.
194
+ * `fc.record(..., { requiredKeys })` omits the optional keys rather than
195
+ * setting them to `undefined`, so the casts only restate at the type level
196
+ * what is already true at run time under `exactOptionalPropertyTypes`.
197
+ */
198
+ function buildChain(fields: readonly PreimageFields[]): AuditEvent[] {
199
+ const events: AuditEvent[] = [];
200
+ let prevChainHash = '';
201
+ fields.forEach((f, i) => {
202
+ const withoutChainHash = {
203
+ ...f,
204
+ replayManifestPosition: i,
205
+ ...(f.sensitivityTier === 'high'
206
+ ? { inputEncrypted: '', outputEncrypted: '' }
207
+ : { inputRaw: {}, outputRaw: null }),
208
+ } as Omit<AuditEvent, 'chainHash'>;
209
+ const chainHash = computeChainHash(
210
+ chainPreimageFields(withoutChainHash),
211
+ prevChainHash,
212
+ chainKey,
213
+ );
214
+ events.push({ ...withoutChainHash, chainHash } as AuditEvent);
215
+ prevChainHash = chainHash;
216
+ });
217
+ return events;
218
+ }
219
+
220
+ function mulberry32(seed: number): () => number {
221
+ let a = seed >>> 0;
222
+ return () => {
223
+ a = (a + 0x6d2b79f5) >>> 0;
224
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
225
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
226
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
227
+ };
228
+ }
229
+
230
+ /** Rebuilds a value with every object's keys inserted in a random order. */
231
+ function reinsertShuffled(value: unknown, rnd: () => number): unknown {
232
+ if (Array.isArray(value)) {
233
+ return value.map((item: unknown) => reinsertShuffled(item, rnd));
234
+ }
235
+ if (value === null || typeof value !== 'object') return value;
236
+ const entries = Object.entries(value);
237
+ for (let i = entries.length - 1; i > 0; i--) {
238
+ const j = Math.floor(rnd() * (i + 1));
239
+ [entries[i], entries[j]] = [entries[j]!, entries[i]!];
240
+ }
241
+ return Object.fromEntries(
242
+ entries.map(([k, v]) => [k, reinsertShuffled(v, rnd)]),
243
+ );
244
+ }
245
+
246
+ function flipHexBit(hex: string, bit: number): string {
247
+ const pos = Math.floor(bit / 4) % hex.length;
248
+ const flipped = (parseInt(hex[pos]!, 16) ^ (1 << (bit % 4))).toString(16);
249
+ return hex.slice(0, pos) + flipped + hex.slice(pos + 1);
250
+ }
251
+
252
+ async function runSession(opts: {
253
+ sessionId: string | undefined;
254
+ delegatedFrom: Identity[] | undefined;
255
+ count: number;
256
+ }): Promise<{ events: AuditEvent[]; manifest: ReplayManifest | undefined }> {
257
+ const events: AuditEvent[] = [];
258
+ const { middleware, closeSession } = createAuditMiddleware({
259
+ signingKey,
260
+ sensitivityResolver: createSensitivityResolver({ search: 'low' }),
261
+ onEvent: (e) => {
262
+ events.push(e);
263
+ },
264
+ });
265
+ for (let i = 0; i < opts.count; i++) {
266
+ await middleware(
267
+ {
268
+ method: 'tools/call' as const,
269
+ params: { name: 'search', arguments: { i } },
270
+ },
271
+ async () => ({ content: [] }),
272
+ createProxyContext({
273
+ transport: 'http',
274
+ identity: baseIdentity,
275
+ sessionId: opts.sessionId,
276
+ delegatedFrom: opts.delegatedFrom,
277
+ proxy: { name: 'test-proxy', version: '0.0.0' },
278
+ }),
279
+ );
280
+ }
281
+ const manifest =
282
+ opts.sessionId === undefined
283
+ ? undefined
284
+ : await closeSession(opts.sessionId);
285
+ return { events, manifest };
286
+ }
287
+
288
+ beforeAll(async () => {
289
+ chainKey = await signingKey.sign(Buffer.from(DOMAIN_CHAIN));
290
+ });
291
+
292
+ // ── Canonical serialization ───────────────────────────────────────────────────
293
+
294
+ describe('canonicalJson (properties)', () => {
295
+ it('is invariant under key insertion order at every depth', () => {
296
+ fc.assert(
297
+ fc.property(nestedObjectArb, fc.integer(), (obj, seed) => {
298
+ const shuffled = reinsertShuffled(obj, mulberry32(seed));
299
+ expect(canonicalJson(shuffled)).toBe(canonicalJson(obj));
300
+ }),
301
+ );
302
+ });
303
+
304
+ it('orders distinct keys by a strict total order, collapsing none', () => {
305
+ fc.assert(
306
+ fc.property(
307
+ fc.uniqueArray(fc.string({ minLength: 1, maxLength: 8 }), {
308
+ minLength: 1,
309
+ maxLength: 12,
310
+ }),
311
+ (keys) => {
312
+ const obj = Object.fromEntries(keys.map((k, i) => [k, i]));
313
+ // Reference serialization: every key present exactly once, in
314
+ // ascending code-unit order — the order `<` induces.
315
+ const expected = `{${[...keys]
316
+ .sort()
317
+ .map((k) => `${JSON.stringify(k)}:${keys.indexOf(k)}`)
318
+ .join(',')}}`;
319
+ expect(canonicalJson(obj)).toBe(expected);
320
+ },
321
+ ),
322
+ );
323
+ });
324
+
325
+ it('treats an undefined-valued key as an absent key', () => {
326
+ fc.assert(
327
+ fc.property(
328
+ nestedObjectArb,
329
+ fc.uniqueArray(keyArb, { maxLength: 4 }),
330
+ (obj, extraKeys) => {
331
+ const withUndefined: Record<string, unknown> = { ...obj };
332
+ for (const k of extraKeys) {
333
+ if (!Object.hasOwn(obj, k)) withUndefined[k] = undefined;
334
+ }
335
+ expect(canonicalJson(withUndefined)).toBe(canonicalJson(obj));
336
+ },
337
+ ),
338
+ );
339
+ });
340
+ });
341
+
342
+ // ── Merkle tree ───────────────────────────────────────────────────────────────
343
+
344
+ describe('Merkle tree (properties)', () => {
345
+ const leavesArb = fc.uniqueArray(hexHash, { minLength: 1, maxLength: 12 });
346
+ const multiLeavesArb = fc.uniqueArray(hexHash, {
347
+ minLength: 2,
348
+ maxLength: 8,
349
+ });
350
+
351
+ it('pins the v2 leaf and node tags', () => {
352
+ fc.assert(
353
+ fc.property(hexHash, hexHash, (a, b) => {
354
+ const leafA = sha256hex(LEAF_TAG + a);
355
+ const leafB = sha256hex(LEAF_TAG + b);
356
+ expect(computeMerkleRoot([a])).toBe(leafA);
357
+ expect(computeMerkleRoot([a, b])).toBe(
358
+ sha256hex(NODE_TAG + leafA + leafB),
359
+ );
360
+ }),
361
+ );
362
+ });
363
+
364
+ it('roots an empty tree at sha256 of the empty string', () => {
365
+ expect(computeMerkleRoot([])).toBe(sha256hex(''));
366
+ });
367
+
368
+ it('round-trips a proof for every leaf of every tree size', () => {
369
+ fc.assert(
370
+ fc.property(leavesArb, (leaves) => {
371
+ const root = computeMerkleRoot(leaves);
372
+ leaves.forEach((leaf, i) => {
373
+ expect(
374
+ verifyMerkleProof(leaf, computeMerkleProof(leaves, i), root),
375
+ ).toBe(true);
376
+ });
377
+ }),
378
+ );
379
+ });
380
+
381
+ it('never verifies a proof against another leaf or an internal node', () => {
382
+ fc.assert(
383
+ fc.property(multiLeavesArb, (leaves) => {
384
+ const root = computeMerkleRoot(leaves);
385
+ leaves.forEach((_, i) => {
386
+ const proof = computeMerkleProof(leaves, i);
387
+ leaves.forEach((other, j) => {
388
+ if (i !== j)
389
+ expect(verifyMerkleProof(other, proof, root)).toBe(false);
390
+ });
391
+ // Siblings above level 0 are internal node hashes. Domain
392
+ // separation is what stops one being replayed as a leaf.
393
+ proof.siblings.slice(1).forEach((internal) => {
394
+ expect(verifyMerkleProof(internal, proof, root)).toBe(false);
395
+ });
396
+ });
397
+ }),
398
+ );
399
+ });
400
+
401
+ it('never hashes an internal node preimage to a valid leaf', () => {
402
+ fc.assert(
403
+ fc.property(hexHash, hexHash, (a, b) => {
404
+ fc.pre(a !== b);
405
+ // A leaf whose value IS the internal node's untagged preimage.
406
+ // Without the leaf/node tags it would hash to that node and could
407
+ // be replayed at any level of the tree.
408
+ const forged =
409
+ NODE_TAG + computeMerkleRoot([a]) + computeMerkleRoot([b]);
410
+ expect(computeMerkleRoot([forged])).not.toBe(computeMerkleRoot([a, b]));
411
+ }),
412
+ );
413
+ });
414
+ });
415
+
416
+ describe('verifyMerkleProof rejects malformed proofs (properties)', () => {
417
+ const multiLeavesArb = fc.uniqueArray(hexHash, {
418
+ minLength: 2,
419
+ maxLength: 8,
420
+ });
421
+
422
+ it('rejects a length disagreement between siblings and directions', () => {
423
+ fc.assert(
424
+ fc.property(multiLeavesArb, fc.nat(), (leaves, raw) => {
425
+ const root = computeMerkleRoot(leaves);
426
+ const i = raw % leaves.length;
427
+ const proof = computeMerkleProof(leaves, i);
428
+ // An extra direction leaves the sibling walk intact, so without the
429
+ // length check this proof would still land on the root.
430
+ expect(
431
+ verifyMerkleProof(
432
+ leaves[i]!,
433
+ { ...proof, directions: [...proof.directions, 'right'] },
434
+ root,
435
+ ),
436
+ ).toBe(false);
437
+ expect(
438
+ verifyMerkleProof(
439
+ leaves[i]!,
440
+ {
441
+ ...proof,
442
+ siblings: [...proof.siblings, proof.siblings[0] ?? ''],
443
+ },
444
+ root,
445
+ ),
446
+ ).toBe(false);
447
+ }),
448
+ );
449
+ });
450
+
451
+ it('rejects a non-integer index that would otherwise verify', () => {
452
+ fc.assert(
453
+ fc.property(multiLeavesArb, fc.nat(), (leaves, raw) => {
454
+ const odd = leaves.map((_, k) => k).filter((k) => k % 2 === 1);
455
+ const i = odd[raw % odd.length]!;
456
+ const root = computeMerkleRoot(leaves);
457
+ const proof = computeMerkleProof(leaves, i);
458
+ // For an odd index, `index + 0.5` derives the identical direction
459
+ // sequence at every level, so only the integer check rejects it.
460
+ expect(
461
+ verifyMerkleProof(leaves[i]!, { ...proof, index: i + 0.5 }, root),
462
+ ).toBe(false);
463
+ }),
464
+ );
465
+ });
466
+
467
+ it('rejects a negative index that would otherwise verify', () => {
468
+ fc.assert(
469
+ fc.property(
470
+ fc.uniqueArray(hexHash, { minLength: 16, maxLength: 16 }),
471
+ fc.integer({ min: 1, max: 4 }),
472
+ (pool, depth) => {
473
+ // A perfect tree's last index is all-ones, so every level's
474
+ // direction is 'left' — exactly what index -1 derives.
475
+ const leaves = pool.slice(0, 2 ** depth);
476
+ const i = leaves.length - 1;
477
+ const root = computeMerkleRoot(leaves);
478
+ const proof = computeMerkleProof(leaves, i);
479
+ expect(proof.directions.every((d) => d === 'left')).toBe(true);
480
+ expect(
481
+ verifyMerkleProof(leaves[i]!, { ...proof, index: -1 }, root),
482
+ ).toBe(false);
483
+ },
484
+ ),
485
+ );
486
+ });
487
+
488
+ it('rejects a hole in siblings rather than coercing it to a string', () => {
489
+ fc.assert(
490
+ fc.property(hexHash, (leaf) => {
491
+ const sparse: string[] = [];
492
+ sparse.length = 1;
493
+ // A root crafted from the coerced hole: the proof must be rejected
494
+ // for being malformed, not walked with `undefined` as a sibling.
495
+ const coercedRoot = sha256hex(
496
+ NODE_TAG + sha256hex(LEAF_TAG + leaf) + 'undefined',
497
+ );
498
+ expect(
499
+ verifyMerkleProof(
500
+ leaf,
501
+ { index: 0, siblings: sparse, directions: ['right'] },
502
+ coercedRoot,
503
+ ),
504
+ ).toBe(false);
505
+ }),
506
+ );
507
+ });
508
+ });
509
+
510
+ // ── Keyed chain verification ──────────────────────────────────────────────────
511
+
512
+ describe('verifyAuditChain (properties)', () => {
513
+ it('accepts every honestly built chain', async () => {
514
+ await fc.assert(
515
+ fc.asyncProperty(chainFieldsArb, async (fields) => {
516
+ expect(await verifyAuditChain(buildChain(fields), signingKey)).toEqual({
517
+ valid: true,
518
+ });
519
+ }),
520
+ );
521
+ });
522
+
523
+ it('detects a change to any preimage field, at that exact index', async () => {
524
+ await fc.assert(
525
+ fc.asyncProperty(
526
+ chainFieldsArb,
527
+ eventFieldsArb,
528
+ fc.nat(),
529
+ async (fields, donor, raw) => {
530
+ const i = raw % fields.length;
531
+ const honest = buildChain(fields);
532
+ for (const field of PREIMAGE_FIELDS) {
533
+ const target = fields[i]!;
534
+ // Canonical equality is the only equality the chain sees: a
535
+ // donor value that serializes identically is not a tamper.
536
+ if (
537
+ canonicalJson(target[field] ?? null) ===
538
+ canonicalJson(donor[field] ?? null)
539
+ ) {
540
+ continue;
541
+ }
542
+ const tampered = { ...honest[i]! } as Record<string, unknown>;
543
+ if (Object.hasOwn(donor, field)) tampered[field] = donor[field];
544
+ else delete tampered[field];
545
+ const events = honest.map((e, k) =>
546
+ k === i ? (tampered as unknown as AuditEvent) : e,
547
+ );
548
+ expect(await verifyAuditChain(events, signingKey)).toEqual({
549
+ valid: false,
550
+ index: i,
551
+ reason: 'chainHash mismatch',
552
+ });
553
+ }
554
+ },
555
+ ),
556
+ );
557
+ });
558
+
559
+ it('detects a single-bit flip of a hashed payload digest', async () => {
560
+ await fc.assert(
561
+ fc.asyncProperty(
562
+ chainFieldsArb,
563
+ fc.nat(),
564
+ fc.nat({ max: 255 }),
565
+ fc.constantFrom('inputHash', 'outputHash'),
566
+ async (fields, raw, bit, field) => {
567
+ const i = raw % fields.length;
568
+ const honest = buildChain(fields);
569
+ const events = honest.map((e, k) =>
570
+ k === i ? { ...e, [field]: flipHexBit(e[field], bit) } : e,
571
+ );
572
+ expect(await verifyAuditChain(events, signingKey)).toEqual({
573
+ valid: false,
574
+ index: i,
575
+ reason: 'chainHash mismatch',
576
+ });
577
+ },
578
+ ),
579
+ );
580
+ });
581
+
582
+ it('rejects a corrupted chainHash of any length without throwing', async () => {
583
+ await fc.assert(
584
+ fc.asyncProperty(
585
+ chainFieldsArb,
586
+ fc.nat(),
587
+ anyLengthHex,
588
+ async (fields, raw, corrupt) => {
589
+ const honest = buildChain(fields);
590
+ const i = raw % honest.length;
591
+ fc.pre(corrupt !== honest[i]!.chainHash);
592
+ const events = honest.map((e, k) =>
593
+ k === i ? { ...e, chainHash: corrupt } : e,
594
+ );
595
+ // A shorter, longer, or empty hash must compare unequal, not
596
+ // throw out of the timing-safe comparison.
597
+ expect(await verifyAuditChain(events, signingKey)).toEqual({
598
+ valid: false,
599
+ index: i,
600
+ reason: 'chainHash mismatch',
601
+ });
602
+ },
603
+ ),
604
+ );
605
+ });
606
+
607
+ it('reports a replayManifestPosition mismatch at the offending index', async () => {
608
+ await fc.assert(
609
+ fc.asyncProperty(
610
+ chainFieldsArb,
611
+ fc.nat(),
612
+ fc.integer({ min: -3, max: 20 }),
613
+ async (fields, raw, position) => {
614
+ const honest = buildChain(fields);
615
+ const i = raw % honest.length;
616
+ fc.pre(position !== i);
617
+ const events = honest.map((e, k) =>
618
+ k === i ? { ...e, replayManifestPosition: position } : e,
619
+ );
620
+ expect(await verifyAuditChain(events, signingKey)).toEqual({
621
+ valid: false,
622
+ index: i,
623
+ reason: `replayManifestPosition ${position}, expected ${i}`,
624
+ });
625
+ },
626
+ ),
627
+ );
628
+ });
629
+ });
630
+
631
+ // ── Optional context fields end to end ────────────────────────────────────────
632
+
633
+ describe('optional event fields (properties)', () => {
634
+ it('round-trips sessionId and delegatedFrom presence through verification', async () => {
635
+ await fc.assert(
636
+ fc.asyncProperty(
637
+ fc.boolean(),
638
+ fc.option(fc.array(identityArb, { minLength: 1, maxLength: 2 }), {
639
+ nil: undefined,
640
+ }),
641
+ fc.integer({ min: 1, max: 3 }),
642
+ async (withSession, delegatedFrom, count) => {
643
+ // Without a sessionId there is no session state, so every event
644
+ // is a standalone position-0 entry (intentional — see the skill).
645
+ const sessionId = withSession ? 'p-session' : undefined;
646
+ const { events, manifest } = await runSession({
647
+ sessionId,
648
+ delegatedFrom,
649
+ count: withSession ? count : 1,
650
+ });
651
+ for (const event of events) {
652
+ expect(Object.hasOwn(event, 'sessionId')).toBe(withSession);
653
+ expect(Object.hasOwn(event, 'delegatedFrom')).toBe(
654
+ delegatedFrom !== undefined,
655
+ );
656
+ expect(event.delegatedFrom).toEqual(delegatedFrom);
657
+ }
658
+ expect(await verifyAuditChain(events, signingKey)).toEqual({
659
+ valid: true,
660
+ });
661
+ expect(manifest?.eventCount).toBe(withSession ? count : undefined);
662
+ },
663
+ ),
664
+ { numRuns: 30 },
665
+ );
666
+ });
667
+ });
668
+
669
+ describe('verifyManifestSignature (properties)', () => {
670
+ let manifest: ReplayManifest;
671
+
672
+ beforeAll(async () => {
673
+ const session = await runSession({
674
+ sessionId: 'm-props',
675
+ delegatedFrom: undefined,
676
+ count: 3,
677
+ });
678
+ manifest = session.manifest!;
679
+ });
680
+
681
+ it('rejects a signature of any length without throwing', async () => {
682
+ await fc.assert(
683
+ fc.asyncProperty(anyLengthHex, async (signature) => {
684
+ fc.pre(signature !== manifest.signature);
685
+ expect(
686
+ await verifyManifestSignature({ ...manifest, signature }, signingKey),
687
+ ).toBe(false);
688
+ }),
689
+ );
690
+ });
691
+ });
692
+
693
+ // ── Signing key derivation ────────────────────────────────────────────────────
694
+
695
+ describe('createDefaultSigningKeyProvider (properties)', () => {
696
+ const secretArb = fc.string({ minLength: 1, maxLength: 40 });
697
+
698
+ it('derives the same keyId and signatures from a string and its bytes', async () => {
699
+ await fc.assert(
700
+ fc.asyncProperty(
701
+ secretArb,
702
+ fc.uint8Array({ minLength: 1, maxLength: 32 }),
703
+ async (secret, data) => {
704
+ const fromString = createDefaultSigningKeyProvider(secret);
705
+ const fromBuffer = createDefaultSigningKeyProvider(
706
+ Buffer.from(secret),
707
+ );
708
+ expect(fromBuffer.keyId).toBe(fromString.keyId);
709
+ const message = Buffer.from(data);
710
+ expect((await fromBuffer.sign(message)).toString('hex')).toBe(
711
+ (await fromString.sign(message)).toString('hex'),
712
+ );
713
+ },
714
+ ),
715
+ );
716
+ });
717
+
718
+ it('derives different keys from different secrets', async () => {
719
+ await fc.assert(
720
+ fc.asyncProperty(
721
+ secretArb,
722
+ secretArb,
723
+ fc.uint8Array({ minLength: 1, maxLength: 32 }),
724
+ async (a, b, data) => {
725
+ fc.pre(!Buffer.from(a).equals(Buffer.from(b)));
726
+ const first = createDefaultSigningKeyProvider(a);
727
+ const second = createDefaultSigningKeyProvider(b);
728
+ expect(first.keyId).not.toBe(second.keyId);
729
+ const message = Buffer.from(data);
730
+ expect((await first.sign(message)).toString('hex')).not.toBe(
731
+ (await second.sign(message)).toString('hex'),
732
+ );
733
+ },
734
+ ),
735
+ );
736
+ });
737
+ });