@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,354 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { writeFileSync } 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
|
+
|
|
8
|
+
// Exact replica of canonicalizeRecord from src/attestation/signer.ts
|
|
9
|
+
function canonicalizeRecord(record) {
|
|
10
|
+
const ordered = [
|
|
11
|
+
["id", record.id],
|
|
12
|
+
["timestamp", record.timestamp],
|
|
13
|
+
["method", record.method],
|
|
14
|
+
["toolName", record.toolName ?? null],
|
|
15
|
+
["namespace", record.namespace ?? null],
|
|
16
|
+
["upstream", record.upstream ?? null],
|
|
17
|
+
["principal", record.principal ?? null],
|
|
18
|
+
["durationMs", record.durationMs],
|
|
19
|
+
["success", record.success],
|
|
20
|
+
["errorCode", record.errorCode ?? null],
|
|
21
|
+
["previousHash", record.previousHash ?? null],
|
|
22
|
+
];
|
|
23
|
+
return JSON.stringify(ordered);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Exact replica of hashRecord from src/attestation/audit-log.ts
|
|
27
|
+
function hashRecord(record) {
|
|
28
|
+
const json = JSON.stringify(record);
|
|
29
|
+
return { hash: sha256Hex(json), json };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sha256Hex(input) {
|
|
33
|
+
return createHash("sha256").update(input).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Canonicalization test vectors ---
|
|
37
|
+
|
|
38
|
+
const vectorDefs = [
|
|
39
|
+
{
|
|
40
|
+
name: "genesis_all_fields",
|
|
41
|
+
record: {
|
|
42
|
+
id: "550e8400-e29b-41d4-a716-446655440001",
|
|
43
|
+
timestamp: "2026-08-21T12:00:00.000Z",
|
|
44
|
+
method: "tools/call",
|
|
45
|
+
toolName: "weather_lookup",
|
|
46
|
+
namespace: "external-apis",
|
|
47
|
+
upstream: "weather-server",
|
|
48
|
+
principal: "user:alice@example.com",
|
|
49
|
+
durationMs: 142,
|
|
50
|
+
success: true,
|
|
51
|
+
previousHash: "genesis",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "genesis_null_optionals",
|
|
56
|
+
record: {
|
|
57
|
+
id: "550e8400-e29b-41d4-a716-446655440002",
|
|
58
|
+
timestamp: "2026-08-21T12:00:01.000Z",
|
|
59
|
+
method: "tools/list",
|
|
60
|
+
durationMs: 3,
|
|
61
|
+
success: true,
|
|
62
|
+
previousHash: "genesis",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "error_with_code",
|
|
67
|
+
record: {
|
|
68
|
+
id: "550e8400-e29b-41d4-a716-446655440003",
|
|
69
|
+
timestamp: "2026-08-21T12:00:02.500Z",
|
|
70
|
+
method: "tools/call",
|
|
71
|
+
toolName: "database_query",
|
|
72
|
+
namespace: "internal",
|
|
73
|
+
upstream: "db-server",
|
|
74
|
+
principal: "service:backend-agent",
|
|
75
|
+
durationMs: 5021,
|
|
76
|
+
success: false,
|
|
77
|
+
errorCode: -32603,
|
|
78
|
+
previousHash: "genesis",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "zero_duration",
|
|
83
|
+
record: {
|
|
84
|
+
id: "550e8400-e29b-41d4-a716-446655440004",
|
|
85
|
+
timestamp: "2026-08-21T12:00:03.000Z",
|
|
86
|
+
method: "tools/call",
|
|
87
|
+
toolName: "cache_get",
|
|
88
|
+
namespace: "infra",
|
|
89
|
+
upstream: "cache-server",
|
|
90
|
+
durationMs: 0,
|
|
91
|
+
success: true,
|
|
92
|
+
previousHash: "genesis",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "invalid_params_error",
|
|
97
|
+
record: {
|
|
98
|
+
id: "550e8400-e29b-41d4-a716-446655440005",
|
|
99
|
+
timestamp: "2026-08-21T12:00:04.000Z",
|
|
100
|
+
method: "tools/call",
|
|
101
|
+
toolName: "send_email",
|
|
102
|
+
namespace: "comms",
|
|
103
|
+
upstream: "email-server",
|
|
104
|
+
principal: "user:bob@example.com",
|
|
105
|
+
durationMs: 12,
|
|
106
|
+
success: false,
|
|
107
|
+
errorCode: -32602,
|
|
108
|
+
previousHash: "genesis",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "unicode_in_fields",
|
|
113
|
+
record: {
|
|
114
|
+
id: "550e8400-e29b-41d4-a716-446655440006",
|
|
115
|
+
timestamp: "2026-08-21T12:00:05.000Z",
|
|
116
|
+
method: "tools/call",
|
|
117
|
+
toolName: "天気検索",
|
|
118
|
+
namespace: "external-apis",
|
|
119
|
+
upstream: "weather-server-⚡",
|
|
120
|
+
principal: "user:tanaka.太郎@example.jp",
|
|
121
|
+
durationMs: 88,
|
|
122
|
+
success: true,
|
|
123
|
+
previousHash: "genesis",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "max_safe_integer_duration",
|
|
128
|
+
record: {
|
|
129
|
+
id: "550e8400-e29b-41d4-a716-446655440007",
|
|
130
|
+
timestamp: "2026-08-21T12:00:06.000Z",
|
|
131
|
+
method: "tools/call",
|
|
132
|
+
toolName: "long_running_task",
|
|
133
|
+
namespace: "compute",
|
|
134
|
+
upstream: "batch-server",
|
|
135
|
+
principal: "service:scheduler",
|
|
136
|
+
durationMs: 9007199254740991,
|
|
137
|
+
success: true,
|
|
138
|
+
previousHash: "genesis",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "empty_string_tool_name",
|
|
143
|
+
record: {
|
|
144
|
+
id: "550e8400-e29b-41d4-a716-446655440008",
|
|
145
|
+
timestamp: "2026-08-21T12:00:07.000Z",
|
|
146
|
+
method: "tools/call",
|
|
147
|
+
toolName: "",
|
|
148
|
+
namespace: "",
|
|
149
|
+
upstream: "legacy-server",
|
|
150
|
+
principal: "user:admin",
|
|
151
|
+
durationMs: 1,
|
|
152
|
+
success: true,
|
|
153
|
+
previousHash: "genesis",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const canonicalizationVectors = vectorDefs.map(({ name, record }) => {
|
|
159
|
+
const canonical = canonicalizeRecord(record);
|
|
160
|
+
return {
|
|
161
|
+
name,
|
|
162
|
+
record,
|
|
163
|
+
canonical,
|
|
164
|
+
sha256_canonical: sha256Hex(canonical),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// --- Chain vectors: 3 linked records ---
|
|
169
|
+
|
|
170
|
+
const chain1 = {
|
|
171
|
+
id: "660e8400-e29b-41d4-a716-446655440001",
|
|
172
|
+
timestamp: "2026-08-21T13:00:00.000Z",
|
|
173
|
+
method: "tools/call",
|
|
174
|
+
toolName: "search",
|
|
175
|
+
namespace: "research",
|
|
176
|
+
upstream: "search-server",
|
|
177
|
+
principal: "user:carol@example.com",
|
|
178
|
+
durationMs: 230,
|
|
179
|
+
success: true,
|
|
180
|
+
previousHash: "genesis",
|
|
181
|
+
attestation: "abc123deadbeef",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const { hash: chain1_hash, json: chain1_json } = hashRecord(chain1);
|
|
185
|
+
|
|
186
|
+
const chain2 = {
|
|
187
|
+
id: "660e8400-e29b-41d4-a716-446655440002",
|
|
188
|
+
timestamp: "2026-08-21T13:00:01.500Z",
|
|
189
|
+
method: "tools/call",
|
|
190
|
+
toolName: "summarize",
|
|
191
|
+
namespace: "research",
|
|
192
|
+
upstream: "llm-server",
|
|
193
|
+
principal: "user:carol@example.com",
|
|
194
|
+
durationMs: 1840,
|
|
195
|
+
success: true,
|
|
196
|
+
previousHash: chain1_hash,
|
|
197
|
+
attestation: "def456cafebabe",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const { hash: chain2_hash, json: chain2_json } = hashRecord(chain2);
|
|
201
|
+
|
|
202
|
+
const chain3 = {
|
|
203
|
+
id: "660e8400-e29b-41d4-a716-446655440003",
|
|
204
|
+
timestamp: "2026-08-21T13:00:05.000Z",
|
|
205
|
+
method: "tools/call",
|
|
206
|
+
toolName: "store_result",
|
|
207
|
+
namespace: "research",
|
|
208
|
+
upstream: "storage-server",
|
|
209
|
+
principal: "user:carol@example.com",
|
|
210
|
+
durationMs: 45,
|
|
211
|
+
success: true,
|
|
212
|
+
previousHash: chain2_hash,
|
|
213
|
+
attestation: "789abcfeedface",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const { hash: chain3_hash, json: chain3_json } = hashRecord(chain3);
|
|
217
|
+
|
|
218
|
+
const chainVectors = {
|
|
219
|
+
description:
|
|
220
|
+
"Three linked records demonstrating hash chain verification. The chain hash covers the FULL record including the attestation field. This means the attestation (signature) is bound into the chain sequence.",
|
|
221
|
+
chain_algorithm: "sha256(JSON.stringify(fullRecord))",
|
|
222
|
+
chain_key_order: [
|
|
223
|
+
"id",
|
|
224
|
+
"timestamp",
|
|
225
|
+
"method",
|
|
226
|
+
"toolName",
|
|
227
|
+
"namespace",
|
|
228
|
+
"upstream",
|
|
229
|
+
"principal",
|
|
230
|
+
"durationMs",
|
|
231
|
+
"success",
|
|
232
|
+
"previousHash",
|
|
233
|
+
"attestation",
|
|
234
|
+
],
|
|
235
|
+
chain_key_order_note:
|
|
236
|
+
"The chain hash uses JSON.stringify which preserves JavaScript insertion order. Verifiers in languages without ordered maps MUST serialize keys in this exact order to reproduce chain hashes. The canonical form (tuple-array) does NOT have this limitation.",
|
|
237
|
+
genesis_seed: "genesis",
|
|
238
|
+
records: [
|
|
239
|
+
{
|
|
240
|
+
record: chain1,
|
|
241
|
+
full_record_json: chain1_json,
|
|
242
|
+
record_hash: chain1_hash,
|
|
243
|
+
canonical: canonicalizeRecord(chain1),
|
|
244
|
+
sha256_canonical: sha256Hex(canonicalizeRecord(chain1)),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
record: chain2,
|
|
248
|
+
full_record_json: chain2_json,
|
|
249
|
+
record_hash: chain2_hash,
|
|
250
|
+
canonical: canonicalizeRecord(chain2),
|
|
251
|
+
sha256_canonical: sha256Hex(canonicalizeRecord(chain2)),
|
|
252
|
+
previous_record_hash: chain1_hash,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
record: chain3,
|
|
256
|
+
full_record_json: chain3_json,
|
|
257
|
+
record_hash: chain3_hash,
|
|
258
|
+
canonical: canonicalizeRecord(chain3),
|
|
259
|
+
sha256_canonical: sha256Hex(canonicalizeRecord(chain3)),
|
|
260
|
+
previous_record_hash: chain2_hash,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// --- Dual-hash demonstration vector ---
|
|
266
|
+
// Same logical fields, different attestation → same canonical hash, different chain hash
|
|
267
|
+
|
|
268
|
+
const dualBase = {
|
|
269
|
+
id: "770e8400-e29b-41d4-a716-446655440001",
|
|
270
|
+
timestamp: "2026-08-21T14:00:00.000Z",
|
|
271
|
+
method: "tools/call",
|
|
272
|
+
toolName: "verify_identity",
|
|
273
|
+
namespace: "auth",
|
|
274
|
+
upstream: "identity-server",
|
|
275
|
+
principal: "user:dave@example.com",
|
|
276
|
+
durationMs: 55,
|
|
277
|
+
success: true,
|
|
278
|
+
previousHash: "genesis",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const dualA = { ...dualBase, attestation: "sig_aaa111" };
|
|
282
|
+
const dualB = { ...dualBase, attestation: "sig_bbb222" };
|
|
283
|
+
|
|
284
|
+
const dualHashDemo = {
|
|
285
|
+
description:
|
|
286
|
+
"Two records with identical auditable fields but different attestation values. They produce the SAME canonical hash (attestation is excluded from signing) but DIFFERENT chain hashes (attestation is included in chain). This demonstrates the dual-hash design.",
|
|
287
|
+
record_a: {
|
|
288
|
+
record: dualA,
|
|
289
|
+
canonical: canonicalizeRecord(dualA),
|
|
290
|
+
sha256_canonical: sha256Hex(canonicalizeRecord(dualA)),
|
|
291
|
+
full_record_json: JSON.stringify(dualA),
|
|
292
|
+
record_hash: sha256Hex(JSON.stringify(dualA)),
|
|
293
|
+
},
|
|
294
|
+
record_b: {
|
|
295
|
+
record: dualB,
|
|
296
|
+
canonical: canonicalizeRecord(dualB),
|
|
297
|
+
sha256_canonical: sha256Hex(canonicalizeRecord(dualB)),
|
|
298
|
+
full_record_json: JSON.stringify(dualB),
|
|
299
|
+
record_hash: sha256Hex(JSON.stringify(dualB)),
|
|
300
|
+
},
|
|
301
|
+
assertions: {
|
|
302
|
+
canonical_hashes_match:
|
|
303
|
+
sha256Hex(canonicalizeRecord(dualA)) ===
|
|
304
|
+
sha256Hex(canonicalizeRecord(dualB)),
|
|
305
|
+
chain_hashes_differ:
|
|
306
|
+
sha256Hex(JSON.stringify(dualA)) !== sha256Hex(JSON.stringify(dualB)),
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// --- Assemble output ---
|
|
311
|
+
|
|
312
|
+
const output = {
|
|
313
|
+
format_version: "1.1.0",
|
|
314
|
+
implementation: "mcp-audit-gateway",
|
|
315
|
+
canonical_form: "tuple-array",
|
|
316
|
+
description:
|
|
317
|
+
"Conformance vectors for mcp-audit-gateway's tuple-array canonicalization and SHA-256 hash chain.",
|
|
318
|
+
encoding: "utf-8",
|
|
319
|
+
hash_algorithm: "sha256",
|
|
320
|
+
hash_output: "hex-lowercase",
|
|
321
|
+
null_rule:
|
|
322
|
+
"Fields listed in field_order that are absent or undefined in the source record MUST be serialized as JSON null in the tuple-array canonical form.",
|
|
323
|
+
signing_exclusions: ["attestation"],
|
|
324
|
+
signing_note:
|
|
325
|
+
"The attestation field is excluded from the canonical form used for signing. It cannot be included because the signature IS the attestation value (including it would be circular). The canonical form contains exactly the 11 fields listed in field_order.",
|
|
326
|
+
chain_hash_note:
|
|
327
|
+
"The chain hash (record_hash) uses JSON.stringify(fullRecord) which INCLUDES the attestation field. This binds the signature into the chain sequence, preventing attestation stripping without breaking the chain. The chain hash depends on JavaScript insertion order; verifiers must serialize keys in the order specified by chain.chain_key_order.",
|
|
328
|
+
field_order: [
|
|
329
|
+
"id",
|
|
330
|
+
"timestamp",
|
|
331
|
+
"method",
|
|
332
|
+
"toolName",
|
|
333
|
+
"namespace",
|
|
334
|
+
"upstream",
|
|
335
|
+
"principal",
|
|
336
|
+
"durationMs",
|
|
337
|
+
"success",
|
|
338
|
+
"errorCode",
|
|
339
|
+
"previousHash",
|
|
340
|
+
],
|
|
341
|
+
canonicalization: canonicalizationVectors,
|
|
342
|
+
chain: chainVectors,
|
|
343
|
+
dual_hash_demo: dualHashDemo,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
writeFileSync(
|
|
347
|
+
join(__dirname, "canonicalization.json"),
|
|
348
|
+
JSON.stringify(output, null, 2) + "\n",
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
console.log("Generated test/vectors/canonicalization.json");
|
|
352
|
+
console.log(` ${canonicalizationVectors.length} canonicalization vectors`);
|
|
353
|
+
console.log(` ${chainVectors.records.length} chain vectors`);
|
|
354
|
+
console.log(` 1 dual-hash demonstration`);
|
|
@@ -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);
|