@mcpose/audit 2.0.3 → 3.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.
- package/LICENSE +21 -0
- package/README.md +440 -28
- package/dist/chain.d.ts +49 -6
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +227 -28
- package/dist/chain.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +3 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +499 -85
- package/dist/middleware.js.map +1 -1
- package/dist/sensitivity.d.ts +11 -2
- package/dist/sensitivity.d.ts.map +1 -1
- package/dist/sensitivity.js +18 -2
- package/dist/sensitivity.js.map +1 -1
- package/dist/signingKey.d.ts +8 -0
- package/dist/signingKey.d.ts.map +1 -1
- package/dist/signingKey.js +21 -4
- package/dist/signingKey.js.map +1 -1
- 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 +170 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +66 -0
- package/dist/verify.js.map +1 -0
- package/package.json +43 -15
- package/src/__tests__/chain.batch.test.ts +30 -0
- package/src/__tests__/chain.test.ts +433 -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.rejection.test.ts +183 -0
- package/src/__tests__/middleware.robustness.test.ts +809 -0
- package/src/__tests__/middleware.test.ts +1121 -0
- package/src/__tests__/middleware.unusableResult.test.ts +141 -0
- package/src/__tests__/properties.test.ts +764 -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 +781 -0
- package/src/sensitivity.ts +33 -0
- package/src/signingKey.ts +35 -0
- package/src/subjectKeyStore.ts +38 -0
- package/src/types.ts +287 -0
- package/src/verify.ts +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcpose/audit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Integrity-chained audit middleware for mcpose — HMAC chain, Merkle proofs, ReplayManifest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,30 +9,58 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
13
14
|
},
|
|
14
|
-
"
|
|
15
|
-
"
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"audit",
|
|
27
|
+
"compliance",
|
|
28
|
+
"fintech",
|
|
29
|
+
"hmac",
|
|
30
|
+
"merkle"
|
|
31
|
+
],
|
|
16
32
|
"author": "Amir Gorji",
|
|
17
33
|
"license": "MIT",
|
|
34
|
+
"homepage": "https://github.com/amir-gorji/mcpose#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/amir-gorji/mcpose/issues"
|
|
37
|
+
},
|
|
18
38
|
"repository": {
|
|
19
39
|
"type": "git",
|
|
20
40
|
"url": "git+https://github.com/amir-gorji/mcpose.git",
|
|
21
41
|
"directory": "packages/audit"
|
|
22
42
|
},
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"ts:ci": "tsc --noEmit",
|
|
26
|
-
"test": "vitest run --passWithNoTests",
|
|
27
|
-
"prepublishOnly": "pnpm run ts:ci && pnpm run test && pnpm run build"
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
28
45
|
},
|
|
29
46
|
"peerDependencies": {
|
|
30
|
-
"mcpose": ">=
|
|
47
|
+
"mcpose": ">=3.0.0 <4"
|
|
31
48
|
},
|
|
32
49
|
"devDependencies": {
|
|
33
|
-
"@
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
50
|
+
"@stryker-mutator/core": "10.0.0",
|
|
51
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
52
|
+
"@types/node": "^26.6.5",
|
|
53
|
+
"@vitest/coverage-v8": "5.0.4",
|
|
54
|
+
"fast-check": "4.10.2",
|
|
55
|
+
"typescript": "6.0.3",
|
|
56
|
+
"vitest": "5.0.4",
|
|
57
|
+
"mcpose": "3.1.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.build.json",
|
|
61
|
+
"benchmark:merkle": "node ./bench/merkle-proofs.mjs",
|
|
62
|
+
"ts:ci": "tsc --noEmit",
|
|
63
|
+
"test": "vitest run --coverage",
|
|
64
|
+
"mutation": "stryker run"
|
|
37
65
|
}
|
|
38
|
-
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
computeMerkleProof,
|
|
4
|
+
computeMerkleRoot,
|
|
5
|
+
computeMerkleRootAndProofs,
|
|
6
|
+
verifyMerkleProof,
|
|
7
|
+
} from '../chain.js';
|
|
8
|
+
|
|
9
|
+
describe('batched Merkle root and proofs', () => {
|
|
10
|
+
it('matches the existing root and individual proofs for empty, odd, and even trees', () => {
|
|
11
|
+
for (let size = 0; size <= 17; size++) {
|
|
12
|
+
const hashes = Array.from(
|
|
13
|
+
{ length: size },
|
|
14
|
+
(_, index) => `hash-${index}`,
|
|
15
|
+
);
|
|
16
|
+
const { merkleRoot, merkleProofs } = computeMerkleRootAndProofs(hashes);
|
|
17
|
+
|
|
18
|
+
expect(merkleRoot).toBe(computeMerkleRoot(hashes));
|
|
19
|
+
expect(merkleProofs).toEqual(
|
|
20
|
+
hashes.map((_, index) => computeMerkleProof(hashes, index)),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < hashes.length; index++) {
|
|
24
|
+
expect(
|
|
25
|
+
verifyMerkleProof(hashes[index]!, merkleProofs[index]!, merkleRoot),
|
|
26
|
+
).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
canonicalJson,
|
|
5
|
+
stableStringify,
|
|
6
|
+
computeMerkleRoot,
|
|
7
|
+
computeMerkleProof,
|
|
8
|
+
verifyMerkleProof,
|
|
9
|
+
sha256hex,
|
|
10
|
+
} from '../chain.js';
|
|
11
|
+
import { verifyAuditChain, verifyManifestSignature } from '../verify.js';
|
|
12
|
+
import { createAuditMiddleware } from '../middleware.js';
|
|
13
|
+
import { createDefaultSigningKeyProvider } from '../signingKey.js';
|
|
14
|
+
import { createSensitivityResolver } from '../sensitivity.js';
|
|
15
|
+
import { createInMemorySubjectKeyStore } from '../subjectKeyStore.js';
|
|
16
|
+
import type { AuditEvent } from '../types.js';
|
|
17
|
+
import { createProxyContext } from 'mcpose';
|
|
18
|
+
import type { Identity } from 'mcpose';
|
|
19
|
+
|
|
20
|
+
const identity: Identity = {
|
|
21
|
+
sub: 'user-1',
|
|
22
|
+
type: 'human',
|
|
23
|
+
roles: ['analyst'],
|
|
24
|
+
claims: {},
|
|
25
|
+
resolvedAt: '2026-06-01T00:00:00.000Z',
|
|
26
|
+
source: 'jwt',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('canonicalJson', () => {
|
|
30
|
+
it('is independent of object key insertion order at every depth', () => {
|
|
31
|
+
expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe(
|
|
32
|
+
canonicalJson({ a: { c: 3, d: 2 }, b: 1 }),
|
|
33
|
+
);
|
|
34
|
+
expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('skips undefined-valued keys and keeps array order', () => {
|
|
38
|
+
expect(canonicalJson({ a: undefined, b: [2, 1] })).toBe('{"b":[2,1]}');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws on circular references and BigInt', () => {
|
|
42
|
+
const circular: Record<string, unknown> = {};
|
|
43
|
+
circular.self = circular;
|
|
44
|
+
expect(() => canonicalJson(circular)).toThrow(TypeError);
|
|
45
|
+
expect(() => canonicalJson({ n: 1n })).toThrow(TypeError);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('stableStringify', () => {
|
|
50
|
+
it('never throws: circular becomes "[Circular]", BigInt becomes a string', () => {
|
|
51
|
+
const circular: Record<string, unknown> = { a: 1 };
|
|
52
|
+
circular.self = circular;
|
|
53
|
+
expect(stableStringify(circular)).toBe('{"a":1,"self":"[Circular]"}');
|
|
54
|
+
expect(stableStringify({ n: 123n })).toBe('{"n":"123"}');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hashes semantically identical payloads identically regardless of key order', () => {
|
|
58
|
+
expect(sha256hex(stableStringify({ x: 1, y: 2 }))).toBe(
|
|
59
|
+
sha256hex(stableStringify({ y: 2, x: 1 })),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('Merkle tree (v2, domain-separated)', () => {
|
|
65
|
+
const leaves = ['aa', 'bb', 'cc'];
|
|
66
|
+
|
|
67
|
+
it('every proof verifies against the root', () => {
|
|
68
|
+
const root = computeMerkleRoot(leaves);
|
|
69
|
+
for (let i = 0; i < leaves.length; i++) {
|
|
70
|
+
const proof = computeMerkleProof(leaves, i);
|
|
71
|
+
expect(verifyMerkleProof(leaves[i]!, proof, root)).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('single leaf root is the tagged leaf hash, not the raw value', () => {
|
|
76
|
+
const root = computeMerkleRoot(['aa']);
|
|
77
|
+
expect(root).not.toBe('aa');
|
|
78
|
+
expect(
|
|
79
|
+
verifyMerkleProof('aa', { index: 0, siblings: [], directions: [] }, root),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('an internal node cannot be replayed as a leaf', () => {
|
|
84
|
+
const root = computeMerkleRoot(leaves);
|
|
85
|
+
const proof = computeMerkleProof(leaves, 0);
|
|
86
|
+
// Try to verify the level-1 sibling (an internal node in disguise)
|
|
87
|
+
// as if it were a leaf under a shortened proof.
|
|
88
|
+
const forged = {
|
|
89
|
+
index: 1,
|
|
90
|
+
siblings: proof.siblings.slice(1),
|
|
91
|
+
directions: proof.directions.slice(1),
|
|
92
|
+
};
|
|
93
|
+
expect(verifyMerkleProof(proof.siblings[0]!, forged, root)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('computeMerkleProof bounds-checks the index', () => {
|
|
97
|
+
expect(() => computeMerkleProof(leaves, -1)).toThrow(RangeError);
|
|
98
|
+
expect(() => computeMerkleProof(leaves, 3)).toThrow(RangeError);
|
|
99
|
+
expect(() => computeMerkleProof(leaves, 1.5)).toThrow(RangeError);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects proofs with mismatched siblings/directions lengths', () => {
|
|
103
|
+
const root = computeMerkleRoot(leaves);
|
|
104
|
+
const proof = computeMerkleProof(leaves, 0);
|
|
105
|
+
expect(
|
|
106
|
+
verifyMerkleProof(
|
|
107
|
+
leaves[0]!,
|
|
108
|
+
{ ...proof, directions: proof.directions.slice(1) },
|
|
109
|
+
root,
|
|
110
|
+
),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects proofs whose directions contradict the index', () => {
|
|
115
|
+
const root = computeMerkleRoot(leaves);
|
|
116
|
+
const proof = computeMerkleProof(leaves, 0);
|
|
117
|
+
const flipped = {
|
|
118
|
+
...proof,
|
|
119
|
+
directions: proof.directions.map((d) =>
|
|
120
|
+
d === 'left' ? 'right' : 'left',
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
expect(verifyMerkleProof(leaves[0]!, flipped, root)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects an index the proof path cannot represent (#175)', () => {
|
|
127
|
+
// One leaf: no siblings, so only index 0 is a position in the tree.
|
|
128
|
+
const one = computeMerkleRoot(['aa']);
|
|
129
|
+
const empty = { siblings: [], directions: [] };
|
|
130
|
+
expect(verifyMerkleProof('aa', { ...empty, index: 0 }, one)).toBe(true);
|
|
131
|
+
for (const index of [1, 2, 7, Number.MAX_SAFE_INTEGER]) {
|
|
132
|
+
expect(verifyMerkleProof('aa', { ...empty, index }, one)).toBe(false);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Two leaves: one sibling level, so index must be 0 or 1. Higher even
|
|
136
|
+
// indices share every direction bit with 0 and used to verify.
|
|
137
|
+
const two = computeMerkleRoot(['aa', 'bb']);
|
|
138
|
+
const proof = computeMerkleProof(['aa', 'bb'], 0);
|
|
139
|
+
expect(verifyMerkleProof('aa', proof, two)).toBe(true);
|
|
140
|
+
for (const index of [2, 4, 6, 1024]) {
|
|
141
|
+
expect(verifyMerkleProof('aa', { ...proof, index }, two)).toBe(false);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Odd tree: the padded last leaf still verifies only at its own index.
|
|
145
|
+
const root = computeMerkleRoot(leaves);
|
|
146
|
+
const last = computeMerkleProof(leaves, 2);
|
|
147
|
+
expect(verifyMerkleProof(leaves[2]!, last, root)).toBe(true);
|
|
148
|
+
for (const index of [4, 6, 10]) {
|
|
149
|
+
expect(verifyMerkleProof(leaves[2]!, { ...last, index }, root)).toBe(
|
|
150
|
+
false,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects unsafe-integer indices', () => {
|
|
156
|
+
const root = computeMerkleRoot(leaves);
|
|
157
|
+
const proof = computeMerkleProof(leaves, 0);
|
|
158
|
+
for (const index of [2 ** 53, 2 ** 60, Number.MAX_VALUE]) {
|
|
159
|
+
expect(verifyMerkleProof(leaves[0]!, { ...proof, index }, root)).toBe(
|
|
160
|
+
false,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Keyed verification end-to-end ─────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async function produceSession(sessionId: string, count: number) {
|
|
169
|
+
const events: AuditEvent[] = [];
|
|
170
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
171
|
+
const { middleware, closeSession } = createAuditMiddleware({
|
|
172
|
+
signingKey,
|
|
173
|
+
sensitivityResolver: createSensitivityResolver({ search: 'low' }),
|
|
174
|
+
onEvent: (e) => {
|
|
175
|
+
events.push(e);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
// `proxy` is a required covered field, and the middleware rejects a context
|
|
179
|
+
// without one before the call runs (ADR-0019).
|
|
180
|
+
const ctx = () =>
|
|
181
|
+
createProxyContext({
|
|
182
|
+
transport: 'http',
|
|
183
|
+
identity,
|
|
184
|
+
sessionId,
|
|
185
|
+
proxy: { name: 'test-proxy', version: '0.0.0' },
|
|
186
|
+
});
|
|
187
|
+
for (let i = 0; i < count; i++) {
|
|
188
|
+
await middleware(
|
|
189
|
+
{
|
|
190
|
+
method: 'tools/call' as const,
|
|
191
|
+
params: { name: 'search', arguments: { i } },
|
|
192
|
+
},
|
|
193
|
+
async () => ({ content: [] }),
|
|
194
|
+
ctx(),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const manifest = await closeSession(sessionId);
|
|
198
|
+
return { events, manifest: manifest!, signingKey };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe('verifyAuditChain (keyed)', () => {
|
|
202
|
+
it('verifies an untampered chain', async () => {
|
|
203
|
+
const { events, signingKey } = await produceSession('s-ok', 4);
|
|
204
|
+
expect(await verifyAuditChain(events, signingKey)).toEqual({ valid: true });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rejects an empty chain', async () => {
|
|
208
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
209
|
+
const result = await verifyAuditChain([], signingKey);
|
|
210
|
+
expect(result.valid).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('reports the exact index of a tampered field', async () => {
|
|
214
|
+
const { events, signingKey } = await produceSession('s-tamper', 4);
|
|
215
|
+
const tampered = events.map((e, i) =>
|
|
216
|
+
i === 2 ? { ...e, duration_ms: e.duration_ms + 1 } : e,
|
|
217
|
+
);
|
|
218
|
+
const result = await verifyAuditChain(tampered, signingKey);
|
|
219
|
+
expect(result).toEqual({
|
|
220
|
+
valid: false,
|
|
221
|
+
index: 2,
|
|
222
|
+
reason: 'chainHash mismatch',
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('detects a renumbered suffix (delete + renumber)', async () => {
|
|
227
|
+
const { events, signingKey } = await produceSession('s-renumber', 4);
|
|
228
|
+
// Delete event 1 and renumber the survivors — the keyless position
|
|
229
|
+
// check would pass; the keyed recompute must not.
|
|
230
|
+
const doctored = [events[0]!, ...events.slice(2)].map((e, i) => ({
|
|
231
|
+
...e,
|
|
232
|
+
replayManifestPosition: i,
|
|
233
|
+
}));
|
|
234
|
+
const result = await verifyAuditChain(doctored, signingKey);
|
|
235
|
+
expect(result.valid).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects a chain produced under a different secret', async () => {
|
|
239
|
+
const { events } = await produceSession('s-wrongkey', 2);
|
|
240
|
+
const otherKey = createDefaultSigningKeyProvider('other-secret');
|
|
241
|
+
const result = await verifyAuditChain(events, otherKey);
|
|
242
|
+
expect(result.valid).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Node's hex decoder drops a trailing odd nibble and stops at the first
|
|
246
|
+
// non-hex character, so these respellings decode to the stored bytes. The
|
|
247
|
+
// verifier authenticates the stored string, not just its decoding (#281).
|
|
248
|
+
it.each([
|
|
249
|
+
['an appended nibble', (h: string) => h + '0'],
|
|
250
|
+
['an appended non-hex character', (h: string) => h + 'z'],
|
|
251
|
+
['an uppercase respelling', (h: string) => h.toUpperCase()],
|
|
252
|
+
])('rejects a chainHash with %s', async (_label, respell) => {
|
|
253
|
+
const { events, signingKey } = await produceSession('s-respell', 3);
|
|
254
|
+
const respelled = events.map((e, i) =>
|
|
255
|
+
i === 1 ? { ...e, chainHash: respell(e.chainHash) } : e,
|
|
256
|
+
);
|
|
257
|
+
expect(await verifyAuditChain(respelled, signingKey)).toEqual({
|
|
258
|
+
valid: false,
|
|
259
|
+
index: 1,
|
|
260
|
+
reason: 'chainHash mismatch',
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('sensitivityTier is covered by the chain (ADR-0015)', () => {
|
|
266
|
+
/** Index 0 is a mapped low tool; index 1 is unmapped, so it fails closed to high. */
|
|
267
|
+
async function produceMixedSession(sessionId: string) {
|
|
268
|
+
const events: AuditEvent[] = [];
|
|
269
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
270
|
+
const { middleware } = createAuditMiddleware({
|
|
271
|
+
signingKey,
|
|
272
|
+
sensitivityResolver: createSensitivityResolver({ search: 'low' }),
|
|
273
|
+
onEvent: (e) => {
|
|
274
|
+
events.push(e);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
for (const name of ['search', 'wire-transfer']) {
|
|
278
|
+
await middleware(
|
|
279
|
+
{ method: 'tools/call' as const, params: { name, arguments: {} } },
|
|
280
|
+
async () => ({ content: [] }),
|
|
281
|
+
createProxyContext({
|
|
282
|
+
transport: 'http',
|
|
283
|
+
identity,
|
|
284
|
+
sessionId,
|
|
285
|
+
proxy: { name: 'test-proxy', version: '0.0.0' },
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return { events, signingKey };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
it('records the resolved tier and still verifies untampered', async () => {
|
|
293
|
+
const { events, signingKey } = await produceMixedSession('s-tier-ok');
|
|
294
|
+
expect(events.map((e) => e.sensitivityTier)).toEqual(['low', 'high']);
|
|
295
|
+
expect(await verifyAuditChain(events, signingKey)).toEqual({ valid: true });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it.each([
|
|
299
|
+
['a high event relabelled as low', 1, 'low'],
|
|
300
|
+
['a low event relabelled as high', 0, 'high'],
|
|
301
|
+
] as const)('detects %s at exactly that index', async (_case, i, tier) => {
|
|
302
|
+
const { events, signingKey } = await produceMixedSession('s-tier-tamper');
|
|
303
|
+
const tampered = events.map((e, k) =>
|
|
304
|
+
k === i ? ({ ...e, sensitivityTier: tier } as AuditEvent) : e,
|
|
305
|
+
);
|
|
306
|
+
expect(await verifyAuditChain(tampered, signingKey)).toEqual({
|
|
307
|
+
valid: false,
|
|
308
|
+
index: i,
|
|
309
|
+
reason: 'chainHash mismatch',
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('detects a tier dropped from a stored event entirely', async () => {
|
|
314
|
+
const { events, signingKey } = await produceMixedSession('s-tier-drop');
|
|
315
|
+
const stripped = events.map((e, k) => {
|
|
316
|
+
if (k !== 0) return e;
|
|
317
|
+
const { sensitivityTier: _dropped, ...rest } = e;
|
|
318
|
+
return rest as unknown as AuditEvent;
|
|
319
|
+
});
|
|
320
|
+
expect(await verifyAuditChain(stripped, signingKey)).toEqual({
|
|
321
|
+
valid: false,
|
|
322
|
+
index: 0,
|
|
323
|
+
reason: 'chainHash mismatch',
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('verifyManifestSignature', () => {
|
|
329
|
+
it('verifies an untampered manifest', async () => {
|
|
330
|
+
const { manifest, signingKey } = await produceSession('m-ok', 3);
|
|
331
|
+
expect(await verifyManifestSignature(manifest, signingKey)).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it.each([
|
|
335
|
+
['sessionId', { sessionId: 'someone-elses-session' }],
|
|
336
|
+
['eventCount', { eventCount: 4 }],
|
|
337
|
+
['identity', { identity: { ...identity, sub: 'attacker' } }],
|
|
338
|
+
['merkleRoot', { merkleRoot: sha256hex('forged') }],
|
|
339
|
+
])('rejects a manifest with a tampered %s', async (_field, patch) => {
|
|
340
|
+
const { manifest, signingKey } = await produceSession('m-tamper', 3);
|
|
341
|
+
const tampered = { ...manifest, ...patch };
|
|
342
|
+
expect(await verifyManifestSignature(tampered, signingKey)).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('rejects a signature from a different secret', async () => {
|
|
346
|
+
const { manifest } = await produceSession('m-wrongkey', 3);
|
|
347
|
+
const otherKey = createDefaultSigningKeyProvider('other-secret');
|
|
348
|
+
expect(await verifyManifestSignature(manifest, otherKey)).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Same decoder leniency as above: a respelled signature must not verify,
|
|
352
|
+
// or a tool that indexes by the stored string disagrees with this one (#281).
|
|
353
|
+
it.each([
|
|
354
|
+
['an appended nibble', (h: string) => h + '0'],
|
|
355
|
+
['an appended non-hex character', (h: string) => h + 'z'],
|
|
356
|
+
['two appended non-hex characters', (h: string) => h + 'zz'],
|
|
357
|
+
['an uppercase respelling', (h: string) => h.toUpperCase()],
|
|
358
|
+
])('rejects a signature with %s', async (_label, respell) => {
|
|
359
|
+
const { manifest, signingKey } = await produceSession('m-respell', 3);
|
|
360
|
+
const respelled = { ...manifest, signature: respell(manifest.signature) };
|
|
361
|
+
expect(await verifyManifestSignature(respelled, signingKey)).toBe(false);
|
|
362
|
+
// Positive control: the untouched manifest still verifies.
|
|
363
|
+
expect(await verifyManifestSignature(manifest, signingKey)).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('domain label pinning', () => {
|
|
368
|
+
// The exact label strings are part of the on-disk format. Changing any of
|
|
369
|
+
// them is a v2 → v3 format break and must follow the ritual in
|
|
370
|
+
// .claude/skills/mcpose-audit-invariants (new ADR, version bump, new labels).
|
|
371
|
+
it('derives subkeys with the pinned v2 labels', async () => {
|
|
372
|
+
const seen: string[] = [];
|
|
373
|
+
const provider = createDefaultSigningKeyProvider('test-secret');
|
|
374
|
+
const spy = vi.fn(async (data: Buffer) => {
|
|
375
|
+
seen.push(data.toString('utf8'));
|
|
376
|
+
return provider.sign(data);
|
|
377
|
+
});
|
|
378
|
+
const { middleware } = createAuditMiddleware({
|
|
379
|
+
signingKey: { ...provider, sign: spy },
|
|
380
|
+
sensitivityResolver: createSensitivityResolver({}),
|
|
381
|
+
onEvent: () => {},
|
|
382
|
+
});
|
|
383
|
+
await middleware(
|
|
384
|
+
{ method: 'tools/call' as const, params: { name: 't', arguments: {} } },
|
|
385
|
+
async () => ({ content: [] }),
|
|
386
|
+
createProxyContext({
|
|
387
|
+
transport: 'http',
|
|
388
|
+
identity,
|
|
389
|
+
proxy: { name: 'test-proxy', version: '0.0.0' },
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
expect(seen).toContain('mcpose/v2/chain');
|
|
393
|
+
expect(seen).toContain('mcpose/v2/enc');
|
|
394
|
+
expect(seen).not.toContain('mcpose/v1/chain');
|
|
395
|
+
expect(seen).not.toContain('mcpose/v1/enc');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// The erasable-mode hash subkey is keyed by the STORED subject key, not by
|
|
399
|
+
// the signing oracle, so it never passes through the sign() spy above. Its
|
|
400
|
+
// label is pinned by recomputing a recorded hash under the literal string
|
|
401
|
+
// (ADR-0018).
|
|
402
|
+
it('derives the erasable-mode hash subkey with the pinned v2 label', async () => {
|
|
403
|
+
const events: AuditEvent[] = [];
|
|
404
|
+
const keyStore = createInMemorySubjectKeyStore();
|
|
405
|
+
const { middleware } = createAuditMiddleware({
|
|
406
|
+
signingKey: createDefaultSigningKeyProvider('test-secret'),
|
|
407
|
+
sensitivityResolver: createSensitivityResolver({}),
|
|
408
|
+
keyStore,
|
|
409
|
+
onEvent: (e) => {
|
|
410
|
+
events.push(e);
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
const args = { acct: 'pinned' };
|
|
414
|
+
await middleware(
|
|
415
|
+
{ method: 'tools/call' as const, params: { name: 't', arguments: args } },
|
|
416
|
+
async () => ({ content: [] }),
|
|
417
|
+
createProxyContext({
|
|
418
|
+
transport: 'http',
|
|
419
|
+
identity,
|
|
420
|
+
proxy: { name: 'test-proxy', version: '0.0.0' },
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const subjectKey = await keyStore.getOrCreate(identity.sub);
|
|
425
|
+
const underPinnedLabel = createHmac(
|
|
426
|
+
'sha256',
|
|
427
|
+
createHmac('sha256', subjectKey).update('mcpose/v2/hashkey').digest(),
|
|
428
|
+
)
|
|
429
|
+
.update(stableStringify(args))
|
|
430
|
+
.digest('hex');
|
|
431
|
+
expect(events[0]!.inputHash).toBe(underPinnedLabel);
|
|
432
|
+
});
|
|
433
|
+
});
|