@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.
Files changed (64) hide show
  1. package/.github/workflows/ci.yml +37 -0
  2. package/README.md +31 -3
  3. package/dist/attestation/audit-log.d.ts +34 -3
  4. package/dist/attestation/audit-log.d.ts.map +1 -1
  5. package/dist/attestation/audit-log.js +286 -10
  6. package/dist/attestation/audit-log.js.map +1 -1
  7. package/dist/attestation/checkpoint.test.d.ts +2 -0
  8. package/dist/attestation/checkpoint.test.d.ts.map +1 -0
  9. package/dist/attestation/checkpoint.test.js +870 -0
  10. package/dist/attestation/checkpoint.test.js.map +1 -0
  11. package/dist/attestation/signer.d.ts +24 -9
  12. package/dist/attestation/signer.d.ts.map +1 -1
  13. package/dist/attestation/signer.js +145 -11
  14. package/dist/attestation/signer.js.map +1 -1
  15. package/dist/attestation/signer.test.js +11 -0
  16. package/dist/attestation/signer.test.js.map +1 -1
  17. package/dist/attestation/verify.d.ts +23 -2
  18. package/dist/attestation/verify.d.ts.map +1 -1
  19. package/dist/attestation/verify.js +219 -2
  20. package/dist/attestation/verify.js.map +1 -1
  21. package/dist/integration.test.js +1 -0
  22. package/dist/integration.test.js.map +1 -1
  23. package/dist/proxy/gateway.d.ts +4 -0
  24. package/dist/proxy/gateway.d.ts.map +1 -1
  25. package/dist/proxy/gateway.js +4 -1
  26. package/dist/proxy/gateway.js.map +1 -1
  27. package/dist/proxy/gateway.test.js +19 -0
  28. package/dist/proxy/gateway.test.js.map +1 -1
  29. package/dist/proxy/mcp-server-adapter.d.ts +1 -0
  30. package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
  31. package/dist/proxy/mcp-server-adapter.js +28 -1
  32. package/dist/proxy/mcp-server-adapter.js.map +1 -1
  33. package/dist/proxy/mcp-server-adapter.test.js +1 -0
  34. package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
  35. package/dist/types.d.ts +74 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +13 -0
  38. package/dist/types.js.map +1 -1
  39. package/dist/wrap/proxy.test.js +2 -2
  40. package/dist/wrap/proxy.test.js.map +1 -1
  41. package/docs/BACKLOG.md +33 -0
  42. package/docs/SECURITY-DESIGN.md +126 -0
  43. package/docs/v0.4.0-patch-audit.md +115 -0
  44. package/package.json +1 -1
  45. package/src/attestation/audit-log.ts +336 -15
  46. package/src/attestation/checkpoint.test.ts +956 -0
  47. package/src/attestation/signer.test.ts +14 -0
  48. package/src/attestation/signer.ts +152 -19
  49. package/src/attestation/verify.ts +270 -4
  50. package/src/integration.test.ts +1 -0
  51. package/src/proxy/gateway.test.ts +18 -0
  52. package/src/proxy/gateway.ts +4 -0
  53. package/src/proxy/mcp-server-adapter.test.ts +1 -0
  54. package/src/proxy/mcp-server-adapter.ts +26 -0
  55. package/src/types.ts +48 -0
  56. package/src/wrap/proxy.test.ts +2 -2
  57. package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
  58. package/test/vectors/aps-action-ref-v1.mjs +145 -0
  59. package/test/vectors/canonicalization.json +182 -0
  60. package/test/vectors/checkpoint.json +450 -0
  61. package/test/vectors/verify-checkpoint.mjs +344 -0
  62. package/test/vectors/verify-checkpoint.py +358 -0
  63. package/test/vectors/verify.mjs +74 -1
  64. package/test/vectors/verify.py +78 -1
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Checkpoint conformance vector verifier (Python).
4
+ Verifies that checkpoint canonicalization, chain hashing, and truncation
5
+ detection produce byte-identical results across implementations.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ vectors_path = Path(__file__).parent / "checkpoint.json"
14
+ vectors = json.loads(vectors_path.read_text())
15
+
16
+ passed = 0
17
+ failed = 0
18
+
19
+
20
+ def sha256(data: str) -> str:
21
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()
22
+
23
+
24
+ def assert_well_formed_string(value: str, context: str) -> None:
25
+ for i, ch in enumerate(value):
26
+ code = ord(ch)
27
+ if 0xD800 <= code <= 0xDFFF:
28
+ raise ValueError(f"{context}: unpaired surrogate at index {i}")
29
+
30
+
31
+ def canonicalize_checkpoint(record: dict) -> str:
32
+ assert_well_formed_string(record["id"], "canonicalizeCheckpoint.id")
33
+ assert_well_formed_string(record["timestamp"], "canonicalizeCheckpoint.timestamp")
34
+ assert_well_formed_string(record["previousHash"], "canonicalizeCheckpoint.previousHash")
35
+ ordered = [
36
+ ["id", record["id"]],
37
+ ["type", "checkpoint"],
38
+ ["timestamp", record["timestamp"]],
39
+ ["sequence", record["sequence"]],
40
+ ["recordCount", record["recordCount"]],
41
+ ["previousHash", record["previousHash"]],
42
+ ]
43
+ if record.get("parties") is not None:
44
+ ordered.append(["parties", record["parties"]])
45
+ return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
46
+
47
+
48
+ def hash_record(record: dict) -> str:
49
+ serialized = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
50
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
51
+
52
+
53
+ def check(condition: bool, name: str, detail: str = ""):
54
+ global passed, failed
55
+ if condition:
56
+ passed += 1
57
+ print(f" PASS: {name}")
58
+ else:
59
+ failed += 1
60
+ print(f" FAIL: {name}")
61
+ if detail:
62
+ print(f" {detail}")
63
+
64
+
65
+ # --- Checkpoint Canonicalization ---
66
+ print("\n=== Checkpoint Canonicalization ===")
67
+ for vec in vectors["checkpoint_canonicalization"]:
68
+ canonical = canonicalize_checkpoint(vec["record"])
69
+ check(
70
+ canonical == vec["canonical"],
71
+ f"{vec['name']} canonical form",
72
+ f"expected: {vec['canonical']}\n got: {canonical}",
73
+ )
74
+ h = sha256(canonical)
75
+ check(
76
+ h == vec["sha256_canonical"],
77
+ f"{vec['name']} SHA-256",
78
+ f"expected: {vec['sha256_canonical']}\n got: {h}",
79
+ )
80
+
81
+ # --- Checkpoint Chain ---
82
+ print("\n=== Checkpoint Chain ===")
83
+ chain_records = vectors["checkpoint_chain"]["records"]
84
+ for i, entry in enumerate(chain_records):
85
+ computed = hash_record(entry["record"])
86
+ check(
87
+ computed == entry["record_hash"],
88
+ f"chain record {i} hash",
89
+ f"expected: {entry['record_hash']}\n got: {computed}",
90
+ )
91
+ if i > 0:
92
+ check(
93
+ entry["record"]["previousHash"] == chain_records[i - 1]["record_hash"],
94
+ f"chain record {i} previousHash links to record {i - 1}",
95
+ f"expected: {chain_records[i - 1]['record_hash']}\n got: {entry['record']['previousHash']}",
96
+ )
97
+
98
+ # --- Truncation Detection ---
99
+ print("\n=== Truncation Detection ===")
100
+ trunc_vec = vectors["truncation_detection"]
101
+ ext_ckpt = trunc_vec["external_checkpoint"]
102
+
103
+ # Full chain should contain the checkpoint
104
+ full_chain = [e["record"] for e in chain_records]
105
+ found_in_full = any(
106
+ r.get("type") == "checkpoint"
107
+ and r.get("previousHash") == ext_ckpt["previousHash"]
108
+ and r.get("sequence") == ext_ckpt["sequence"]
109
+ and r.get("recordCount") == ext_ckpt["recordCount"]
110
+ for r in full_chain
111
+ )
112
+ check(found_in_full, "full chain contains externalized checkpoint")
113
+
114
+ # Truncated chain should NOT contain the checkpoint
115
+ truncated_chain = trunc_vec["truncated_chain"]["records_delivered"]
116
+ found_in_truncated = any(
117
+ r.get("type") == "checkpoint"
118
+ and r.get("previousHash") == ext_ckpt["previousHash"]
119
+ and r.get("sequence") == ext_ckpt["sequence"]
120
+ and r.get("recordCount") == ext_ckpt["recordCount"]
121
+ for r in truncated_chain
122
+ )
123
+ has_descendant = any(
124
+ r.get("type") == "checkpoint" and r.get("sequence", 0) > ext_ckpt["sequence"]
125
+ for r in truncated_chain
126
+ )
127
+ check(
128
+ not found_in_truncated and not has_descendant,
129
+ "truncated chain missing checkpoint (truncation detected)",
130
+ )
131
+
132
+ # --- canonicalizeValue ---
133
+ print("\n=== canonicalizeValue ===")
134
+
135
+
136
+ def canonicalize_value(value):
137
+ if value is None:
138
+ return None
139
+ if isinstance(value, bool):
140
+ return value
141
+ if isinstance(value, int):
142
+ if abs(value) > 2**53 - 1:
143
+ raise ValueError(f"unsafe number {value}")
144
+ return value
145
+ if isinstance(value, float):
146
+ raise ValueError(f"unsafe number {value}")
147
+ if isinstance(value, str):
148
+ for i, ch in enumerate(value):
149
+ code = ord(ch)
150
+ if 0xD800 <= code <= 0xDFFF:
151
+ raise ValueError(f"unpaired surrogate at index {i}")
152
+ return value
153
+ if isinstance(value, list):
154
+ return ["L", [canonicalize_value(v) for v in value]]
155
+ if isinstance(value, dict):
156
+ # Sort by UTF-16 code-unit order to match JavaScript's String.prototype.sort().
157
+ # Python sorts by code points; these diverge for astral-plane characters.
158
+ keys = sorted(value.keys(), key=lambda k: k.encode("utf-16-be"))
159
+ return ["M", [[k, canonicalize_value(value[k])] for k in keys]]
160
+ raise ValueError(f"unsupported type {type(value)}")
161
+
162
+
163
+ def compute_extensions_digest(extensions: dict) -> tuple:
164
+ canonicalized = canonicalize_value(extensions)
165
+ canonical = json.dumps(canonicalized, separators=(",", ":"), ensure_ascii=False)
166
+ return canonical, sha256(canonical)
167
+
168
+
169
+ cv_vectors = vectors["canonicalize_value"]["vectors"]
170
+ for vec in cv_vectors:
171
+ if "expected_error" in vec:
172
+ threw = False
173
+ if "construct" in vec:
174
+ # Programmatic: lone surrogate can't reliably live in JSON
175
+ try:
176
+ canonicalize_value(chr(0xD800))
177
+ except (ValueError, UnicodeEncodeError):
178
+ threw = True
179
+ else:
180
+ try:
181
+ canonicalize_value(vec["input"])
182
+ except ValueError:
183
+ threw = True
184
+ check(threw, f"{vec['name']} throws on invalid input")
185
+ continue
186
+ if "input_a" in vec and "input_b" in vec and "canonical_form" in vec:
187
+ ra_c, ra_d = compute_extensions_digest(vec["input_a"])
188
+ rb_c, rb_d = compute_extensions_digest(vec["input_b"])
189
+ check(ra_c == vec["canonical_form"], f"{vec['name']} canonical form",
190
+ f"expected: {vec['canonical_form']}\n got: {ra_c}")
191
+ check(ra_d == vec["digest"], f"{vec['name']} digest",
192
+ f"expected: {vec['digest']}\n got: {ra_d}")
193
+ check(ra_d == rb_d, f"{vec['name']} both inputs produce same digest")
194
+ elif "input_a" in vec and "canonical_a" in vec:
195
+ ra_c, ra_d = compute_extensions_digest(vec["input_a"])
196
+ rb_c, rb_d = compute_extensions_digest(vec["input_b"])
197
+ check(ra_c == vec["canonical_a"], f"{vec['name']} canonical_a")
198
+ check(ra_d == vec["digest_a"], f"{vec['name']} digest_a")
199
+ check(rb_c == vec["canonical_b"], f"{vec['name']} canonical_b")
200
+ check(rb_d == vec["digest_b"], f"{vec['name']} digest_b")
201
+ check(ra_d != rb_d, f"{vec['name']} digests differ")
202
+ elif "input" in vec:
203
+ r_c, r_d = compute_extensions_digest(vec["input"])
204
+ check(r_c == vec["canonical_form"], f"{vec['name']} canonical form",
205
+ f"expected: {vec['canonical_form']}\n got: {r_c}")
206
+ check(r_d == vec["digest"], f"{vec['name']} digest",
207
+ f"expected: {vec['digest']}\n got: {r_d}")
208
+
209
+ # --- Extensions Digest ---
210
+ print("\n=== Extensions Digest ===")
211
+ ext_vectors = vectors["extensions_digest"]["vectors"]
212
+
213
+ for vec in ext_vectors:
214
+ canonical, digest = compute_extensions_digest(vec["extensions"])
215
+ check(
216
+ canonical == vec["canonical_form"],
217
+ f"{vec['name']} canonical form",
218
+ f"expected: {vec['canonical_form']}\n got: {canonical}",
219
+ )
220
+ check(
221
+ digest == vec["digest"],
222
+ f"{vec['name']} digest",
223
+ f"expected: {vec['digest']}\n got: {digest}",
224
+ )
225
+
226
+
227
+ def canonicalize_record(record: dict) -> str:
228
+ assert_well_formed_string(record["id"], "canonicalizeRecord.id")
229
+ assert_well_formed_string(record["timestamp"], "canonicalizeRecord.timestamp")
230
+ assert_well_formed_string(record["method"], "canonicalizeRecord.method")
231
+ if record.get("toolName") is not None:
232
+ assert_well_formed_string(record["toolName"], "canonicalizeRecord.toolName")
233
+ if record.get("namespace") is not None:
234
+ assert_well_formed_string(record["namespace"], "canonicalizeRecord.namespace")
235
+ if record.get("upstream") is not None:
236
+ assert_well_formed_string(record["upstream"], "canonicalizeRecord.upstream")
237
+ if record.get("principal") is not None:
238
+ assert_well_formed_string(record["principal"], "canonicalizeRecord.principal")
239
+ if record.get("previousHash") is not None:
240
+ assert_well_formed_string(record["previousHash"], "canonicalizeRecord.previousHash")
241
+ if record.get("decisionContextDigest") is not None:
242
+ assert_well_formed_string(record["decisionContextDigest"], "canonicalizeRecord.decisionContextDigest")
243
+ if record.get("extensionsDigest") is not None:
244
+ assert_well_formed_string(record["extensionsDigest"], "canonicalizeRecord.extensionsDigest")
245
+ ordered = [
246
+ ["id", record["id"]],
247
+ ["timestamp", record["timestamp"]],
248
+ ["method", record["method"]],
249
+ ["toolName", record.get("toolName")],
250
+ ["namespace", record.get("namespace")],
251
+ ["upstream", record.get("upstream")],
252
+ ["principal", record.get("principal")],
253
+ ["durationMs", record["durationMs"]],
254
+ ["success", record["success"]],
255
+ ["errorCode", record.get("errorCode")],
256
+ ["previousHash", record.get("previousHash")],
257
+ ]
258
+ insert_at = 11
259
+ if record.get("decisionContextDigest") is not None:
260
+ ordered.insert(10, ["decisionContextDigest", record["decisionContextDigest"]])
261
+ insert_at = 12
262
+ if record.get("extensionsDigest") is not None:
263
+ ordered.insert(insert_at, ["extensionsDigest", record["extensionsDigest"]])
264
+ insert_at += 1
265
+ if record.get("aiInvocation") is not None:
266
+ ordered.insert(insert_at, ["aiInvocation", canonicalize_value(record["aiInvocation"])])
267
+ insert_at += 1
268
+ if record.get("parties") is not None:
269
+ ordered.insert(insert_at, ["parties", record["parties"]])
270
+ return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
271
+
272
+
273
+ rec_canon = vectors["extensions_digest"]["record_canonicalization"]
274
+
275
+ with_ext = rec_canon["with_extensions_digest"]
276
+ with_ext_canonical = canonicalize_record(with_ext["record"])
277
+ check(
278
+ with_ext_canonical == with_ext["canonical"],
279
+ "record with extensionsDigest canonical form",
280
+ f"expected: {with_ext['canonical']}\n got: {with_ext_canonical}",
281
+ )
282
+ check(
283
+ sha256(with_ext_canonical) == with_ext["sha256_canonical"],
284
+ "record with extensionsDigest SHA-256",
285
+ f"expected: {with_ext['sha256_canonical']}\n got: {sha256(with_ext_canonical)}",
286
+ )
287
+
288
+ without_ext = rec_canon["without_extensions_digest"]
289
+ without_ext_canonical = canonicalize_record(without_ext["record"])
290
+ check(
291
+ without_ext_canonical == without_ext["canonical"],
292
+ "record without extensionsDigest canonical form (backward compat)",
293
+ f"expected: {without_ext['canonical']}\n got: {without_ext_canonical}",
294
+ )
295
+ check(
296
+ sha256(without_ext_canonical) == without_ext["sha256_canonical"],
297
+ "record without extensionsDigest SHA-256",
298
+ f"expected: {without_ext['sha256_canonical']}\n got: {sha256(without_ext_canonical)}",
299
+ )
300
+
301
+ # --- Rotation Boundary ---
302
+ print("\n=== Rotation Boundary ===")
303
+ rotation = vectors["rotation_boundary"]
304
+
305
+ for i, entry in enumerate(rotation["file_1_records"]):
306
+ h = hash_record(entry["record"])
307
+ check(
308
+ h == entry["record_hash"],
309
+ f"rotation file1 record {i} hash",
310
+ f"expected: {entry['record_hash']}\n got: {h}",
311
+ )
312
+
313
+ file1_last_hash = rotation["file_1_records"][-1]["record_hash"]
314
+ file2_first = rotation["file_2_records"][0]
315
+ check(
316
+ file2_first["record"]["previousHash"] == file1_last_hash,
317
+ "rotation: file2 first record chains to file1 last hash",
318
+ f"expected: {file1_last_hash}\n got: {file2_first['record']['previousHash']}",
319
+ )
320
+ file2_hash = hash_record(file2_first["record"])
321
+ check(
322
+ file2_hash == file2_first["record_hash"],
323
+ "rotation file2 record 0 hash",
324
+ f"expected: {file2_first['record_hash']}\n got: {file2_hash}",
325
+ )
326
+
327
+ # --- Sequence Regression ---
328
+ print("\n=== Sequence Regression ===")
329
+ seq_reg = vectors["sequence_regression"]
330
+ checkpoints = [e for e in seq_reg["chain"] if e["record"].get("type") == "checkpoint"]
331
+ regression_detected = False
332
+ for i in range(1, len(checkpoints)):
333
+ if checkpoints[i]["record"]["sequence"] <= checkpoints[i - 1]["record"]["sequence"]:
334
+ regression_detected = True
335
+ check(regression_detected, "sequence regression detected in chain")
336
+ check(
337
+ seq_reg["detection_result"]["failureCode"] == "sequence_regression",
338
+ "failure code is sequence_regression",
339
+ )
340
+
341
+ # --- Chain Break ---
342
+ print("\n=== Chain Break ===")
343
+ chain_break = vectors["chain_break"]
344
+ for i, entry in enumerate(chain_break["records"]):
345
+ h = hash_record(entry["record"])
346
+ check(
347
+ h == entry["record_hash"],
348
+ f"chain_break record {i} hash",
349
+ f"expected: {entry['record_hash']}\n got: {h}",
350
+ )
351
+ check(
352
+ chain_break["records"][1]["record"]["previousHash"] == chain_break["records"][0]["record_hash"],
353
+ "record after chain_break chains from break record hash",
354
+ )
355
+
356
+ # --- Summary ---
357
+ print(f"\n=== Results: {passed} passed, {failed} failed ({passed + failed} total) ===")
358
+ sys.exit(1 if failed > 0 else 0)
@@ -12,13 +12,53 @@ function sha256Hex(input) {
12
12
  return createHash("sha256").update(input).digest("hex");
13
13
  }
