@sanning/proof 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/agent-proof.d.ts +28 -0
- package/dist/agent-proof.js +188 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +569 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +124 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +215 -0
- package/dist/evidence.d.ts +214 -0
- package/dist/evidence.js +1049 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +38 -0
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +114 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +5 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +321 -0
- package/package.json +59 -0
- package/src/agent-proof.ts +229 -0
- package/src/cli.ts +619 -0
- package/src/compose.ts +189 -0
- package/src/crypto.ts +243 -0
- package/src/evidence.ts +1491 -0
- package/src/index.ts +105 -0
- package/src/merkle.ts +125 -0
- package/src/types.ts +72 -0
- package/src/verifier.ts +340 -0
package/src/evidence.ts
ADDED
|
@@ -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
|
+
}
|