@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.
- package/LICENSE +21 -0
- package/README.md +437 -28
- package/dist/chain.d.ts +55 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +265 -0
- package/dist/chain.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +488 -0
- package/dist/middleware.js.map +1 -0
- package/dist/sensitivity.d.ts +12 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js +24 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/signingKey.d.ts +11 -0
- package/dist/signingKey.d.ts.map +1 -0
- package/dist/signingKey.js +30 -0
- package/dist/signingKey.js.map +1 -0
- package/dist/subjectKeyStore.d.ts +17 -0
- package/dist/subjectKeyStore.d.ts.map +1 -0
- package/dist/subjectKeyStore.js +37 -0
- package/dist/subjectKeyStore.js.map +1 -0
- package/dist/types.d.ts +244 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +50 -0
- package/dist/verify.js.map +1 -0
- package/package.json +43 -14
- package/src/__tests__/chain.batch.test.ts +30 -0
- package/src/__tests__/chain.test.ts +399 -0
- package/src/__tests__/erasable.test.ts +499 -0
- package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
- package/src/__tests__/middleware.http.test.ts +172 -0
- package/src/__tests__/middleware.robustness.test.ts +737 -0
- package/src/__tests__/middleware.test.ts +1121 -0
- package/src/__tests__/properties.test.ts +737 -0
- package/src/__tests__/sensitivity.test.ts +72 -0
- package/src/__tests__/signingKey.test.ts +76 -0
- package/src/chain.ts +317 -0
- package/src/index.ts +31 -0
- package/src/middleware.ts +690 -0
- package/src/sensitivity.ts +33 -0
- package/src/signingKey.ts +35 -0
- package/src/subjectKeyStore.ts +38 -0
- package/src/types.ts +281 -0
- 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
|
+
});
|