@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,1491 @@
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
+
19
+ import {
20
+ deriveOperatorAddress,
21
+ ed25519Verify,
22
+ hexToBytes,
23
+ sha256Hex,
24
+ utf8,
25
+ verifyRsaPssSha256,
26
+ } from "./crypto.js";
27
+ import { leafHash, verifyInclusion } from "./merkle.js";
28
+ import {
29
+ MAX_CANONICAL_DEPTH,
30
+ contentHashes,
31
+ exceedsDepth,
32
+ jcs,
33
+ verifyEnvelope,
34
+ } from "./verifier.js";
35
+ import type { Envelope } from "./types.js";
36
+
37
+ // Only the evidence-bundle major(s) this kernel verifies. A new major is a
38
+ // deliberate one-entry addition HERE — same fail-closed discipline as the
39
+ // envelope verifier's ACCEPTED_SPEC_MAJORS.
40
+ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
41
+
42
+ // The body_type this module knows how to verify structurally. An unknown
43
+ // body_type is not a hard failure of the WRAPPER (the signature + body_hash
44
+ // still verify), but its body cannot be re-derived, so the per-body verdict is
45
+ // "undetermined" rather than "verified".
46
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
47
+
48
+ // The issuer-composed attested-evidence-export body (specs/evidence-export.md
49
+ // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
50
+ // carries a cached verdict + the inline source bundle + embedded RSA-PSS
51
+ // operator attestations. verifyEvidenceBundle dispatches on it to
52
+ // verifyExportBody (§5).
53
+ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
54
+
55
+ // ---- Wire types (evidence-bundle.md §2 + §5.1) ------------------------------
56
+
57
+ export interface EvidenceIssuer {
58
+ kind: string;
59
+ tenant_id?: string;
60
+ agent_id?: string;
61
+ producer_id?: string;
62
+ [k: string]: unknown;
63
+ }
64
+
65
+ export interface EvidenceVerdict {
66
+ status: string;
67
+ summary?: string;
68
+ counts?: Record<string, number>;
69
+ as_of?: string;
70
+ [k: string]: unknown;
71
+ }
72
+
73
+ // `sanning.anchor.trace/v1` body (§5.1). Uint8Array fields ride as lowercase hex.
74
+ export interface TraceCheckpoint {
75
+ tx_id: string;
76
+ envelope: Envelope;
77
+ record_bytes: string; // hex of JCS(checkpoint record)
78
+ merkle_root: string; // hex
79
+ }
80
+
81
+ export interface TraceInclusion {
82
+ leaf_hash: string; // hex
83
+ leaf_index: number;
84
+ leaf_count: number;
85
+ audit_path: string[]; // hex[]
86
+ checkpoint_tx_id: string;
87
+ }
88
+
89
+ export interface TraceEvent {
90
+ envelope: Envelope;
91
+ record_bytes?: string; // hex of JCS(event record); omitted == record withheld
92
+ // Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 MUST equal the
93
+ // committed event.content_hash inside record_bytes (evidence-bundle §5.1).
94
+ // Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
95
+ content?: string;
96
+ inclusion: TraceInclusion;
97
+ }
98
+
99
+ export interface AnchorTraceBody {
100
+ checkpoints: TraceCheckpoint[];
101
+ events: TraceEvent[];
102
+ }
103
+
104
+ export interface EvidenceBundle {
105
+ spec_version: string;
106
+ body_type: string;
107
+ issuer: EvidenceIssuer;
108
+ generated_at: string;
109
+ gateway?: string | null;
110
+ verdict: EvidenceVerdict;
111
+ body?: unknown;
112
+ body_ref?: string;
113
+ body_hash: string;
114
+ previous_hash?: string;
115
+ signature_alg: string;
116
+ public_key: string;
117
+ signature: string;
118
+ [k: string]: unknown;
119
+ }
120
+
121
+ // ---- Verification result ----------------------------------------------------
122
+
123
+ export type EvidenceStatus =
124
+ | "verified" // every in-scope item passed (sig + integrity + inclusion)
125
+ | "partial" // integrity confirmed but full proof incomplete (e.g. records withheld)
126
+ | "failed" // a check failed: bad wrapper sig / tamper / broken inclusion
127
+ | "malformed"; // structurally unparseable / unknown major / body_hash mismatch
128
+
129
+ // Per-gateway on-chain outcome for one checkpoint tx (evidence-export.md §4.2).
130
+ // The on-chain re-fetch resolves each checkpoint_tx_id against EACH configured
131
+ // gateway and records that gateway's individual outcome:
132
+ // confirm — the gateway returned the tx and its bytes match the record.
133
+ // mismatch — the gateway returned the tx but its bytes disagree — a finding.
134
+ // unreachable — no response / timeout / no such tx — an availability gap.
135
+ export type OnChainOutcome = "confirm" | "mismatch" | "unreachable";
136
+
137
+ export interface PerGatewayOutcome {
138
+ gateway: string;
139
+ outcome: OnChainOutcome;
140
+ }
141
+
142
+ export interface OnChainResult {
143
+ // Rollup (worst-finding-wins, then best-evidence): mismatch if ANY gateway is
144
+ // mismatch; else confirm if ANY is confirm; else unreachable.
145
+ rollup: OnChainOutcome;
146
+ // Derived collapsed field (retained for backward-compat with the pre-§4.2
147
+ // single `onChainOk`): false on rollup mismatch, true on confirm, null on
148
+ // all-unreachable.
149
+ onChainOk: boolean | null;
150
+ perGateway: PerGatewayOutcome[];
151
+ }
152
+
153
+ export interface CheckpointResult {
154
+ txId: string;
155
+ // The checkpoint envelope verified (signature + payload binding to its record).
156
+ envelopeOk: boolean;
157
+ // The committed record's merkle_root equals the checkpoint's claimed merkle_root.
158
+ merkleRootOk: boolean;
159
+ // If gateways were supplied: the on-chain bytes at tx_id matched the envelope.
160
+ // null when no gateway re-fetch was requested. Derived collapse of `onChain`
161
+ // (§4.2), retained so existing consumers keep the single-boolean field.
162
+ onChainOk: boolean | null;
163
+ // Per-gateway on-chain outcomes (§4.2), ADDITIVE to `onChainOk`. null when no
164
+ // gateway re-fetch was requested (offline-only verification).
165
+ onChain: OnChainResult | null;
166
+ ok: boolean;
167
+ errors: string[];
168
+ }
169
+
170
+ export interface EventResult {
171
+ eventId: string;
172
+ // The event envelope verified (signature ok; payload binding ok OR undetermined).
173
+ envelopeOk: boolean;
174
+ // payload binding: true matched the committed record, false mismatch,
175
+ // null record withheld (semantics-undetermined — NOT a failure, §3.1/§6.2).
176
+ payloadBindingOk: boolean | null;
177
+ // RFC 9162 inclusion of this event's leaf in its checkpoint's root.
178
+ inclusionOk: boolean;
179
+ // The inclusion's checkpoint_tx_id resolved to a checkpoint in the bundle.
180
+ checkpointBound: boolean;
181
+ // Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
182
+ // committed event.content_hash (in record_bytes) — or, for a promoted
183
+ // envelope, a hash it commits to. true matched, false mismatch (→ event
184
+ // fails), null undetermined (nothing disclosed OR nothing committed OR record
185
+ // withheld — NOT a failure, mirroring payloadBindingOk).
186
+ contentOk: boolean | null;
187
+ ok: boolean;
188
+ errors: string[];
189
+ }
190
+
191
+ export interface EvidenceBundleResult {
192
+ status: EvidenceStatus;
193
+ // The wrapper: accepted major, body_hash recompute, Ed25519 wrapper signature.
194
+ specVersionOk: boolean;
195
+ bodyHashOk: boolean;
196
+ signatureOk: boolean;
197
+ bodyType: string | null;
198
+ // null when no gateways were supplied (offline-only verification).
199
+ onChainChecked: boolean;
200
+ checkpoints: CheckpointResult[];
201
+ events: EventResult[];
202
+ // The producer's asserted verdict.status (for display only — never trusted).
203
+ assertedStatus: string | null;
204
+ // Present ONLY for a `sanning.evidence.export/v1` body (evidence-export.md §5):
205
+ // the export-specific dimensions (source linkage, verdict agreement, embedded
206
+ // attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
207
+ // above are the recomputed SOURCE-bundle results in that case. Absent for a
208
+ // plain anchor-trace bundle — additive, backward-compatible.
209
+ export?: ExportResult;
210
+ errors: string[];
211
+ }
212
+
213
+ // ---- Attested-evidence-export wire + result types (evidence-export.md) -------
214
+
215
+ // One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
216
+ // JCS(payload); the operator key is the operator's Arweave RSA wallet.
217
+ export interface AttestationRecord {
218
+ checkpoint_tx_id: string;
219
+ payload: AttestationPayload;
220
+ signature_alg: string; // "rsa-pss-sha256"
221
+ public_key: { kty: "RSA"; n: string; e: string };
222
+ signature: string; // lowercase hex, RSA-PSS over JCS(payload)
223
+ [k: string]: unknown;
224
+ }
225
+
226
+ export interface AttestationPayload {
227
+ tx_id: string;
228
+ data_hash: string; // sha256-hex of the tx data
229
+ operator: string; // Arweave wallet == base64url(SHA-256(rsa modulus))
230
+ subject_ref?: { hash: string; type: string };
231
+ level?: number;
232
+ gateway?: string;
233
+ [k: string]: unknown;
234
+ }
235
+
236
+ // The export body (§2.2). Inline `source_bundle` is the default.
237
+ export interface ExportBody {
238
+ kernel_verdict: VerdictObject;
239
+ source_bundle?: EvidenceBundle;
240
+ source_bundle_ref?: string;
241
+ source_bundle_hash: string;
242
+ attestations: AttestationRecord[];
243
+ export_schema?: string;
244
+ [k: string]: unknown;
245
+ }
246
+
247
+ // ---- The §4 kernel verdict object (snake_case, spec-canonical computed wire) --
248
+ // Recomputed by the kernel; the copy cached in body.kernel_verdict is a
249
+ // rendering convenience the verifier recomputes and (over its deterministic
250
+ // dimensions, §5 step 5) confirms.
251
+
252
+ export interface VerdictEvent {
253
+ event_id: string;
254
+ signature_ok: boolean;
255
+ payload_bound: boolean | null;
256
+ inclusion_ok: boolean;
257
+ content_ok: boolean | null;
258
+ status: string;
259
+ }
260
+
261
+ export interface VerdictOnChainGateway {
262
+ gateway: string;
263
+ outcome: OnChainOutcome;
264
+ block_height?: number;
265
+ }
266
+
267
+ export interface VerdictOnChain {
268
+ rollup: OnChainOutcome;
269
+ on_chain_ok: boolean | null;
270
+ per_gateway: VerdictOnChainGateway[];
271
+ }
272
+
273
+ export interface VerdictAttestation {
274
+ operator: string;
275
+ gateway?: string;
276
+ signature_ok: boolean;
277
+ operator_address_bound: boolean;
278
+ data_hash_bound: boolean;
279
+ level?: number;
280
+ subject_ref_ok: boolean | null;
281
+ }
282
+
283
+ export interface VerdictCheckpoint {
284
+ checkpoint_tx_id: string;
285
+ merkle_root_ok: boolean;
286
+ on_chain: VerdictOnChain | null;
287
+ attestations: VerdictAttestation[];
288
+ }
289
+
290
+ export interface VerdictObject {
291
+ schema_version: string; // "sanning.evidence.verdict/v1"
292
+ status: string;
293
+ summary?: string;
294
+ counts?: { verified: number; failed: number; undetermined: number };
295
+ as_of?: string;
296
+ events: VerdictEvent[];
297
+ checkpoints: VerdictCheckpoint[];
298
+ custody_chain: boolean | null;
299
+ [k: string]: unknown;
300
+ }
301
+
302
+ export interface AttestationResult {
303
+ checkpointTxId: string;
304
+ operator: string;
305
+ gateway: string | null;
306
+ // The RSA-PSS-SHA-256 signature over JCS(payload) verified (§5 step 6a).
307
+ signatureOk: boolean;
308
+ // base64url(SHA-256(public_key.n)) == payload.operator (§3.3 / step 6b).
309
+ operatorAddressBound: boolean;
310
+ // payload.data_hash == the resolved source checkpoint's committed content hash
311
+ // (SHA-256(JCS(checkpoint.envelope))) (§5 step 6c).
312
+ dataHashBound: boolean;
313
+ // checkpoint_tx_id resolved to a present source checkpoint.
314
+ checkpointResolved: boolean;
315
+ level: number | null;
316
+ // subject_ref (§3.2) tri-state: null absent/undetermined, true/false only
317
+ // against a supplied side-input subject. Never gates the attestation.
318
+ subjectRefOk: boolean | null;
319
+ ok: boolean;
320
+ errors: string[];
321
+ }
322
+
323
+ export interface ExportResult {
324
+ // SHA-256(JCS(source_bundle)) == source_bundle_hash (§5 step 3).
325
+ sourceLinkageOk: boolean;
326
+ // Recomputed source-bundle verdict status (verifyEvidenceBundle over the
327
+ // inline source_bundle, §5 step 4).
328
+ sourceStatus: EvidenceStatus;
329
+ // Cached-vs-recomputed verdict agreement over the DETERMINISTIC, offline-
330
+ // recomputable dimensions only (§5 step 5 + the on-chain-is-informational
331
+ // erratum — see verifyExportBody). The on-chain per-gateway dimension is
332
+ // environment/time-dependent and is EXCLUDED from this comparison.
333
+ verdictAgreementOk: boolean;
334
+ attestations: AttestationResult[];
335
+ // The freshly recomputed §4 verdict object (snake_case) — what renderers and
336
+ // the verify API display (never the cached copy).
337
+ verdict: VerdictObject;
338
+ // The export's own rollup status; drives the CLI exit code.
339
+ status: EvidenceStatus;
340
+ }
341
+
342
+ export interface VerifyEvidenceOptions {
343
+ // Gateways to re-fetch each checkpoint_tx_id from (first responsive wins).
344
+ // When supplied, the on-chain bytes MUST byte-match the bundle's checkpoint
345
+ // envelope, proving the proof is anchored, not just locally asserted.
346
+ gateways?: string[];
347
+ // Out-of-band disclosed raw bytes, keyed by event_id, to bind against each
348
+ // event's committed content_hash (the rawLog → content_hash link). A
349
+ // Uint8Array is used as-is; a string is parsed as lowercase hex. In-body
350
+ // `events[].content` (signed) takes precedence over this side input; if both
351
+ // are present for an event and disagree, the event fails (§5.1).
352
+ content?: Record<string, Uint8Array | string>;
353
+ // Injectable fetch (Node >=18 global `fetch` by default) — test seam.
354
+ fetchImpl?: typeof fetch;
355
+ }
356
+
357
+ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
358
+ if (typeof specVersion !== "string" || specVersion === "") return false;
359
+ return ACCEPTED_EVIDENCE_MAJORS.some((m) => {
360
+ if (specVersion === m) return true;
361
+ if (!specVersion.startsWith(`${m}.`)) return false;
362
+ const minor = specVersion.slice(m.length + 1);
363
+ return minor.length > 0 && /^[0-9]+$/.test(minor);
364
+ });
365
+ }
366
+
367
+ function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
368
+ return {
369
+ status: "malformed",
370
+ specVersionOk,
371
+ bodyHashOk: false,
372
+ signatureOk: false,
373
+ bodyType: null,
374
+ onChainChecked: false,
375
+ checkpoints: [],
376
+ events: [],
377
+ assertedStatus: null,
378
+ errors: [error],
379
+ };
380
+ }
381
+
382
+ function stringifyErr(e: unknown): string {
383
+ return e instanceof Error ? e.message : String(e);
384
+ }
385
+
386
+ // Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
387
+ //
388
+ // Returns a structured per-checkpoint + per-event result and a recomputed
389
+ // rollup `status`. The producer's asserted `verdict.status` is surfaced
390
+ // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
391
+ // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
392
+ export async function verifyEvidenceBundle(
393
+ bundle: unknown,
394
+ options: VerifyEvidenceOptions = {},
395
+ ): Promise<EvidenceBundleResult> {
396
+ // --- Step 1: parse + reject unknown spec_version major ---------------------
397
+ if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
398
+ return malformed("evidence bundle is not a JSON object");
399
+ }
400
+ // Reject a pathologically deep bundle BEFORE any canonicalization (which
401
+ // recurses). A fixed shared bound keeps this a `malformed` verdict in every
402
+ // kernel rather than a stack-limit-dependent split verdict — see
403
+ // MAX_CANONICAL_DEPTH.
404
+ if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
405
+ return malformed(
406
+ `evidence bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
407
+ );
408
+ }
409
+ const b = bundle as EvidenceBundle;
410
+ const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
411
+ if (!specVersionOk) {
412
+ return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version)}`, false);
413
+ }
414
+ if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
415
+ // The reference verifier implements ed25519 only (evidence-bundle §2 +
416
+ // decision #2); other algs are a deliberate future addition.
417
+ return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
418
+ }
419
+ if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
420
+ return malformed("evidence bundle is missing public_key/signature", true);
421
+ }
422
+ if (typeof b.body_hash !== "string") {
423
+ return malformed("evidence bundle is missing body_hash", true);
424
+ }
425
+ if (b.body === undefined || b.body === null) {
426
+ // body_ref (out-of-line body) is a defined growth hook but not part of the
427
+ // anchor-trace use case — refuse rather than silently pass.
428
+ return malformed("evidence bundle has no inline body (body_ref unsupported here)", true);
429
+ }
430
+
431
+ // --- Step 2: verify the wrapper signature + recompute body_hash ------------
432
+ const errors: string[] = [];
433
+
434
+ let bodyHashOk = false;
435
+ try {
436
+ const recomputed = await sha256Hex(utf8(jcs(b.body)));
437
+ bodyHashOk = recomputed === b.body_hash;
438
+ if (!bodyHashOk) {
439
+ errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
440
+ }
441
+ } catch (e) {
442
+ errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
443
+ }
444
+
445
+ let signatureOk = false;
446
+ try {
447
+ const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
448
+ co_signatures?: unknown;
449
+ };
450
+ signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
451
+ if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
452
+ } catch (e) {
453
+ errors.push(`wrapper signature verification error: ${stringifyErr(e)}`);
454
+ }
455
+
456
+ const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
457
+
458
+ // A broken wrapper (bad signature or tampered body) is a hard failure — the
459
+ // body is untrustworthy, so we do not pretend to verify its contents.
460
+ if (!signatureOk || !bodyHashOk) {
461
+ return {
462
+ status: "failed",
463
+ specVersionOk,
464
+ bodyHashOk,
465
+ signatureOk,
466
+ bodyType: typeof b.body_type === "string" ? b.body_type : null,
467
+ onChainChecked: false,
468
+ checkpoints: [],
469
+ events: [],
470
+ assertedStatus,
471
+ errors,
472
+ };
473
+ }
474
+
475
+ // --- Body dispatch ---------------------------------------------------------
476
+ const bodyType = typeof b.body_type === "string" ? b.body_type : null;
477
+
478
+ // Attested-evidence-export body (evidence-export.md §5). The wrapper is
479
+ // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
480
+ // hand off to the export verifier for steps 3–9.
481
+ if (bodyType === EXPORT_BODY_TYPE) {
482
+ return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
483
+ }
484
+
485
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
486
+ // The wrapper is authentic but we don't know this body's structure. Per
487
+ // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
488
+ // body verdict undetermined) — not a failure.
489
+ errors.push(
490
+ `body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
491
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
492
+ );
493
+ return {
494
+ status: "partial",
495
+ specVersionOk,
496
+ bodyHashOk,
497
+ signatureOk,
498
+ bodyType,
499
+ onChainChecked: false,
500
+ checkpoints: [],
501
+ events: [],
502
+ assertedStatus,
503
+ errors,
504
+ };
505
+ }
506
+
507
+ return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
508
+ }
509
+
510
+ async function verifyAnchorTrace(
511
+ b: EvidenceBundle,
512
+ bodyType: string,
513
+ specVersionOk: boolean,
514
+ assertedStatus: string | null,
515
+ errors: string[],
516
+ options: VerifyEvidenceOptions,
517
+ ): Promise<EvidenceBundleResult> {
518
+ const body = b.body as AnchorTraceBody;
519
+ if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
520
+ return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
521
+ }
522
+
523
+ const gateways = options.gateways ?? [];
524
+ const onChainChecked = gateways.length > 0;
525
+
526
+ // --- Step 3: per-checkpoint -------------------------------------------------
527
+ const byTxId = new Map<string, TraceCheckpoint>();
528
+ const checkpointResults: CheckpointResult[] = [];
529
+ for (const cp of body.checkpoints) {
530
+ const cpErrors: string[] = [];
531
+ let envelopeOk = false;
532
+ let merkleRootOk = false;
533
+
534
+ if (typeof cp.tx_id !== "string") {
535
+ cpErrors.push("checkpoint missing tx_id");
536
+ } else if (byTxId.has(cp.tx_id)) {
537
+ cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
538
+ } else {
539
+ byTxId.set(cp.tx_id, cp);
540
+ }
541
+
542
+ // Verify the checkpoint envelope against its committed record (external
543
+ // commitment — the record is the payload these external envelopes bind to).
544
+ let recordBytes: Uint8Array | undefined;
545
+ try {
546
+ if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
547
+ } catch (e) {
548
+ cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
549
+ }
550
+ try {
551
+ const res = await verifyEnvelope(
552
+ cp.envelope,
553
+ recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
554
+ );
555
+ // A checkpoint must fully bind: signature + the committed record matching
556
+ // payload_hash. An undetermined binding (no record) is not enough to
557
+ // anchor the events' inclusion claims to a trustworthy root.
558
+ envelopeOk = res.ok && res.payloadHashOk === true;
559
+ if (!res.signatureOk) cpErrors.push("checkpoint envelope signature failed");
560
+ if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
561
+ if (res.payloadHashOk === null) cpErrors.push("checkpoint record_bytes missing — root untrusted");
562
+ } catch (e) {
563
+ cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
564
+ }
565
+
566
+ // The committed record's merkle_root must equal the checkpoint's claimed
567
+ // merkle_root (recompute, don't trust the bundle's top-level claim).
568
+ if (recordBytes !== undefined) {
569
+ try {
570
+ const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
571
+ event?: { merkle_root?: unknown };
572
+ };
573
+ const committedRoot = record.event?.merkle_root;
574
+ merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
575
+ if (!merkleRootOk) {
576
+ cpErrors.push(
577
+ `checkpoint merkle_root mismatch: claimed=${cp.merkle_root} committed=${String(committedRoot)}`,
578
+ );
579
+ }
580
+ } catch (e) {
581
+ cpErrors.push(`checkpoint record is not JSON: ${stringifyErr(e)}`);
582
+ }
583
+ }
584
+
585
+ checkpointResults.push({
586
+ txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
587
+ envelopeOk,
588
+ merkleRootOk,
589
+ onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
590
+ onChain: null, // per-gateway (§4.2), filled in step 5
591
+ ok: envelopeOk && merkleRootOk,
592
+ errors: cpErrors,
593
+ });
594
+ }
595
+
596
+ // --- Step 4: per-event ------------------------------------------------------
597
+ const eventResults: EventResult[] = [];
598
+ for (const ev of body.events) {
599
+ const evErrors: string[] = [];
600
+ const eventId = getEventId(ev.envelope);
601
+ let envelopeOk = false;
602
+ let payloadBindingOk: boolean | null = null;
603
+
604
+ let recordBytes: Uint8Array | undefined;
605
+ try {
606
+ if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
607
+ } catch (e) {
608
+ evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
609
+ }
610
+ try {
611
+ const res = await verifyEnvelope(
612
+ ev.envelope,
613
+ recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
614
+ );
615
+ envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
616
+ payloadBindingOk = res.payloadHashOk;
617
+ if (!res.signatureOk) evErrors.push("event envelope signature failed");
618
+ if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
619
+ } catch (e) {
620
+ evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
621
+ }
622
+
623
+ // Bind the event to exactly one checkpoint in the bundle, then verify
624
+ // RFC 9162 inclusion against THAT checkpoint's merkle_root.
625
+ const incl = ev.inclusion;
626
+ const cp = incl && typeof incl.checkpoint_tx_id === "string" ? byTxId.get(incl.checkpoint_tx_id) : undefined;
627
+ const checkpointBound = cp !== undefined;
628
+ if (!incl || typeof incl !== "object") {
629
+ evErrors.push("event missing inclusion proof");
630
+ } else if (!checkpointBound) {
631
+ evErrors.push(
632
+ `event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`,
633
+ );
634
+ }
635
+
636
+ let inclusionOk = false;
637
+ if (incl && checkpointBound && cp) {
638
+ try {
639
+ inclusionOk = await verifyInclusion(
640
+ hexToBytes(incl.leaf_hash),
641
+ incl.leaf_index,
642
+ incl.leaf_count,
643
+ (incl.audit_path ?? []).map((h) => hexToBytes(h)),
644
+ hexToBytes(cp.merkle_root),
645
+ );
646
+ if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
647
+
648
+ // Defense in depth: the claimed leaf_hash MUST be the hash of the
649
+ // event's own signed envelope bytes (profile §6) — otherwise a valid
650
+ // inclusion proof for an UNRELATED leaf could be smuggled in.
651
+ const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
652
+ const expectedLeafHex = toHex(expectedLeaf);
653
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
654
+ inclusionOk = false;
655
+ evErrors.push(
656
+ `inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`,
657
+ );
658
+ }
659
+ } catch (e) {
660
+ evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
661
+ }
662
+ }
663
+
664
+ // Content (raw-log) binding — the rawLog → content_hash link the rest of
665
+ // the chain (content_hash → record → payload_hash → signature → leaf →
666
+ // root) already covers. The committed hash lives in the RECORD
667
+ // (record.event.content_hash, minimal disclosure); for a promoted-disclosure
668
+ // envelope it falls back to the envelope's own committed hashes
669
+ // (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
670
+ // and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
671
+ // from the signed in-body `ev.content` FIRST, then the out-of-band
672
+ // options.content side input. Undetermined (null) when nothing is disclosed
673
+ // or nothing is committed; false ONLY on a real mismatch or an in-body vs
674
+ // side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
675
+ // discipline.
676
+ let contentOk: boolean | null = null;
677
+ {
678
+ const committed: string[] = [];
679
+ let recordContentHash: string | undefined;
680
+ if (recordBytes !== undefined) {
681
+ try {
682
+ const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
683
+ event?: { content_hash?: unknown };
684
+ };
685
+ const ch = record.event?.content_hash;
686
+ if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
687
+ } catch {
688
+ // record not JSON — the payload binding already reflects that; content
689
+ // stays undetermined rather than throwing.
690
+ }
691
+ }
692
+ if (recordContentHash !== undefined) {
693
+ committed.push(recordContentHash);
694
+ } else if (ev.envelope && typeof ev.envelope === "object") {
695
+ // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
696
+ // which throws on a null/non-object envelope — a verifier must never
697
+ // raise on adversarial input (such an event already fails via envelopeOk).
698
+ for (const c of contentHashes(ev.envelope)) committed.push(c.hash.toLowerCase());
699
+ }
700
+
701
+ // Disclosed bytes, precedence: in-body (signed) → side input. A string
702
+ // side input is hex; a Uint8Array is used as-is.
703
+ let inBody: Uint8Array | undefined;
704
+ if (typeof ev.content === "string") {
705
+ try {
706
+ inBody = hexToBytes(ev.content);
707
+ } catch (e) {
708
+ evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
709
+ }
710
+ }
711
+ let side: Uint8Array | undefined;
712
+ const sideRaw = options.content?.[eventId];
713
+ if (sideRaw instanceof Uint8Array) {
714
+ side = sideRaw;
715
+ } else if (typeof sideRaw === "string") {
716
+ try {
717
+ side = hexToBytes(sideRaw);
718
+ } catch (e) {
719
+ evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
720
+ }
721
+ }
722
+
723
+ if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
724
+ // Two disclosures that disagree: a genuine conflict, not undetermined.
725
+ contentOk = false;
726
+ evErrors.push(
727
+ "disclosed content disagreement: in-body events[].content and the supplied content differ",
728
+ );
729
+ } else {
730
+ const disclosed = inBody ?? side;
731
+ if (disclosed === undefined || committed.length === 0) {
732
+ contentOk = null;
733
+ if (disclosed !== undefined && committed.length === 0) {
734
+ evErrors.push(
735
+ "disclosed content present but no committed content_hash to bind to " +
736
+ "(record withheld or record has no event.content_hash) — undetermined",
737
+ );
738
+ }
739
+ } else {
740
+ const got = await sha256Hex(disclosed);
741
+ contentOk = committed.includes(got);
742
+ if (!contentOk) {
743
+ evErrors.push(
744
+ "disclosed content does not match the committed content_hash: " +
745
+ `sha256(disclosed)=${got} committed=${committed.join("|")}`,
746
+ );
747
+ }
748
+ }
749
+ }
750
+ }
751
+
752
+ eventResults.push({
753
+ eventId,
754
+ envelopeOk,
755
+ payloadBindingOk,
756
+ inclusionOk,
757
+ checkpointBound,
758
+ contentOk,
759
+ // An event is "ok" when its envelope is authentic (signature + binding
760
+ // not-failed), its inclusion proof reconstructs the root, it binds to a
761
+ // present checkpoint, and its disclosed content did not MISMATCH. A
762
+ // withheld record or undisclosed content (binding/content null) does not
763
+ // fail it.
764
+ ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
765
+ errors: evErrors,
766
+ });
767
+ }
768
+
769
+ // --- Step 5: optional on-chain re-fetch ------------------------------------
770
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
771
+ let gatewayUnavailable = false;
772
+ if (onChainChecked) {
773
+ if (typeof fetchImpl !== "function") {
774
+ gatewayUnavailable = true;
775
+ errors.push("on-chain re-fetch requested but no fetch implementation is available");
776
+ } else {
777
+ for (const cpr of checkpointResults) {
778
+ const cp = byTxId.get(cpr.txId);
779
+ if (!cp) continue;
780
+ const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
781
+ cpr.onChain = outcome.onChain;
782
+ cpr.onChainOk = outcome.onChain.onChainOk;
783
+ if (outcome.unavailable) gatewayUnavailable = true;
784
+ if (outcome.error) cpr.errors.push(outcome.error);
785
+ // Fold the on-chain dimension in: a MISMATCH (false) fails the
786
+ // checkpoint — the proof was not anchored as claimed. An UNREACHABLE
787
+ // gateway (null) leaves the offline-sound checkpoint ok; the run
788
+ // downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
789
+ // failure. A match (true) keeps it ok.
790
+ cpr.ok = cpr.envelopeOk && cpr.merkleRootOk && cpr.onChainOk !== false;
791
+ }
792
+ }
793
+ }
794
+
795
+ // --- Rollup ----------------------------------------------------------------
796
+ const allCheckpointsOk = checkpointResults.length > 0 && checkpointResults.every((c) => c.ok);
797
+ const allEventsOk = eventResults.every((e) => e.ok);
798
+ const anyHardFailure =
799
+ checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok);
800
+ const anyWithheldRecord = eventResults.some((e) => e.payloadBindingOk === null);
801
+
802
+ let status: EvidenceStatus;
803
+ if (anyHardFailure) {
804
+ status = "failed";
805
+ } else if (gatewayUnavailable) {
806
+ // Every offline check passed and nothing MISMATCHED, but a requested
807
+ // on-chain confirmation could not be made — the bundle is offline-sound,
808
+ // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
809
+ status = "partial";
810
+ errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
811
+ } else if (anyWithheldRecord) {
812
+ // Everything that COULD be checked passed; some record bytes were withheld
813
+ // so their semantic binding is undetermined (not a failure) — "partial".
814
+ status = "partial";
815
+ } else if (allCheckpointsOk && allEventsOk) {
816
+ status = "verified";
817
+ } else {
818
+ status = "partial";
819
+ }
820
+
821
+ return {
822
+ status,
823
+ specVersionOk,
824
+ bodyHashOk: true,
825
+ signatureOk: true,
826
+ bodyType,
827
+ onChainChecked,
828
+ checkpoints: checkpointResults,
829
+ events: eventResults,
830
+ assertedStatus,
831
+ errors,
832
+ };
833
+ }
834
+
835
+ // ===========================================================================
836
+ // Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
837
+ // Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
838
+ // + body_hash) has already verified (steps 1–2). This composes the existing
839
+ // evidence path (recompute the inline source verdict) with the one new
840
+ // primitive (embedded RSA-PSS operator attestations, verifyRsaPssSha256).
841
+ // ===========================================================================
842
+ async function verifyExportBody(
843
+ b: EvidenceBundle,
844
+ specVersionOk: boolean,
845
+ assertedStatus: string | null,
846
+ errors: string[],
847
+ options: VerifyEvidenceOptions,
848
+ ): Promise<EvidenceBundleResult> {
849
+ // Malformed-as-an-export (§5 exit 2): a required field is missing or a key is
850
+ // unparseable — the wrapper verified, but no verdict can be rendered. The
851
+ // wrapper fields stay true (they DID verify) for any consumer that inspects
852
+ // them; the CLI's malformed branch shows the errors and exits 2.
853
+ const exportMalformed = (error: string): EvidenceBundleResult => ({
854
+ status: "malformed",
855
+ specVersionOk,
856
+ bodyHashOk: true,
857
+ signatureOk: true,
858
+ bodyType: EXPORT_BODY_TYPE,
859
+ onChainChecked: false,
860
+ checkpoints: [],
861
+ events: [],
862
+ assertedStatus,
863
+ errors: [...errors, error],
864
+ });
865
+
866
+ const body = b.body as ExportBody;
867
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
868
+ return exportMalformed("export body is not a JSON object");
869
+ }
870
+ if (typeof body.source_bundle_hash !== "string") {
871
+ return exportMalformed("export body missing source_bundle_hash");
872
+ }
873
+ if (!Array.isArray(body.attestations)) {
874
+ return exportMalformed("export body missing attestations[]");
875
+ }
876
+ if (
877
+ body.kernel_verdict === null ||
878
+ typeof body.kernel_verdict !== "object" ||
879
+ Array.isArray(body.kernel_verdict)
880
+ ) {
881
+ return exportMalformed("export body missing kernel_verdict");
882
+ }
883
+
884
+ // --- Step 3: source-bundle linkage ----------------------------------------
885
+ const inline = body.source_bundle;
886
+ if (inline === undefined || inline === null) {
887
+ // source_bundle_ref (out-of-line) is not verifiable on a network-isolated
888
+ // machine unless the referenced bytes are co-delivered — the source-
889
+ // dependent checks are UNDETERMINED (exit 3), NOT a failure (§5 step 3).
890
+ return {
891
+ status: "partial",
892
+ specVersionOk,
893
+ bodyHashOk: true,
894
+ signatureOk: true,
895
+ bodyType: EXPORT_BODY_TYPE,
896
+ onChainChecked: false,
897
+ checkpoints: [],
898
+ events: [],
899
+ assertedStatus,
900
+ errors: [
901
+ ...errors,
902
+ "source_bundle is not inline (source_bundle_ref) — bytes unavailable offline; " +
903
+ "source-dependent checks undetermined",
904
+ ],
905
+ };
906
+ }
907
+
908
+ let sourceLinkageOk = false;
909
+ try {
910
+ const recomputed = await sha256Hex(utf8(jcs(inline)));
911
+ sourceLinkageOk = recomputed === body.source_bundle_hash;
912
+ if (!sourceLinkageOk) {
913
+ errors.push(
914
+ `source_bundle_hash mismatch: body=${body.source_bundle_hash} recomputed=${recomputed}`,
915
+ );
916
+ }
917
+ } catch (e) {
918
+ errors.push(`source_bundle canonicalization failed: ${stringifyErr(e)}`);
919
+ }
920
+
921
+ // --- Step 4: recompute the source verdict ---------------------------------
922
+ // The existing evidence path over the inline source bundle — same primitives
923
+ // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
924
+ // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
925
+ const sourceResult = await verifyEvidenceBundle(inline, options);
926
+
927
+ // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c).
928
+ const sourceCheckpoints = new Map<string, TraceCheckpoint>();
929
+ const sb = inline.body as AnchorTraceBody | undefined;
930
+ if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
931
+ for (const cp of sb.checkpoints) {
932
+ if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
933
+ }
934
+ }
935
+
936
+ // --- Step 6: embedded attestation records ---------------------------------
937
+ const attestations: AttestationResult[] = [];
938
+ for (const rec of body.attestations) {
939
+ const r = await verifyAttestation(rec, sourceCheckpoints);
940
+ if (r.malformed !== undefined) return exportMalformed(r.malformed);
941
+ attestations.push(r.result);
942
+ }
943
+
944
+ // --- Build the recomputed §4 verdict object -------------------------------
945
+ const verdict = buildVerdictObject(sourceResult, attestations);
946
+ if (typeof b.generated_at === "string") verdict.as_of = b.generated_at;
947
+
948
+ // --- Step 5: verdict agreement (DETERMINISTIC dimensions only) ------------
949
+ // The cached body.kernel_verdict is recompute-don't-trust (principle 3). But
950
+ // agreement compares ONLY the deterministic, offline-recomputable dimensions:
951
+ // per-event signature/payload-binding/inclusion/content, per-checkpoint
952
+ // merkle roots, and per-attestation signature/operator-address/data_hash
953
+ // binding. The on-chain per-gateway dimension is environment/time-dependent —
954
+ // the issuer observed it online at compose time; an offline verifier
955
+ // legitimately sees `null` — so it is INFORMATIONAL and MUST NOT by itself
956
+ // trigger an exit-1 tamper verdict (the verifier's own on-chain outcomes fold
957
+ // in at step 7 only if it re-fetches). Advisory rollups (status / counts /
958
+ // summary / as_of / custody_chain) are derived, not compared. See the §5
959
+ // step-5 erratum flagged in the slice report.
960
+ let verdictAgreementOk = false;
961
+ try {
962
+ const cachedProjection = jcs(projectVerdictForAgreement(body.kernel_verdict));
963
+ const freshProjection = jcs(projectVerdictForAgreement(verdict));
964
+ verdictAgreementOk = cachedProjection === freshProjection;
965
+ if (!verdictAgreementOk) {
966
+ errors.push(
967
+ "recomputed verdict disagrees with the cached kernel_verdict " +
968
+ "(deterministic dimensions) — recompute-don't-trust",
969
+ );
970
+ }
971
+ } catch (e) {
972
+ errors.push(`verdict agreement comparison failed: ${stringifyErr(e)}`);
973
+ }
974
+
975
+ // --- Step 7 already folded in by the source recompute ---------------------
976
+ // (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
977
+ // a checkpoint `mismatch` made it "failed"; an all-`unreachable` checkpoint
978
+ // made it "partial" + an unreachable note → exit 3.)
979
+
980
+ // Propagate the source recompute's notes so the auditor — and the CLI's
981
+ // exit-3 detection (which keys off "unreachable") — see them.
982
+ for (const e of sourceResult.errors) errors.push(`source: ${e}`);
983
+
984
+ // --- Step 9: export rollup + exit -----------------------------------------
985
+ const anyAttestationFailed = attestations.some((a) => !a.ok);
986
+ let status: EvidenceStatus;
987
+ if (
988
+ !sourceLinkageOk ||
989
+ !verdictAgreementOk ||
990
+ sourceResult.status === "failed" ||
991
+ sourceResult.status === "malformed" ||
992
+ anyAttestationFailed
993
+ ) {
994
+ status = "failed";
995
+ } else if (sourceResult.status === "partial") {
996
+ // Undetermined source (withheld record → exit 0; gateway-unreachable →
997
+ // exit 3 via the propagated "unreachable" note). Never a failure.
998
+ status = "partial";
999
+ } else {
1000
+ status = "verified";
1001
+ }
1002
+
1003
+ // The DISPLAYED verdict reflects the full recompute (incl. attestations).
1004
+ verdict.status = status;
1005
+ verdict.summary = summarizeExport(status, sourceResult, attestations);
1006
+
1007
+ return {
1008
+ status,
1009
+ specVersionOk,
1010
+ bodyHashOk: true,
1011
+ signatureOk: true,
1012
+ bodyType: EXPORT_BODY_TYPE,
1013
+ onChainChecked: sourceResult.onChainChecked,
1014
+ // The top-level per-checkpoint / per-event results ARE the recomputed source
1015
+ // results, so the CLI's existing rendering shows them unchanged.
1016
+ checkpoints: sourceResult.checkpoints,
1017
+ events: sourceResult.events,
1018
+ assertedStatus,
1019
+ export: {
1020
+ sourceLinkageOk,
1021
+ sourceStatus: sourceResult.status,
1022
+ verdictAgreementOk,
1023
+ attestations,
1024
+ verdict,
1025
+ status,
1026
+ },
1027
+ errors,
1028
+ };
1029
+ }
1030
+
1031
+ // ===========================================================================
1032
+ // Compose-side verdict recompute (evidence-export.md §5 steps 4 + 6, minus the
1033
+ // wrapper). The `proof export` composer must embed a cached `kernel_verdict`
1034
+ // (§4) whose deterministic dimensions BYTE-AGREE with what verifyExportBody will
1035
+ // later recompute over the same inline source bundle + attestation records (§5
1036
+ // step 5). Rather than reimplement that recompute, this reuses the EXACT verify
1037
+ // primitives verifyExportBody uses — verifyEvidenceBundle over the source,
1038
+ // verifyAttestation per record, then buildVerdictObject — so the composed cache
1039
+ // and the verifier's recompute cannot drift. Offline/pure: no gateways, so the
1040
+ // on-chain dimension is null (excluded from agreement anyway, §5 step 5).
1041
+ //
1042
+ // Throws on a malformed attestation record (unparseable RSA key, wrong
1043
+ // signature_alg, missing field) — a composer INPUT error the caller surfaces,
1044
+ // mirroring verifyExportBody's malformed→exit-2 for the same conditions.
1045
+ // ===========================================================================
1046
+ export interface ComposeVerdictResult {
1047
+ // The §4 verdict object to embed as body.kernel_verdict (status/summary/as_of
1048
+ // finalized exactly as verifyExportBody would render them).
1049
+ verdict: VerdictObject;
1050
+ // The recomputed inline-source-bundle status (verifyEvidenceBundle).
1051
+ sourceStatus: EvidenceStatus;
1052
+ // The export rollup status (§4.5 / §5 step 9) — verified | partial | failed.
1053
+ status: EvidenceStatus;
1054
+ // Per-attestation results (each already bound to a source checkpoint).
1055
+ attestations: AttestationResult[];
1056
+ }
1057
+
1058
+ export async function recomputeExportVerdict(
1059
+ sourceBundle: EvidenceBundle,
1060
+ attestationRecords: AttestationRecord[],
1061
+ generatedAt: string,
1062
+ options: VerifyEvidenceOptions = {},
1063
+ ): Promise<ComposeVerdictResult> {
1064
+ const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
1065
+
1066
+ // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c) —
1067
+ // identical to verifyExportBody's map construction.
1068
+ const sourceCheckpoints = new Map<string, TraceCheckpoint>();
1069
+ const sb = sourceBundle.body as AnchorTraceBody | undefined;
1070
+ if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
1071
+ for (const cp of sb.checkpoints) {
1072
+ if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
1073
+ }
1074
+ }
1075
+
1076
+ const attestations: AttestationResult[] = [];
1077
+ for (const rec of attestationRecords) {
1078
+ const r = await verifyAttestation(rec, sourceCheckpoints);
1079
+ if (r.malformed !== undefined) {
1080
+ throw new Error(`attestation record is malformed: ${r.malformed}`);
1081
+ }
1082
+ attestations.push(r.result);
1083
+ }
1084
+
1085
+ const verdict = buildVerdictObject(sourceResult, attestations);
1086
+ verdict.as_of = generatedAt;
1087
+
1088
+ // Export rollup (§5 step 9). Source linkage and verdict agreement are always
1089
+ // satisfied at compose time (the composer computes the source hash itself and
1090
+ // there is no prior cached verdict to disagree with), so the only failure
1091
+ // inputs here are the recomputed source status and any failed attestation.
1092
+ const anyAttestationFailed = attestations.some((a) => !a.ok);
1093
+ let status: EvidenceStatus;
1094
+ if (
1095
+ sourceResult.status === "failed" ||
1096
+ sourceResult.status === "malformed" ||
1097
+ anyAttestationFailed
1098
+ ) {
1099
+ status = "failed";
1100
+ } else if (sourceResult.status === "partial") {
1101
+ status = "partial";
1102
+ } else {
1103
+ status = "verified";
1104
+ }
1105
+ verdict.status = status;
1106
+ verdict.summary = summarizeExport(status, sourceResult, attestations);
1107
+
1108
+ return { verdict, sourceStatus: sourceResult.status, status, attestations };
1109
+ }
1110
+
1111
+ // Verify one embedded attestation record (§5 step 6a–d). Returns `malformed`
1112
+ // (→ exit 2) for an unparseable RSA key / signature hex or a missing required
1113
+ // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
1114
+ // three binding checks (sig / operator-address / data_hash). subject_ref is
1115
+ // undetermined-tolerant and never gates.
1116
+ async function verifyAttestation(
1117
+ rec: AttestationRecord,
1118
+ sourceCheckpoints: Map<string, TraceCheckpoint>,
1119
+ ): Promise<{ result: AttestationResult; malformed?: string }> {
1120
+ const errs: string[] = [];
1121
+ const result: AttestationResult = {
1122
+ checkpointTxId:
1123
+ rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
1124
+ operator: "",
1125
+ gateway: null,
1126
+ signatureOk: false,
1127
+ operatorAddressBound: false,
1128
+ dataHashBound: false,
1129
+ checkpointResolved: false,
1130
+ level: null,
1131
+ subjectRefOk: null,
1132
+ ok: false,
1133
+ errors: errs,
1134
+ };
1135
+
1136
+ if (rec === null || typeof rec !== "object" || Array.isArray(rec)) {
1137
+ return { result, malformed: "attestation record is not a JSON object" };
1138
+ }
1139
+ const payload = rec.payload;
1140
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
1141
+ return { result, malformed: "attestation record missing payload" };
1142
+ }
1143
+ if (rec.signature_alg !== "rsa-pss-sha256") {
1144
+ return {
1145
+ result,
1146
+ malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
1147
+ };
1148
+ }
1149
+ const pub = rec.public_key as { kty?: unknown; n?: unknown; e?: unknown } | null;
1150
+ if (
1151
+ pub === null ||
1152
+ typeof pub !== "object" ||
1153
+ pub.kty !== "RSA" ||
1154
+ typeof pub.n !== "string" ||
1155
+ typeof pub.e !== "string"
1156
+ ) {
1157
+ return { result, malformed: "attestation record missing/invalid RSA public_key JWK" };
1158
+ }
1159
+ if (typeof rec.signature !== "string") {
1160
+ return { result, malformed: "attestation record missing signature" };
1161
+ }
1162
+ const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
1163
+
1164
+ result.operator = typeof payload.operator === "string" ? payload.operator : "";
1165
+ result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
1166
+ result.level = typeof payload.level === "number" ? payload.level : null;
1167
+
1168
+ // (a) RSA-PSS-SHA-256 signature over JCS(payload). A malformed sig hex or
1169
+ // unimportable key THROWS (malformed → exit 2); a well-formed key that simply
1170
+ // fails to verify returns false (→ exit 1).
1171
+ let payloadBytes: Uint8Array;
1172
+ try {
1173
+ payloadBytes = utf8(jcs(payload));
1174
+ } catch (e) {
1175
+ return { result, malformed: `attestation payload canonicalization failed: ${stringifyErr(e)}` };
1176
+ }
1177
+ try {
1178
+ result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
1179
+ if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
1180
+ } catch (e) {
1181
+ return { result, malformed: `attestation ${stringifyErr(e)}` };
1182
+ }
1183
+
1184
+ // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
1185
+ try {
1186
+ const derived = await deriveOperatorAddress(pub.n);
1187
+ result.operatorAddressBound = result.operator !== "" && derived === result.operator;
1188
+ if (!result.operatorAddressBound) {
1189
+ errs.push(
1190
+ `operator-address binding failed: derived=${derived} payload.operator=${result.operator}`,
1191
+ );
1192
+ }
1193
+ } catch (e) {
1194
+ return { result, malformed: `attestation operator key: ${stringifyErr(e)}` };
1195
+ }
1196
+
1197
+ // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
1198
+ // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
1199
+ // against, §4.2), so the checkpoint's committed content hash is
1200
+ // SHA-256(JCS(checkpoint.envelope)).
1201
+ const cp = sourceCheckpoints.get(result.checkpointTxId);
1202
+ result.checkpointResolved = cp !== undefined;
1203
+ if (!cp) {
1204
+ errs.push(
1205
+ `attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`,
1206
+ );
1207
+ } else {
1208
+ try {
1209
+ const committed = await sha256Hex(utf8(jcs(cp.envelope)));
1210
+ const dataHash = typeof payload.data_hash === "string" ? payload.data_hash.toLowerCase() : "";
1211
+ result.dataHashBound = dataHash !== "" && dataHash === committed;
1212
+ if (!result.dataHashBound) {
1213
+ errs.push(
1214
+ `data_hash does not bind to the checkpoint's committed content hash: ` +
1215
+ `data_hash=${dataHash} committed=${committed}`,
1216
+ );
1217
+ }
1218
+ } catch (e) {
1219
+ errs.push(`cannot recompute checkpoint content hash: ${stringifyErr(e)}`);
1220
+ }
1221
+ }
1222
+
1223
+ // (d) subject_ref (§3.2) — well-formedness only (no side-input subject channel
1224
+ // in v1); undetermined (null), never a hard failure.
1225
+ result.subjectRefOk = evaluateSubjectRef(payload.subject_ref, errs);
1226
+
1227
+ result.ok =
1228
+ result.signatureOk &&
1229
+ result.operatorAddressBound &&
1230
+ result.dataHashBound &&
1231
+ result.checkpointResolved;
1232
+ return { result };
1233
+ }
1234
+
1235
+ // subject_ref (§3.2): absent → null (unbound). Present + well-formed but no
1236
+ // side-input subject supplied → null (undetermined, mirrors disclosed-content).
1237
+ // Present + malformed → null + a surfaced note (never gates the attestation).
1238
+ function evaluateSubjectRef(subjectRef: unknown, errs: string[]): boolean | null {
1239
+ if (subjectRef === undefined || subjectRef === null) return null;
1240
+ if (typeof subjectRef !== "object" || Array.isArray(subjectRef)) {
1241
+ errs.push("subject_ref is present but is not an object");
1242
+ return null;
1243
+ }
1244
+ const sr = subjectRef as { hash?: unknown; type?: unknown };
1245
+ const hashOk = typeof sr.hash === "string" && /^[0-9a-f]{64}$/.test(sr.hash);
1246
+ const typeOk = typeof sr.type === "string" && /^[a-z0-9.:-]+$/.test(sr.type);
1247
+ if (!hashOk || !typeOk) {
1248
+ errs.push("subject_ref is present but not well-formed ({hash: sha256-hex, type: token})");
1249
+ return null;
1250
+ }
1251
+ return null; // well-formed, no side input → undetermined
1252
+ }
1253
+
1254
+ // Build the §4 verdict object (snake_case, spec-canonical) from the recomputed
1255
+ // source result + the attestation results. This is the recomputed copy the
1256
+ // renderer/verify-API display; it is compared (over its deterministic subset)
1257
+ // to the cached body.kernel_verdict in step 5.
1258
+ function buildVerdictObject(
1259
+ source: EvidenceBundleResult,
1260
+ attestations: AttestationResult[],
1261
+ ): VerdictObject {
1262
+ const attByCheckpoint = new Map<string, VerdictAttestation[]>();
1263
+ for (const a of attestations) {
1264
+ const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
1265
+ list.push({
1266
+ operator: a.operator,
1267
+ ...(a.gateway ? { gateway: a.gateway } : {}),
1268
+ signature_ok: a.signatureOk,
1269
+ operator_address_bound: a.operatorAddressBound,
1270
+ data_hash_bound: a.dataHashBound,
1271
+ ...(a.level !== null ? { level: a.level } : {}),
1272
+ subject_ref_ok: a.subjectRefOk,
1273
+ });
1274
+ attByCheckpoint.set(a.checkpointTxId, list);
1275
+ }
1276
+
1277
+ const events: VerdictEvent[] = source.events.map((e) => ({
1278
+ event_id: e.eventId,
1279
+ signature_ok: e.envelopeOk,
1280
+ payload_bound: e.payloadBindingOk,
1281
+ inclusion_ok: e.inclusionOk,
1282
+ content_ok: e.contentOk,
1283
+ status: eventVerdictStatus(e),
1284
+ }));
1285
+
1286
+ const checkpoints: VerdictCheckpoint[] = source.checkpoints.map((c) => ({
1287
+ checkpoint_tx_id: c.txId,
1288
+ merkle_root_ok: c.merkleRootOk,
1289
+ on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
1290
+ attestations: attByCheckpoint.get(c.txId) ?? [],
1291
+ }));
1292
+
1293
+ let verified = 0;
1294
+ let failed = 0;
1295
+ let undetermined = 0;
1296
+ for (const e of events) {
1297
+ if (e.status === "failed") failed++;
1298
+ else if (e.status === "verified") verified++;
1299
+ else undetermined++;
1300
+ }
1301
+ for (const a of attestations) {
1302
+ if (a.ok) verified++;
1303
+ else failed++;
1304
+ }
1305
+
1306
+ return {
1307
+ schema_version: "sanning.evidence.verdict/v1",
1308
+ status: source.status === "malformed" ? "failed" : source.status,
1309
+ counts: { verified, failed, undetermined },
1310
+ events,
1311
+ checkpoints,
1312
+ custody_chain: null,
1313
+ };
1314
+ }
1315
+
1316
+ function toVerdictOnChain(o: OnChainResult): VerdictOnChain {
1317
+ return {
1318
+ rollup: o.rollup,
1319
+ on_chain_ok: o.onChainOk,
1320
+ // The kernel emits {gateway, outcome}; block_height is issuer-side
1321
+ // enrichment (optional in the §4.4 schema) the offline verifier does not
1322
+ // recompute.
1323
+ per_gateway: o.perGateway.map((g) => ({ gateway: g.gateway, outcome: g.outcome })),
1324
+ };
1325
+ }
1326
+
1327
+ function eventVerdictStatus(e: EventResult): string {
1328
+ if (!e.ok) return "failed";
1329
+ if (e.payloadBindingOk === null) return "partial"; // withheld record — undetermined
1330
+ return "verified";
1331
+ }
1332
+
1333
+ // The §5 step-5 agreement projection: strip every environment/time-dependent
1334
+ // and derived dimension, leaving only what an offline verifier deterministically
1335
+ // recomputes. Tolerant of a hostile/garbled cached verdict (optional chaining +
1336
+ // nullish normalization) — a structural difference surfaces as disagreement,
1337
+ // which is the correct tamper signal. Works on both the cached snake_case JSON
1338
+ // and the freshly built VerdictObject.
1339
+ function projectVerdictForAgreement(v: unknown): unknown {
1340
+ const o = (v ?? {}) as VerdictObject;
1341
+ const events = Array.isArray(o.events) ? o.events : [];
1342
+ const checkpoints = Array.isArray(o.checkpoints) ? o.checkpoints : [];
1343
+ return {
1344
+ schema_version: typeof o.schema_version === "string" ? o.schema_version : null,
1345
+ events: events.map((e) => {
1346
+ const ev = (e ?? {}) as VerdictEvent;
1347
+ return {
1348
+ event_id: ev.event_id ?? null,
1349
+ signature_ok: ev.signature_ok ?? null,
1350
+ payload_bound: ev.payload_bound ?? null,
1351
+ inclusion_ok: ev.inclusion_ok ?? null,
1352
+ content_ok: ev.content_ok ?? null,
1353
+ };
1354
+ }),
1355
+ checkpoints: checkpoints.map((c) => {
1356
+ const cp = (c ?? {}) as VerdictCheckpoint;
1357
+ const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
1358
+ return {
1359
+ checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
1360
+ merkle_root_ok: cp.merkle_root_ok ?? null,
1361
+ // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
1362
+ attestations: atts.map((a) => {
1363
+ const at = (a ?? {}) as VerdictAttestation;
1364
+ return {
1365
+ operator: at.operator ?? null,
1366
+ signature_ok: at.signature_ok ?? null,
1367
+ operator_address_bound: at.operator_address_bound ?? null,
1368
+ data_hash_bound: at.data_hash_bound ?? null,
1369
+ subject_ref_ok: at.subject_ref_ok ?? null,
1370
+ };
1371
+ }),
1372
+ };
1373
+ }),
1374
+ };
1375
+ }
1376
+
1377
+ function summarizeExport(
1378
+ status: EvidenceStatus,
1379
+ source: EvidenceBundleResult,
1380
+ attestations: AttestationResult[],
1381
+ ): string {
1382
+ const attOk = attestations.filter((a) => a.ok).length;
1383
+ return (
1384
+ `Export ${status}: source bundle ${source.status} ` +
1385
+ `(${source.events.length} event(s) across ${source.checkpoints.length} checkpoint(s)); ` +
1386
+ `${attOk}/${attestations.length} operator attestation(s) valid.`
1387
+ );
1388
+ }
1389
+
1390
+ // Re-fetch a checkpoint's raw bytes from EACH gateway and classify every
1391
+ // gateway's individual outcome (evidence-export.md §4.2), then roll them up
1392
+ // worst-finding-wins. Unlike the pre-§4.2 first-responsive-wins collapse, this
1393
+ // records a per-gateway array so a confirm/mismatch/unreachable MIX across
1394
+ // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
1395
+ // single-boolean exactly for a single gateway.
1396
+ async function refetchCheckpoint(
1397
+ cp: TraceCheckpoint,
1398
+ gateways: string[],
1399
+ fetchImpl: typeof fetch,
1400
+ ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
1401
+ let expectedHex: string | undefined;
1402
+ try {
1403
+ expectedHex = toHex(utf8(jcs(cp.envelope)));
1404
+ } catch {
1405
+ // Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
1406
+ // (we can never confirm bytes we cannot compute).
1407
+ expectedHex = undefined;
1408
+ }
1409
+
1410
+ const perGateway: PerGatewayOutcome[] = [];
1411
+ for (const gw of gateways) {
1412
+ perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
1413
+ }
1414
+
1415
+ const onChain = rollupOnChain(perGateway);
1416
+ let error: string | undefined;
1417
+ if (onChain.rollup === "mismatch") {
1418
+ error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
1419
+ } else if (onChain.rollup === "unreachable") {
1420
+ error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
1421
+ }
1422
+ return { onChain, unavailable: onChain.rollup === "unreachable", error };
1423
+ }
1424
+
1425
+ // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
1426
+ // (bytes disagree), or unreachable (no response / !ok / network error).
1427
+ async function probeGateway(
1428
+ cp: TraceCheckpoint,
1429
+ gateway: string,
1430
+ expectedHex: string | undefined,
1431
+ fetchImpl: typeof fetch,
1432
+ ): Promise<OnChainOutcome> {
1433
+ const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
1434
+ let resp: Awaited<ReturnType<typeof fetch>>;
1435
+ try {
1436
+ resp = await fetchImpl(url);
1437
+ } catch {
1438
+ return "unreachable"; // network error / timeout
1439
+ }
1440
+ if (!resp.ok) return "unreachable"; // no such tx / gateway error
1441
+ let onChainBytes: Uint8Array;
1442
+ try {
1443
+ onChainBytes = new Uint8Array(await resp.arrayBuffer());
1444
+ } catch {
1445
+ return "unreachable";
1446
+ }
1447
+ if (expectedHex === undefined) return "mismatch";
1448
+ // The on-chain bytes are the uploaded envelope bytes (JCS-canonical, per the
1449
+ // family upload invariant). Compare structurally too, so a gateway that
1450
+ // re-pretty-prints does not spuriously fail — but the canonical re-hash is
1451
+ // the load-bearing check.
1452
+ if (toHex(onChainBytes) === expectedHex) return "confirm";
1453
+ try {
1454
+ const parsed = JSON.parse(new TextDecoder().decode(onChainBytes));
1455
+ if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
1456
+ } catch {
1457
+ // not JSON / not parseable — a genuine mismatch
1458
+ }
1459
+ return "mismatch";
1460
+ }
1461
+
1462
+ // Rollup rule (§4.2): mismatch if ANY gateway is mismatch; else confirm if ANY
1463
+ // is confirm; else unreachable. The collapsed `onChainOk`: false on mismatch,
1464
+ // true on confirm, null on all-unreachable. An empty gateway list is treated as
1465
+ // all-unreachable (no signal).
1466
+ function rollupOnChain(perGateway: PerGatewayOutcome[]): OnChainResult {
1467
+ let rollup: OnChainOutcome = "unreachable";
1468
+ if (perGateway.some((g) => g.outcome === "mismatch")) rollup = "mismatch";
1469
+ else if (perGateway.some((g) => g.outcome === "confirm")) rollup = "confirm";
1470
+ const onChainOk = rollup === "mismatch" ? false : rollup === "confirm" ? true : null;
1471
+ return { rollup, onChainOk, perGateway };
1472
+ }
1473
+
1474
+ function getEventId(env: unknown): string {
1475
+ if (env && typeof env === "object" && typeof (env as Envelope).event_id === "string") {
1476
+ return (env as Envelope).event_id;
1477
+ }
1478
+ return "<unknown>";
1479
+ }
1480
+
1481
+ function toHex(bytes: Uint8Array): string {
1482
+ let out = "";
1483
+ for (const x of bytes) out += x.toString(16).padStart(2, "0");
1484
+ return out;
1485
+ }
1486
+
1487
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
1488
+ if (a.length !== b.length) return false;
1489
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
1490
+ return true;
1491
+ }