@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,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)
|
package/test/vectors/verify.mjs
CHANGED
|
@@ -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);
|
package/test/vectors/verify.py
CHANGED
|
@@ -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
|
|