@sanning/proof 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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/agent-proof.d.ts +28 -0
- package/dist/agent-proof.js +188 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +569 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +124 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +215 -0
- package/dist/evidence.d.ts +214 -0
- package/dist/evidence.js +1049 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +38 -0
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +114 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +5 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +321 -0
- package/package.json +59 -0
- package/src/agent-proof.ts +229 -0
- package/src/cli.ts +619 -0
- package/src/compose.ts +189 -0
- package/src/crypto.ts +243 -0
- package/src/evidence.ts +1491 -0
- package/src/index.ts +105 -0
- package/src/merkle.ts +125 -0
- package/src/types.ts +72 -0
- package/src/verifier.ts +340 -0
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
// Evidence-bundle verification (sanning-io/proof specs/evidence-bundle.md).
|
|
2
|
+
//
|
|
3
|
+
// A `sanning.evidence/v1` bundle is a SIGNED wrapper around a producer-specific
|
|
4
|
+
// `body`. This module verifies the `sanning.anchor.trace/v1` body — the
|
|
5
|
+
// @ar.io/anchor SDK trace: a self-verifying serialization of InclusionReceipt[]
|
|
6
|
+
// (per-event signed envelopes + their checkpoints + RFC 9162 inclusion proofs).
|
|
7
|
+
//
|
|
8
|
+
// The algorithm is the kernel discipline applied one level up (evidence-bundle
|
|
9
|
+
// §1, §4): inherit-don't-invent (same JCS, SHA-256, Ed25519), wrap-don't-merge
|
|
10
|
+
// (the body is opaque to the wrapper), recompute-don't-trust-the-verdict
|
|
11
|
+
// (every claim is re-derived from `body`, never read off `verdict.status`),
|
|
12
|
+
// reject-unknown-major. No Sanning service is in the trust path: the bundle is
|
|
13
|
+
// self-verifying offline against the embedded `public_key`; the optional
|
|
14
|
+
// gateway re-fetch only confirms the checkpoints are anchored on-chain.
|
|
15
|
+
//
|
|
16
|
+
// Scope: this verifies a SINGLE evidence bundle. Chain walking over
|
|
17
|
+
// `previous_hash` across successive exports is consumer-layer logic above it.
|
|
18
|
+
import { deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
19
|
+
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
20
|
+
import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, verifyEnvelope, } from "./verifier.js";
|
|
21
|
+
// Only the evidence-bundle major(s) this kernel verifies. A new major is a
|
|
22
|
+
// deliberate one-entry addition HERE — same fail-closed discipline as the
|
|
23
|
+
// envelope verifier's ACCEPTED_SPEC_MAJORS.
|
|
24
|
+
const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
|
|
25
|
+
// The body_type this module knows how to verify structurally. An unknown
|
|
26
|
+
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
27
|
+
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
28
|
+
// "undetermined" rather than "verified".
|
|
29
|
+
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
|
|
30
|
+
// The issuer-composed attested-evidence-export body (specs/evidence-export.md
|
|
31
|
+
// §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
|
|
32
|
+
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
33
|
+
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
34
|
+
// verifyExportBody (§5).
|
|
35
|
+
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
36
|
+
function isEvidenceMajorAccepted(specVersion) {
|
|
37
|
+
if (typeof specVersion !== "string" || specVersion === "")
|
|
38
|
+
return false;
|
|
39
|
+
return ACCEPTED_EVIDENCE_MAJORS.some((m) => {
|
|
40
|
+
if (specVersion === m)
|
|
41
|
+
return true;
|
|
42
|
+
if (!specVersion.startsWith(`${m}.`))
|
|
43
|
+
return false;
|
|
44
|
+
const minor = specVersion.slice(m.length + 1);
|
|
45
|
+
return minor.length > 0 && /^[0-9]+$/.test(minor);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function malformed(error, specVersionOk = false) {
|
|
49
|
+
return {
|
|
50
|
+
status: "malformed",
|
|
51
|
+
specVersionOk,
|
|
52
|
+
bodyHashOk: false,
|
|
53
|
+
signatureOk: false,
|
|
54
|
+
bodyType: null,
|
|
55
|
+
onChainChecked: false,
|
|
56
|
+
checkpoints: [],
|
|
57
|
+
events: [],
|
|
58
|
+
assertedStatus: null,
|
|
59
|
+
errors: [error],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function stringifyErr(e) {
|
|
63
|
+
return e instanceof Error ? e.message : String(e);
|
|
64
|
+
}
|
|
65
|
+
// Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
|
|
66
|
+
//
|
|
67
|
+
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
68
|
+
// rollup `status`. The producer's asserted `verdict.status` is surfaced
|
|
69
|
+
// (`assertedStatus`) but NEVER trusted — the displayed status is the one this
|
|
70
|
+
// function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
|
|
71
|
+
export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
72
|
+
// --- Step 1: parse + reject unknown spec_version major ---------------------
|
|
73
|
+
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
74
|
+
return malformed("evidence bundle is not a JSON object");
|
|
75
|
+
}
|
|
76
|
+
// Reject a pathologically deep bundle BEFORE any canonicalization (which
|
|
77
|
+
// recurses). A fixed shared bound keeps this a `malformed` verdict in every
|
|
78
|
+
// kernel rather than a stack-limit-dependent split verdict — see
|
|
79
|
+
// MAX_CANONICAL_DEPTH.
|
|
80
|
+
if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
|
|
81
|
+
return malformed(`evidence bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`);
|
|
82
|
+
}
|
|
83
|
+
const b = bundle;
|
|
84
|
+
const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
|
|
85
|
+
if (!specVersionOk) {
|
|
86
|
+
return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version)}`, false);
|
|
87
|
+
}
|
|
88
|
+
if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
|
|
89
|
+
// The reference verifier implements ed25519 only (evidence-bundle §2 +
|
|
90
|
+
// decision #2); other algs are a deliberate future addition.
|
|
91
|
+
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
|
|
92
|
+
}
|
|
93
|
+
if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
|
|
94
|
+
return malformed("evidence bundle is missing public_key/signature", true);
|
|
95
|
+
}
|
|
96
|
+
if (typeof b.body_hash !== "string") {
|
|
97
|
+
return malformed("evidence bundle is missing body_hash", true);
|
|
98
|
+
}
|
|
99
|
+
if (b.body === undefined || b.body === null) {
|
|
100
|
+
// body_ref (out-of-line body) is a defined growth hook but not part of the
|
|
101
|
+
// anchor-trace use case — refuse rather than silently pass.
|
|
102
|
+
return malformed("evidence bundle has no inline body (body_ref unsupported here)", true);
|
|
103
|
+
}
|
|
104
|
+
// --- Step 2: verify the wrapper signature + recompute body_hash ------------
|
|
105
|
+
const errors = [];
|
|
106
|
+
let bodyHashOk = false;
|
|
107
|
+
try {
|
|
108
|
+
const recomputed = await sha256Hex(utf8(jcs(b.body)));
|
|
109
|
+
bodyHashOk = recomputed === b.body_hash;
|
|
110
|
+
if (!bodyHashOk) {
|
|
111
|
+
errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
|
|
116
|
+
}
|
|
117
|
+
let signatureOk = false;
|
|
118
|
+
try {
|
|
119
|
+
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b;
|
|
120
|
+
signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
|
|
121
|
+
if (!signatureOk)
|
|
122
|
+
errors.push("wrapper Ed25519 signature verification failed");
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
errors.push(`wrapper signature verification error: ${stringifyErr(e)}`);
|
|
126
|
+
}
|
|
127
|
+
const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
|
|
128
|
+
// A broken wrapper (bad signature or tampered body) is a hard failure — the
|
|
129
|
+
// body is untrustworthy, so we do not pretend to verify its contents.
|
|
130
|
+
if (!signatureOk || !bodyHashOk) {
|
|
131
|
+
return {
|
|
132
|
+
status: "failed",
|
|
133
|
+
specVersionOk,
|
|
134
|
+
bodyHashOk,
|
|
135
|
+
signatureOk,
|
|
136
|
+
bodyType: typeof b.body_type === "string" ? b.body_type : null,
|
|
137
|
+
onChainChecked: false,
|
|
138
|
+
checkpoints: [],
|
|
139
|
+
events: [],
|
|
140
|
+
assertedStatus,
|
|
141
|
+
errors,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// --- Body dispatch ---------------------------------------------------------
|
|
145
|
+
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
146
|
+
// Attested-evidence-export body (evidence-export.md §5). The wrapper is
|
|
147
|
+
// authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
|
|
148
|
+
// hand off to the export verifier for steps 3–9.
|
|
149
|
+
if (bodyType === EXPORT_BODY_TYPE) {
|
|
150
|
+
return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
|
|
151
|
+
}
|
|
152
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
153
|
+
// The wrapper is authentic but we don't know this body's structure. Per
|
|
154
|
+
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
155
|
+
// body verdict undetermined) — not a failure.
|
|
156
|
+
errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
157
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
|
|
158
|
+
return {
|
|
159
|
+
status: "partial",
|
|
160
|
+
specVersionOk,
|
|
161
|
+
bodyHashOk,
|
|
162
|
+
signatureOk,
|
|
163
|
+
bodyType,
|
|
164
|
+
onChainChecked: false,
|
|
165
|
+
checkpoints: [],
|
|
166
|
+
events: [],
|
|
167
|
+
assertedStatus,
|
|
168
|
+
errors,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
|
|
172
|
+
}
|
|
173
|
+
async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
|
|
174
|
+
const body = b.body;
|
|
175
|
+
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
176
|
+
return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
|
|
177
|
+
}
|
|
178
|
+
const gateways = options.gateways ?? [];
|
|
179
|
+
const onChainChecked = gateways.length > 0;
|
|
180
|
+
// --- Step 3: per-checkpoint -------------------------------------------------
|
|
181
|
+
const byTxId = new Map();
|
|
182
|
+
const checkpointResults = [];
|
|
183
|
+
for (const cp of body.checkpoints) {
|
|
184
|
+
const cpErrors = [];
|
|
185
|
+
let envelopeOk = false;
|
|
186
|
+
let merkleRootOk = false;
|
|
187
|
+
if (typeof cp.tx_id !== "string") {
|
|
188
|
+
cpErrors.push("checkpoint missing tx_id");
|
|
189
|
+
}
|
|
190
|
+
else if (byTxId.has(cp.tx_id)) {
|
|
191
|
+
cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
byTxId.set(cp.tx_id, cp);
|
|
195
|
+
}
|
|
196
|
+
// Verify the checkpoint envelope against its committed record (external
|
|
197
|
+
// commitment — the record is the payload these external envelopes bind to).
|
|
198
|
+
let recordBytes;
|
|
199
|
+
try {
|
|
200
|
+
if (typeof cp.record_bytes === "string")
|
|
201
|
+
recordBytes = hexToBytes(cp.record_bytes);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
208
|
+
// A checkpoint must fully bind: signature + the committed record matching
|
|
209
|
+
// payload_hash. An undetermined binding (no record) is not enough to
|
|
210
|
+
// anchor the events' inclusion claims to a trustworthy root.
|
|
211
|
+
envelopeOk = res.ok && res.payloadHashOk === true;
|
|
212
|
+
if (!res.signatureOk)
|
|
213
|
+
cpErrors.push("checkpoint envelope signature failed");
|
|
214
|
+
if (res.payloadHashOk === false)
|
|
215
|
+
cpErrors.push("checkpoint record does not bind to payload_hash");
|
|
216
|
+
if (res.payloadHashOk === null)
|
|
217
|
+
cpErrors.push("checkpoint record_bytes missing — root untrusted");
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
|
|
221
|
+
}
|
|
222
|
+
// The committed record's merkle_root must equal the checkpoint's claimed
|
|
223
|
+
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
224
|
+
if (recordBytes !== undefined) {
|
|
225
|
+
try {
|
|
226
|
+
const record = JSON.parse(new TextDecoder().decode(recordBytes));
|
|
227
|
+
const committedRoot = record.event?.merkle_root;
|
|
228
|
+
merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
|
|
229
|
+
if (!merkleRootOk) {
|
|
230
|
+
cpErrors.push(`checkpoint merkle_root mismatch: claimed=${cp.merkle_root} committed=${String(committedRoot)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
cpErrors.push(`checkpoint record is not JSON: ${stringifyErr(e)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
checkpointResults.push({
|
|
238
|
+
txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
|
|
239
|
+
envelopeOk,
|
|
240
|
+
merkleRootOk,
|
|
241
|
+
onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
|
|
242
|
+
onChain: null, // per-gateway (§4.2), filled in step 5
|
|
243
|
+
ok: envelopeOk && merkleRootOk,
|
|
244
|
+
errors: cpErrors,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// --- Step 4: per-event ------------------------------------------------------
|
|
248
|
+
const eventResults = [];
|
|
249
|
+
for (const ev of body.events) {
|
|
250
|
+
const evErrors = [];
|
|
251
|
+
const eventId = getEventId(ev.envelope);
|
|
252
|
+
let envelopeOk = false;
|
|
253
|
+
let payloadBindingOk = null;
|
|
254
|
+
let recordBytes;
|
|
255
|
+
try {
|
|
256
|
+
if (typeof ev.record_bytes === "string")
|
|
257
|
+
recordBytes = hexToBytes(ev.record_bytes);
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
264
|
+
envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
|
|
265
|
+
payloadBindingOk = res.payloadHashOk;
|
|
266
|
+
if (!res.signatureOk)
|
|
267
|
+
evErrors.push("event envelope signature failed");
|
|
268
|
+
if (res.payloadHashOk === false)
|
|
269
|
+
evErrors.push("event record does not bind to payload_hash");
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
273
|
+
}
|
|
274
|
+
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
275
|
+
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
276
|
+
const incl = ev.inclusion;
|
|
277
|
+
const cp = incl && typeof incl.checkpoint_tx_id === "string" ? byTxId.get(incl.checkpoint_tx_id) : undefined;
|
|
278
|
+
const checkpointBound = cp !== undefined;
|
|
279
|
+
if (!incl || typeof incl !== "object") {
|
|
280
|
+
evErrors.push("event missing inclusion proof");
|
|
281
|
+
}
|
|
282
|
+
else if (!checkpointBound) {
|
|
283
|
+
evErrors.push(`event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`);
|
|
284
|
+
}
|
|
285
|
+
let inclusionOk = false;
|
|
286
|
+
if (incl && checkpointBound && cp) {
|
|
287
|
+
try {
|
|
288
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root));
|
|
289
|
+
if (!inclusionOk)
|
|
290
|
+
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
291
|
+
// Defense in depth: the claimed leaf_hash MUST be the hash of the
|
|
292
|
+
// event's own signed envelope bytes (profile §6) — otherwise a valid
|
|
293
|
+
// inclusion proof for an UNRELATED leaf could be smuggled in.
|
|
294
|
+
const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
|
|
295
|
+
const expectedLeafHex = toHex(expectedLeaf);
|
|
296
|
+
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
297
|
+
inclusionOk = false;
|
|
298
|
+
evErrors.push(`inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Content (raw-log) binding — the rawLog → content_hash link the rest of
|
|
306
|
+
// the chain (content_hash → record → payload_hash → signature → leaf →
|
|
307
|
+
// root) already covers. The committed hash lives in the RECORD
|
|
308
|
+
// (record.event.content_hash, minimal disclosure); for a promoted-disclosure
|
|
309
|
+
// envelope it falls back to the envelope's own committed hashes
|
|
310
|
+
// (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
|
|
311
|
+
// and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
|
|
312
|
+
// from the signed in-body `ev.content` FIRST, then the out-of-band
|
|
313
|
+
// options.content side input. Undetermined (null) when nothing is disclosed
|
|
314
|
+
// or nothing is committed; false ONLY on a real mismatch or an in-body vs
|
|
315
|
+
// side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
|
|
316
|
+
// discipline.
|
|
317
|
+
let contentOk = null;
|
|
318
|
+
{
|
|
319
|
+
const committed = [];
|
|
320
|
+
let recordContentHash;
|
|
321
|
+
if (recordBytes !== undefined) {
|
|
322
|
+
try {
|
|
323
|
+
const record = JSON.parse(new TextDecoder().decode(recordBytes));
|
|
324
|
+
const ch = record.event?.content_hash;
|
|
325
|
+
if (typeof ch === "string" && ch)
|
|
326
|
+
recordContentHash = ch.toLowerCase();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// record not JSON — the payload binding already reflects that; content
|
|
330
|
+
// stays undetermined rather than throwing.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (recordContentHash !== undefined) {
|
|
334
|
+
committed.push(recordContentHash);
|
|
335
|
+
}
|
|
336
|
+
else if (ev.envelope && typeof ev.envelope === "object") {
|
|
337
|
+
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
338
|
+
// which throws on a null/non-object envelope — a verifier must never
|
|
339
|
+
// raise on adversarial input (such an event already fails via envelopeOk).
|
|
340
|
+
for (const c of contentHashes(ev.envelope))
|
|
341
|
+
committed.push(c.hash.toLowerCase());
|
|
342
|
+
}
|
|
343
|
+
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
344
|
+
// side input is hex; a Uint8Array is used as-is.
|
|
345
|
+
let inBody;
|
|
346
|
+
if (typeof ev.content === "string") {
|
|
347
|
+
try {
|
|
348
|
+
inBody = hexToBytes(ev.content);
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
let side;
|
|
355
|
+
const sideRaw = options.content?.[eventId];
|
|
356
|
+
if (sideRaw instanceof Uint8Array) {
|
|
357
|
+
side = sideRaw;
|
|
358
|
+
}
|
|
359
|
+
else if (typeof sideRaw === "string") {
|
|
360
|
+
try {
|
|
361
|
+
side = hexToBytes(sideRaw);
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
368
|
+
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
369
|
+
contentOk = false;
|
|
370
|
+
evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
const disclosed = inBody ?? side;
|
|
374
|
+
if (disclosed === undefined || committed.length === 0) {
|
|
375
|
+
contentOk = null;
|
|
376
|
+
if (disclosed !== undefined && committed.length === 0) {
|
|
377
|
+
evErrors.push("disclosed content present but no committed content_hash to bind to " +
|
|
378
|
+
"(record withheld or record has no event.content_hash) — undetermined");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const got = await sha256Hex(disclosed);
|
|
383
|
+
contentOk = committed.includes(got);
|
|
384
|
+
if (!contentOk) {
|
|
385
|
+
evErrors.push("disclosed content does not match the committed content_hash: " +
|
|
386
|
+
`sha256(disclosed)=${got} committed=${committed.join("|")}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
eventResults.push({
|
|
392
|
+
eventId,
|
|
393
|
+
envelopeOk,
|
|
394
|
+
payloadBindingOk,
|
|
395
|
+
inclusionOk,
|
|
396
|
+
checkpointBound,
|
|
397
|
+
contentOk,
|
|
398
|
+
// An event is "ok" when its envelope is authentic (signature + binding
|
|
399
|
+
// not-failed), its inclusion proof reconstructs the root, it binds to a
|
|
400
|
+
// present checkpoint, and its disclosed content did not MISMATCH. A
|
|
401
|
+
// withheld record or undisclosed content (binding/content null) does not
|
|
402
|
+
// fail it.
|
|
403
|
+
ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
|
|
404
|
+
errors: evErrors,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
// --- Step 5: optional on-chain re-fetch ------------------------------------
|
|
408
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
409
|
+
let gatewayUnavailable = false;
|
|
410
|
+
if (onChainChecked) {
|
|
411
|
+
if (typeof fetchImpl !== "function") {
|
|
412
|
+
gatewayUnavailable = true;
|
|
413
|
+
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
for (const cpr of checkpointResults) {
|
|
417
|
+
const cp = byTxId.get(cpr.txId);
|
|
418
|
+
if (!cp)
|
|
419
|
+
continue;
|
|
420
|
+
const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
|
|
421
|
+
cpr.onChain = outcome.onChain;
|
|
422
|
+
cpr.onChainOk = outcome.onChain.onChainOk;
|
|
423
|
+
if (outcome.unavailable)
|
|
424
|
+
gatewayUnavailable = true;
|
|
425
|
+
if (outcome.error)
|
|
426
|
+
cpr.errors.push(outcome.error);
|
|
427
|
+
// Fold the on-chain dimension in: a MISMATCH (false) fails the
|
|
428
|
+
// checkpoint — the proof was not anchored as claimed. An UNREACHABLE
|
|
429
|
+
// gateway (null) leaves the offline-sound checkpoint ok; the run
|
|
430
|
+
// downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
|
|
431
|
+
// failure. A match (true) keeps it ok.
|
|
432
|
+
cpr.ok = cpr.envelopeOk && cpr.merkleRootOk && cpr.onChainOk !== false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// --- Rollup ----------------------------------------------------------------
|
|
437
|
+
const allCheckpointsOk = checkpointResults.length > 0 && checkpointResults.every((c) => c.ok);
|
|
438
|
+
const allEventsOk = eventResults.every((e) => e.ok);
|
|
439
|
+
const anyHardFailure = checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok);
|
|
440
|
+
const anyWithheldRecord = eventResults.some((e) => e.payloadBindingOk === null);
|
|
441
|
+
let status;
|
|
442
|
+
if (anyHardFailure) {
|
|
443
|
+
status = "failed";
|
|
444
|
+
}
|
|
445
|
+
else if (gatewayUnavailable) {
|
|
446
|
+
// Every offline check passed and nothing MISMATCHED, but a requested
|
|
447
|
+
// on-chain confirmation could not be made — the bundle is offline-sound,
|
|
448
|
+
// its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
|
|
449
|
+
status = "partial";
|
|
450
|
+
errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
|
|
451
|
+
}
|
|
452
|
+
else if (anyWithheldRecord) {
|
|
453
|
+
// Everything that COULD be checked passed; some record bytes were withheld
|
|
454
|
+
// so their semantic binding is undetermined (not a failure) — "partial".
|
|
455
|
+
status = "partial";
|
|
456
|
+
}
|
|
457
|
+
else if (allCheckpointsOk && allEventsOk) {
|
|
458
|
+
status = "verified";
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
status = "partial";
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
status,
|
|
465
|
+
specVersionOk,
|
|
466
|
+
bodyHashOk: true,
|
|
467
|
+
signatureOk: true,
|
|
468
|
+
bodyType,
|
|
469
|
+
onChainChecked,
|
|
470
|
+
checkpoints: checkpointResults,
|
|
471
|
+
events: eventResults,
|
|
472
|
+
assertedStatus,
|
|
473
|
+
errors,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// ===========================================================================
|
|
477
|
+
// Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
|
|
478
|
+
// Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
|
|
479
|
+
// + body_hash) has already verified (steps 1–2). This composes the existing
|
|
480
|
+
// evidence path (recompute the inline source verdict) with the one new
|
|
481
|
+
// primitive (embedded RSA-PSS operator attestations, verifyRsaPssSha256).
|
|
482
|
+
// ===========================================================================
|
|
483
|
+
async function verifyExportBody(b, specVersionOk, assertedStatus, errors, options) {
|
|
484
|
+
// Malformed-as-an-export (§5 exit 2): a required field is missing or a key is
|
|
485
|
+
// unparseable — the wrapper verified, but no verdict can be rendered. The
|
|
486
|
+
// wrapper fields stay true (they DID verify) for any consumer that inspects
|
|
487
|
+
// them; the CLI's malformed branch shows the errors and exits 2.
|
|
488
|
+
const exportMalformed = (error) => ({
|
|
489
|
+
status: "malformed",
|
|
490
|
+
specVersionOk,
|
|
491
|
+
bodyHashOk: true,
|
|
492
|
+
signatureOk: true,
|
|
493
|
+
bodyType: EXPORT_BODY_TYPE,
|
|
494
|
+
onChainChecked: false,
|
|
495
|
+
checkpoints: [],
|
|
496
|
+
events: [],
|
|
497
|
+
assertedStatus,
|
|
498
|
+
errors: [...errors, error],
|
|
499
|
+
});
|
|
500
|
+
const body = b.body;
|
|
501
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
502
|
+
return exportMalformed("export body is not a JSON object");
|
|
503
|
+
}
|
|
504
|
+
if (typeof body.source_bundle_hash !== "string") {
|
|
505
|
+
return exportMalformed("export body missing source_bundle_hash");
|
|
506
|
+
}
|
|
507
|
+
if (!Array.isArray(body.attestations)) {
|
|
508
|
+
return exportMalformed("export body missing attestations[]");
|
|
509
|
+
}
|
|
510
|
+
if (body.kernel_verdict === null ||
|
|
511
|
+
typeof body.kernel_verdict !== "object" ||
|
|
512
|
+
Array.isArray(body.kernel_verdict)) {
|
|
513
|
+
return exportMalformed("export body missing kernel_verdict");
|
|
514
|
+
}
|
|
515
|
+
// --- Step 3: source-bundle linkage ----------------------------------------
|
|
516
|
+
const inline = body.source_bundle;
|
|
517
|
+
if (inline === undefined || inline === null) {
|
|
518
|
+
// source_bundle_ref (out-of-line) is not verifiable on a network-isolated
|
|
519
|
+
// machine unless the referenced bytes are co-delivered — the source-
|
|
520
|
+
// dependent checks are UNDETERMINED (exit 3), NOT a failure (§5 step 3).
|
|
521
|
+
return {
|
|
522
|
+
status: "partial",
|
|
523
|
+
specVersionOk,
|
|
524
|
+
bodyHashOk: true,
|
|
525
|
+
signatureOk: true,
|
|
526
|
+
bodyType: EXPORT_BODY_TYPE,
|
|
527
|
+
onChainChecked: false,
|
|
528
|
+
checkpoints: [],
|
|
529
|
+
events: [],
|
|
530
|
+
assertedStatus,
|
|
531
|
+
errors: [
|
|
532
|
+
...errors,
|
|
533
|
+
"source_bundle is not inline (source_bundle_ref) — bytes unavailable offline; " +
|
|
534
|
+
"source-dependent checks undetermined",
|
|
535
|
+
],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
let sourceLinkageOk = false;
|
|
539
|
+
try {
|
|
540
|
+
const recomputed = await sha256Hex(utf8(jcs(inline)));
|
|
541
|
+
sourceLinkageOk = recomputed === body.source_bundle_hash;
|
|
542
|
+
if (!sourceLinkageOk) {
|
|
543
|
+
errors.push(`source_bundle_hash mismatch: body=${body.source_bundle_hash} recomputed=${recomputed}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch (e) {
|
|
547
|
+
errors.push(`source_bundle canonicalization failed: ${stringifyErr(e)}`);
|
|
548
|
+
}
|
|
549
|
+
// --- Step 4: recompute the source verdict ---------------------------------
|
|
550
|
+
// The existing evidence path over the inline source bundle — same primitives
|
|
551
|
+
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
552
|
+
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
553
|
+
const sourceResult = await verifyEvidenceBundle(inline, options);
|
|
554
|
+
// Map source checkpoints by tx_id for attestation data_hash binding (§5 6c).
|
|
555
|
+
const sourceCheckpoints = new Map();
|
|
556
|
+
const sb = inline.body;
|
|
557
|
+
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
558
|
+
for (const cp of sb.checkpoints) {
|
|
559
|
+
if (cp && typeof cp.tx_id === "string")
|
|
560
|
+
sourceCheckpoints.set(cp.tx_id, cp);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// --- Step 6: embedded attestation records ---------------------------------
|
|
564
|
+
const attestations = [];
|
|
565
|
+
for (const rec of body.attestations) {
|
|
566
|
+
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
567
|
+
if (r.malformed !== undefined)
|
|
568
|
+
return exportMalformed(r.malformed);
|
|
569
|
+
attestations.push(r.result);
|
|
570
|
+
}
|
|
571
|
+
// --- Build the recomputed §4 verdict object -------------------------------
|
|
572
|
+
const verdict = buildVerdictObject(sourceResult, attestations);
|
|
573
|
+
if (typeof b.generated_at === "string")
|
|
574
|
+
verdict.as_of = b.generated_at;
|
|
575
|
+
// --- Step 5: verdict agreement (DETERMINISTIC dimensions only) ------------
|
|
576
|
+
// The cached body.kernel_verdict is recompute-don't-trust (principle 3). But
|
|
577
|
+
// agreement compares ONLY the deterministic, offline-recomputable dimensions:
|
|
578
|
+
// per-event signature/payload-binding/inclusion/content, per-checkpoint
|
|
579
|
+
// merkle roots, and per-attestation signature/operator-address/data_hash
|
|
580
|
+
// binding. The on-chain per-gateway dimension is environment/time-dependent —
|
|
581
|
+
// the issuer observed it online at compose time; an offline verifier
|
|
582
|
+
// legitimately sees `null` — so it is INFORMATIONAL and MUST NOT by itself
|
|
583
|
+
// trigger an exit-1 tamper verdict (the verifier's own on-chain outcomes fold
|
|
584
|
+
// in at step 7 only if it re-fetches). Advisory rollups (status / counts /
|
|
585
|
+
// summary / as_of / custody_chain) are derived, not compared. See the §5
|
|
586
|
+
// step-5 erratum flagged in the slice report.
|
|
587
|
+
let verdictAgreementOk = false;
|
|
588
|
+
try {
|
|
589
|
+
const cachedProjection = jcs(projectVerdictForAgreement(body.kernel_verdict));
|
|
590
|
+
const freshProjection = jcs(projectVerdictForAgreement(verdict));
|
|
591
|
+
verdictAgreementOk = cachedProjection === freshProjection;
|
|
592
|
+
if (!verdictAgreementOk) {
|
|
593
|
+
errors.push("recomputed verdict disagrees with the cached kernel_verdict " +
|
|
594
|
+
"(deterministic dimensions) — recompute-don't-trust");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch (e) {
|
|
598
|
+
errors.push(`verdict agreement comparison failed: ${stringifyErr(e)}`);
|
|
599
|
+
}
|
|
600
|
+
// --- Step 7 already folded in by the source recompute ---------------------
|
|
601
|
+
// (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
|
|
602
|
+
// a checkpoint `mismatch` made it "failed"; an all-`unreachable` checkpoint
|
|
603
|
+
// made it "partial" + an unreachable note → exit 3.)
|
|
604
|
+
// Propagate the source recompute's notes so the auditor — and the CLI's
|
|
605
|
+
// exit-3 detection (which keys off "unreachable") — see them.
|
|
606
|
+
for (const e of sourceResult.errors)
|
|
607
|
+
errors.push(`source: ${e}`);
|
|
608
|
+
// --- Step 9: export rollup + exit -----------------------------------------
|
|
609
|
+
const anyAttestationFailed = attestations.some((a) => !a.ok);
|
|
610
|
+
let status;
|
|
611
|
+
if (!sourceLinkageOk ||
|
|
612
|
+
!verdictAgreementOk ||
|
|
613
|
+
sourceResult.status === "failed" ||
|
|
614
|
+
sourceResult.status === "malformed" ||
|
|
615
|
+
anyAttestationFailed) {
|
|
616
|
+
status = "failed";
|
|
617
|
+
}
|
|
618
|
+
else if (sourceResult.status === "partial") {
|
|
619
|
+
// Undetermined source (withheld record → exit 0; gateway-unreachable →
|
|
620
|
+
// exit 3 via the propagated "unreachable" note). Never a failure.
|
|
621
|
+
status = "partial";
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
status = "verified";
|
|
625
|
+
}
|
|
626
|
+
// The DISPLAYED verdict reflects the full recompute (incl. attestations).
|
|
627
|
+
verdict.status = status;
|
|
628
|
+
verdict.summary = summarizeExport(status, sourceResult, attestations);
|
|
629
|
+
return {
|
|
630
|
+
status,
|
|
631
|
+
specVersionOk,
|
|
632
|
+
bodyHashOk: true,
|
|
633
|
+
signatureOk: true,
|
|
634
|
+
bodyType: EXPORT_BODY_TYPE,
|
|
635
|
+
onChainChecked: sourceResult.onChainChecked,
|
|
636
|
+
// The top-level per-checkpoint / per-event results ARE the recomputed source
|
|
637
|
+
// results, so the CLI's existing rendering shows them unchanged.
|
|
638
|
+
checkpoints: sourceResult.checkpoints,
|
|
639
|
+
events: sourceResult.events,
|
|
640
|
+
assertedStatus,
|
|
641
|
+
export: {
|
|
642
|
+
sourceLinkageOk,
|
|
643
|
+
sourceStatus: sourceResult.status,
|
|
644
|
+
verdictAgreementOk,
|
|
645
|
+
attestations,
|
|
646
|
+
verdict,
|
|
647
|
+
status,
|
|
648
|
+
},
|
|
649
|
+
errors,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
|
|
653
|
+
const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
|
|
654
|
+
// Map source checkpoints by tx_id for attestation data_hash binding (§5 6c) —
|
|
655
|
+
// identical to verifyExportBody's map construction.
|
|
656
|
+
const sourceCheckpoints = new Map();
|
|
657
|
+
const sb = sourceBundle.body;
|
|
658
|
+
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
659
|
+
for (const cp of sb.checkpoints) {
|
|
660
|
+
if (cp && typeof cp.tx_id === "string")
|
|
661
|
+
sourceCheckpoints.set(cp.tx_id, cp);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const attestations = [];
|
|
665
|
+
for (const rec of attestationRecords) {
|
|
666
|
+
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
667
|
+
if (r.malformed !== undefined) {
|
|
668
|
+
throw new Error(`attestation record is malformed: ${r.malformed}`);
|
|
669
|
+
}
|
|
670
|
+
attestations.push(r.result);
|
|
671
|
+
}
|
|
672
|
+
const verdict = buildVerdictObject(sourceResult, attestations);
|
|
673
|
+
verdict.as_of = generatedAt;
|
|
674
|
+
// Export rollup (§5 step 9). Source linkage and verdict agreement are always
|
|
675
|
+
// satisfied at compose time (the composer computes the source hash itself and
|
|
676
|
+
// there is no prior cached verdict to disagree with), so the only failure
|
|
677
|
+
// inputs here are the recomputed source status and any failed attestation.
|
|
678
|
+
const anyAttestationFailed = attestations.some((a) => !a.ok);
|
|
679
|
+
let status;
|
|
680
|
+
if (sourceResult.status === "failed" ||
|
|
681
|
+
sourceResult.status === "malformed" ||
|
|
682
|
+
anyAttestationFailed) {
|
|
683
|
+
status = "failed";
|
|
684
|
+
}
|
|
685
|
+
else if (sourceResult.status === "partial") {
|
|
686
|
+
status = "partial";
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
status = "verified";
|
|
690
|
+
}
|
|
691
|
+
verdict.status = status;
|
|
692
|
+
verdict.summary = summarizeExport(status, sourceResult, attestations);
|
|
693
|
+
return { verdict, sourceStatus: sourceResult.status, status, attestations };
|
|
694
|
+
}
|
|
695
|
+
// Verify one embedded attestation record (§5 step 6a–d). Returns `malformed`
|
|
696
|
+
// (→ exit 2) for an unparseable RSA key / signature hex or a missing required
|
|
697
|
+
// field; otherwise a per-dimension AttestationResult whose `ok` gates on the
|
|
698
|
+
// three binding checks (sig / operator-address / data_hash). subject_ref is
|
|
699
|
+
// undetermined-tolerant and never gates.
|
|
700
|
+
async function verifyAttestation(rec, sourceCheckpoints) {
|
|
701
|
+
const errs = [];
|
|
702
|
+
const result = {
|
|
703
|
+
checkpointTxId: rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
|
|
704
|
+
operator: "",
|
|
705
|
+
gateway: null,
|
|
706
|
+
signatureOk: false,
|
|
707
|
+
operatorAddressBound: false,
|
|
708
|
+
dataHashBound: false,
|
|
709
|
+
checkpointResolved: false,
|
|
710
|
+
level: null,
|
|
711
|
+
subjectRefOk: null,
|
|
712
|
+
ok: false,
|
|
713
|
+
errors: errs,
|
|
714
|
+
};
|
|
715
|
+
if (rec === null || typeof rec !== "object" || Array.isArray(rec)) {
|
|
716
|
+
return { result, malformed: "attestation record is not a JSON object" };
|
|
717
|
+
}
|
|
718
|
+
const payload = rec.payload;
|
|
719
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
720
|
+
return { result, malformed: "attestation record missing payload" };
|
|
721
|
+
}
|
|
722
|
+
if (rec.signature_alg !== "rsa-pss-sha256") {
|
|
723
|
+
return {
|
|
724
|
+
result,
|
|
725
|
+
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const pub = rec.public_key;
|
|
729
|
+
if (pub === null ||
|
|
730
|
+
typeof pub !== "object" ||
|
|
731
|
+
pub.kty !== "RSA" ||
|
|
732
|
+
typeof pub.n !== "string" ||
|
|
733
|
+
typeof pub.e !== "string") {
|
|
734
|
+
return { result, malformed: "attestation record missing/invalid RSA public_key JWK" };
|
|
735
|
+
}
|
|
736
|
+
if (typeof rec.signature !== "string") {
|
|
737
|
+
return { result, malformed: "attestation record missing signature" };
|
|
738
|
+
}
|
|
739
|
+
const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
|
|
740
|
+
result.operator = typeof payload.operator === "string" ? payload.operator : "";
|
|
741
|
+
result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
|
|
742
|
+
result.level = typeof payload.level === "number" ? payload.level : null;
|
|
743
|
+
// (a) RSA-PSS-SHA-256 signature over JCS(payload). A malformed sig hex or
|
|
744
|
+
// unimportable key THROWS (malformed → exit 2); a well-formed key that simply
|
|
745
|
+
// fails to verify returns false (→ exit 1).
|
|
746
|
+
let payloadBytes;
|
|
747
|
+
try {
|
|
748
|
+
payloadBytes = utf8(jcs(payload));
|
|
749
|
+
}
|
|
750
|
+
catch (e) {
|
|
751
|
+
return { result, malformed: `attestation payload canonicalization failed: ${stringifyErr(e)}` };
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
|
|
755
|
+
if (!result.signatureOk)
|
|
756
|
+
errs.push("attestation RSA-PSS signature verification failed");
|
|
757
|
+
}
|
|
758
|
+
catch (e) {
|
|
759
|
+
return { result, malformed: `attestation ${stringifyErr(e)}` };
|
|
760
|
+
}
|
|
761
|
+
// (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
|
|
762
|
+
try {
|
|
763
|
+
const derived = await deriveOperatorAddress(pub.n);
|
|
764
|
+
result.operatorAddressBound = result.operator !== "" && derived === result.operator;
|
|
765
|
+
if (!result.operatorAddressBound) {
|
|
766
|
+
errs.push(`operator-address binding failed: derived=${derived} payload.operator=${result.operator}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
return { result, malformed: `attestation operator key: ${stringifyErr(e)}` };
|
|
771
|
+
}
|
|
772
|
+
// (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
|
|
773
|
+
// uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
|
|
774
|
+
// against, §4.2), so the checkpoint's committed content hash is
|
|
775
|
+
// SHA-256(JCS(checkpoint.envelope)).
|
|
776
|
+
const cp = sourceCheckpoints.get(result.checkpointTxId);
|
|
777
|
+
result.checkpointResolved = cp !== undefined;
|
|
778
|
+
if (!cp) {
|
|
779
|
+
errs.push(`attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
try {
|
|
783
|
+
const committed = await sha256Hex(utf8(jcs(cp.envelope)));
|
|
784
|
+
const dataHash = typeof payload.data_hash === "string" ? payload.data_hash.toLowerCase() : "";
|
|
785
|
+
result.dataHashBound = dataHash !== "" && dataHash === committed;
|
|
786
|
+
if (!result.dataHashBound) {
|
|
787
|
+
errs.push(`data_hash does not bind to the checkpoint's committed content hash: ` +
|
|
788
|
+
`data_hash=${dataHash} committed=${committed}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
errs.push(`cannot recompute checkpoint content hash: ${stringifyErr(e)}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// (d) subject_ref (§3.2) — well-formedness only (no side-input subject channel
|
|
796
|
+
// in v1); undetermined (null), never a hard failure.
|
|
797
|
+
result.subjectRefOk = evaluateSubjectRef(payload.subject_ref, errs);
|
|
798
|
+
result.ok =
|
|
799
|
+
result.signatureOk &&
|
|
800
|
+
result.operatorAddressBound &&
|
|
801
|
+
result.dataHashBound &&
|
|
802
|
+
result.checkpointResolved;
|
|
803
|
+
return { result };
|
|
804
|
+
}
|
|
805
|
+
// subject_ref (§3.2): absent → null (unbound). Present + well-formed but no
|
|
806
|
+
// side-input subject supplied → null (undetermined, mirrors disclosed-content).
|
|
807
|
+
// Present + malformed → null + a surfaced note (never gates the attestation).
|
|
808
|
+
function evaluateSubjectRef(subjectRef, errs) {
|
|
809
|
+
if (subjectRef === undefined || subjectRef === null)
|
|
810
|
+
return null;
|
|
811
|
+
if (typeof subjectRef !== "object" || Array.isArray(subjectRef)) {
|
|
812
|
+
errs.push("subject_ref is present but is not an object");
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
const sr = subjectRef;
|
|
816
|
+
const hashOk = typeof sr.hash === "string" && /^[0-9a-f]{64}$/.test(sr.hash);
|
|
817
|
+
const typeOk = typeof sr.type === "string" && /^[a-z0-9.:-]+$/.test(sr.type);
|
|
818
|
+
if (!hashOk || !typeOk) {
|
|
819
|
+
errs.push("subject_ref is present but not well-formed ({hash: sha256-hex, type: token})");
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
return null; // well-formed, no side input → undetermined
|
|
823
|
+
}
|
|
824
|
+
// Build the §4 verdict object (snake_case, spec-canonical) from the recomputed
|
|
825
|
+
// source result + the attestation results. This is the recomputed copy the
|
|
826
|
+
// renderer/verify-API display; it is compared (over its deterministic subset)
|
|
827
|
+
// to the cached body.kernel_verdict in step 5.
|
|
828
|
+
function buildVerdictObject(source, attestations) {
|
|
829
|
+
const attByCheckpoint = new Map();
|
|
830
|
+
for (const a of attestations) {
|
|
831
|
+
const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
|
|
832
|
+
list.push({
|
|
833
|
+
operator: a.operator,
|
|
834
|
+
...(a.gateway ? { gateway: a.gateway } : {}),
|
|
835
|
+
signature_ok: a.signatureOk,
|
|
836
|
+
operator_address_bound: a.operatorAddressBound,
|
|
837
|
+
data_hash_bound: a.dataHashBound,
|
|
838
|
+
...(a.level !== null ? { level: a.level } : {}),
|
|
839
|
+
subject_ref_ok: a.subjectRefOk,
|
|
840
|
+
});
|
|
841
|
+
attByCheckpoint.set(a.checkpointTxId, list);
|
|
842
|
+
}
|
|
843
|
+
const events = source.events.map((e) => ({
|
|
844
|
+
event_id: e.eventId,
|
|
845
|
+
signature_ok: e.envelopeOk,
|
|
846
|
+
payload_bound: e.payloadBindingOk,
|
|
847
|
+
inclusion_ok: e.inclusionOk,
|
|
848
|
+
content_ok: e.contentOk,
|
|
849
|
+
status: eventVerdictStatus(e),
|
|
850
|
+
}));
|
|
851
|
+
const checkpoints = source.checkpoints.map((c) => ({
|
|
852
|
+
checkpoint_tx_id: c.txId,
|
|
853
|
+
merkle_root_ok: c.merkleRootOk,
|
|
854
|
+
on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
|
|
855
|
+
attestations: attByCheckpoint.get(c.txId) ?? [],
|
|
856
|
+
}));
|
|
857
|
+
let verified = 0;
|
|
858
|
+
let failed = 0;
|
|
859
|
+
let undetermined = 0;
|
|
860
|
+
for (const e of events) {
|
|
861
|
+
if (e.status === "failed")
|
|
862
|
+
failed++;
|
|
863
|
+
else if (e.status === "verified")
|
|
864
|
+
verified++;
|
|
865
|
+
else
|
|
866
|
+
undetermined++;
|
|
867
|
+
}
|
|
868
|
+
for (const a of attestations) {
|
|
869
|
+
if (a.ok)
|
|
870
|
+
verified++;
|
|
871
|
+
else
|
|
872
|
+
failed++;
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
schema_version: "sanning.evidence.verdict/v1",
|
|
876
|
+
status: source.status === "malformed" ? "failed" : source.status,
|
|
877
|
+
counts: { verified, failed, undetermined },
|
|
878
|
+
events,
|
|
879
|
+
checkpoints,
|
|
880
|
+
custody_chain: null,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function toVerdictOnChain(o) {
|
|
884
|
+
return {
|
|
885
|
+
rollup: o.rollup,
|
|
886
|
+
on_chain_ok: o.onChainOk,
|
|
887
|
+
// The kernel emits {gateway, outcome}; block_height is issuer-side
|
|
888
|
+
// enrichment (optional in the §4.4 schema) the offline verifier does not
|
|
889
|
+
// recompute.
|
|
890
|
+
per_gateway: o.perGateway.map((g) => ({ gateway: g.gateway, outcome: g.outcome })),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
function eventVerdictStatus(e) {
|
|
894
|
+
if (!e.ok)
|
|
895
|
+
return "failed";
|
|
896
|
+
if (e.payloadBindingOk === null)
|
|
897
|
+
return "partial"; // withheld record — undetermined
|
|
898
|
+
return "verified";
|
|
899
|
+
}
|
|
900
|
+
// The §5 step-5 agreement projection: strip every environment/time-dependent
|
|
901
|
+
// and derived dimension, leaving only what an offline verifier deterministically
|
|
902
|
+
// recomputes. Tolerant of a hostile/garbled cached verdict (optional chaining +
|
|
903
|
+
// nullish normalization) — a structural difference surfaces as disagreement,
|
|
904
|
+
// which is the correct tamper signal. Works on both the cached snake_case JSON
|
|
905
|
+
// and the freshly built VerdictObject.
|
|
906
|
+
function projectVerdictForAgreement(v) {
|
|
907
|
+
const o = (v ?? {});
|
|
908
|
+
const events = Array.isArray(o.events) ? o.events : [];
|
|
909
|
+
const checkpoints = Array.isArray(o.checkpoints) ? o.checkpoints : [];
|
|
910
|
+
return {
|
|
911
|
+
schema_version: typeof o.schema_version === "string" ? o.schema_version : null,
|
|
912
|
+
events: events.map((e) => {
|
|
913
|
+
const ev = (e ?? {});
|
|
914
|
+
return {
|
|
915
|
+
event_id: ev.event_id ?? null,
|
|
916
|
+
signature_ok: ev.signature_ok ?? null,
|
|
917
|
+
payload_bound: ev.payload_bound ?? null,
|
|
918
|
+
inclusion_ok: ev.inclusion_ok ?? null,
|
|
919
|
+
content_ok: ev.content_ok ?? null,
|
|
920
|
+
};
|
|
921
|
+
}),
|
|
922
|
+
checkpoints: checkpoints.map((c) => {
|
|
923
|
+
const cp = (c ?? {});
|
|
924
|
+
const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
|
|
925
|
+
return {
|
|
926
|
+
checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
|
|
927
|
+
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
928
|
+
// on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
|
|
929
|
+
attestations: atts.map((a) => {
|
|
930
|
+
const at = (a ?? {});
|
|
931
|
+
return {
|
|
932
|
+
operator: at.operator ?? null,
|
|
933
|
+
signature_ok: at.signature_ok ?? null,
|
|
934
|
+
operator_address_bound: at.operator_address_bound ?? null,
|
|
935
|
+
data_hash_bound: at.data_hash_bound ?? null,
|
|
936
|
+
subject_ref_ok: at.subject_ref_ok ?? null,
|
|
937
|
+
};
|
|
938
|
+
}),
|
|
939
|
+
};
|
|
940
|
+
}),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function summarizeExport(status, source, attestations) {
|
|
944
|
+
const attOk = attestations.filter((a) => a.ok).length;
|
|
945
|
+
return (`Export ${status}: source bundle ${source.status} ` +
|
|
946
|
+
`(${source.events.length} event(s) across ${source.checkpoints.length} checkpoint(s)); ` +
|
|
947
|
+
`${attOk}/${attestations.length} operator attestation(s) valid.`);
|
|
948
|
+
}
|
|
949
|
+
// Re-fetch a checkpoint's raw bytes from EACH gateway and classify every
|
|
950
|
+
// gateway's individual outcome (evidence-export.md §4.2), then roll them up
|
|
951
|
+
// worst-finding-wins. Unlike the pre-§4.2 first-responsive-wins collapse, this
|
|
952
|
+
// records a per-gateway array so a confirm/mismatch/unreachable MIX across
|
|
953
|
+
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
954
|
+
// single-boolean exactly for a single gateway.
|
|
955
|
+
async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
956
|
+
let expectedHex;
|
|
957
|
+
try {
|
|
958
|
+
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
// Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
|
|
962
|
+
// (we can never confirm bytes we cannot compute).
|
|
963
|
+
expectedHex = undefined;
|
|
964
|
+
}
|
|
965
|
+
const perGateway = [];
|
|
966
|
+
for (const gw of gateways) {
|
|
967
|
+
perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
|
|
968
|
+
}
|
|
969
|
+
const onChain = rollupOnChain(perGateway);
|
|
970
|
+
let error;
|
|
971
|
+
if (onChain.rollup === "mismatch") {
|
|
972
|
+
error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
|
|
973
|
+
}
|
|
974
|
+
else if (onChain.rollup === "unreachable") {
|
|
975
|
+
error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
|
|
976
|
+
}
|
|
977
|
+
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
978
|
+
}
|
|
979
|
+
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
980
|
+
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
981
|
+
async function probeGateway(cp, gateway, expectedHex, fetchImpl) {
|
|
982
|
+
const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
|
|
983
|
+
let resp;
|
|
984
|
+
try {
|
|
985
|
+
resp = await fetchImpl(url);
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
return "unreachable"; // network error / timeout
|
|
989
|
+
}
|
|
990
|
+
if (!resp.ok)
|
|
991
|
+
return "unreachable"; // no such tx / gateway error
|
|
992
|
+
let onChainBytes;
|
|
993
|
+
try {
|
|
994
|
+
onChainBytes = new Uint8Array(await resp.arrayBuffer());
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
return "unreachable";
|
|
998
|
+
}
|
|
999
|
+
if (expectedHex === undefined)
|
|
1000
|
+
return "mismatch";
|
|
1001
|
+
// The on-chain bytes are the uploaded envelope bytes (JCS-canonical, per the
|
|
1002
|
+
// family upload invariant). Compare structurally too, so a gateway that
|
|
1003
|
+
// re-pretty-prints does not spuriously fail — but the canonical re-hash is
|
|
1004
|
+
// the load-bearing check.
|
|
1005
|
+
if (toHex(onChainBytes) === expectedHex)
|
|
1006
|
+
return "confirm";
|
|
1007
|
+
try {
|
|
1008
|
+
const parsed = JSON.parse(new TextDecoder().decode(onChainBytes));
|
|
1009
|
+
if (toHex(utf8(jcs(parsed))) === expectedHex)
|
|
1010
|
+
return "confirm";
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
// not JSON / not parseable — a genuine mismatch
|
|
1014
|
+
}
|
|
1015
|
+
return "mismatch";
|
|
1016
|
+
}
|
|
1017
|
+
// Rollup rule (§4.2): mismatch if ANY gateway is mismatch; else confirm if ANY
|
|
1018
|
+
// is confirm; else unreachable. The collapsed `onChainOk`: false on mismatch,
|
|
1019
|
+
// true on confirm, null on all-unreachable. An empty gateway list is treated as
|
|
1020
|
+
// all-unreachable (no signal).
|
|
1021
|
+
function rollupOnChain(perGateway) {
|
|
1022
|
+
let rollup = "unreachable";
|
|
1023
|
+
if (perGateway.some((g) => g.outcome === "mismatch"))
|
|
1024
|
+
rollup = "mismatch";
|
|
1025
|
+
else if (perGateway.some((g) => g.outcome === "confirm"))
|
|
1026
|
+
rollup = "confirm";
|
|
1027
|
+
const onChainOk = rollup === "mismatch" ? false : rollup === "confirm" ? true : null;
|
|
1028
|
+
return { rollup, onChainOk, perGateway };
|
|
1029
|
+
}
|
|
1030
|
+
function getEventId(env) {
|
|
1031
|
+
if (env && typeof env === "object" && typeof env.event_id === "string") {
|
|
1032
|
+
return env.event_id;
|
|
1033
|
+
}
|
|
1034
|
+
return "<unknown>";
|
|
1035
|
+
}
|
|
1036
|
+
function toHex(bytes) {
|
|
1037
|
+
let out = "";
|
|
1038
|
+
for (const x of bytes)
|
|
1039
|
+
out += x.toString(16).padStart(2, "0");
|
|
1040
|
+
return out;
|
|
1041
|
+
}
|
|
1042
|
+
function bytesEqual(a, b) {
|
|
1043
|
+
if (a.length !== b.length)
|
|
1044
|
+
return false;
|
|
1045
|
+
for (let i = 0; i < a.length; i++)
|
|
1046
|
+
if (a[i] !== b[i])
|
|
1047
|
+
return false;
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|