@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.
@@ -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
+ }