@mcp-audit-gateway/core 0.1.0 → 0.4.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/.github/workflows/ci.yml +37 -0
- package/.well-known/agent-governance.json +47 -0
- package/.well-known/security-insights-snippet.yml +9 -0
- package/CHANGELOG.md +32 -0
- package/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +35 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +303 -8
- package/dist/attestation/audit-log.js.map +1 -1
- package/dist/attestation/checkpoint.test.d.ts +2 -0
- package/dist/attestation/checkpoint.test.d.ts.map +1 -0
- package/dist/attestation/checkpoint.test.js +870 -0
- package/dist/attestation/checkpoint.test.js.map +1 -0
- package/dist/attestation/signer.d.ts +24 -9
- package/dist/attestation/signer.d.ts.map +1 -1
- package/dist/attestation/signer.js +151 -10
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +83 -0
- package/dist/attestation/signer.test.js.map +1 -1
- package/dist/attestation/verify.d.ts +23 -2
- package/dist/attestation/verify.d.ts.map +1 -1
- package/dist/attestation/verify.js +219 -2
- package/dist/attestation/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.test.js +1 -0
- package/dist/integration.test.js.map +1 -1
- package/dist/policy/engine.d.ts +12 -1
- package/dist/policy/engine.d.ts.map +1 -1
- package/dist/policy/engine.js +26 -4
- package/dist/policy/engine.js.map +1 -1
- package/dist/policy/engine.test.js +42 -1
- package/dist/policy/engine.test.js.map +1 -1
- package/dist/proxy/gateway.d.ts +4 -0
- package/dist/proxy/gateway.d.ts.map +1 -1
- package/dist/proxy/gateway.js +9 -2
- package/dist/proxy/gateway.js.map +1 -1
- package/dist/proxy/gateway.test.js +19 -0
- package/dist/proxy/gateway.test.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.d.ts +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
- package/dist/proxy/mcp-server-adapter.js +28 -1
- package/dist/proxy/mcp-server-adapter.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.test.js +1 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/dist/wrap/proxy.test.js +2 -2
- package/dist/wrap/proxy.test.js.map +1 -1
- package/docs/BACKLOG.md +33 -0
- package/docs/SECURITY-DESIGN.md +126 -0
- package/docs/v0.4.0-patch-audit.md +115 -0
- package/package.json +1 -1
- package/src/attestation/audit-log.ts +357 -13
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +98 -0
- package/src/attestation/signer.ts +159 -19
- package/src/attestation/verify.ts +270 -4
- package/src/index.ts +1 -1
- package/src/integration.test.ts +1 -0
- package/src/policy/engine.test.ts +47 -1
- package/src/policy/engine.ts +38 -5
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +10 -1
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +56 -0
- package/src/wrap/proxy.test.ts +2 -2
- package/test/vectors/README.md +44 -0
- package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
- package/test/vectors/aps-action-ref-v1.mjs +145 -0
- package/test/vectors/canonicalization.json +764 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/generate.mjs +354 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +346 -0
- package/test/vectors/verify.py +354 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const vectors = JSON.parse(
|
|
8
|
+
readFileSync(join(__dirname, "canonicalization.json"), "utf-8"),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
function sha256Hex(input) {
|
|
12
|
+
return createHash("sha256").update(input).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function canonicalizeValue(value) {
|
|
16
|
+
if (value === null || value === undefined) return null;
|
|
17
|
+
switch (typeof value) {
|
|
18
|
+
case "string":
|
|
19
|
+
for (let i = 0; i < value.length; i++) {
|
|
20
|
+
const code = value.charCodeAt(i);
|
|
21
|
+
if (code >= 0xD800 && code <= 0xDBFF) {
|
|
22
|
+
const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
|
|
23
|
+
if (next < 0xDC00 || next > 0xDFFF)
|
|
24
|
+
throw new Error(`unpaired surrogate at index ${i}`);
|
|
25
|
+
i++;
|
|
26
|
+
} else if (code >= 0xDC00 && code <= 0xDFFF) {
|
|
27
|
+
throw new Error(`unpaired surrogate at index ${i}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
case "boolean":
|
|
32
|
+
return value;
|
|
33
|
+
case "number":
|
|
34
|
+
if (!Number.isSafeInteger(value)) throw new Error(`unsafe number ${value}`);
|
|
35
|
+
return value;
|
|
36
|
+
case "object": {
|
|
37
|
+
if (Array.isArray(value)) return ["L", value.map(canonicalizeValue)];
|
|
38
|
+
const keys = Object.keys(value).sort().filter((k) => value[k] !== undefined);
|
|
39
|
+
return ["M", keys.map((k) => [k, canonicalizeValue(value[k])])];
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`unsupported type ${typeof value}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function canonicalizeFromRecord(record, fieldOrder) {
|
|
47
|
+
const ordered = fieldOrder.map((key) => [key, record[key] ?? null]);
|
|
48
|
+
let insertAt = 11;
|
|
49
|
+
if (record.decisionContextDigest != null) {
|
|
50
|
+
ordered.splice(10, 0, ["decisionContextDigest", record.decisionContextDigest]);
|
|
51
|
+
insertAt = 12;
|
|
52
|
+
}
|
|
53
|
+
if (record.extensionsDigest != null) {
|
|
54
|
+
ordered.splice(insertAt, 0, ["extensionsDigest", record.extensionsDigest]);
|
|
55
|
+
insertAt++;
|
|
56
|
+
}
|
|
57
|
+
if (record.aiInvocation != null) {
|
|
58
|
+
ordered.splice(insertAt, 0, ["aiInvocation", canonicalizeValue(record.aiInvocation)]);
|
|
59
|
+
insertAt++;
|
|
60
|
+
}
|
|
61
|
+
if (record.parties != null) {
|
|
62
|
+
ordered.splice(insertAt, 0, ["parties", record.parties]);
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify(ordered);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let passed = 0;
|
|
68
|
+
let failed = 0;
|
|
69
|
+
|
|
70
|
+
console.log(`Format version: ${vectors.format_version}`);
|
|
71
|
+
console.log(`Encoding: ${vectors.encoding}`);
|
|
72
|
+
console.log(`Hash: ${vectors.hash_algorithm} (${vectors.hash_output})`);
|
|
73
|
+
console.log();
|
|
74
|
+
|
|
75
|
+
// --- Canonicalization vectors ---
|
|
76
|
+
console.log("=== Canonicalization Vectors ===\n");
|
|
77
|
+
|
|
78
|
+
for (const v of vectors.canonicalization) {
|
|
79
|
+
const canonical = canonicalizeFromRecord(v.record, vectors.field_order);
|
|
80
|
+
const hash = sha256Hex(canonical);
|
|
81
|
+
|
|
82
|
+
const canonMatch = canonical === v.canonical;
|
|
83
|
+
const hashMatch = hash === v.sha256_canonical;
|
|
84
|
+
|
|
85
|
+
if (canonMatch && hashMatch) {
|
|
86
|
+
console.log(` PASS: ${v.name}`);
|
|
87
|
+
passed++;
|
|
88
|
+
} else {
|
|
89
|
+
console.log(` FAIL: ${v.name}`);
|
|
90
|
+
if (!canonMatch) {
|
|
91
|
+
console.log(` canonical expected: ${v.canonical}`);
|
|
92
|
+
console.log(` canonical got: ${canonical}`);
|
|
93
|
+
}
|
|
94
|
+
if (!hashMatch) {
|
|
95
|
+
console.log(` hash expected: ${v.sha256_canonical}`);
|
|
96
|
+
console.log(` hash got: ${hash}`);
|
|
97
|
+
}
|
|
98
|
+
failed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Chain vectors ---
|
|
103
|
+
console.log("\n=== Chain Vectors ===\n");
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < vectors.chain.records.length; i++) {
|
|
106
|
+
const entry = vectors.chain.records[i];
|
|
107
|
+
const record = entry.record;
|
|
108
|
+
|
|
109
|
+
// Verify canonical form
|
|
110
|
+
const canonical = canonicalizeFromRecord(record, vectors.field_order);
|
|
111
|
+
const canonHash = sha256Hex(canonical);
|
|
112
|
+
const canonMatch = canonical === entry.canonical;
|
|
113
|
+
const canonHashMatch = canonHash === entry.sha256_canonical;
|
|
114
|
+
|
|
115
|
+
if (canonMatch && canonHashMatch) {
|
|
116
|
+
console.log(` PASS: chain[${i}] canonical (${record.toolName})`);
|
|
117
|
+
passed++;
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` FAIL: chain[${i}] canonical (${record.toolName})`);
|
|
120
|
+
if (!canonMatch) console.log(` canonical mismatch`);
|
|
121
|
+
if (!canonHashMatch) console.log(` canonical hash mismatch`);
|
|
122
|
+
failed++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Verify chain hash via full_record_json
|
|
126
|
+
const refHash = sha256Hex(entry.full_record_json);
|
|
127
|
+
if (refHash === entry.record_hash) {
|
|
128
|
+
console.log(` PASS: chain[${i}] record_hash (${record.toolName})`);
|
|
129
|
+
passed++;
|
|
130
|
+
} else {
|
|
131
|
+
console.log(` FAIL: chain[${i}] record_hash (${record.toolName})`);
|
|
132
|
+
console.log(` expected: ${entry.record_hash}`);
|
|
133
|
+
console.log(` got: ${refHash}`);
|
|
134
|
+
failed++;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Verify native JSON.stringify produces same hash
|
|
138
|
+
const nativeJson = JSON.stringify(record);
|
|
139
|
+
const nativeHash = sha256Hex(nativeJson);
|
|
140
|
+
if (nativeHash === entry.record_hash) {
|
|
141
|
+
console.log(` PASS: chain[${i}] native stringify match (${record.toolName})`);
|
|
142
|
+
passed++;
|
|
143
|
+
} else {
|
|
144
|
+
console.log(` FAIL: chain[${i}] native stringify diverges (${record.toolName})`);
|
|
145
|
+
console.log(` reference: ${entry.full_record_json}`);
|
|
146
|
+
console.log(` native: ${nativeJson}`);
|
|
147
|
+
failed++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Verify linkage
|
|
151
|
+
let linkageOk;
|
|
152
|
+
if (i === 0) {
|
|
153
|
+
linkageOk = record.previousHash === vectors.chain.genesis_seed;
|
|
154
|
+
} else {
|
|
155
|
+
const prev = vectors.chain.records[i - 1];
|
|
156
|
+
linkageOk =
|
|
157
|
+
record.previousHash === prev.record_hash &&
|
|
158
|
+
entry.previous_record_hash === prev.record_hash;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (linkageOk) {
|
|
162
|
+
console.log(` PASS: chain[${i}] linkage (${record.toolName})`);
|
|
163
|
+
passed++;
|
|
164
|
+
} else {
|
|
165
|
+
console.log(` FAIL: chain[${i}] linkage (${record.toolName})`);
|
|
166
|
+
failed++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Dual-hash demonstration ---
|
|
171
|
+
console.log("\n=== Dual-Hash Demonstration ===\n");
|
|
172
|
+
|
|
173
|
+
const demo = vectors.dual_hash_demo;
|
|
174
|
+
|
|
175
|
+
const canonA = canonicalizeFromRecord(demo.record_a.record, vectors.field_order);
|
|
176
|
+
const canonB = canonicalizeFromRecord(demo.record_b.record, vectors.field_order);
|
|
177
|
+
const hashA = sha256Hex(canonA);
|
|
178
|
+
const hashB = sha256Hex(canonB);
|
|
179
|
+
|
|
180
|
+
if (hashA === hashB && hashA === demo.record_a.sha256_canonical) {
|
|
181
|
+
console.log(" PASS: canonical hashes match (attestation excluded)");
|
|
182
|
+
passed++;
|
|
183
|
+
} else {
|
|
184
|
+
console.log(" FAIL: canonical hashes should match");
|
|
185
|
+
failed++;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const chainA = sha256Hex(demo.record_a.full_record_json);
|
|
189
|
+
const chainB = sha256Hex(demo.record_b.full_record_json);
|
|
190
|
+
|
|
191
|
+
if (chainA !== chainB && chainA === demo.record_a.record_hash && chainB === demo.record_b.record_hash) {
|
|
192
|
+
console.log(" PASS: chain hashes differ (attestation included)");
|
|
193
|
+
passed++;
|
|
194
|
+
} else {
|
|
195
|
+
console.log(" FAIL: chain hashes should differ");
|
|
196
|
+
failed++;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (demo.assertions.canonical_hashes_match === true) {
|
|
200
|
+
console.log(" PASS: assertions.canonical_hashes_match");
|
|
201
|
+
passed++;
|
|
202
|
+
} else {
|
|
203
|
+
console.log(" FAIL: assertions.canonical_hashes_match should be true");
|
|
204
|
+
failed++;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (demo.assertions.chain_hashes_differ === true) {
|
|
208
|
+
console.log(" PASS: assertions.chain_hashes_differ");
|
|
209
|
+
passed++;
|
|
210
|
+
} else {
|
|
211
|
+
console.log(" FAIL: assertions.chain_hashes_differ should be true");
|
|
212
|
+
failed++;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- Party Attribution vectors ---
|
|
216
|
+
if (vectors.party_attribution) {
|
|
217
|
+
console.log("\n=== Party Attribution Vectors ===\n");
|
|
218
|
+
|
|
219
|
+
for (const v of vectors.party_attribution.vectors) {
|
|
220
|
+
const canonical = canonicalizeFromRecord(v.record, vectors.field_order);
|
|
221
|
+
const hash = sha256Hex(canonical);
|
|
222
|
+
|
|
223
|
+
const canonMatch = canonical === v.canonical;
|
|
224
|
+
const hashMatch = hash === v.sha256_canonical;
|
|
225
|
+
|
|
226
|
+
if (canonMatch && hashMatch) {
|
|
227
|
+
console.log(` PASS: ${v.name}`);
|
|
228
|
+
passed++;
|
|
229
|
+
} else {
|
|
230
|
+
console.log(` FAIL: ${v.name}`);
|
|
231
|
+
if (!canonMatch) {
|
|
232
|
+
console.log(` canonical expected: ${v.canonical}`);
|
|
233
|
+
console.log(` canonical got: ${canonical}`);
|
|
234
|
+
}
|
|
235
|
+
if (!hashMatch) {
|
|
236
|
+
console.log(` hash expected: ${v.sha256_canonical}`);
|
|
237
|
+
console.log(` hash got: ${hash}`);
|
|
238
|
+
}
|
|
239
|
+
failed++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Chain with Parties ---
|
|
245
|
+
if (vectors.party_attribution?.chain_with_parties) {
|
|
246
|
+
console.log("\n=== Chain with Parties ===\n");
|
|
247
|
+
|
|
248
|
+
const cwp = vectors.party_attribution.chain_with_parties;
|
|
249
|
+
for (let i = 0; i < cwp.records.length; i++) {
|
|
250
|
+
const entry = cwp.records[i];
|
|
251
|
+
const record = entry.record;
|
|
252
|
+
|
|
253
|
+
const canonical = canonicalizeFromRecord(record, vectors.field_order);
|
|
254
|
+
const canonHash = sha256Hex(canonical);
|
|
255
|
+
if (canonical === entry.canonical && canonHash === entry.sha256_canonical) {
|
|
256
|
+
console.log(` PASS: chain_with_parties[${i}] canonical (${record.toolName})`);
|
|
257
|
+
passed++;
|
|
258
|
+
} else {
|
|
259
|
+
console.log(` FAIL: chain_with_parties[${i}] canonical (${record.toolName})`);
|
|
260
|
+
failed++;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const refHash = sha256Hex(entry.full_record_json);
|
|
264
|
+
if (refHash === entry.record_hash) {
|
|
265
|
+
console.log(` PASS: chain_with_parties[${i}] record_hash (${record.toolName})`);
|
|
266
|
+
passed++;
|
|
267
|
+
} else {
|
|
268
|
+
console.log(` FAIL: chain_with_parties[${i}] record_hash (${record.toolName})`);
|
|
269
|
+
console.log(` expected: ${entry.record_hash}`);
|
|
270
|
+
console.log(` got: ${refHash}`);
|
|
271
|
+
failed++;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let linkageOk;
|
|
275
|
+
if (i === 0) {
|
|
276
|
+
linkageOk = record.previousHash === cwp.genesis_seed;
|
|
277
|
+
} else {
|
|
278
|
+
const prev = cwp.records[i - 1];
|
|
279
|
+
linkageOk =
|
|
280
|
+
record.previousHash === prev.record_hash &&
|
|
281
|
+
entry.previous_record_hash === prev.record_hash;
|
|
282
|
+
}
|
|
283
|
+
if (linkageOk) {
|
|
284
|
+
console.log(` PASS: chain_with_parties[${i}] linkage (${record.toolName})`);
|
|
285
|
+
passed++;
|
|
286
|
+
} else {
|
|
287
|
+
console.log(` FAIL: chain_with_parties[${i}] linkage (${record.toolName})`);
|
|
288
|
+
failed++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Scope Order Significance ---
|
|
294
|
+
if (vectors.party_attribution) {
|
|
295
|
+
const paVecs = vectors.party_attribution.vectors;
|
|
296
|
+
const origVec = paVecs.find((v) => v.name === "scope_order_original");
|
|
297
|
+
const sortVec = paVecs.find((v) => v.name === "scope_order_sorted");
|
|
298
|
+
if (origVec && sortVec) {
|
|
299
|
+
console.log("\n=== Scope Order Significance ===\n");
|
|
300
|
+
const hOrig = sha256Hex(canonicalizeFromRecord(origVec.record, vectors.field_order));
|
|
301
|
+
const hSort = sha256Hex(canonicalizeFromRecord(sortVec.record, vectors.field_order));
|
|
302
|
+
if (hOrig !== hSort) {
|
|
303
|
+
console.log(" PASS: different scope order produces different hash");
|
|
304
|
+
passed++;
|
|
305
|
+
} else {
|
|
306
|
+
console.log(" FAIL: scope order should produce different hashes");
|
|
307
|
+
failed++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
// --- aiInvocation signing vectors ---
|
|
314
|
+
if (vectors.ai_invocation_signing) {
|
|
315
|
+
console.log("\n=== aiInvocation Signing ===\n");
|
|
316
|
+
for (const v of vectors.ai_invocation_signing.vectors) {
|
|
317
|
+
const canonical = canonicalizeFromRecord(v.record, vectors.field_order);
|
|
318
|
+
if (canonical === v.canonical) { console.log(` PASS: ${v.name} canonical form`); passed++; }
|
|
319
|
+
else { console.log(` FAIL: ${v.name} canonical form`); failed++; }
|
|
320
|
+
if (sha256Hex(canonical) === v.sha256_canonical) { console.log(` PASS: ${v.name} digest`); passed++; }
|
|
321
|
+
else { console.log(` FAIL: ${v.name} digest`); failed++; }
|
|
322
|
+
}
|
|
323
|
+
const mn = vectors.ai_invocation_signing.mutation_negative;
|
|
324
|
+
const hOrig = sha256Hex(canonicalizeFromRecord(mn.original.record, vectors.field_order));
|
|
325
|
+
const hMut = sha256Hex(canonicalizeFromRecord(mn.mutated.record, vectors.field_order));
|
|
326
|
+
if (hOrig === mn.original.sha256_canonical && hMut === mn.mutated.sha256_canonical) {
|
|
327
|
+
console.log(" PASS: mutation pair digests reproduce"); passed++;
|
|
328
|
+
} else { console.log(" FAIL: mutation pair digests reproduce"); failed++; }
|
|
329
|
+
if (hOrig !== hMut) { console.log(" PASS: mutated aiInvocation changes signing digest"); passed++; }
|
|
330
|
+
else { console.log(" FAIL: mutated aiInvocation must change signing digest"); failed++; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- extensionsDigest base-suite vectors ---
|
|
334
|
+
if (vectors.extensions_digest_base) {
|
|
335
|
+
console.log("\n=== extensionsDigest (base suite) ===\n");
|
|
336
|
+
for (const v of vectors.extensions_digest_base.vectors) {
|
|
337
|
+
const canonical = canonicalizeFromRecord(v.record, vectors.field_order);
|
|
338
|
+
if (canonical === v.canonical) { console.log(` PASS: ${v.name} canonical form`); passed++; }
|
|
339
|
+
else { console.log(` FAIL: ${v.name} canonical form`); failed++; }
|
|
340
|
+
if (sha256Hex(canonical) === v.sha256_canonical) { console.log(` PASS: ${v.name} digest`); passed++; }
|
|
341
|
+
else { console.log(` FAIL: ${v.name} digest`); failed++; }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
346
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cross-language verification of mcp-audit-gateway conformance vectors.
|
|
4
|
+
|
|
5
|
+
Proves the tuple-array canonical form is reproducible outside the
|
|
6
|
+
original JavaScript implementation. Chain hashes are verified against
|
|
7
|
+
the full_record_json reference string (avoiding insertion-order issues).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
vectors_path = Path(__file__).parent / "canonicalization.json"
|
|
16
|
+
vectors = json.loads(vectors_path.read_text())
|
|
17
|
+
|
|
18
|
+
FIELD_ORDER = vectors["field_order"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def assert_well_formed(value: str) -> None:
|
|
22
|
+
for i, ch in enumerate(value):
|
|
23
|
+
if 0xD800 <= ord(ch) <= 0xDFFF:
|
|
24
|
+
raise ValueError(f"unpaired surrogate at index {i}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonicalize_value(value):
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
assert_well_formed(value)
|
|
32
|
+
return value
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, int):
|
|
36
|
+
if abs(value) > 2**53 - 1:
|
|
37
|
+
raise ValueError(f"unsafe number {value}")
|
|
38
|
+
return value
|
|
39
|
+
if isinstance(value, float):
|
|
40
|
+
raise ValueError(f"unsafe number {value}")
|
|
41
|
+
if isinstance(value, list):
|
|
42
|
+
return ["L", [canonicalize_value(v) for v in value]]
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
keys = sorted(value.keys(), key=lambda k: k.encode("utf-16-be"))
|
|
45
|
+
for k in keys:
|
|
46
|
+
assert_well_formed(k)
|
|
47
|
+
return ["M", [[k, canonicalize_value(value[k])] for k in keys]]
|
|
48
|
+
raise ValueError(f"unsupported type {type(value)}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonicalize(record: dict) -> str:
|
|
52
|
+
"""Reproduce the tuple-array canonical form in Python."""
|
|
53
|
+
ordered = []
|
|
54
|
+
for key in FIELD_ORDER:
|
|
55
|
+
value = record.get(key)
|
|
56
|
+
ordered.append([key, value])
|
|
57
|
+
insert_at = 11
|
|
58
|
+
if record.get("decisionContextDigest") is not None:
|
|
59
|
+
ordered.insert(10, ["decisionContextDigest", record["decisionContextDigest"]])
|
|
60
|
+
insert_at = 12
|
|
61
|
+
if record.get("extensionsDigest") is not None:
|
|
62
|
+
ordered.insert(insert_at, ["extensionsDigest", record["extensionsDigest"]])
|
|
63
|
+
insert_at += 1
|
|
64
|
+
if record.get("aiInvocation") is not None:
|
|
65
|
+
ordered.insert(insert_at, ["aiInvocation", canonicalize_value(record["aiInvocation"])])
|
|
66
|
+
insert_at += 1
|
|
67
|
+
if record.get("parties") is not None:
|
|
68
|
+
ordered.insert(insert_at, ["parties", record["parties"]])
|
|
69
|
+
return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sha256_hex(data: str) -> str:
|
|
73
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
passed = 0
|
|
77
|
+
failed = 0
|
|
78
|
+
|
|
79
|
+
print(f"Format version: {vectors['format_version']}")
|
|
80
|
+
print(f"Encoding: {vectors['encoding']}")
|
|
81
|
+
print(f"Hash: {vectors['hash_algorithm']} ({vectors['hash_output']})")
|
|
82
|
+
print(f"Null rule: {vectors['null_rule']}")
|
|
83
|
+
print()
|
|
84
|
+
|
|
85
|
+
# --- Canonicalization vectors ---
|
|
86
|
+
print("=== Canonicalization Vectors ===\n")
|
|
87
|
+
|
|
88
|
+
for v in vectors["canonicalization"]:
|
|
89
|
+
canonical = canonicalize(v["record"])
|
|
90
|
+
h = sha256_hex(canonical)
|
|
91
|
+
|
|
92
|
+
canon_match = canonical == v["canonical"]
|
|
93
|
+
hash_match = h == v["sha256_canonical"]
|
|
94
|
+
|
|
95
|
+
if canon_match and hash_match:
|
|
96
|
+
print(f" PASS: {v['name']}")
|
|
97
|
+
passed += 1
|
|
98
|
+
else:
|
|
99
|
+
print(f" FAIL: {v['name']}")
|
|
100
|
+
if not canon_match:
|
|
101
|
+
print(f" canonical expected: {v['canonical']}")
|
|
102
|
+
print(f" canonical got: {canonical}")
|
|
103
|
+
if not hash_match:
|
|
104
|
+
print(f" hash expected: {v['sha256_canonical']}")
|
|
105
|
+
print(f" hash got: {h}")
|
|
106
|
+
failed += 1
|
|
107
|
+
|
|
108
|
+
# --- Chain vectors ---
|
|
109
|
+
print("\n=== Chain Vectors ===\n")
|
|
110
|
+
|
|
111
|
+
for i, entry in enumerate(vectors["chain"]["records"]):
|
|
112
|
+
record = entry["record"]
|
|
113
|
+
|
|
114
|
+
# Verify canonical form (cross-language safe)
|
|
115
|
+
canonical = canonicalize(record)
|
|
116
|
+
canon_hash = sha256_hex(canonical)
|
|
117
|
+
canon_match = canonical == entry["canonical"]
|
|
118
|
+
canon_hash_match = canon_hash == entry["sha256_canonical"]
|
|
119
|
+
|
|
120
|
+
if canon_match and canon_hash_match:
|
|
121
|
+
print(f" PASS: chain[{i}] canonical ({record['toolName']})")
|
|
122
|
+
passed += 1
|
|
123
|
+
else:
|
|
124
|
+
print(f" FAIL: chain[{i}] canonical ({record['toolName']})")
|
|
125
|
+
if not canon_match:
|
|
126
|
+
print(f" canonical mismatch")
|
|
127
|
+
if not canon_hash_match:
|
|
128
|
+
print(f" canonical hash mismatch")
|
|
129
|
+
failed += 1
|
|
130
|
+
|
|
131
|
+
# Verify chain hash against full_record_json reference
|
|
132
|
+
reference_json = entry["full_record_json"]
|
|
133
|
+
reference_hash = sha256_hex(reference_json)
|
|
134
|
+
chain_match = reference_hash == entry["record_hash"]
|
|
135
|
+
|
|
136
|
+
if chain_match:
|
|
137
|
+
print(f" PASS: chain[{i}] record_hash via reference JSON ({record['toolName']})")
|
|
138
|
+
passed += 1
|
|
139
|
+
else:
|
|
140
|
+
print(f" FAIL: chain[{i}] record_hash via reference JSON")
|
|
141
|
+
print(f" expected: {entry['record_hash']}")
|
|
142
|
+
print(f" got: {reference_hash}")
|
|
143
|
+
failed += 1
|
|
144
|
+
|
|
145
|
+
# Verify chain linkage
|
|
146
|
+
if i == 0:
|
|
147
|
+
linkage_ok = record["previousHash"] == vectors["chain"]["genesis_seed"]
|
|
148
|
+
else:
|
|
149
|
+
prev = vectors["chain"]["records"][i - 1]
|
|
150
|
+
linkage_ok = (
|
|
151
|
+
record["previousHash"] == prev["record_hash"]
|
|
152
|
+
and entry.get("previous_record_hash") == prev["record_hash"]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if linkage_ok:
|
|
156
|
+
print(f" PASS: chain[{i}] linkage ({record['toolName']})")
|
|
157
|
+
passed += 1
|
|
158
|
+
else:
|
|
159
|
+
print(f" FAIL: chain[{i}] linkage ({record['toolName']})")
|
|
160
|
+
failed += 1
|
|
161
|
+
|
|
162
|
+
# --- Dual-hash demonstration ---
|
|
163
|
+
print("\n=== Dual-Hash Demonstration ===\n")
|
|
164
|
+
|
|
165
|
+
demo = vectors["dual_hash_demo"]
|
|
166
|
+
a = demo["record_a"]
|
|
167
|
+
b = demo["record_b"]
|
|
168
|
+
|
|
169
|
+
# Canonical hashes should match (attestation excluded)
|
|
170
|
+
canon_a = canonicalize(a["record"])
|
|
171
|
+
canon_b = canonicalize(b["record"])
|
|
172
|
+
hash_a = sha256_hex(canon_a)
|
|
173
|
+
hash_b = sha256_hex(canon_b)
|
|
174
|
+
|
|
175
|
+
if hash_a == hash_b and hash_a == a["sha256_canonical"] and hash_b == b["sha256_canonical"]:
|
|
176
|
+
print(" PASS: canonical hashes match (attestation excluded from signing)")
|
|
177
|
+
passed += 1
|
|
178
|
+
else:
|
|
179
|
+
print(" FAIL: canonical hashes should match")
|
|
180
|
+
print(f" a: {hash_a}")
|
|
181
|
+
print(f" b: {hash_b}")
|
|
182
|
+
failed += 1
|
|
183
|
+
|
|
184
|
+
# Chain hashes should differ (attestation included)
|
|
185
|
+
chain_a = sha256_hex(a["full_record_json"])
|
|
186
|
+
chain_b = sha256_hex(b["full_record_json"])
|
|
187
|
+
|
|
188
|
+
if chain_a != chain_b and chain_a == a["record_hash"] and chain_b == b["record_hash"]:
|
|
189
|
+
print(" PASS: chain hashes differ (attestation included in chain)")
|
|
190
|
+
passed += 1
|
|
191
|
+
else:
|
|
192
|
+
print(" FAIL: chain hashes should differ")
|
|
193
|
+
print(f" a: {chain_a}")
|
|
194
|
+
print(f" b: {chain_b}")
|
|
195
|
+
failed += 1
|
|
196
|
+
|
|
197
|
+
# Verify assertions field
|
|
198
|
+
if demo["assertions"]["canonical_hashes_match"] is True:
|
|
199
|
+
print(" PASS: assertions.canonical_hashes_match confirmed")
|
|
200
|
+
passed += 1
|
|
201
|
+
else:
|
|
202
|
+
print(" FAIL: assertions.canonical_hashes_match should be true")
|
|
203
|
+
failed += 1
|
|
204
|
+
|
|
205
|
+
if demo["assertions"]["chain_hashes_differ"] is True:
|
|
206
|
+
print(" PASS: assertions.chain_hashes_differ confirmed")
|
|
207
|
+
passed += 1
|
|
208
|
+
else:
|
|
209
|
+
print(" FAIL: assertions.chain_hashes_differ should be true")
|
|
210
|
+
failed += 1
|
|
211
|
+
|
|
212
|
+
# --- Party Attribution vectors ---
|
|
213
|
+
if "party_attribution" in vectors:
|
|
214
|
+
print("\n=== Party Attribution Vectors ===\n")
|
|
215
|
+
|
|
216
|
+
for v in vectors["party_attribution"]["vectors"]:
|
|
217
|
+
canonical = canonicalize(v["record"])
|
|
218
|
+
h = sha256_hex(canonical)
|
|
219
|
+
|
|
220
|
+
canon_match = canonical == v["canonical"]
|
|
221
|
+
hash_match = h == v["sha256_canonical"]
|
|
222
|
+
|
|
223
|
+
if canon_match and hash_match:
|
|
224
|
+
print(f" PASS: {v['name']}")
|
|
225
|
+
passed += 1
|
|
226
|
+
else:
|
|
227
|
+
print(f" FAIL: {v['name']}")
|
|
228
|
+
if not canon_match:
|
|
229
|
+
print(f" canonical expected: {v['canonical']}")
|
|
230
|
+
print(f" canonical got: {canonical}")
|
|
231
|
+
if not hash_match:
|
|
232
|
+
print(f" hash expected: {v['sha256_canonical']}")
|
|
233
|
+
print(f" hash got: {h}")
|
|
234
|
+
failed += 1
|
|
235
|
+
|
|
236
|
+
# --- Chain with Parties vectors ---
|
|
237
|
+
if "party_attribution" in vectors and "chain_with_parties" in vectors["party_attribution"]:
|
|
238
|
+
print("\n=== Chain with Parties ===\n")
|
|
239
|
+
|
|
240
|
+
cwp = vectors["party_attribution"]["chain_with_parties"]
|
|
241
|
+
for i, entry in enumerate(cwp["records"]):
|
|
242
|
+
record = entry["record"]
|
|
243
|
+
|
|
244
|
+
canonical = canonicalize(record)
|
|
245
|
+
canon_hash = sha256_hex(canonical)
|
|
246
|
+
canon_match = canonical == entry["canonical"]
|
|
247
|
+
canon_hash_match = canon_hash == entry["sha256_canonical"]
|
|
248
|
+
|
|
249
|
+
if canon_match and canon_hash_match:
|
|
250
|
+
print(f" PASS: chain_with_parties[{i}] canonical ({record['toolName']})")
|
|
251
|
+
passed += 1
|
|
252
|
+
else:
|
|
253
|
+
print(f" FAIL: chain_with_parties[{i}] canonical ({record['toolName']})")
|
|
254
|
+
if not canon_match:
|
|
255
|
+
print(f" canonical mismatch")
|
|
256
|
+
if not canon_hash_match:
|
|
257
|
+
print(f" canonical hash mismatch")
|
|
258
|
+
failed += 1
|
|
259
|
+
|
|
260
|
+
reference_json = entry["full_record_json"]
|
|
261
|
+
reference_hash = sha256_hex(reference_json)
|
|
262
|
+
chain_match = reference_hash == entry["record_hash"]
|
|
263
|
+
|
|
264
|
+
if chain_match:
|
|
265
|
+
print(f" PASS: chain_with_parties[{i}] record_hash ({record['toolName']})")
|
|
266
|
+
passed += 1
|
|
267
|
+
else:
|
|
268
|
+
print(f" FAIL: chain_with_parties[{i}] record_hash ({record['toolName']})")
|
|
269
|
+
print(f" expected: {entry['record_hash']}")
|
|
270
|
+
print(f" got: {reference_hash}")
|
|
271
|
+
failed += 1
|
|
272
|
+
|
|
273
|
+
if i == 0:
|
|
274
|
+
linkage_ok = record["previousHash"] == cwp["genesis_seed"]
|
|
275
|
+
else:
|
|
276
|
+
prev = cwp["records"][i - 1]
|
|
277
|
+
linkage_ok = (
|
|
278
|
+
record["previousHash"] == prev["record_hash"]
|
|
279
|
+
and entry.get("previous_record_hash") == prev["record_hash"]
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if linkage_ok:
|
|
283
|
+
print(f" PASS: chain_with_parties[{i}] linkage ({record['toolName']})")
|
|
284
|
+
passed += 1
|
|
285
|
+
else:
|
|
286
|
+
print(f" FAIL: chain_with_parties[{i}] linkage ({record['toolName']})")
|
|
287
|
+
failed += 1
|
|
288
|
+
|
|
289
|
+
# --- Scope Order Significance ---
|
|
290
|
+
if "party_attribution" in vectors:
|
|
291
|
+
pa_vectors = vectors["party_attribution"]["vectors"]
|
|
292
|
+
scope_orig = next((v for v in pa_vectors if v["name"] == "scope_order_original"), None)
|
|
293
|
+
scope_sort = next((v for v in pa_vectors if v["name"] == "scope_order_sorted"), None)
|
|
294
|
+
if scope_orig and scope_sort:
|
|
295
|
+
print("\n=== Scope Order Significance ===\n")
|
|
296
|
+
h_orig = sha256_hex(canonicalize(scope_orig["record"]))
|
|
297
|
+
h_sort = sha256_hex(canonicalize(scope_sort["record"]))
|
|
298
|
+
if h_orig != h_sort:
|
|
299
|
+
print(" PASS: different scope order produces different hash")
|
|
300
|
+
passed += 1
|
|
301
|
+
else:
|
|
302
|
+
print(" FAIL: scope order should produce different hashes")
|
|
303
|
+
failed += 1
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- aiInvocation signing vectors ---
|
|
307
|
+
if vectors.get("ai_invocation_signing"):
|
|
308
|
+
print("\n=== aiInvocation Signing ===\n")
|
|
309
|
+
for v in vectors["ai_invocation_signing"]["vectors"]:
|
|
310
|
+
canonical = canonicalize(v["record"])
|
|
311
|
+
if canonical == v["canonical"]:
|
|
312
|
+
print(f" PASS: {v['name']} canonical form"); passed += 1
|
|
313
|
+
else:
|
|
314
|
+
print(f" FAIL: {v['name']} canonical form"); failed += 1
|
|
315
|
+
if sha256_hex(canonical) == v["sha256_canonical"]:
|
|
316
|
+
print(f" PASS: {v['name']} digest"); passed += 1
|
|
317
|
+
else:
|
|
318
|
+
print(f" FAIL: {v['name']} digest"); failed += 1
|
|
319
|
+
mn = vectors["ai_invocation_signing"]["mutation_negative"]
|
|
320
|
+
h_orig = sha256_hex(canonicalize(mn["original"]["record"]))
|
|
321
|
+
h_mut = sha256_hex(canonicalize(mn["mutated"]["record"]))
|
|
322
|
+
if h_orig == mn["original"]["sha256_canonical"] and h_mut == mn["mutated"]["sha256_canonical"]:
|
|
323
|
+
print(" PASS: mutation pair digests reproduce"); passed += 1
|
|
324
|
+
else:
|
|
325
|
+
print(" FAIL: mutation pair digests reproduce"); failed += 1
|
|
326
|
+
if h_orig != h_mut:
|
|
327
|
+
print(" PASS: mutated aiInvocation changes signing digest"); passed += 1
|
|
328
|
+
else:
|
|
329
|
+
print(" FAIL: mutated aiInvocation must change signing digest"); failed += 1
|
|
330
|
+
|
|
331
|
+
# --- extensionsDigest base-suite vectors ---
|
|
332
|
+
if vectors.get("extensions_digest_base"):
|
|
333
|
+
print("\n=== extensionsDigest (base suite) ===\n")
|
|
334
|
+
for v in vectors["extensions_digest_base"]["vectors"]:
|
|
335
|
+
canonical = canonicalize(v["record"])
|
|
336
|
+
if canonical == v["canonical"]:
|
|
337
|
+
print(f" PASS: {v['name']} canonical form"); passed += 1
|
|
338
|
+
else:
|
|
339
|
+
print(f" FAIL: {v['name']} canonical form"); failed += 1
|
|
340
|
+
if sha256_hex(canonical) == v["sha256_canonical"]:
|
|
341
|
+
print(f" PASS: {v['name']} digest"); passed += 1
|
|
342
|
+
else:
|
|
343
|
+
print(f" FAIL: {v['name']} digest"); failed += 1
|
|
344
|
+
|
|
345
|
+
# --- Summary ---
|
|
346
|
+
print(f"\n=== Results: {passed} passed, {failed} failed ===")
|
|
347
|
+
|
|
348
|
+
if failed > 0:
|
|
349
|
+
sys.exit(1)
|
|
350
|
+
else:
|
|
351
|
+
print("\nAll vectors verified cross-language (Python).")
|
|
352
|
+
print("Canonical form: tuple-array, cross-language safe.")
|
|
353
|
+
print("Chain hash: verified via full_record_json reference string.")
|
|
354
|
+
sys.exit(0)
|