14
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
+
15
46
  function canonicalizeFromRecord(record, fieldOrder) {
16
47
  const ordered = fieldOrder.map((key) => [key, record[key] ?? null]);
48
+ let insertAt = 11;
17
49
  if (record.decisionContextDigest != null) {
18
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++;
19
60
  }
20
61
  if (record.parties != null) {
21
- const insertAt = record.decisionContextDigest != null ? 12 : 11;
22
62
  ordered.splice(insertAt, 0, ["parties", record.parties]);
23
63
  }
24
64
  return JSON.stringify(ordered);
@@ -269,5 +309,38 @@ if (vectors.party_attribution) {
269
309
  }
270
310
  }
271
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
+
272
345
  console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
273
346
  process.exit(failed > 0 ? 1 : 0);
@@ -18,16 +18,53 @@ vectors = json.loads(vectors_path.read_text())
18
18
  FIELD_ORDER = vectors["field_order"]
19
19
 
20
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
+
21
51
  def canonicalize(record: dict) -> str:
22
52
  """Reproduce the tuple-array canonical form in Python."""
23
53
  ordered = []
24
54
  for key in FIELD_ORDER:
25
55
  value = record.get(key)
26
56
  ordered.append([key, value])
57
+ insert_at = 11
27
58
  if record.get("decisionContextDigest") is not None:
28
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
29
67
  if record.get("parties") is not None:
30
- insert_at = 12 if record.get("decisionContextDigest") is not None else 11
31
68
  ordered.insert(insert_at, ["parties", record["parties"]])
32
69
  return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
33
70
 
@@ -265,6 +302,46 @@ if "party_attribution" in vectors:
265
302
  print(" FAIL: scope order should produce different hashes")
266
303
  failed += 1
267
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
+
268
345
  # --- Summary ---
269
346
  print(f"\n=== Results: {passed} passed, {failed} failed ===")
270
347