@mcp-audit-gateway/core 0.2.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/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +34 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +286 -10
- 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 +145 -11
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +11 -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/integration.test.js +1 -0
- package/dist/integration.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 +4 -1
- 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 +74 -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 +336 -15
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +14 -0
- package/src/attestation/signer.ts +152 -19
- package/src/attestation/verify.ts +270 -4
- package/src/integration.test.ts +1 -0
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +4 -0
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +48 -0
- package/src/wrap/proxy.test.ts +2 -2
- 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 +182 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +74 -1
- package/test/vectors/verify.py +78 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Checkpoint conformance vector verifier (JavaScript).
|
|
4
|
+
* Verifies that checkpoint canonicalization, chain hashing, and truncation
|
|
5
|
+
* detection produce byte-identical results across implementations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const vectors = JSON.parse(await readFile(join(__dirname, "checkpoint.json"), "utf-8"));
|
|
15
|
+
|
|
16
|
+
let passed = 0;
|
|
17
|
+
let failed = 0;
|
|
18
|
+
|
|
19
|
+
function sha256(input) {
|
|
20
|
+
return createHash("sha256").update(input).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canonicalizeCheckpoint(record) {
|
|
24
|
+
const ordered = [
|
|
25
|
+
["id", record.id],
|
|
26
|
+
["type", "checkpoint"],
|
|
27
|
+
["timestamp", record.timestamp],
|
|
28
|
+
["sequence", record.sequence],
|
|
29
|
+
["recordCount", record.recordCount],
|
|
30
|
+
["previousHash", record.previousHash],
|
|
31
|
+
];
|
|
32
|
+
if (record.parties != null) {
|
|
33
|
+
ordered.push(["parties", record.parties]);
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(ordered);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hashRecord(record) {
|
|
39
|
+
return createHash("sha256").update(JSON.stringify(record)).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assert(condition, name, detail) {
|
|
43
|
+
if (condition) {
|
|
44
|
+
passed++;
|
|
45
|
+
console.log(` PASS: ${name}`);
|
|
46
|
+
} else {
|
|
47
|
+
failed++;
|
|
48
|
+
console.log(` FAIL: ${name}`);
|
|
49
|
+
if (detail) console.log(` ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Checkpoint Canonicalization ---
|
|
54
|
+
console.log("\n=== Checkpoint Canonicalization ===");
|
|
55
|
+
for (const vec of vectors.checkpoint_canonicalization) {
|
|
56
|
+
const canonical = canonicalizeCheckpoint(vec.record);
|
|
57
|
+
assert(
|
|
58
|
+
canonical === vec.canonical,
|
|
59
|
+
`${vec.name} canonical form`,
|
|
60
|
+
`expected: ${vec.canonical}\n got: ${canonical}`
|
|
61
|
+
);
|
|
62
|
+
const hash = sha256(canonical);
|
|
63
|
+
assert(
|
|
64
|
+
hash === vec.sha256_canonical,
|
|
65
|
+
`${vec.name} SHA-256`,
|
|
66
|
+
`expected: ${vec.sha256_canonical}\n got: ${hash}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Checkpoint Chain ---
|
|
71
|
+
console.log("\n=== Checkpoint Chain ===");
|
|
72
|
+
const chainRecords = vectors.checkpoint_chain.records;
|
|
73
|
+
for (let i = 0; i < chainRecords.length; i++) {
|
|
74
|
+
const entry = chainRecords[i];
|
|
75
|
+
const computedHash = hashRecord(entry.record);
|
|
76
|
+
assert(
|
|
77
|
+
computedHash === entry.record_hash,
|
|
78
|
+
`chain record ${i} hash`,
|
|
79
|
+
`expected: ${entry.record_hash}\n got: ${computedHash}`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (i > 0) {
|
|
83
|
+
assert(
|
|
84
|
+
entry.record.previousHash === chainRecords[i - 1].record_hash,
|
|
85
|
+
`chain record ${i} previousHash links to record ${i - 1}`,
|
|
86
|
+
`expected: ${chainRecords[i - 1].record_hash}\n got: ${entry.record.previousHash}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Truncation Detection ---
|
|
92
|
+
console.log("\n=== Truncation Detection ===");
|
|
93
|
+
const truncVec = vectors.truncation_detection;
|
|
94
|
+
const extCkpt = truncVec.external_checkpoint;
|
|
95
|
+
|
|
96
|
+
// Full chain should contain the checkpoint
|
|
97
|
+
const fullChain = chainRecords.map(e => e.record);
|
|
98
|
+
let foundInFull = false;
|
|
99
|
+
for (const rec of fullChain) {
|
|
100
|
+
if (rec.type === "checkpoint" &&
|
|
101
|
+
rec.previousHash === extCkpt.previousHash &&
|
|
102
|
+
rec.sequence === extCkpt.sequence &&
|
|
103
|
+
rec.recordCount === extCkpt.recordCount) {
|
|
104
|
+
foundInFull = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
assert(foundInFull, "full chain contains externalized checkpoint");
|
|
108
|
+
|
|
109
|
+
// Truncated chain should NOT contain the checkpoint
|
|
110
|
+
const truncatedChain = truncVec.truncated_chain.records_delivered;
|
|
111
|
+
let foundInTruncated = false;
|
|
112
|
+
let hasDescendant = false;
|
|
113
|
+
for (const rec of truncatedChain) {
|
|
114
|
+
if (rec.type === "checkpoint") {
|
|
115
|
+
if (rec.previousHash === extCkpt.previousHash &&
|
|
116
|
+
rec.sequence === extCkpt.sequence &&
|
|
117
|
+
rec.recordCount === extCkpt.recordCount) {
|
|
118
|
+
foundInTruncated = true;
|
|
119
|
+
}
|
|
120
|
+
if (rec.sequence > extCkpt.sequence) {
|
|
121
|
+
hasDescendant = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
assert(!foundInTruncated && !hasDescendant, "truncated chain missing checkpoint (truncation detected)");
|
|
126
|
+
|
|
127
|
+
// --- canonicalizeValue ---
|
|
128
|
+
console.log("\n=== canonicalizeValue ===");
|
|
129
|
+
|
|
130
|
+
function canonicalizeValue(value) {
|
|
131
|
+
if (value === null || value === undefined) return null;
|
|
132
|
+
switch (typeof value) {
|
|
133
|
+
case "string":
|
|
134
|
+
for (let i = 0; i < value.length; i++) {
|
|
135
|
+
const code = value.charCodeAt(i);
|
|
136
|
+
if (code >= 0xD800 && code <= 0xDBFF) {
|
|
137
|
+
const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
|
|
138
|
+
if (next < 0xDC00 || next > 0xDFFF)
|
|
139
|
+
throw new Error(`unpaired surrogate at index ${i}`);
|
|
140
|
+
i++;
|
|
141
|
+
} else if (code >= 0xDC00 && code <= 0xDFFF) {
|
|
142
|
+
throw new Error(`unpaired surrogate at index ${i}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
case "boolean":
|
|
147
|
+
return value;
|
|
148
|
+
case "number":
|
|
149
|
+
if (!Number.isSafeInteger(value))
|
|
150
|
+
throw new Error(`unsafe number ${value}`);
|
|
151
|
+
return value;
|
|
152
|
+
case "object":
|
|
153
|
+
if (Array.isArray(value)) return ["L", value.map(canonicalizeValue)];
|
|
154
|
+
const keys = Object.keys(value).sort().filter(k => value[k] !== undefined);
|
|
155
|
+
return ["M", keys.map(k => [k, canonicalizeValue(value[k])])];
|
|
156
|
+
default:
|
|
157
|
+
throw new Error(`unsupported type ${typeof value}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function computeExtensionsDigest(extensions) {
|
|
162
|
+
const canonicalized = canonicalizeValue(extensions);
|
|
163
|
+
const canonical = JSON.stringify(canonicalized);
|
|
164
|
+
return { canonical, digest: sha256(canonical) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const cvVectors = vectors.canonicalize_value.vectors;
|
|
168
|
+
for (const vec of cvVectors) {
|
|
169
|
+
if (vec.expected_error) {
|
|
170
|
+
if (vec.construct) {
|
|
171
|
+
// Programmatic construction (e.g. lone surrogate can't be in JSON)
|
|
172
|
+
let threw = false;
|
|
173
|
+
try { canonicalizeValue(String.fromCharCode(0xD800)); } catch { threw = true; }
|
|
174
|
+
assert(threw, `${vec.name} throws on invalid input`);
|
|
175
|
+
} else {
|
|
176
|
+
let threw = false;
|
|
177
|
+
try { canonicalizeValue(vec.input); } catch { threw = true; }
|
|
178
|
+
assert(threw, `${vec.name} throws on invalid input`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (vec.input_a && vec.input_b && vec.canonical_form) {
|
|
183
|
+
// Same content different order -> same digest
|
|
184
|
+
const ra = computeExtensionsDigest(vec.input_a);
|
|
185
|
+
const rb = computeExtensionsDigest(vec.input_b);
|
|
186
|
+
assert(ra.canonical === vec.canonical_form, `${vec.name} canonical form`,
|
|
187
|
+
`expected: ${vec.canonical_form}\n got: ${ra.canonical}`);
|
|
188
|
+
assert(ra.digest === vec.digest, `${vec.name} digest`, `expected: ${vec.digest}\n got: ${ra.digest}`);
|
|
189
|
+
assert(ra.digest === rb.digest, `${vec.name} both inputs produce same digest`);
|
|
190
|
+
} else if (vec.input_a && vec.input_b && vec.canonical_a) {
|
|
191
|
+
// Array order matters
|
|
192
|
+
const ra = computeExtensionsDigest(vec.input_a);
|
|
193
|
+
const rb = computeExtensionsDigest(vec.input_b);
|
|
194
|
+
assert(ra.canonical === vec.canonical_a, `${vec.name} canonical_a`);
|
|
195
|
+
assert(ra.digest === vec.digest_a, `${vec.name} digest_a`);
|
|
196
|
+
assert(rb.canonical === vec.canonical_b, `${vec.name} canonical_b`);
|
|
197
|
+
assert(rb.digest === vec.digest_b, `${vec.name} digest_b`);
|
|
198
|
+
assert(ra.digest !== rb.digest, `${vec.name} digests differ`);
|
|
199
|
+
} else if (vec.input) {
|
|
200
|
+
// Unicode keys
|
|
201
|
+
const r = computeExtensionsDigest(vec.input);
|
|
202
|
+
assert(r.canonical === vec.canonical_form, `${vec.name} canonical form`,
|
|
203
|
+
`expected: ${vec.canonical_form}\n got: ${r.canonical}`);
|
|
204
|
+
assert(r.digest === vec.digest, `${vec.name} digest`,
|
|
205
|
+
`expected: ${vec.digest}\n got: ${r.digest}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Extensions Digest ---
|
|
210
|
+
console.log("\n=== Extensions Digest ===");
|
|
211
|
+
const extVectors = vectors.extensions_digest.vectors;
|
|
212
|
+
|
|
213
|
+
for (const vec of extVectors) {
|
|
214
|
+
const { canonical, digest } = computeExtensionsDigest(vec.extensions);
|
|
215
|
+
assert(
|
|
216
|
+
canonical === vec.canonical_form,
|
|
217
|
+
`${vec.name} canonical form`,
|
|
218
|
+
`expected: ${vec.canonical_form}\n got: ${canonical}`
|
|
219
|
+
);
|
|
220
|
+
assert(
|
|
221
|
+
digest === vec.digest,
|
|
222
|
+
`${vec.name} digest`,
|
|
223
|
+
`expected: ${vec.digest}\n got: ${digest}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Record canonicalization with extensionsDigest
|
|
228
|
+
function canonicalizeRecord(record) {
|
|
229
|
+
const ordered = [
|
|
230
|
+
["id", record.id],
|
|
231
|
+
["timestamp", record.timestamp],
|
|
232
|
+
["method", record.method],
|
|
233
|
+
["toolName", record.toolName ?? null],
|
|
234
|
+
["namespace", record.namespace ?? null],
|
|
235
|
+
["upstream", record.upstream ?? null],
|
|
236
|
+
["principal", record.principal ?? null],
|
|
237
|
+
["durationMs", record.durationMs],
|
|
238
|
+
["success", record.success],
|
|
239
|
+
["errorCode", record.errorCode ?? null],
|
|
240
|
+
["previousHash", record.previousHash ?? null],
|
|
241
|
+
];
|
|
242
|
+
let insertAt = 11;
|
|
243
|
+
if (record.decisionContextDigest != null) {
|
|
244
|
+
ordered.splice(10, 0, ["decisionContextDigest", record.decisionContextDigest]);
|
|
245
|
+
insertAt = 12;
|
|
246
|
+
}
|
|
247
|
+
if (record.extensionsDigest != null) {
|
|
248
|
+
ordered.splice(insertAt, 0, ["extensionsDigest", record.extensionsDigest]);
|
|
249
|
+
insertAt++;
|
|
250
|
+
}
|
|
251
|
+
if (record.aiInvocation != null) {
|
|
252
|
+
ordered.splice(insertAt, 0, ["aiInvocation", canonicalizeValue(record.aiInvocation)]);
|
|
253
|
+
insertAt++;
|
|
254
|
+
}
|
|
255
|
+
if (record.parties != null) {
|
|
256
|
+
ordered.splice(insertAt, 0, ["parties", record.parties]);
|
|
257
|
+
}
|
|
258
|
+
return JSON.stringify(ordered);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const withExt = vectors.extensions_digest.record_canonicalization.with_extensions_digest;
|
|
262
|
+
const withExtCanonical = canonicalizeRecord(withExt.record);
|
|
263
|
+
assert(
|
|
264
|
+
withExtCanonical === withExt.canonical,
|
|
265
|
+
"record with extensionsDigest canonical form",
|
|
266
|
+
`expected: ${withExt.canonical}\n got: ${withExtCanonical}`
|
|
267
|
+
);
|
|
268
|
+
assert(
|
|
269
|
+
sha256(withExtCanonical) === withExt.sha256_canonical,
|
|
270
|
+
"record with extensionsDigest SHA-256",
|
|
271
|
+
`expected: ${withExt.sha256_canonical}\n got: ${sha256(withExtCanonical)}`
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const withoutExt = vectors.extensions_digest.record_canonicalization.without_extensions_digest;
|
|
275
|
+
const withoutExtCanonical = canonicalizeRecord(withoutExt.record);
|
|
276
|
+
assert(
|
|
277
|
+
withoutExtCanonical === withoutExt.canonical,
|
|
278
|
+
"record without extensionsDigest canonical form (backward compat)",
|
|
279
|
+
`expected: ${withoutExt.canonical}\n got: ${withoutExtCanonical}`
|
|
280
|
+
);
|
|
281
|
+
assert(
|
|
282
|
+
sha256(withoutExtCanonical) === withoutExt.sha256_canonical,
|
|
283
|
+
"record without extensionsDigest SHA-256",
|
|
284
|
+
`expected: ${withoutExt.sha256_canonical}\n got: ${sha256(withoutExtCanonical)}`
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// --- Rotation Boundary ---
|
|
288
|
+
console.log("\n=== Rotation Boundary ===");
|
|
289
|
+
const rotation = vectors.rotation_boundary;
|
|
290
|
+
|
|
291
|
+
// Verify file 1 hashes
|
|
292
|
+
for (let i = 0; i < rotation.file_1_records.length; i++) {
|
|
293
|
+
const entry = rotation.file_1_records[i];
|
|
294
|
+
const h = hashRecord(entry.record);
|
|
295
|
+
assert(h === entry.record_hash, `rotation file1 record ${i} hash`,
|
|
296
|
+
`expected: ${entry.record_hash}\n got: ${h}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Verify file 2 chains to file 1's last record
|
|
300
|
+
const file1LastHash = rotation.file_1_records[rotation.file_1_records.length - 1].record_hash;
|
|
301
|
+
const file2First = rotation.file_2_records[0];
|
|
302
|
+
assert(
|
|
303
|
+
file2First.record.previousHash === file1LastHash,
|
|
304
|
+
"rotation: file2 first record chains to file1 last hash",
|
|
305
|
+
`expected: ${file1LastHash}\n got: ${file2First.record.previousHash}`
|
|
306
|
+
);
|
|
307
|
+
const file2Hash = hashRecord(file2First.record);
|
|
308
|
+
assert(file2Hash === file2First.record_hash, "rotation file2 record 0 hash",
|
|
309
|
+
`expected: ${file2First.record_hash}\n got: ${file2Hash}`);
|
|
310
|
+
|
|
311
|
+
// --- Sequence Regression ---
|
|
312
|
+
console.log("\n=== Sequence Regression ===");
|
|
313
|
+
const seqReg = vectors.sequence_regression;
|
|
314
|
+
const checkpoints = seqReg.chain.filter(e => e.record.type === "checkpoint");
|
|
315
|
+
let regressionDetected = false;
|
|
316
|
+
for (let i = 1; i < checkpoints.length; i++) {
|
|
317
|
+
if (checkpoints[i].record.sequence <= checkpoints[i - 1].record.sequence) {
|
|
318
|
+
regressionDetected = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
assert(regressionDetected, "sequence regression detected in chain");
|
|
322
|
+
assert(
|
|
323
|
+
seqReg.detection_result.failureCode === "sequence_regression",
|
|
324
|
+
"failure code is sequence_regression"
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// --- Chain Break ---
|
|
328
|
+
console.log("\n=== Chain Break ===");
|
|
329
|
+
const chainBreak = vectors.chain_break;
|
|
330
|
+
for (let i = 0; i < chainBreak.records.length; i++) {
|
|
331
|
+
const entry = chainBreak.records[i];
|
|
332
|
+
const h = hashRecord(entry.record);
|
|
333
|
+
assert(h === entry.record_hash, `chain_break record ${i} hash`,
|
|
334
|
+
`expected: ${entry.record_hash}\n got: ${h}`);
|
|
335
|
+
}
|
|
336
|
+
// Verify chaining: second record's previousHash = first record's hash
|
|
337
|
+
assert(
|
|
338
|
+
chainBreak.records[1].record.previousHash === chainBreak.records[0].record_hash,
|
|
339
|
+
"record after chain_break chains from break record hash"
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// --- Summary ---
|
|
343
|
+
console.log(`\n=== Results: ${passed} passed, ${failed} failed (${passed + failed} total) ===`);
|
|
344
|
+
process.exit(failed > 0 ? 1 : 0);
|