@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,450 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format_version": "1.0.0",
|
|
3
|
+
"implementation": "mcp-audit-gateway",
|
|
4
|
+
"description": "Conformance vectors for checkpoint records. Checkpoints enable truncation detection by providing externally-anchorable chain state. The completeness guarantee requires the consumer to externalize at least one checkpoint somewhere the gateway cannot rewrite.",
|
|
5
|
+
"canonical_form": "tuple-array",
|
|
6
|
+
"checkpoint_field_order": [
|
|
7
|
+
"id",
|
|
8
|
+
"type",
|
|
9
|
+
"timestamp",
|
|
10
|
+
"sequence",
|
|
11
|
+
"recordCount",
|
|
12
|
+
"previousHash"
|
|
13
|
+
],
|
|
14
|
+
"conditional_fields": {
|
|
15
|
+
"parties": "Appended at position 6 when present and non-null."
|
|
16
|
+
},
|
|
17
|
+
"design_notes": {
|
|
18
|
+
"self_sufficiency": "Checkpoints alone do NOT solve truncation. An attacker with log access can drop checkpoints along with the records they cover. The detection guarantee comes from the consumer externalizing the checkpoint (storing previousHash + sequence + recordCount) somewhere the adversary cannot rewrite.",
|
|
19
|
+
"detection_vs_attribution": "A missing checkpoint tells the consumer something was dropped, but cannot distinguish malicious truncation from legitimate gateway downtime or crash.",
|
|
20
|
+
"no_chainHead_field": "No separate chainHead field exists. The checkpoint's previousHash IS the chain head at checkpoint time. A separate field would be redundant and introduce ambiguity about whether it means head-before or head-after the checkpoint."
|
|
21
|
+
},
|
|
22
|
+
"checkpoint_canonicalization": [
|
|
23
|
+
{
|
|
24
|
+
"name": "checkpoint_basic",
|
|
25
|
+
"description": "Basic checkpoint record without parties. Canonical form includes the type field at position 1.",
|
|
26
|
+
"record": {
|
|
27
|
+
"id": "ckpt_990e8400-e29b-41d4-a716-446655440001",
|
|
28
|
+
"type": "checkpoint",
|
|
29
|
+
"timestamp": "2026-08-22T20:00:00.000Z",
|
|
30
|
+
"sequence": 1,
|
|
31
|
+
"recordCount": 5,
|
|
32
|
+
"previousHash": "4e9c29d6e22fc2ef1f931994997b01f48f054c1294a7c287bfeece79417e5d43"
|
|
33
|
+
},
|
|
34
|
+
"canonical": "[[\"id\",\"ckpt_990e8400-e29b-41d4-a716-446655440001\"],[\"type\",\"checkpoint\"],[\"timestamp\",\"2026-08-22T20:00:00.000Z\"],[\"sequence\",1],[\"recordCount\",5],[\"previousHash\",\"4e9c29d6e22fc2ef1f931994997b01f48f054c1294a7c287bfeece79417e5d43\"]]",
|
|
35
|
+
"sha256_canonical": "29fcb385d7306fe136b50eb46fd55731ff3c2aadbecb18554267890c3b625794"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "checkpoint_with_parties",
|
|
39
|
+
"description": "Checkpoint with gateway witness party declaring which fields it attests to. Parties array appended at end of tuple-array.",
|
|
40
|
+
"record": {
|
|
41
|
+
"id": "ckpt_990e8400-e29b-41d4-a716-446655440002",
|
|
42
|
+
"type": "checkpoint",
|
|
43
|
+
"timestamp": "2026-08-22T20:01:00.000Z",
|
|
44
|
+
"sequence": 2,
|
|
45
|
+
"recordCount": 10,
|
|
46
|
+
"previousHash": "abc123def456789",
|
|
47
|
+
"parties": [
|
|
48
|
+
{
|
|
49
|
+
"party": "gateway",
|
|
50
|
+
"role": "witness",
|
|
51
|
+
"scope": ["sequence", "recordCount", "previousHash"]
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"canonical": "[[\"id\",\"ckpt_990e8400-e29b-41d4-a716-446655440002\"],[\"type\",\"checkpoint\"],[\"timestamp\",\"2026-08-22T20:01:00.000Z\"],[\"sequence\",2],[\"recordCount\",10],[\"previousHash\",\"abc123def456789\"],[\"parties\",[{\"party\":\"gateway\",\"role\":\"witness\",\"scope\":[\"sequence\",\"recordCount\",\"previousHash\"]}]]]",
|
|
56
|
+
"sha256_canonical": "0b23e3a13467b142f6baaa206450428c03c9757889753d91e496f02c4b1c9d0f"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"checkpoint_chain": {
|
|
60
|
+
"description": "Three-record chain with a checkpoint in the middle. Proves checkpoints participate in hash chain identically to tool_call records. The checkpoint's previousHash equals the hash of the preceding tool_call record. The subsequent tool_call record's previousHash equals the hash of the checkpoint (including its attestation field).",
|
|
61
|
+
"records": [
|
|
62
|
+
{
|
|
63
|
+
"record": {
|
|
64
|
+
"id": "990e8400-e29b-41d4-a716-446655440010",
|
|
65
|
+
"timestamp": "2026-08-22T20:10:00.000Z",
|
|
66
|
+
"method": "tools/call",
|
|
67
|
+
"toolName": "read_file",
|
|
68
|
+
"namespace": "fs",
|
|
69
|
+
"upstream": "fs-server",
|
|
70
|
+
"principal": "user:bob@example.com",
|
|
71
|
+
"durationMs": 25,
|
|
72
|
+
"success": true,
|
|
73
|
+
"previousHash": "genesis",
|
|
74
|
+
"attestation": "sig_chain_rec1"
|
|
75
|
+
},
|
|
76
|
+
"record_hash": "336524ce7dd7e651e7ef8774be253808fe5e1f8d3e4f62889e9bcc18390c7e89"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"record": {
|
|
80
|
+
"id": "ckpt_990e8400-e29b-41d4-a716-446655440011",
|
|
81
|
+
"type": "checkpoint",
|
|
82
|
+
"timestamp": "2026-08-22T20:10:01.000Z",
|
|
83
|
+
"sequence": 1,
|
|
84
|
+
"recordCount": 1,
|
|
85
|
+
"previousHash": "336524ce7dd7e651e7ef8774be253808fe5e1f8d3e4f62889e9bcc18390c7e89",
|
|
86
|
+
"parties": [
|
|
87
|
+
{
|
|
88
|
+
"party": "gateway",
|
|
89
|
+
"role": "witness",
|
|
90
|
+
"scope": ["sequence", "recordCount", "previousHash"]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"attestation": "sig_chain_ckpt"
|
|
94
|
+
},
|
|
95
|
+
"record_hash": "f92d28a4494bd9700aa5b903acf4a538fca66f1971d772c8cf996ea925d12ef0",
|
|
96
|
+
"previous_record_hash": "336524ce7dd7e651e7ef8774be253808fe5e1f8d3e4f62889e9bcc18390c7e89"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"record": {
|
|
100
|
+
"id": "990e8400-e29b-41d4-a716-446655440012",
|
|
101
|
+
"timestamp": "2026-08-22T20:10:02.000Z",
|
|
102
|
+
"method": "tools/call",
|
|
103
|
+
"toolName": "write_file",
|
|
104
|
+
"namespace": "fs",
|
|
105
|
+
"upstream": "fs-server",
|
|
106
|
+
"principal": "user:bob@example.com",
|
|
107
|
+
"durationMs": 42,
|
|
108
|
+
"success": true,
|
|
109
|
+
"previousHash": "f92d28a4494bd9700aa5b903acf4a538fca66f1971d772c8cf996ea925d12ef0",
|
|
110
|
+
"attestation": "sig_chain_rec2"
|
|
111
|
+
},
|
|
112
|
+
"record_hash": "48838c2d0c0f389c3c45bde0f5d8d4ee58d07ab8d9948889000e2295f0f2b118",
|
|
113
|
+
"previous_record_hash": "f92d28a4494bd9700aa5b903acf4a538fca66f1971d772c8cf996ea925d12ef0"
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
"truncation_detection": {
|
|
118
|
+
"description": "Demonstrates completeness verification using externalized checkpoints. The consumer stores one checkpoint (previousHash + sequence + recordCount) in a location the gateway cannot reach. During verification, the consumer checks that the delivered chain contains that checkpoint or a descendant. If neither is found, records were dropped.",
|
|
119
|
+
"external_checkpoint": {
|
|
120
|
+
"previousHash": "336524ce7dd7e651e7ef8774be253808fe5e1f8d3e4f62889e9bcc18390c7e89",
|
|
121
|
+
"sequence": 1,
|
|
122
|
+
"recordCount": 1
|
|
123
|
+
},
|
|
124
|
+
"complete_chain_result": {
|
|
125
|
+
"truncated": false,
|
|
126
|
+
"reason": "checkpoint found in delivered chain"
|
|
127
|
+
},
|
|
128
|
+
"truncated_chain": {
|
|
129
|
+
"description": "If only rec1 is delivered (checkpoint and rec2 dropped from tail), the external checkpoint cannot be matched. The remaining prefix verifies its own integrity (signatures valid, chain hashes correct) but the consumer knows records are missing because no checkpoint matches or descends from the externalized one.",
|
|
130
|
+
"records_delivered": [
|
|
131
|
+
{
|
|
132
|
+
"id": "990e8400-e29b-41d4-a716-446655440010",
|
|
133
|
+
"timestamp": "2026-08-22T20:10:00.000Z",
|
|
134
|
+
"method": "tools/call",
|
|
135
|
+
"toolName": "read_file",
|
|
136
|
+
"namespace": "fs",
|
|
137
|
+
"upstream": "fs-server",
|
|
138
|
+
"principal": "user:bob@example.com",
|
|
139
|
+
"durationMs": 25,
|
|
140
|
+
"success": true,
|
|
141
|
+
"previousHash": "genesis",
|
|
142
|
+
"attestation": "sig_chain_rec1"
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"detection_result": "truncated",
|
|
146
|
+
"reason": "externalized checkpoint not found in chain and no descendant checkpoint exists",
|
|
147
|
+
"failureCode": "head_missing"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"canonicalize_value": {
|
|
151
|
+
"description": "canonicalizeValue() recursively converts values to an injective canonical form using type tags. Objects become [\"M\", [[k,v],...]] with sorted keys. Arrays become [\"L\", [...]]. Type tags ensure {a:1} and [[\"a\",1]] produce distinct digests (no type-confusion collisions). Numbers must be safe integers (Number.isSafeInteger); floats and unsafe integers throw. Strings, booleans, and null pass through as scalars. Keys with undefined values are dropped.",
|
|
152
|
+
"constraints": {
|
|
153
|
+
"numbers": "Safe integers only (-(2^53-1) to 2^53-1). Floats, Infinity, NaN, and integers outside safe range must be pre-encoded as strings by the caller.",
|
|
154
|
+
"objects": "Recursively converted to [\"M\", sorted-pairs] at all depths. Type tag \"M\" ensures injectivity.",
|
|
155
|
+
"arrays": "[\"L\", elements] — element order preserved; each element recursively canonicalized. Type tag \"L\" ensures injectivity.",
|
|
156
|
+
"null_undefined": "Both canonicalize to JSON null.",
|
|
157
|
+
"undefined_keys": "Object keys whose value is undefined are dropped (matches JSON.stringify behavior)."
|
|
158
|
+
},
|
|
159
|
+
"vectors": [
|
|
160
|
+
{
|
|
161
|
+
"name": "nested_same_content_different_order",
|
|
162
|
+
"description": "Same logical content with different key insertion order produces identical canonical form and digest.",
|
|
163
|
+
"input_a": { "z": 1, "a": { "y": 2, "b": 3 } },
|
|
164
|
+
"input_b": { "a": { "b": 3, "y": 2 }, "z": 1 },
|
|
165
|
+
"canonical_form": "[\"M\",[[\"a\",[\"M\",[[\"b\",3],[\"y\",2]]]],[\"z\",1]]]",
|
|
166
|
+
"digest": "780dbde7596450dde10d8362bc4590826315109f85a55a2349f13539f023a07c"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"name": "array_order_matters",
|
|
170
|
+
"description": "Array element order is preserved — [1,2,3] and [3,2,1] produce different digests.",
|
|
171
|
+
"input_a": { "items": [1, 2, 3] },
|
|
172
|
+
"input_b": { "items": [3, 2, 1] },
|
|
173
|
+
"canonical_a": "[\"M\",[[\"items\",[\"L\",[1,2,3]]]]]",
|
|
174
|
+
"digest_a": "84a9ff2d4ccb26df641e5c01e9894a6b301d939f7f093f3acb76565018e50c94",
|
|
175
|
+
"canonical_b": "[\"M\",[[\"items\",[\"L\",[3,2,1]]]]]",
|
|
176
|
+
"digest_b": "24ec68e95f9bb0b135d4a8dd89b5939ac05fd7d4d43b2a7c85d800151441f2e9"
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "unicode_keys_sorted",
|
|
180
|
+
"description": "Unicode keys sorted by JS default string comparison (UTF-16 code unit order).",
|
|
181
|
+
"input": { "ñ": 1, "a": 2, "z": 3, "à": 4 },
|
|
182
|
+
"canonical_form": "[\"M\",[[\"a\",2],[\"z\",3],[\"à\",4],[\"ñ\",1]]]",
|
|
183
|
+
"digest": "6f8e940801e3f524e3f0db529e705583a0ab57c5ad5966cb29d6808fd064bc49"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "astral_plane_keys_utf16_order",
|
|
187
|
+
"description": "Keys containing astral-plane characters (above BMP) must sort by UTF-16 code-unit order, not code-point order. U+10000 (surrogate pair D800 DC00, first code unit 0xD800=55296) sorts BEFORE U+FF61 (code unit 0xFF61=65377). Python's default sorted() uses code-point order and gets this wrong — must sort by key.encode('utf-16-be').",
|
|
188
|
+
"input": { "。": 1, "𐀀": 2 },
|
|
189
|
+
"canonical_form": "[\"M\",[[\"𐀀\",2],[\"。\",1]]]",
|
|
190
|
+
"digest": "17e0391b630e2a8ba408b2d47f689fbb6c4fd4e63c606691d3eca219d1843495",
|
|
191
|
+
"note": "If your implementation sorts U+FF61 before U+10000, you are using code-point order (wrong). UTF-16 code-unit order puts U+10000 first because its first code unit (0xD800) is numerically less than 0xFF61."
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"name": "lone_surrogate_throws",
|
|
195
|
+
"description": "Strings containing unpaired surrogates must throw. This eliminates cross-language divergence in JSON serialization of lone surrogates. Test this by constructing a string with charCodeAt(0)===0xD800 in JS or chr(0xD800) in Python — do NOT rely on JSON parsing behavior for lone surrogates.",
|
|
196
|
+
"construct": "String.fromCharCode(0xD800) in JS; chr(0xD800) in Python",
|
|
197
|
+
"expected_error": "unpaired surrogate"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"name": "float_throws",
|
|
201
|
+
"description": "Non-integer numbers must throw. Callers encode floats as strings.",
|
|
202
|
+
"input": 0.1,
|
|
203
|
+
"expected_error": "unsafe number"
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
"extensions_digest": {
|
|
208
|
+
"description": "extensionsDigest binds an extension map into the signed canonical form via canonicalizeValue(). The digest is SHA-256 of the injective type-tagged canonicalization ([\"M\",...] for objects, [\"L\",...] for arrays). Numbers must be safe integers; floats must be pre-encoded as strings. When extensionsDigest is absent/undefined, it is omitted from the record canonical form entirely (backward compatible).",
|
|
209
|
+
"vectors": [
|
|
210
|
+
{
|
|
211
|
+
"name": "simple_integer_map",
|
|
212
|
+
"description": "Extension map with string and integer values (integers are safe).",
|
|
213
|
+
"extensions": { "model": "gpt-4", "max_tokens": 1024 },
|
|
214
|
+
"canonical_form": "[\"M\",[[\"max_tokens\",1024],[\"model\",\"gpt-4\"]]]",
|
|
215
|
+
"digest": "bae5f878e0a23ed1918f2cb454e70d1306a862aa9bdc96117bbdc7d5b25b0d02"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "empty_extension_map",
|
|
219
|
+
"description": "An empty object still produces a digest ([\"M\",[]]). Absent extensions (undefined) should NOT call computeExtensionsDigest at all.",
|
|
220
|
+
"extensions": {},
|
|
221
|
+
"canonical_form": "[\"M\",[]]",
|
|
222
|
+
"digest": "f262a0065c4f9cd6c0d849d4b5b291f7243748ddca5525ce54c6390ee07f126f"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "nested_recursive_sort",
|
|
226
|
+
"description": "Keys sorted at every depth. Nested object {version: 2} becomes [\"M\",[[\"version\",2]]].",
|
|
227
|
+
"extensions": { "z_trace_id": "abc123", "a_deployment": "prod", "metadata": { "version": 2 } },
|
|
228
|
+
"canonical_form": "[\"M\",[[\"a_deployment\",\"prod\"],[\"metadata\",[\"M\",[[\"version\",2]]]],[\"z_trace_id\",\"abc123\"]]]",
|
|
229
|
+
"digest": "34fa7aa32aeda878bd56d0283d028dcd5bea5be5630dd7b3d15acdddd033a555"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"name": "float_as_string",
|
|
233
|
+
"description": "Floats must be pre-encoded as strings by the caller. This is the correct way to include non-integer numbers.",
|
|
234
|
+
"extensions": { "model": "gpt-4", "temperature": "0.7" },
|
|
235
|
+
"canonical_form": "[\"M\",[[\"model\",\"gpt-4\"],[\"temperature\",\"0.7\"]]]",
|
|
236
|
+
"digest": "83565ba3212a9bd8ebd3926fc2a1e7eb4c410804a8dbf2a564a2468fcc44f37c"
|
|
237
|
+
}
|
|
238
|
+
],
|
|
239
|
+
"record_canonicalization": {
|
|
240
|
+
"description": "extensionsDigest appears after previousHash (or after decisionContextDigest if present) in the AuditRecord canonical form. When absent, the field is simply not included (backward compatible with pre-v0.3.0 records).",
|
|
241
|
+
"with_extensions_digest": {
|
|
242
|
+
"record": {
|
|
243
|
+
"id": "550e8400-e29b-41d4-a716-446655440099",
|
|
244
|
+
"timestamp": "2026-08-22T21:00:00.000Z",
|
|
245
|
+
"method": "tools/call",
|
|
246
|
+
"toolName": "query_db",
|
|
247
|
+
"namespace": "data",
|
|
248
|
+
"upstream": "data-server",
|
|
249
|
+
"principal": "user:alice@example.com",
|
|
250
|
+
"durationMs": 150,
|
|
251
|
+
"success": true,
|
|
252
|
+
"errorCode": null,
|
|
253
|
+
"previousHash": "genesis",
|
|
254
|
+
"extensionsDigest": "bae5f878e0a23ed1918f2cb454e70d1306a862aa9bdc96117bbdc7d5b25b0d02"
|
|
255
|
+
},
|
|
256
|
+
"canonical": "[[\"id\",\"550e8400-e29b-41d4-a716-446655440099\"],[\"timestamp\",\"2026-08-22T21:00:00.000Z\"],[\"method\",\"tools/call\"],[\"toolName\",\"query_db\"],[\"namespace\",\"data\"],[\"upstream\",\"data-server\"],[\"principal\",\"user:alice@example.com\"],[\"durationMs\",150],[\"success\",true],[\"errorCode\",null],[\"previousHash\",\"genesis\"],[\"extensionsDigest\",\"bae5f878e0a23ed1918f2cb454e70d1306a862aa9bdc96117bbdc7d5b25b0d02\"]]",
|
|
257
|
+
"sha256_canonical": "f5aa5b0cfbfcfd141c3e70a5ad2b4bee51ec170bdc88391829d1c05230550967"
|
|
258
|
+
},
|
|
259
|
+
"without_extensions_digest": {
|
|
260
|
+
"description": "Same record without extensionsDigest. Canonical form is identical to pre-v0.3.0 format.",
|
|
261
|
+
"record": {
|
|
262
|
+
"id": "550e8400-e29b-41d4-a716-446655440099",
|
|
263
|
+
"timestamp": "2026-08-22T21:00:00.000Z",
|
|
264
|
+
"method": "tools/call",
|
|
265
|
+
"toolName": "query_db",
|
|
266
|
+
"namespace": "data",
|
|
267
|
+
"upstream": "data-server",
|
|
268
|
+
"principal": "user:alice@example.com",
|
|
269
|
+
"durationMs": 150,
|
|
270
|
+
"success": true,
|
|
271
|
+
"errorCode": null,
|
|
272
|
+
"previousHash": "genesis"
|
|
273
|
+
},
|
|
274
|
+
"canonical": "[[\"id\",\"550e8400-e29b-41d4-a716-446655440099\"],[\"timestamp\",\"2026-08-22T21:00:00.000Z\"],[\"method\",\"tools/call\"],[\"toolName\",\"query_db\"],[\"namespace\",\"data\"],[\"upstream\",\"data-server\"],[\"principal\",\"user:alice@example.com\"],[\"durationMs\",150],[\"success\",true],[\"errorCode\",null],[\"previousHash\",\"genesis\"]]",
|
|
275
|
+
"sha256_canonical": "9b7aa85506cfe76fd53b11425c206b32a5d3d2c1423d7e00686a201798475e54"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
"rotation_boundary": {
|
|
280
|
+
"description": "Chain continuity across log rotation. The chain-global sequence (lastHash, checkpointSequence, totalRecordCount) persists via .state.json sidecar file. A rotation does NOT reset previousHash to 'genesis'. The first record in the new file chains to the last hash of the previous file.",
|
|
281
|
+
"file_1_records": [
|
|
282
|
+
{
|
|
283
|
+
"record": {
|
|
284
|
+
"id": "660e8400-e29b-41d4-a716-446655440001",
|
|
285
|
+
"timestamp": "2026-08-22T22:00:00.000Z",
|
|
286
|
+
"method": "tools/call",
|
|
287
|
+
"toolName": "list_files",
|
|
288
|
+
"namespace": "fs",
|
|
289
|
+
"upstream": "fs-server",
|
|
290
|
+
"principal": "user:charlie@example.com",
|
|
291
|
+
"durationMs": 12,
|
|
292
|
+
"success": true,
|
|
293
|
+
"previousHash": "genesis",
|
|
294
|
+
"attestation": "sig_rotation_rec1"
|
|
295
|
+
},
|
|
296
|
+
"record_hash": "ebed26d3fd72ead1ee70a6dd676720fc0c987d76b4d0454ee769abfd8c1634e6"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"record": {
|
|
300
|
+
"id": "ckpt_660e8400-e29b-41d4-a716-446655440002",
|
|
301
|
+
"type": "checkpoint",
|
|
302
|
+
"timestamp": "2026-08-22T22:00:01.000Z",
|
|
303
|
+
"sequence": 5,
|
|
304
|
+
"recordCount": 1,
|
|
305
|
+
"previousHash": "ebed26d3fd72ead1ee70a6dd676720fc0c987d76b4d0454ee769abfd8c1634e6",
|
|
306
|
+
"parties": [{ "party": "gateway", "role": "witness", "scope": ["sequence", "recordCount", "previousHash"] }],
|
|
307
|
+
"attestation": "sig_rotation_ckpt"
|
|
308
|
+
},
|
|
309
|
+
"record_hash": "9876964114ee76158f4551f0802c80d9a8869ef79f851e7524602861f1af0cc4"
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
"rotation_note": "Log rotation happens here. State persisted: {lastHash: '9876964114ee76158f4551f0802c80d9a8869ef79f851e7524602861f1af0cc4', checkpointSequence: 5, totalRecordCount: 1}",
|
|
313
|
+
"file_2_records": [
|
|
314
|
+
{
|
|
315
|
+
"record": {
|
|
316
|
+
"id": "660e8400-e29b-41d4-a716-446655440003",
|
|
317
|
+
"timestamp": "2026-08-22T22:01:00.000Z",
|
|
318
|
+
"method": "tools/call",
|
|
319
|
+
"toolName": "read_file",
|
|
320
|
+
"namespace": "fs",
|
|
321
|
+
"upstream": "fs-server",
|
|
322
|
+
"principal": "user:charlie@example.com",
|
|
323
|
+
"durationMs": 35,
|
|
324
|
+
"success": true,
|
|
325
|
+
"previousHash": "9876964114ee76158f4551f0802c80d9a8869ef79f851e7524602861f1af0cc4",
|
|
326
|
+
"attestation": "sig_rotation_rec2"
|
|
327
|
+
},
|
|
328
|
+
"record_hash": "defa22cefc1c4fd457aff29b0c282e13b0d9d1faaaebb40c581802b99ae9a151",
|
|
329
|
+
"previous_record_hash": "9876964114ee76158f4551f0802c80d9a8869ef79f851e7524602861f1af0cc4",
|
|
330
|
+
"note": "This record's previousHash matches the checkpoint from file 1, proving chain survived rotation"
|
|
331
|
+
}
|
|
332
|
+
],
|
|
333
|
+
"verification": {
|
|
334
|
+
"chain_valid": true,
|
|
335
|
+
"reason": "rec2.previousHash matches hash(checkpoint) from previous file. Chain is continuous across rotation boundary."
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
"sequence_regression": {
|
|
339
|
+
"description": "Detects rotation laundering or replay attacks where checkpoint sequence numbers go backward. If checkpoint N has sequence <= checkpoint N-1's sequence, the chain is compromised. This catches an attacker who rotates the log, resets state, and replays old records to hide the gap.",
|
|
340
|
+
"failure_code": "sequence_regression",
|
|
341
|
+
"chain": [
|
|
342
|
+
{
|
|
343
|
+
"record": {
|
|
344
|
+
"id": "770e8400-e29b-41d4-a716-446655440000",
|
|
345
|
+
"timestamp": "2026-08-22T22:59:00.000Z",
|
|
346
|
+
"method": "tools/call",
|
|
347
|
+
"toolName": "exec",
|
|
348
|
+
"namespace": "shell",
|
|
349
|
+
"upstream": "shell-server",
|
|
350
|
+
"principal": "user:dave@example.com",
|
|
351
|
+
"durationMs": 100,
|
|
352
|
+
"success": true,
|
|
353
|
+
"previousHash": "genesis",
|
|
354
|
+
"attestation": "sig_seqreg_rec0"
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"record": {
|
|
359
|
+
"id": "ckpt_770e8400-e29b-41d4-a716-446655440001",
|
|
360
|
+
"type": "checkpoint",
|
|
361
|
+
"timestamp": "2026-08-22T23:00:00.000Z",
|
|
362
|
+
"sequence": 3,
|
|
363
|
+
"recordCount": 15,
|
|
364
|
+
"previousHash": "aaa111",
|
|
365
|
+
"parties": [{ "party": "gateway", "role": "witness", "scope": ["sequence", "recordCount", "previousHash"] }],
|
|
366
|
+
"attestation": "sig_seqreg_ckpt1"
|
|
367
|
+
},
|
|
368
|
+
"note": "Valid checkpoint at sequence 3"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
"record": {
|
|
372
|
+
"id": "ckpt_770e8400-e29b-41d4-a716-446655440002",
|
|
373
|
+
"type": "checkpoint",
|
|
374
|
+
"timestamp": "2026-08-22T23:01:00.000Z",
|
|
375
|
+
"sequence": 2,
|
|
376
|
+
"recordCount": 20,
|
|
377
|
+
"previousHash": "bbb222",
|
|
378
|
+
"parties": [{ "party": "gateway", "role": "witness", "scope": ["sequence", "recordCount", "previousHash"] }],
|
|
379
|
+
"attestation": "sig_seqreg_ckpt2"
|
|
380
|
+
},
|
|
381
|
+
"note": "INVALID: sequence 2 <= previous sequence 3 (regression)"
|
|
382
|
+
}
|
|
383
|
+
],
|
|
384
|
+
"detection_result": {
|
|
385
|
+
"truncated": true,
|
|
386
|
+
"failureCode": "sequence_regression",
|
|
387
|
+
"reason": "checkpoint sequence regressed: 2 <= 3"
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
"chain_break": {
|
|
391
|
+
"description": "A chain_break record is emitted when the operator forces a new chain (e.g. after a corrupt state file). It carries recoverable state from the prior chain as evidence of the discontinuity. The chain_break record is a valid chain start — subsequent records chain from its hash. This converts 'gap in evidence' into 'signed evidence of a gap'.",
|
|
392
|
+
"canonical_field_order": ["id", "type", "timestamp", "reason", "priorHead", "priorSequence", "priorRecordCount"],
|
|
393
|
+
"records": [
|
|
394
|
+
{
|
|
395
|
+
"record": {
|
|
396
|
+
"id": "break_880e8400-e29b-41d4-a716-446655440001",
|
|
397
|
+
"type": "chain_break",
|
|
398
|
+
"timestamp": "2026-08-22T23:30:00.000Z",
|
|
399
|
+
"reason": "state_file_corrupt",
|
|
400
|
+
"priorHead": "deadbeef1234567890abcdef",
|
|
401
|
+
"priorSequence": 10,
|
|
402
|
+
"priorRecordCount": 500,
|
|
403
|
+
"attestation": "sig_chain_break_1"
|
|
404
|
+
},
|
|
405
|
+
"record_hash": "3339fda07ff6daa328aa601cd61012b88ceb00ec1617357368692790ab7f31b2"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"record": {
|
|
409
|
+
"id": "880e8400-e29b-41d4-a716-446655440002",
|
|
410
|
+
"timestamp": "2026-08-22T23:30:01.000Z",
|
|
411
|
+
"method": "tools/call",
|
|
412
|
+
"toolName": "exec",
|
|
413
|
+
"namespace": "shell",
|
|
414
|
+
"upstream": "shell-server",
|
|
415
|
+
"principal": "user:eve@example.com",
|
|
416
|
+
"durationMs": 50,
|
|
417
|
+
"success": true,
|
|
418
|
+
"previousHash": "3339fda07ff6daa328aa601cd61012b88ceb00ec1617357368692790ab7f31b2",
|
|
419
|
+
"attestation": "sig_after_break"
|
|
420
|
+
},
|
|
421
|
+
"record_hash": "43701b65dbd89b8e22c34c9e2c75784749c42700925b5d27703b5b98c0832c23",
|
|
422
|
+
"note": "First record after chain_break chains from the break record's hash, proving continuity from the signed gap marker"
|
|
423
|
+
}
|
|
424
|
+
]
|
|
425
|
+
},
|
|
426
|
+
"verification_modes": {
|
|
427
|
+
"description": "verifyCompleteness supports two explicit modes. Strict: full chain required, absolute counts verified, adjacent-pair deltas checked. Relative (suffix): descendant match acceptable, deltas checked, absolute counts marked unverified. This prevents a verifier from silently claiming full verification on a suffix.",
|
|
428
|
+
"modes": {
|
|
429
|
+
"strict": "Full chain from genesis (or chain_break). Absolute recordCount checked against prefix. Adjacent checkpoint deltas validated. absoluteCountVerified=true on success.",
|
|
430
|
+
"relative": "Suffix delivery acceptable. Descendant checkpoint match accepted. Deltas between adjacent checkpoints in the suffix are validated. absoluteCountVerified=false (honest downgrade). Result explicitly states verificationMode='relative'."
|
|
431
|
+
},
|
|
432
|
+
"adjacent_delta_check": {
|
|
433
|
+
"description": "Between every pair of adjacent checkpoints, the number of non-checkpoint records must equal the recordCount delta. This catches interior splices that absolute-count-only verification misses.",
|
|
434
|
+
"example": {
|
|
435
|
+
"checkpoint_a": { "sequence": 1, "recordCount": 5 },
|
|
436
|
+
"checkpoint_b": { "sequence": 2, "recordCount": 10 },
|
|
437
|
+
"expected_delta": 5,
|
|
438
|
+
"note": "If only 3 non-checkpoint records exist between checkpoint_a and checkpoint_b, failureCode is count_mismatch with reason mentioning 'adjacent checkpoint delta mismatch'."
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
"failure_codes": {
|
|
443
|
+
"description": "verifyCompleteness returns distinct failure codes to aid consumer diagnostics and automated response. Each code indicates a different class of tampering or failure.",
|
|
444
|
+
"codes": {
|
|
445
|
+
"head_missing": "The externalized checkpoint was not found in the delivered chain and no descendant checkpoint exists. Indicates tail truncation: records after the last externalized checkpoint were dropped.",
|
|
446
|
+
"count_mismatch": "The checkpoint exists in the chain but the number of non-checkpoint records preceding it does not match its recordCount claim, OR the delta between adjacent checkpoints doesn't match. Indicates a splice attack: valid checkpoint presented with a fabricated prefix or interior.",
|
|
447
|
+
"sequence_regression": "Two checkpoints in the chain have non-monotonic sequence numbers. Indicates rotation laundering: the chain was reset and old records replayed to hide the gap."
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|