@sanning/proof 0.19.1 → 0.20.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/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/src/evidence.ts
CHANGED
|
@@ -1,29 +1,18 @@
|
|
|
1
|
-
// Evidence-bundle verification (
|
|
1
|
+
// Evidence-bundle verification (specs/evidence-bundle.md).
|
|
2
2
|
//
|
|
3
|
-
// A `sanning.evidence/v1` bundle is a
|
|
4
|
-
// `body`.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
3
|
+
// A `sanning.evidence/v1` bundle is a signed wrapper around a producer-specific
|
|
4
|
+
// `body`. Every claim is re-derived from `body` with the same JCS, SHA-256 and
|
|
5
|
+
// Ed25519 as the envelope layer, never read off `verdict.status`, and an
|
|
6
|
+
// unknown major is rejected. The bundle verifies offline against its embedded
|
|
7
|
+
// `public_key`; the optional gateway re-fetch only confirms checkpoints are
|
|
8
|
+
// witnessed on-chain.
|
|
9
|
+
|
|
10
|
+
import { readAgentRecords, type AgentHeldRecord, type AgentRecordReading } from "./agent-record.js";
|
|
11
|
+
import { isObject } from "./json-schema.js";
|
|
19
12
|
import {
|
|
20
13
|
deriveOperatorAddress,
|
|
21
14
|
ed25519Verify,
|
|
22
15
|
hexToBytes,
|
|
23
|
-
// The one hex-field test, which lives in `crypto.ts` beside the one hex
|
|
24
|
-
// reader (proof#146). It was a second copy here until then, with the same
|
|
25
|
-
// body: two copies of one rule is how two kernels drift apart one edit at a
|
|
26
|
-
// time, which is the whole defect class this fix is about.
|
|
27
16
|
isHexField,
|
|
28
17
|
sha256Hex,
|
|
29
18
|
utf8,
|
|
@@ -33,13 +22,13 @@ import { safeInteger, signedLeafCount } from "./grading.js";
|
|
|
33
22
|
import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
|
|
34
23
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
35
24
|
import {
|
|
36
|
-
DuplicateKeyError,
|
|
37
25
|
MAX_CANONICAL_DEPTH,
|
|
38
26
|
contentHashes,
|
|
39
27
|
exceedsDepth,
|
|
40
28
|
isRfc3339UtcInstant,
|
|
41
29
|
jcs,
|
|
42
30
|
jcsParse,
|
|
31
|
+
specVersionSupported,
|
|
43
32
|
verifyEnvelope,
|
|
44
33
|
} from "./verifier.js";
|
|
45
34
|
import {
|
|
@@ -47,42 +36,28 @@ import {
|
|
|
47
36
|
sideContentEntry,
|
|
48
37
|
type SideEntry,
|
|
49
38
|
} from "./side-content.js";
|
|
50
|
-
import type { Envelope } from "./types.js";
|
|
39
|
+
import type { Envelope, EnvelopeFault } from "./types.js";
|
|
40
|
+
import { ENVELOPE_FAULTS } from "./types.js";
|
|
51
41
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
// envelope verifier's ACCEPTED_SPEC_MAJORS.
|
|
55
|
-
/** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
|
|
42
|
+
// Fail-closed, like ACCEPTED_SPEC_MAJORS: a new major is a one-entry addition here.
|
|
43
|
+
/** The accepted evidence-bundle wrapper majors. */
|
|
56
44
|
export const ACCEPTED_EVIDENCE_MAJORS: readonly string[] = Object.freeze(["sanning.evidence/v1"]);
|
|
57
45
|
|
|
58
|
-
// The
|
|
59
|
-
// body_type
|
|
60
|
-
//
|
|
61
|
-
// "undetermined" rather than "verified".
|
|
62
|
-
//
|
|
63
|
-
// The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
|
|
64
|
-
// `event_bytes` and moved every body that carries it to a new major. This
|
|
65
|
-
// kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
|
|
66
|
-
// `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
|
|
67
|
-
// types here and answer `partial`. DL-73 releases evidence made before the cut
|
|
68
|
-
// from any obligation to keep verifying, so there is no second field name to
|
|
69
|
-
// read and no dual-accept path.
|
|
46
|
+
// The anchor-trace body this module verifies structurally. An unknown
|
|
47
|
+
// body_type does not fail the wrapper, but its body cannot be re-derived, so
|
|
48
|
+
// it reads `incomplete`. Bodies before the `event_bytes` majors are unknown here.
|
|
70
49
|
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
|
|
71
50
|
|
|
72
51
|
/**
|
|
73
|
-
* The name `event_bytes` replaced.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* checked, the fail-open the new majors exist to prevent (governance.md §8,
|
|
77
|
-
* W-3).
|
|
52
|
+
* The field name `event_bytes` replaced. A body entry still carrying it is a
|
|
53
|
+
* hard reject: ignored, it would read as a withheld record and verify with
|
|
54
|
+
* nothing checked.
|
|
78
55
|
*/
|
|
79
56
|
export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
|
|
80
57
|
|
|
81
58
|
/**
|
|
82
|
-
* Name an envelope the accept-set refused
|
|
83
|
-
*
|
|
84
|
-
* writing `sanning.events/v1`) fails with no reason at all. Spelled with
|
|
85
|
-
* `JSON.stringify` so both kernels write the same sentence.
|
|
59
|
+
* Name an envelope the accept-set refused, so a retired profile major fails
|
|
60
|
+
* with a reason. The same sentence in both kernels.
|
|
86
61
|
*/
|
|
87
62
|
function specVersionFinding(
|
|
88
63
|
what: "checkpoint" | "event",
|
|
@@ -97,10 +72,7 @@ function specVersionFinding(
|
|
|
97
72
|
return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
|
|
98
73
|
}
|
|
99
74
|
|
|
100
|
-
/**
|
|
101
|
-
* The reject for an entry that still carries `record_bytes`. One sentence,
|
|
102
|
-
* spelled the same in both kernels, so a negative vector can assert it.
|
|
103
|
-
*/
|
|
75
|
+
/** The reject for an entry that still carries `record_bytes`. The same sentence in both kernels. */
|
|
104
76
|
function legacyEventBytesError(
|
|
105
77
|
entry: object,
|
|
106
78
|
what: "checkpoint" | "event",
|
|
@@ -113,43 +85,25 @@ function legacyEventBytesError(
|
|
|
113
85
|
);
|
|
114
86
|
}
|
|
115
87
|
|
|
116
|
-
// The
|
|
117
|
-
//
|
|
118
|
-
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
119
|
-
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
120
|
-
// verifyExportBody (§5).
|
|
88
|
+
// The attested-evidence-export body (specs/evidence-export.md §2): a cached
|
|
89
|
+
// verdict, the inline source bundle and RSA-PSS operator attestations.
|
|
121
90
|
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
|
|
122
91
|
|
|
123
92
|
/**
|
|
124
|
-
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
128
|
-
* `sanning.events/v2` envelope, and a stamp has no producer signature at all:
|
|
129
|
-
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
130
|
-
* The two rejected options were making `envelope` optional and synthesising an
|
|
131
|
-
* envelope that Sanning signs; the second would have made a Sanning
|
|
132
|
-
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
133
|
-
* unrecoverable once witnessed.
|
|
93
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2). Its own
|
|
94
|
+
* body type, because a stamp has no producer signature: a Sanning attestation
|
|
95
|
+
* must never be indistinguishable on the wire from a producer's.
|
|
134
96
|
*/
|
|
135
97
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
136
98
|
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
|
|
137
99
|
|
|
138
100
|
/**
|
|
139
|
-
* The version string
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
143
|
-
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
144
|
-
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
145
|
-
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
146
|
-
* version to exactly this value: two answers to one question inside one
|
|
147
|
-
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
101
|
+
* The version string inside a witnessed stamp body, distinct from the
|
|
102
|
+
* `body_type` above (the dispatch key). A stamp trace body pins it to exactly
|
|
103
|
+
* this value (§5.1.2).
|
|
148
104
|
*/
|
|
149
105
|
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
150
106
|
|
|
151
|
-
// ---- Wire types (evidence-bundle.md §2 + §5.1) ------------------------------
|
|
152
|
-
|
|
153
107
|
export interface EvidenceIssuer {
|
|
154
108
|
kind: string;
|
|
155
109
|
tenant_id?: string;
|
|
@@ -157,24 +111,9 @@ export interface EvidenceIssuer {
|
|
|
157
111
|
[k: string]: unknown;
|
|
158
112
|
}
|
|
159
113
|
|
|
160
|
-
export interface EvidenceVerdict {
|
|
161
|
-
status: string;
|
|
162
|
-
summary?: string;
|
|
163
|
-
counts?: Record<string, number>;
|
|
164
|
-
as_of?: string;
|
|
165
|
-
[k: string]: unknown;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
114
|
/**
|
|
169
|
-
* Where a commitment was witnessed (evidence-bundle.md §5.1.1).
|
|
170
|
-
*
|
|
171
|
-
* The same three-part shape the control plane indexes (`kind` / one promoted
|
|
172
|
-
* `id` / a typed remainder), so the signed body, the index row and the SDK
|
|
173
|
-
* artifact stop being three vocabularies for one fact.
|
|
174
|
-
*
|
|
175
|
-
* NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
|
|
176
|
-
* and packing live in `ref`, opaque to every reader above this seam. A field
|
|
177
|
-
* that would need renaming for a second witness belongs in `ref` by definition.
|
|
115
|
+
* Where a commitment was witnessed (evidence-bundle.md §5.1.1). No field here
|
|
116
|
+
* names a chain: chain-specific detail lives in the opaque `ref`.
|
|
178
117
|
*/
|
|
179
118
|
export interface AnchorRef {
|
|
180
119
|
kind: string;
|
|
@@ -182,18 +121,16 @@ export interface AnchorRef {
|
|
|
182
121
|
ref?: Record<string, unknown>;
|
|
183
122
|
}
|
|
184
123
|
|
|
185
|
-
// `sanning.anchor.trace/v3` body (§5.1).
|
|
124
|
+
// `sanning.anchor.trace/v3` body (§5.1). Byte fields ride as lowercase hex.
|
|
186
125
|
export interface TraceCheckpoint {
|
|
187
126
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* label.
|
|
127
|
+
* Hex SHA-256 of the decoded `event_bytes`: the checkpoint's identity and join
|
|
128
|
+
* key. Recomputed by the verifier, never trusted.
|
|
191
129
|
*/
|
|
192
130
|
checkpoint_ref: string;
|
|
193
131
|
/**
|
|
194
|
-
* Where it was witnessed,
|
|
195
|
-
*
|
|
196
|
-
* required field that must be filled dishonestly is a defect in the format.
|
|
132
|
+
* Where it was witnessed, if it has been. Absent is honest: checkpoints are
|
|
133
|
+
* placed before they are witnessed.
|
|
197
134
|
*/
|
|
198
135
|
anchor_ref?: AnchorRef;
|
|
199
136
|
envelope: Envelope;
|
|
@@ -213,9 +150,8 @@ export interface TraceInclusion {
|
|
|
213
150
|
export interface TraceEvent {
|
|
214
151
|
envelope: Envelope;
|
|
215
152
|
event_bytes?: string; // hex of JCS(event record); omitted == record withheld
|
|
216
|
-
// Opt-in disclosed raw bytes (lowercase hex) whose SHA-256
|
|
217
|
-
// committed event.content_hash
|
|
218
|
-
// Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
|
|
153
|
+
// Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 must equal the
|
|
154
|
+
// committed `event.content_hash` (§5.1). Absent is undetermined, not failed.
|
|
219
155
|
content?: string;
|
|
220
156
|
inclusion: TraceInclusion;
|
|
221
157
|
}
|
|
@@ -225,16 +161,10 @@ export interface AnchorTraceBody {
|
|
|
225
161
|
events: TraceEvent[];
|
|
226
162
|
}
|
|
227
163
|
|
|
228
|
-
// ---- `sanning.stamp.trace/v1` wire types (evidence-bundle.md §5.2) ---------
|
|
229
|
-
|
|
230
164
|
/**
|
|
231
|
-
* One sub-tree of a stamp, as the
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* never publish), so a reader learns that the org runs N agents and their
|
|
235
|
-
* relative volumes and learns no name. The RANGE is deliberately in the clear:
|
|
236
|
-
* it is what makes a later per-agent slice claim checkable by a third party
|
|
237
|
-
* without Sanning, and hiding it would put us back in the trust path.
|
|
165
|
+
* One sub-tree of a stamp, as the witnessed bytes carry it. Keyed by a blinded
|
|
166
|
+
* reference, so a reader learns no agent name. The range is in the clear so a
|
|
167
|
+
* per-agent slice claim is checkable without Sanning.
|
|
238
168
|
*/
|
|
239
169
|
export interface StampSubtree {
|
|
240
170
|
first_leaf: number;
|
|
@@ -244,15 +174,12 @@ export interface StampSubtree {
|
|
|
244
174
|
}
|
|
245
175
|
|
|
246
176
|
/**
|
|
247
|
-
* The stamp body as the control plane witnesses it
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* Never read from the bundle directly — it is parsed out of `stamp_bytes`,
|
|
251
|
-
* which is the only authority. Present as a type so the parse has a shape.
|
|
177
|
+
* The stamp body as the control plane witnesses it. Only ever parsed out of
|
|
178
|
+
* `stamp_bytes`, the sole authority.
|
|
252
179
|
*/
|
|
253
180
|
export interface StampBody {
|
|
254
181
|
stamp_version: string;
|
|
255
|
-
/** The org,
|
|
182
|
+
/** The org, blinded. Not reversible without the secret. */
|
|
256
183
|
org_ref: string;
|
|
257
184
|
interval_start: string;
|
|
258
185
|
interval_end: string;
|
|
@@ -263,18 +190,13 @@ export interface StampBody {
|
|
|
263
190
|
|
|
264
191
|
/** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
|
|
265
192
|
export interface StampEntry {
|
|
266
|
-
/**
|
|
267
|
-
* The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
|
|
268
|
-
* DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
|
|
269
|
-
*/
|
|
193
|
+
/** Hex SHA-256 of the decoded `stamp_bytes`, not the hex text. Recomputed by the verifier. */
|
|
270
194
|
stamp_ref: string;
|
|
271
195
|
/** Lowercase hex of the exact JCS bytes that were handed to the witness. */
|
|
272
196
|
stamp_bytes: string;
|
|
273
197
|
/**
|
|
274
|
-
* Where the stamp was witnessed,
|
|
275
|
-
*
|
|
276
|
-
* filled this in from a submission receipt would be claiming a chain
|
|
277
|
-
* position that does not exist yet.
|
|
198
|
+
* Where the stamp was witnessed, if it has been (§5.1.1). Absent is honest: a
|
|
199
|
+
* submission receipt is not a chain position.
|
|
278
200
|
*/
|
|
279
201
|
anchor_ref?: AnchorRef;
|
|
280
202
|
}
|
|
@@ -283,13 +205,9 @@ export interface StampTraceBody {
|
|
|
283
205
|
stamps: StampEntry[];
|
|
284
206
|
}
|
|
285
207
|
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// Sanning signs and witnesses the stamp whose root commits to it. Neither
|
|
290
|
-
// earlier body joins them, and a field added to §5.2 could not — a conformant
|
|
291
|
-
// reader MUST ignore fields it does not know (envelope-spec.md §2), so the
|
|
292
|
-
// join has to live in a body_type readers DISPATCH on. See §5.3.1.
|
|
208
|
+
// `sanning.stamp.evidence/v2` (evidence-bundle.md §5.3): binds the producer's
|
|
209
|
+
// envelope to the Sanning stamp whose root commits to it. Its own body type,
|
|
210
|
+
// because readers ignore unknown fields and the join must be dispatched on.
|
|
293
211
|
|
|
294
212
|
/** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
|
|
295
213
|
export interface StampEvidenceInclusion {
|
|
@@ -297,7 +215,7 @@ export interface StampEvidenceInclusion {
|
|
|
297
215
|
leaf_index: number;
|
|
298
216
|
leaf_count: number;
|
|
299
217
|
audit_path: string[]; // hex[]
|
|
300
|
-
/** Resolves against `stamps[].stamp_ref` in the
|
|
218
|
+
/** Resolves against `stamps[].stamp_ref` in the same body. */
|
|
301
219
|
stamp_ref: string;
|
|
302
220
|
}
|
|
303
221
|
|
|
@@ -309,7 +227,7 @@ export interface StampEvidenceEvent {
|
|
|
309
227
|
}
|
|
310
228
|
|
|
311
229
|
export interface StampEvidenceBody {
|
|
312
|
-
/** Byte-identical to §5.2.2
|
|
230
|
+
/** Byte-identical to §5.2.2. */
|
|
313
231
|
stamps: StampEntry[];
|
|
314
232
|
events: StampEvidenceEvent[];
|
|
315
233
|
}
|
|
@@ -320,7 +238,6 @@ export interface EvidenceBundle {
|
|
|
320
238
|
issuer: EvidenceIssuer;
|
|
321
239
|
generated_at: string;
|
|
322
240
|
gateway?: string | null;
|
|
323
|
-
verdict: EvidenceVerdict;
|
|
324
241
|
body?: unknown;
|
|
325
242
|
body_ref?: string;
|
|
326
243
|
body_hash: string;
|
|
@@ -331,20 +248,15 @@ export interface EvidenceBundle {
|
|
|
331
248
|
[k: string]: unknown;
|
|
332
249
|
}
|
|
333
250
|
|
|
334
|
-
// ---- Verification result ----------------------------------------------------
|
|
335
|
-
|
|
336
251
|
export type EvidenceStatus =
|
|
337
252
|
| "verified" // every in-scope item passed (sig + integrity + inclusion)
|
|
338
|
-
| "partial" // integrity confirmed but full proof incomplete (e.g. records withheld)
|
|
339
253
|
| "failed" // a check failed: bad wrapper sig / tamper / broken inclusion
|
|
340
|
-
| "
|
|
341
|
-
|
|
342
|
-
// Per-gateway on-chain outcome for one checkpoint
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
// mismatch — the gateway returned the tx but its bytes disagree — a finding.
|
|
347
|
-
// unreachable — no response / timeout / no such tx — an availability gap.
|
|
254
|
+
| "incomplete"; // nothing failed and something could not be checked or read
|
|
255
|
+
|
|
256
|
+
// Per-gateway on-chain outcome for one checkpoint (evidence-export.md §4.2):
|
|
257
|
+
// confirm the gateway returned the tx and its bytes match.
|
|
258
|
+
// mismatch the bytes disagree: a finding.
|
|
259
|
+
// unreachable no response, timeout or no such tx: an availability gap.
|
|
348
260
|
export type OnChainOutcome = "confirm" | "mismatch" | "unreachable";
|
|
349
261
|
|
|
350
262
|
export interface PerGatewayOutcome {
|
|
@@ -353,12 +265,10 @@ export interface PerGatewayOutcome {
|
|
|
353
265
|
}
|
|
354
266
|
|
|
355
267
|
export interface OnChainResult {
|
|
356
|
-
//
|
|
357
|
-
//
|
|
268
|
+
// Worst finding wins: mismatch if any gateway mismatched, else confirm if
|
|
269
|
+
// any confirmed, else unreachable.
|
|
358
270
|
rollup: OnChainOutcome;
|
|
359
|
-
//
|
|
360
|
-
// single `onChainOk`): false on rollup mismatch, true on confirm, null on
|
|
361
|
-
// all-unreachable.
|
|
271
|
+
// Collapsed view: false on mismatch, true on confirm, null on all-unreachable.
|
|
362
272
|
onChainOk: boolean | null;
|
|
363
273
|
perGateway: PerGatewayOutcome[];
|
|
364
274
|
}
|
|
@@ -366,55 +276,81 @@ export interface OnChainResult {
|
|
|
366
276
|
export interface CheckpointResult {
|
|
367
277
|
/** The derived `checkpoint_ref`, empty when it could not be derived. */
|
|
368
278
|
checkpointRef: string;
|
|
369
|
-
/**
|
|
370
|
-
* Where this checkpoint was witnessed. `null` when the checkpoint carries no
|
|
371
|
-
* `anchor_ref`: placed but not yet witnessed, which is a normal state and NOT
|
|
372
|
-
* a deficiency.
|
|
373
|
-
*/
|
|
279
|
+
/** Where it was witnessed; `null` when placed but not yet witnessed, which is normal. */
|
|
374
280
|
anchorRef: AnchorRef | null;
|
|
375
|
-
// The checkpoint envelope verified (signature
|
|
281
|
+
// The checkpoint envelope verified (signature and payload binding).
|
|
376
282
|
envelopeOk: boolean;
|
|
377
283
|
// The committed record's merkle_root equals the checkpoint's claimed merkle_root.
|
|
378
284
|
merkleRootOk: boolean;
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
// no witness adapter, or its id is not one the adapter accepts; the witness
|
|
382
|
-
// was not checked. Derived collapse of `onChain` (§4.2), retained so existing
|
|
383
|
-
// consumers keep the single-boolean field.
|
|
285
|
+
// Collapsed `onChain`. null when the witness was not checked: no gateways, no
|
|
286
|
+
// witness adapter for `anchor_ref.kind`, or an id the adapter rejects.
|
|
384
287
|
onChainOk: boolean | null;
|
|
385
|
-
// Per-gateway
|
|
386
|
-
// gateway re-fetch was requested (offline-only verification), or when
|
|
387
|
-
// `anchor_ref.kind` has no witness adapter, or its id is not one the adapter
|
|
388
|
-
// accepts; the witness was not checked.
|
|
288
|
+
// Per-gateway outcomes (§4.2); null when the witness was not checked.
|
|
389
289
|
onChain: OnChainResult | null;
|
|
390
290
|
ok: boolean;
|
|
391
291
|
errors: string[];
|
|
292
|
+
// True where the only thing wrong is a malformed `anchor_ref`: the bytes the pack
|
|
293
|
+
// signed hold and break their own format (`follows_rules` disproven).
|
|
294
|
+
anchorRefMalformed?: boolean;
|
|
392
295
|
}
|
|
393
296
|
|
|
394
297
|
export interface EventResult {
|
|
395
298
|
eventId: string;
|
|
396
|
-
//
|
|
299
|
+
// Signature ok, and payload binding ok or undetermined.
|
|
397
300
|
envelopeOk: boolean;
|
|
398
|
-
//
|
|
399
|
-
//
|
|
301
|
+
// true matched, false mismatch, null record withheld: undetermined, not a
|
|
302
|
+
// failure (envelope-spec §3.1, §6 item 2).
|
|
400
303
|
payloadBindingOk: boolean | null;
|
|
401
304
|
// RFC 9162 inclusion of this event's leaf in its checkpoint's root.
|
|
402
305
|
inclusionOk: boolean;
|
|
403
306
|
// The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
|
|
404
307
|
checkpointBound: boolean;
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// fails), null undetermined (nothing disclosed OR nothing committed OR record
|
|
409
|
-
// withheld — NOT a failure, mirroring payloadBindingOk).
|
|
308
|
+
// SHA-256 of the disclosed bytes equals the committed `content_hash` (or, for
|
|
309
|
+
// a promoted envelope, a hash it commits to). false fails the event; null is
|
|
310
|
+
// undetermined (nothing disclosed or committed, or record withheld).
|
|
410
311
|
contentOk: boolean | null;
|
|
411
312
|
ok: boolean;
|
|
412
313
|
errors: string[];
|
|
314
|
+
/**
|
|
315
|
+
* The `sanning.agent` reading of this record (agent-events.md section 10), read with
|
|
316
|
+
* every other record of the pile it was verified in: the bundle, or every pack
|
|
317
|
+
* of a case. `null` where the record is not an agent record, or its envelope
|
|
318
|
+
* did not verify, or its record bytes did not travel or did not bind. It never
|
|
319
|
+
* moves `ok` or the bundle status; the pack grade reads it (section 5.4.5).
|
|
320
|
+
*/
|
|
321
|
+
agent: AgentRecordReading | null;
|
|
322
|
+
/**
|
|
323
|
+
* True where the record's `stamp_ref` or `checkpoint_ref` names a stamp or checkpoint the
|
|
324
|
+
* pack does not hold: `witnessed` is not proven (`missing`), never disproven, whatever else
|
|
325
|
+
* about the record fails.
|
|
326
|
+
*/
|
|
327
|
+
stampAbsent?: boolean;
|
|
328
|
+
/**
|
|
329
|
+
* True where the record carries a retired field name, or bytes that are present and cannot
|
|
330
|
+
* be read: a defect in the artifact that fails the record under a key that can be read.
|
|
331
|
+
*/
|
|
332
|
+
ownFormFailed?: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* True where the record's signed bytes bind to its `payload_hash` under a signature that
|
|
335
|
+
* holds and are not JSON: the producer broke the format it signed, so `follows_rules` is
|
|
336
|
+
* disproven and nothing was proven altered.
|
|
337
|
+
*/
|
|
338
|
+
recordNotJson?: boolean;
|
|
339
|
+
/**
|
|
340
|
+
* True where the bytes this record's content was checked against came from the side
|
|
341
|
+
* input, which nobody signed: a mismatch then leaves `unchanged` not proven
|
|
342
|
+
* (`not_checked`), never disproven.
|
|
343
|
+
*/
|
|
344
|
+
contentUnsigned?: boolean;
|
|
345
|
+
/** Why the record's envelope does not verify: the worst of `envelopeFaults`, or `null`. */
|
|
346
|
+
envelopeFault?: EnvelopeFault | null;
|
|
347
|
+
/** Every envelope fault found (`ENVELOPE_FAULTS`), worst first. */
|
|
348
|
+
envelopeFaults?: EnvelopeFault[];
|
|
413
349
|
}
|
|
414
350
|
|
|
415
351
|
/** One sub-tree's recomputed result (§5.2). */
|
|
416
352
|
export interface StampSubtreeResult {
|
|
417
|
-
/** The blinded key the body
|
|
353
|
+
/** The blinded key the body uses. */
|
|
418
354
|
ref: string;
|
|
419
355
|
firstLeaf: number;
|
|
420
356
|
leafCount: number;
|
|
@@ -423,17 +359,13 @@ export interface StampSubtreeResult {
|
|
|
423
359
|
|
|
424
360
|
/** One `stamps[]` entry's result (§5.2). */
|
|
425
361
|
export interface StampResult {
|
|
426
|
-
/** The stamp's derived identity
|
|
362
|
+
/** The stamp's derived identity. "" when unusable. */
|
|
427
363
|
stampRef: string;
|
|
428
364
|
/** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
|
|
429
365
|
stampRefOk: boolean;
|
|
430
366
|
/**
|
|
431
|
-
* The witnessed bytes
|
|
432
|
-
*
|
|
433
|
-
* The last clause is not pedantry. The plane stores these bytes as TEXT and
|
|
434
|
-
* replays them verbatim precisely because a round-trip through a normalizing
|
|
435
|
-
* serializer is not guaranteed to reproduce what was signed; a body that is
|
|
436
|
-
* not JCS-canonical is one that every re-deriving reader hashes differently.
|
|
367
|
+
* The witnessed bytes decode, parse and re-canonicalize to themselves. A body
|
|
368
|
+
* that is not JCS-canonical hashes differently for every re-deriving reader.
|
|
437
369
|
*/
|
|
438
370
|
bodyOk: boolean;
|
|
439
371
|
stampVersion: string | null;
|
|
@@ -442,55 +374,34 @@ export interface StampResult {
|
|
|
442
374
|
merkleRoot: string | null;
|
|
443
375
|
leafCount: number | null;
|
|
444
376
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* overlap, and their counts sum to `leaf_count`.
|
|
377
|
+
* The sub-tree ranges, sorted by `first_leaf`, tile `[0, leaf_count)` with no
|
|
378
|
+
* gap or overlap.
|
|
448
379
|
*/
|
|
449
380
|
subtreesOk: boolean;
|
|
450
381
|
subtrees: StampSubtreeResult[];
|
|
451
382
|
/**
|
|
452
|
-
* Where it was witnessed, or `null`
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* of every stamp for the first minutes of its life and the permanent state
|
|
456
|
-
* of every stamp when no witness is configured. It does hold the bundle at
|
|
457
|
-
* `partial`: a stamp is Sanning's own assertion until something independent
|
|
458
|
-
* carries it.
|
|
383
|
+
* Where it was witnessed, or `null` if nothing has witnessed it yet. Never an
|
|
384
|
+
* accusation, but it holds the bundle at `partial`: until witnessed, a stamp
|
|
385
|
+
* is Sanning's own assertion.
|
|
459
386
|
*/
|
|
460
387
|
anchorRef: AnchorRef | null;
|
|
461
388
|
/**
|
|
462
|
-
* Per-gateway
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* `null` means not checked — no gateways were supplied, or this stamp names
|
|
468
|
-
* no witness to check, or its `anchor_ref.kind` has no witness adapter, or
|
|
469
|
-
* its id is not one the adapter accepts; the witness was not checked. It is
|
|
470
|
-
* a statement about the RUN, never about the stamp. The other three are
|
|
471
|
-
* statements about the stamp and live in `onChain.rollup`: `confirm` (a
|
|
472
|
-
* gateway returned the witnessed bytes), `mismatch` (a gateway returned
|
|
473
|
-
* DIFFERENT bytes: a finding, and the only one of the four that fails
|
|
474
|
-
* anything), `unreachable` (we asked and nobody answered: an availability
|
|
475
|
-
* gap, never an accusation).
|
|
476
|
-
*
|
|
477
|
-
* Keeping "not checked" distinct from "unreachable" is the whole point. A
|
|
478
|
-
* surface handed a collapsed boolean cannot tell "no gateway could be
|
|
479
|
-
* reached" from "no gateway was tried", and reported the first when the
|
|
480
|
-
* second was true — a false accusation manufactured by a missing state.
|
|
389
|
+
* Per-gateway outcomes for this stamp's `anchor_ref`, or `null` when nothing
|
|
390
|
+
* was checked (a statement about the run, not the stamp). Of `confirm`,
|
|
391
|
+
* `mismatch` and `unreachable`, only `mismatch` fails anything. "Not checked"
|
|
392
|
+
* and "unreachable" stay distinct so a surface never reports an availability
|
|
393
|
+
* gap that was never tested.
|
|
481
394
|
*/
|
|
482
395
|
onChain: OnChainResult | null;
|
|
483
396
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* on confirm, `null` on unreachable AND on not-checked.
|
|
487
|
-
*
|
|
488
|
-
* It is deliberately the LOSSY view — `null` is three-quarters of the state
|
|
489
|
-
* space here. Anything that needs to tell those apart reads `onChain`.
|
|
397
|
+
* Lossy collapse of `onChain`, as on `CheckpointResult`: `false` mismatch,
|
|
398
|
+
* `true` confirm, `null` unreachable or not checked.
|
|
490
399
|
*/
|
|
491
400
|
onChainOk: boolean | null;
|
|
492
401
|
ok: boolean;
|
|
493
402
|
errors: string[];
|
|
403
|
+
/** True where the only thing wrong is a malformed `anchor_ref` (as on a checkpoint). */
|
|
404
|
+
anchorRefMalformed?: boolean;
|
|
494
405
|
}
|
|
495
406
|
|
|
496
407
|
export interface EvidenceBundleResult {
|
|
@@ -500,37 +411,43 @@ export interface EvidenceBundleResult {
|
|
|
500
411
|
bodyHashOk: boolean;
|
|
501
412
|
signatureOk: boolean;
|
|
502
413
|
bodyType: string | null;
|
|
503
|
-
// null when no gateways were supplied (offline-only verification).
|
|
504
414
|
onChainChecked: boolean;
|
|
505
415
|
checkpoints: CheckpointResult[];
|
|
506
416
|
events: EventResult[];
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
// Present ONLY for a `sanning.evidence.export/v2` body (evidence-export.md §5):
|
|
510
|
-
// the export-specific dimensions (source linkage, verdict agreement, embedded
|
|
511
|
-
// attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
|
|
512
|
-
// above are the recomputed SOURCE-bundle results in that case. Absent for a
|
|
513
|
-
// plain anchor-trace bundle — additive, backward-compatible.
|
|
417
|
+
// Only for a `sanning.evidence.export/v2` body (evidence-export.md §5). The
|
|
418
|
+
// top-level `checkpoints`/`events` are then the recomputed source bundle's.
|
|
514
419
|
export?: ExportResult;
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
// such a bundle — a stamp body carries neither, and reporting an empty list
|
|
518
|
-
// as "nothing was wrong" is the exact confusion `partial` exists to prevent.
|
|
519
|
-
// Absent for every other body — additive, backward-compatible.
|
|
420
|
+
// Only for a `sanning.stamp.trace/v1` body (§5.2). `checkpoints` and `events`
|
|
421
|
+
// stay empty: a stamp body carries neither.
|
|
520
422
|
stamps?: StampResult[];
|
|
521
423
|
errors: string[];
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
424
|
+
// True where the bundle could not be read as one (the claim it blocks is not proven,
|
|
425
|
+
// `unreadable`): not a JSON object, an unknown major or `signature_alg`, a required
|
|
426
|
+
// wrapper field missing, or an export body that cannot be read. `status` is then
|
|
427
|
+
// `incomplete`.
|
|
428
|
+
unreadable?: boolean;
|
|
429
|
+
// True where the wrapper's `body_hash` and signature hold and its `generated_at` has
|
|
430
|
+
// another form: the producer broke the format it signed (`follows_rules` disproven).
|
|
431
|
+
// `status` is then `failed`, and the body is still read, so a disproof inside it stands.
|
|
432
|
+
// Under a key that cannot be read the form is not judged.
|
|
433
|
+
generatedAtMalformed?: boolean;
|
|
434
|
+
// True where the wrapper's `public_key` cannot be read as a key: `signed` is not proven
|
|
435
|
+
// (`unreadable`). `status` is `failed` where `body_hash` does not hold, and otherwise the
|
|
436
|
+
// body's own reading, never above `incomplete`.
|
|
437
|
+
keyUnreadable?: boolean;
|
|
438
|
+
// True where the wrapper's `spec_version` names a major this verifier does not know:
|
|
439
|
+
// the body is not read, and `follows_rules` is not proven (`not_checked`). The wrapper
|
|
440
|
+
// signature is still checked: where it fails, `status` is `failed` and `signatureOk` is
|
|
441
|
+
// false; where it holds, `signatureOk` is true, the only claim about the wrapper this
|
|
442
|
+
// verifier can make, and `bodyHashOk` stays false, unchecked.
|
|
443
|
+
versionUnknown?: boolean;
|
|
444
|
+
// Informational only: never moves `status` or the exit code. Carries
|
|
445
|
+
// `CO_SIGNATURES_NOTE` when the wrapper's `co_signatures` holds an entry.
|
|
527
446
|
notes?: string[];
|
|
528
447
|
}
|
|
529
448
|
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
// One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
|
|
533
|
-
// JCS(payload); the operator key is the operator's Arweave RSA wallet.
|
|
449
|
+
// One embedded operator attestation (§2.2, §3): RSA-PSS-SHA-256 over
|
|
450
|
+
// JCS(payload), keyed by the operator's Arweave RSA wallet.
|
|
534
451
|
export interface AttestationRecord {
|
|
535
452
|
/** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
|
|
536
453
|
checkpoint_ref: string;
|
|
@@ -562,10 +479,8 @@ export interface ExportBody {
|
|
|
562
479
|
[k: string]: unknown;
|
|
563
480
|
}
|
|
564
481
|
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
// rendering convenience the verifier recomputes and (over its deterministic
|
|
568
|
-
// dimensions, §5 step 5) confirms.
|
|
482
|
+
// The §4 verdict object, recomputed by the kernel. The copy cached in
|
|
483
|
+
// `body.kernel_verdict` is a convenience the verifier checks (§5 step 5).
|
|
569
484
|
|
|
570
485
|
export interface VerdictEvent {
|
|
571
486
|
event_id: string;
|
|
@@ -621,87 +536,70 @@ export interface AttestationResult {
|
|
|
621
536
|
checkpointRef: string;
|
|
622
537
|
operator: string;
|
|
623
538
|
gateway: string | null;
|
|
624
|
-
//
|
|
539
|
+
// RSA-PSS-SHA-256 over JCS(payload) verified (§5 step 6a).
|
|
625
540
|
signatureOk: boolean;
|
|
626
|
-
// base64url(SHA-256(public_key.n)) == payload.operator (§
|
|
541
|
+
// base64url(SHA-256(public_key.n)) == payload.operator (§5 step 6b).
|
|
627
542
|
operatorAddressBound: boolean;
|
|
628
|
-
// payload.data_hash ==
|
|
629
|
-
// (SHA-256(JCS(checkpoint.envelope))) (§5 step 6c).
|
|
543
|
+
// payload.data_hash == SHA-256(JCS(source checkpoint envelope)) (§5 step 6c).
|
|
630
544
|
dataHashBound: boolean;
|
|
631
545
|
// checkpoint_ref resolved to a present source checkpoint.
|
|
632
546
|
checkpointResolved: boolean;
|
|
633
547
|
level: number | null;
|
|
634
|
-
// subject_ref (§3.2)
|
|
635
|
-
//
|
|
548
|
+
// subject_ref (§3.2): null absent or undetermined; true/false only against a
|
|
549
|
+
// supplied subject. Never gates the attestation.
|
|
636
550
|
subjectRefOk: boolean | null;
|
|
637
551
|
ok: boolean;
|
|
638
552
|
errors: string[];
|
|
553
|
+
// Set where the signature verifies and a payload field breaks section 3.1: the issuer
|
|
554
|
+
// signed a record that does not follow its format (`follows_rules` disproven).
|
|
555
|
+
fieldError?: string | null;
|
|
639
556
|
}
|
|
640
557
|
|
|
641
558
|
export interface ExportResult {
|
|
642
559
|
// SHA-256(JCS(source_bundle)) == source_bundle_hash (§5 step 3).
|
|
643
560
|
sourceLinkageOk: boolean;
|
|
644
|
-
// Recomputed source-bundle
|
|
645
|
-
// inline source_bundle, §5 step 4).
|
|
561
|
+
// Recomputed source-bundle status (§5 step 4).
|
|
646
562
|
sourceStatus: EvidenceStatus;
|
|
647
|
-
// Cached-vs-recomputed
|
|
648
|
-
//
|
|
649
|
-
// erratum — see verifyExportBody). The on-chain per-gateway dimension is
|
|
650
|
-
// environment/time-dependent and is EXCLUDED from this comparison.
|
|
563
|
+
// Cached-vs-recomputed agreement over the deterministic, offline dimensions
|
|
564
|
+
// only (§5 step 5). The on-chain dimension depends on time and environment.
|
|
651
565
|
verdictAgreementOk: boolean;
|
|
652
566
|
attestations: AttestationResult[];
|
|
653
|
-
// The freshly recomputed §4 verdict
|
|
654
|
-
// the verify API display (never the cached copy).
|
|
567
|
+
// The freshly recomputed §4 verdict: what surfaces display, never the cache.
|
|
655
568
|
verdict: VerdictObject;
|
|
656
|
-
// The export's own rollup
|
|
569
|
+
// The export's own rollup; drives the CLI exit code.
|
|
657
570
|
status: EvidenceStatus;
|
|
571
|
+
// True where the inline source bundle cannot be read while `source_bundle_hash` holds
|
|
572
|
+
// and the export wrapper verifies: the issuer signed a source that does not follow its
|
|
573
|
+
// format (`follows_rules` disproven), and nothing was proven altered.
|
|
574
|
+
sourceUnreadable?: boolean;
|
|
658
575
|
}
|
|
659
576
|
|
|
660
577
|
export interface VerifyEvidenceOptions {
|
|
661
|
-
// Gateways to re-fetch each anchor_ref.id from (first responsive wins).
|
|
662
|
-
//
|
|
663
|
-
// envelope, proving the proof is anchored, not just locally asserted.
|
|
578
|
+
// Gateways to re-fetch each `anchor_ref.id` from (first responsive wins). The
|
|
579
|
+
// on-chain bytes must byte-match the checkpoint envelope.
|
|
664
580
|
gateways?: string[];
|
|
665
|
-
// Out-of-band disclosed
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
// A `Map` is the SAFE shape and the one to prefer — it is what
|
|
672
|
-
// `disclosedContent` hands back, so our output feeds straight back in, and a
|
|
673
|
-
// Map keys on the string itself: no prototype to inherit from and no
|
|
674
|
-
// `__proto__` write that silently lands somewhere else. The plain `Record` is
|
|
675
|
-
// still accepted (it was the original signature and remains supported), and
|
|
676
|
-
// the lookup guards it — see `lookupSideContent`. Widening is additive: every
|
|
677
|
-
// existing caller passing a Record keeps working unchanged.
|
|
581
|
+
// Out-of-band disclosed bytes by event_id, bound against each event's
|
|
582
|
+
// committed `content_hash`. A Uint8Array is used as-is; a string is hex.
|
|
583
|
+
// In-body `events[].content` (signed) wins; if both are present and disagree,
|
|
584
|
+
// the event fails (§5.1). Prefer a `Map`: a plain object is guarded (see
|
|
585
|
+
// `./side-content.ts`), but a Map has no prototype to inherit from.
|
|
678
586
|
content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
|
|
679
|
-
// Injectable fetch (
|
|
587
|
+
// Injectable fetch (global `fetch` by default).
|
|
680
588
|
fetchImpl?: typeof fetch;
|
|
681
589
|
}
|
|
682
590
|
|
|
683
591
|
/**
|
|
684
|
-
* A
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
|
|
688
|
-
* append it with no key at all. This is the one sentence both kernels attach
|
|
689
|
-
* as a note, never a verdict, never an exit code.
|
|
592
|
+
* A wrapper's `co_signatures` is not evidence (evidence-bundle.md §2): nothing
|
|
593
|
+
* verifies it, and it sits outside the signed scope, so anyone can append it.
|
|
594
|
+
* Attached as a note, never a verdict or exit code.
|
|
690
595
|
*/
|
|
691
596
|
export const CO_SIGNATURES_NOTE =
|
|
692
597
|
"unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
|
|
693
598
|
|
|
694
599
|
/**
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* Absence and an empty array read the same way here: the field asserts
|
|
699
|
-
* nothing about a second signer until it holds one, and evidence-bundle.md's
|
|
700
|
-
* own carve-out language for the analogous envelope field treats "absent"
|
|
701
|
-
* and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
|
|
702
|
-
* value (malformed shape, but the field is reserved, not yet specified)
|
|
703
|
-
* still counts as present: it is not the well-formed absence either rule
|
|
704
|
-
* describes.
|
|
600
|
+
* Whether the wrapper's reserved `co_signatures` carries at least one entry.
|
|
601
|
+
* Absent and empty read the same (envelope-spec.md §7.1); a non-array value
|
|
602
|
+
* counts as present.
|
|
705
603
|
*/
|
|
706
604
|
export function coSignaturesPresent(b: unknown): boolean {
|
|
707
605
|
if (b === null || typeof b !== "object" || Array.isArray(b)) return false;
|
|
@@ -727,24 +625,20 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
|
727
625
|
});
|
|
728
626
|
}
|
|
729
627
|
|
|
730
|
-
/**
|
|
731
|
-
* A bundle this kernel cannot read.
|
|
732
|
-
*
|
|
733
|
-
* `wrapperOk` is true only where the wrapper signature and `body_hash` were
|
|
734
|
-
* CHECKED and held before the body was found unreadable. It is then reported
|
|
735
|
-
* as held, because it did: `signatureOk` and `bodyHashOk` are statements about
|
|
736
|
-
* the wrapper, and a body this kernel cannot parse does not unsign it. The
|
|
737
|
-
* case reader decides whose pack a bundle is on exactly those two fields
|
|
738
|
-
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
739
|
-
* broke its own body shape read as a forger.
|
|
740
|
-
*/
|
|
741
|
-
/** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
|
|
628
|
+
/** evidence-bundle.md §2. Byte-identical to the Python kernel's. */
|
|
742
629
|
export const GENERATED_AT_MALFORMED =
|
|
743
|
-
"malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle
|
|
630
|
+
"malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle section 2)";
|
|
744
631
|
|
|
632
|
+
/**
|
|
633
|
+
* A bundle this kernel cannot read. `wrapperOk` is true only where the wrapper
|
|
634
|
+
* signature and `body_hash` were checked and held: an unparseable body does not
|
|
635
|
+
* unsign the wrapper, and the case reader decides whose pack it is on those
|
|
636
|
+
* fields (§5.5.1).
|
|
637
|
+
*/
|
|
745
638
|
function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
|
|
746
639
|
return {
|
|
747
|
-
status: "
|
|
640
|
+
status: "incomplete",
|
|
641
|
+
unreadable: true,
|
|
748
642
|
specVersionOk,
|
|
749
643
|
bodyHashOk: wrapperOk,
|
|
750
644
|
signatureOk: wrapperOk,
|
|
@@ -752,82 +646,87 @@ function malformed(error: string, specVersionOk = false, wrapperOk = false): Evi
|
|
|
752
646
|
onChainChecked: false,
|
|
753
647
|
checkpoints: [],
|
|
754
648
|
events: [],
|
|
755
|
-
assertedStatus: null,
|
|
756
649
|
errors: [error],
|
|
757
650
|
};
|
|
758
651
|
}
|
|
759
652
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
763
|
-
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
764
|
-
*/
|
|
765
|
-
// `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
|
|
766
|
-
// `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
|
|
653
|
+
// The prefixes of exception text this kernel writes itself, identically in
|
|
654
|
+
// Python. Anything else comes from the host. See `thrownDetail`.
|
|
767
655
|
const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
|
|
768
656
|
|
|
769
657
|
/**
|
|
770
|
-
* The
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
* kernel wrote that text. A finding travels into a case reading through `case`
|
|
774
|
-
* `reasons`, and both kernels promise one reading and one rendered sentence for
|
|
775
|
-
* one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
|
|
776
|
-
* not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
|
|
777
|
-
* for one pack anyone can build (gate 4 on proof#140, finding 1).
|
|
778
|
-
*
|
|
779
|
-
* Returns `": <text>"` for this kernel's own wording and the empty string for
|
|
780
|
-
* anything else. The label at the call site already names the check that
|
|
781
|
-
* failed and both kernels agree on it character for character; what is dropped
|
|
782
|
-
* is the host's parse offset, which no reader can act on and which was never
|
|
783
|
-
* the same in the two kernels.
|
|
658
|
+
* The record as the agent reader holds it, or `null` where it takes no part:
|
|
659
|
+
* only a `sanning.events/v2` record whose envelope verified and whose bytes
|
|
660
|
+
* bound to it (section 10).
|
|
784
661
|
*/
|
|
662
|
+
function agentItem(ev: { envelope?: unknown }, eventBytes: Uint8Array | undefined, bound: boolean): AgentHeldRecord | null {
|
|
663
|
+
if (!bound || eventBytes === undefined) return null;
|
|
664
|
+
const envelope = ev.envelope;
|
|
665
|
+
if (!isObject(envelope) || !specVersionSupported(envelope.spec_version as string, AGENT_RECORD_PROFILES)) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
let record: unknown;
|
|
669
|
+
try {
|
|
670
|
+
record = jcsParse(eventBytes);
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
event_id: envelope.event_id as string,
|
|
676
|
+
public_key: envelope.public_key as string,
|
|
677
|
+
record,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// The profile whose records the agent vocabulary governs (agent-events.md section 3).
|
|
682
|
+
const AGENT_RECORD_PROFILES = ["sanning.events/v2"];
|
|
683
|
+
|
|
684
|
+
/** Read the bundle's records as one pile and put each agent record's reading on its result. */
|
|
685
|
+
async function attachAgentReadings(
|
|
686
|
+
results: EventResult[],
|
|
687
|
+
items: readonly (AgentHeldRecord | null)[],
|
|
688
|
+
): Promise<void> {
|
|
689
|
+
const pile = items.filter((item): item is AgentHeldRecord => item !== null);
|
|
690
|
+
const readings = await readAgentRecords(pile);
|
|
691
|
+
let next = 0;
|
|
692
|
+
for (const [i, item] of items.entries()) {
|
|
693
|
+
if (item !== null) results[i]!.agent = readings[next++]!;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
785
697
|
/**
|
|
786
|
-
* The
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
* about a record whose bytes it had read. A record that repeats a name is
|
|
793
|
-
* a defect in the artifact, and a defect this kernel can see fails, the rule
|
|
794
|
-
* `event_bytes` that are not hex already follow.
|
|
698
|
+
* The finding for record bytes a producer signed that are not JSON, else
|
|
699
|
+
* `undefined` (`_record_not_json` in the Python twin): `event record is not
|
|
700
|
+
* JSON`, with the kernel's `duplicate key "<name>"` where the bytes repeat a
|
|
701
|
+
* member name. The bytes are signed, so the defect is the producer's and
|
|
702
|
+
* visible, and the record fails (evidence-bundle.md section 4), never reads as
|
|
703
|
+
* withheld.
|
|
795
704
|
*/
|
|
796
|
-
function
|
|
705
|
+
function recordNotJson(eventBytes: Uint8Array | undefined): string | undefined {
|
|
797
706
|
if (eventBytes === undefined) return undefined;
|
|
798
707
|
try {
|
|
799
708
|
jcsParse(eventBytes);
|
|
800
709
|
} catch (e) {
|
|
801
|
-
|
|
710
|
+
return `event record is not JSON${thrownDetail(e)}`;
|
|
802
711
|
}
|
|
803
712
|
return undefined;
|
|
804
713
|
}
|
|
805
714
|
|
|
715
|
+
/**
|
|
716
|
+
* The detail a finding may append about a caught exception: `": <text>"` for
|
|
717
|
+
* this kernel's own wording, `""` for anything else. Host parser messages
|
|
718
|
+
* differ by kernel, and both kernels must render one sentence for one set of
|
|
719
|
+
* bytes.
|
|
720
|
+
*/
|
|
806
721
|
function thrownDetail(e: unknown): string {
|
|
807
722
|
const text = e instanceof Error ? e.message : String(e);
|
|
808
723
|
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
809
724
|
}
|
|
810
725
|
|
|
811
726
|
/**
|
|
812
|
-
* Decode `bytes` as UTF-8
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
* ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
|
|
817
|
-
* every set of signed bytes in this kernel now; this function survives only for
|
|
818
|
-
* the canonicality comparison on the stamp path, which needs the DECODED TEXT
|
|
819
|
-
* of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
|
|
820
|
-
* performs exactly the decode `jcsParse` performs, so the comparison cannot
|
|
821
|
-
* read the bytes differently from the parse that produced `body`.
|
|
822
|
-
*
|
|
823
|
-
* `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
|
|
824
|
-
* every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
|
|
825
|
-
* one raw `0x80` inside a string value read `verified` here and `failed` there
|
|
826
|
-
* because of it (gate 4 on proof#140). The symmetric claim the first version of
|
|
827
|
-
* this comment made, that "Python's `json.loads` raises on both", is FALSE and
|
|
828
|
-
* is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
|
|
829
|
-
* too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
|
|
830
|
-
* is stated once, in `jcsParse`.
|
|
727
|
+
* Decode `bytes` as strict UTF-8, keeping a leading BOM: the decode `jcsParse`
|
|
728
|
+
* performs, for the stamp path's canonicality comparison on bytes `jcsParse`
|
|
729
|
+
* already accepted.
|
|
831
730
|
*/
|
|
832
731
|
function decodeUtf8Strict(bytes: Uint8Array): string {
|
|
833
732
|
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
@@ -835,12 +734,59 @@ function decodeUtf8Strict(bytes: Uint8Array): string {
|
|
|
835
734
|
|
|
836
735
|
/**
|
|
837
736
|
* Name an envelope `public_key` or `signature` spelled outside the hex charset
|
|
838
|
-
* or at the wrong length
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
|
|
842
|
-
|
|
737
|
+
* or at the wrong length, in the same words as Python, so both kernels give one
|
|
738
|
+
* reading. Only a string is checked; a missing one is the envelope layer's to
|
|
739
|
+
* fail.
|
|
740
|
+
*/
|
|
741
|
+
/**
|
|
742
|
+
* The envelope faults that leave a claim not proven rather than disproven: the record's
|
|
743
|
+
* envelope or key cannot be read, or its version is one this verifier does not know.
|
|
744
|
+
*/
|
|
745
|
+
export const UNPROVEN_ENVELOPE_FAULTS: readonly EnvelopeFault[] = [
|
|
746
|
+
"not_an_envelope",
|
|
747
|
+
"key_unreadable",
|
|
748
|
+
"spec_version_unknown",
|
|
749
|
+
];
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* A record that fails only on something not proven: a stamp or checkpoint the pack does not
|
|
753
|
+
* hold, an envelope or key that cannot be read, or a version this verifier does not know,
|
|
754
|
+
* while every check that runs without it holds (the binding, the inclusion proof and the
|
|
755
|
+
* content). It holds the bundle at `incomplete`, never `failed`.
|
|
843
756
|
*/
|
|
757
|
+
function unproven(e: EventResult): boolean {
|
|
758
|
+
const envelopeUnproven =
|
|
759
|
+
e.envelopeFault !== undefined && e.envelopeFault !== null && UNPROVEN_ENVELOPE_FAULTS.includes(e.envelopeFault);
|
|
760
|
+
const stampAbsent = e.stampAbsent === true;
|
|
761
|
+
return (
|
|
762
|
+
(stampAbsent || envelopeUnproven) &&
|
|
763
|
+
(e.envelopeOk || envelopeUnproven) &&
|
|
764
|
+
(envelopeUnproven || e.ownFormFailed !== true) &&
|
|
765
|
+
e.payloadBindingOk !== false &&
|
|
766
|
+
e.contentOk !== false &&
|
|
767
|
+
(stampAbsent || (e.inclusionOk && e.checkpointBound))
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Why a record's envelope does not verify, worst first: the envelope layer's own faults,
|
|
773
|
+
* and, where only this kernel's strict hex reading refuses a field, a key that cannot be read
|
|
774
|
+
* or a signature that does not hold. A field's form is not judged under either.
|
|
775
|
+
*/
|
|
776
|
+
function eventEnvelopeFaults(faults: readonly EnvelopeFault[], hexError: string | null): EnvelopeFault[] {
|
|
777
|
+
const found = new Set<EnvelopeFault>(faults);
|
|
778
|
+
if (hexError !== null) {
|
|
779
|
+
found.delete("nonconforming");
|
|
780
|
+
if (hexError.includes("public_key")) {
|
|
781
|
+
found.delete("signature_failed");
|
|
782
|
+
found.add("key_unreadable");
|
|
783
|
+
} else {
|
|
784
|
+
found.add("signature_failed");
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return ENVELOPE_FAULTS.filter((f) => found.has(f));
|
|
788
|
+
}
|
|
789
|
+
|
|
844
790
|
function envelopeHexError(what: string, envelope: unknown): string | null {
|
|
845
791
|
if (!isJsonObject(envelope)) return null;
|
|
846
792
|
for (const [name, nibbles] of [
|
|
@@ -856,34 +802,24 @@ function envelopeHexError(what: string, envelope: unknown): string | null {
|
|
|
856
802
|
}
|
|
857
803
|
|
|
858
804
|
/**
|
|
859
|
-
* Spell a value inside a finding
|
|
860
|
-
*
|
|
861
|
-
* `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
|
|
862
|
-
* absent field interpolates as the word `undefined` where Python writes `null`.
|
|
863
|
-
* One word, and a real difference in what an auditor reads: someone comparing
|
|
864
|
-
* the two CLIs on one bundle sees two different sentences for one fact. Both
|
|
865
|
-
* kernels route every quoted value through this spelling, and the cross-kernel
|
|
866
|
-
* anchor-trace leg byte-compares them.
|
|
805
|
+
* Spell a value inside a finding as Python does: an absent value reads `null`,
|
|
806
|
+
* never `undefined`.
|
|
867
807
|
*/
|
|
868
808
|
function jsonStr(value: unknown): string {
|
|
869
809
|
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
870
810
|
}
|
|
871
811
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
// function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
|
|
812
|
+
/**
|
|
813
|
+
* Verify a `sanning.evidence/v1` bundle, dispatching on its `body_type`. The
|
|
814
|
+
* returned `status` is recomputed from the body; a wrapper `verdict` member,
|
|
815
|
+
* where a producer still writes one, is never read (evidence-bundle.md section 4).
|
|
816
|
+
*/
|
|
878
817
|
export async function verifyEvidenceBundle(
|
|
879
818
|
bundle: unknown,
|
|
880
819
|
options: VerifyEvidenceOptions = {},
|
|
881
820
|
): Promise<EvidenceBundleResult> {
|
|
882
|
-
// The
|
|
883
|
-
//
|
|
884
|
-
// it, however the wrapper otherwise verifies. Never touches `status` or
|
|
885
|
-
// the exit code: a bundle that would otherwise verify still verifies, note
|
|
886
|
-
// or not (proof#148).
|
|
821
|
+
// The one place a present `co_signatures` becomes a note, so `case.ts` gets
|
|
822
|
+
// it too. Never touches `status`.
|
|
887
823
|
const result = await verifyBundle(bundle, options, false);
|
|
888
824
|
const note = coSignaturesNote(bundle);
|
|
889
825
|
if (note !== undefined) {
|
|
@@ -893,20 +829,18 @@ export async function verifyEvidenceBundle(
|
|
|
893
829
|
}
|
|
894
830
|
|
|
895
831
|
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
896
|
-
// §2.4), where
|
|
832
|
+
// §2.4), where only `sanning.anchor.trace/v3` is recognised.
|
|
897
833
|
async function verifyBundle(
|
|
898
834
|
bundle: unknown,
|
|
899
835
|
options: VerifyEvidenceOptions,
|
|
900
836
|
exportSource: boolean,
|
|
901
837
|
): Promise<EvidenceBundleResult> {
|
|
902
|
-
//
|
|
838
|
+
// Step 1: parse and reject an unknown spec_version major.
|
|
903
839
|
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
904
840
|
return malformed("evidence bundle is not a JSON object");
|
|
905
841
|
}
|
|
906
|
-
// Reject a pathologically deep bundle
|
|
907
|
-
//
|
|
908
|
-
// kernel rather than a stack-limit-dependent split verdict — see
|
|
909
|
-
// MAX_CANONICAL_DEPTH.
|
|
842
|
+
// Reject a pathologically deep bundle before canonicalization recurses (see
|
|
843
|
+
// MAX_CANONICAL_DEPTH).
|
|
910
844
|
if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
|
|
911
845
|
return malformed(
|
|
912
846
|
`evidence bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
|
|
@@ -915,15 +849,38 @@ async function verifyBundle(
|
|
|
915
849
|
const b = bundle as EvidenceBundle;
|
|
916
850
|
const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
|
|
917
851
|
if (!specVersionOk) {
|
|
918
|
-
//
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
852
|
+
// An absent field is spelled `null`, as in Python.
|
|
853
|
+
const unknown = malformed(
|
|
854
|
+
`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`,
|
|
855
|
+
false,
|
|
856
|
+
);
|
|
857
|
+
unknown.versionUnknown = typeof b.spec_version === "string";
|
|
858
|
+
if (unknown.versionUnknown && typeof b.public_key === "string" && typeof b.signature === "string") {
|
|
859
|
+
// A version this verifier does not know still carries a signature it can check: one
|
|
860
|
+
// that fails is a pack changed after it was signed.
|
|
861
|
+
const found: string[] = [];
|
|
862
|
+
const [keyFormOk, signatureOk] = await checkWrapperSignature(b, found);
|
|
863
|
+
if (keyFormOk && !signatureOk) {
|
|
864
|
+
return {
|
|
865
|
+
status: "failed",
|
|
866
|
+
specVersionOk: false,
|
|
867
|
+
// Not checked under this version, so not the disproof here.
|
|
868
|
+
bodyHashOk: true,
|
|
869
|
+
signatureOk: false,
|
|
870
|
+
bodyType: null,
|
|
871
|
+
onChainChecked: false,
|
|
872
|
+
checkpoints: [],
|
|
873
|
+
events: [],
|
|
874
|
+
errors: [...unknown.errors, ...found],
|
|
875
|
+
versionUnknown: true,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
unknown.signatureOk = signatureOk;
|
|
879
|
+
}
|
|
880
|
+
return unknown;
|
|
923
881
|
}
|
|
924
882
|
if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
|
|
925
|
-
//
|
|
926
|
-
// decision #2); other algs are a deliberate future addition.
|
|
883
|
+
// Ed25519 only (evidence-bundle.md §2).
|
|
927
884
|
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
|
|
928
885
|
}
|
|
929
886
|
if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
|
|
@@ -933,22 +890,18 @@ async function verifyBundle(
|
|
|
933
890
|
return malformed("evidence bundle is missing body_hash", true);
|
|
934
891
|
}
|
|
935
892
|
if (b.body === undefined || b.body === null) {
|
|
936
|
-
// body_ref (out-of-line body) is
|
|
937
|
-
// anchor-trace use case — refuse rather than silently pass.
|
|
893
|
+
// `body_ref` (out-of-line body) is refused rather than passed.
|
|
938
894
|
return malformed("evidence bundle has no inline body (body_ref unsupported here)", true);
|
|
939
895
|
}
|
|
940
896
|
|
|
941
|
-
//
|
|
897
|
+
// Step 2: verify the wrapper signature and recompute `body_hash`.
|
|
942
898
|
const errors: string[] = [];
|
|
943
899
|
|
|
944
|
-
// Every wrapper hex field is read
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
// `signature` are 64 and 128 hex characters in either case (proof#135,
|
|
950
|
-
// option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
|
|
951
|
-
// recomputed in and compared against.
|
|
900
|
+
// Every wrapper hex field is read strictly, before any decoder sees it:
|
|
901
|
+
// `signature` is outside the signed scope, so a lenient decoder would let
|
|
902
|
+
// anyone flip a verdict by adding whitespace. `public_key` and `signature`
|
|
903
|
+
// are 64 and 128 hex characters in either case; `body_hash` is 64 lowercase,
|
|
904
|
+
// the form it is recomputed in.
|
|
952
905
|
let bodyHashOk = false;
|
|
953
906
|
if (!isHexField(b.body_hash, 64, true)) {
|
|
954
907
|
errors.push("wrapper body_hash is not 64 lowercase hex characters");
|
|
@@ -964,27 +917,35 @@ async function verifyBundle(
|
|
|
964
917
|
}
|
|
965
918
|
}
|
|
966
919
|
|
|
967
|
-
|
|
968
|
-
const keyFormOk = isHexField(b.public_key, 64);
|
|
969
|
-
const signatureFormOk = isHexField(b.signature, 128);
|
|
970
|
-
if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
|
|
971
|
-
if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
|
|
972
|
-
if (keyFormOk && signatureFormOk) {
|
|
973
|
-
try {
|
|
974
|
-
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
|
|
975
|
-
co_signatures?: unknown;
|
|
976
|
-
};
|
|
977
|
-
signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
|
|
978
|
-
if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
|
|
979
|
-
} catch (e) {
|
|
980
|
-
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
981
|
-
}
|
|
982
|
-
}
|
|
920
|
+
const [keyFormOk, signatureOk] = await checkWrapperSignature(b, errors);
|
|
983
921
|
|
|
984
|
-
|
|
922
|
+
// A key that cannot be read checks no signature: `signed` is not proven on that claim
|
|
923
|
+
// only. A `body_hash` that does not hold still fails, and a body that holds is still read,
|
|
924
|
+
// so a disproof inside it stands.
|
|
925
|
+
if (!keyFormOk && bodyHashOk) {
|
|
926
|
+
// Form is judged only under a signature that holds, so the wrapper's time is not.
|
|
927
|
+
const result = await readWrappedBody(b, specVersionOk, errors, options, exportSource, false);
|
|
928
|
+
result.signatureOk = false;
|
|
929
|
+
result.keyUnreadable = true;
|
|
930
|
+
if (result.status === "verified") result.status = "incomplete";
|
|
931
|
+
return result;
|
|
932
|
+
}
|
|
933
|
+
if (!keyFormOk) {
|
|
934
|
+
return {
|
|
935
|
+
status: "failed",
|
|
936
|
+
specVersionOk,
|
|
937
|
+
bodyHashOk,
|
|
938
|
+
signatureOk: false,
|
|
939
|
+
bodyType: typeof b.body_type === "string" ? b.body_type : null,
|
|
940
|
+
onChainChecked: false,
|
|
941
|
+
checkpoints: [],
|
|
942
|
+
events: [],
|
|
943
|
+
errors,
|
|
944
|
+
keyUnreadable: true,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
985
947
|
|
|
986
|
-
// A broken wrapper
|
|
987
|
-
// body is untrustworthy, so we do not pretend to verify its contents.
|
|
948
|
+
// A broken wrapper is a hard failure: the body is untrustworthy.
|
|
988
949
|
if (!signatureOk || !bodyHashOk) {
|
|
989
950
|
return {
|
|
990
951
|
status: "failed",
|
|
@@ -995,102 +956,135 @@ async function verifyBundle(
|
|
|
995
956
|
onChainChecked: false,
|
|
996
957
|
checkpoints: [],
|
|
997
958
|
events: [],
|
|
998
|
-
assertedStatus,
|
|
999
959
|
errors,
|
|
1000
960
|
};
|
|
1001
961
|
}
|
|
1002
962
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
963
|
+
return readWrappedBody(b, specVersionOk, errors, options, exportSource);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Everything a bundle says past its wrapper: its time, then its body by type. */
|
|
967
|
+
async function readWrappedBody(
|
|
968
|
+
b: EvidenceBundle,
|
|
969
|
+
specVersionOk: boolean,
|
|
970
|
+
errors: string[],
|
|
971
|
+
options: VerifyEvidenceOptions,
|
|
972
|
+
exportSource: boolean,
|
|
973
|
+
judgeTime = true,
|
|
974
|
+
): Promise<EvidenceBundleResult> {
|
|
975
|
+
const result = await readBody(b, specVersionOk, errors, options, exportSource);
|
|
976
|
+
// The wrapper's `generated_at` (evidence-bundle.md §2) is printed beside every verdict
|
|
977
|
+
// and copied into `as_of`, so a present value that is not RFC 3339 UTC is a format the
|
|
978
|
+
// producer broke. The body is read first, so a disproof inside it stands beside this one.
|
|
1009
979
|
if (
|
|
980
|
+
judgeTime &&
|
|
1010
981
|
Object.prototype.hasOwnProperty.call(b, "generated_at") &&
|
|
1011
982
|
!isRfc3339UtcInstant((b as { generated_at?: unknown }).generated_at)
|
|
1012
983
|
) {
|
|
1013
|
-
|
|
984
|
+
result.status = "failed";
|
|
985
|
+
result.generatedAtMalformed = true;
|
|
986
|
+
result.errors = [GENERATED_AT_MALFORMED, ...result.errors.filter((e) => e !== GENERATED_AT_MALFORMED)];
|
|
1014
987
|
}
|
|
988
|
+
return result;
|
|
989
|
+
}
|
|
1015
990
|
|
|
1016
|
-
|
|
991
|
+
/**
|
|
992
|
+
* `[keyFormOk, signatureOk]` for the wrapper's Ed25519 signature over every field but
|
|
993
|
+
* `signature` and `co_signatures`. `public_key` and `signature` are strings here.
|
|
994
|
+
*/
|
|
995
|
+
async function checkWrapperSignature(b: EvidenceBundle, errors: string[]): Promise<[boolean, boolean]> {
|
|
996
|
+
let signatureOk = false;
|
|
997
|
+
const keyFormOk = isHexField(b.public_key, 64);
|
|
998
|
+
const signatureFormOk = isHexField(b.signature, 128);
|
|
999
|
+
if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
|
|
1000
|
+
if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
|
|
1001
|
+
if (keyFormOk && signatureFormOk) {
|
|
1002
|
+
try {
|
|
1003
|
+
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
|
|
1004
|
+
co_signatures?: unknown;
|
|
1005
|
+
};
|
|
1006
|
+
signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
|
|
1007
|
+
if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return [keyFormOk, signatureOk];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** A wrapped body, read by its type. */
|
|
1016
|
+
async function readBody(
|
|
1017
|
+
b: EvidenceBundle,
|
|
1018
|
+
specVersionOk: boolean,
|
|
1019
|
+
errors: string[],
|
|
1020
|
+
options: VerifyEvidenceOptions,
|
|
1021
|
+
exportSource: boolean,
|
|
1022
|
+
): Promise<EvidenceBundleResult> {
|
|
1017
1023
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
1018
1024
|
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
// verifies on its own, is undetermined here, after the wrapper checks above.
|
|
1025
|
+
// As an export's source, only sanning.anchor.trace/v3 is recognised; any
|
|
1026
|
+
// other body is undetermined.
|
|
1022
1027
|
if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
1023
1028
|
errors.push(
|
|
1024
1029
|
`body_type ${JSON.stringify(bodyType)} is not an export source ` +
|
|
1025
|
-
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body
|
|
1030
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`,
|
|
1026
1031
|
);
|
|
1027
1032
|
return {
|
|
1028
|
-
status: "
|
|
1033
|
+
status: "incomplete",
|
|
1029
1034
|
specVersionOk,
|
|
1030
|
-
bodyHashOk,
|
|
1031
|
-
signatureOk,
|
|
1035
|
+
bodyHashOk: true,
|
|
1036
|
+
signatureOk: true,
|
|
1032
1037
|
bodyType,
|
|
1033
1038
|
onChainChecked: false,
|
|
1034
1039
|
checkpoints: [],
|
|
1035
1040
|
events: [],
|
|
1036
|
-
assertedStatus,
|
|
1037
1041
|
errors,
|
|
1038
1042
|
};
|
|
1039
1043
|
}
|
|
1040
1044
|
|
|
1041
|
-
//
|
|
1042
|
-
// authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
|
|
1043
|
-
// hand off to the export verifier for steps 3–9.
|
|
1045
|
+
// The wrapper is authentic (§5 step 2); the export verifier runs steps 3 to 9.
|
|
1044
1046
|
if (bodyType === EXPORT_BODY_TYPE) {
|
|
1045
|
-
return verifyExportBody(b, specVersionOk,
|
|
1047
|
+
return verifyExportBody(b, specVersionOk, errors, options);
|
|
1046
1048
|
}
|
|
1047
1049
|
|
|
1048
|
-
// The
|
|
1049
|
-
//
|
|
1050
|
-
// a stamp carries no second signature of its own inside the bundle.
|
|
1050
|
+
// The stamp body (§5.2). For this body the wrapper signature is what
|
|
1051
|
+
// "Sanning signed it" means: a stamp carries no second signature in the bundle.
|
|
1051
1052
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
1052
|
-
return verifyStampTrace(b, specVersionOk,
|
|
1053
|
+
return verifyStampTrace(b, specVersionOk, errors, options);
|
|
1053
1054
|
}
|
|
1054
1055
|
|
|
1055
|
-
// The
|
|
1056
|
+
// The hand-over body (§5.3): stamps and the customer's events, joined.
|
|
1056
1057
|
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
1057
|
-
return verifyStampEvidence(b, specVersionOk,
|
|
1058
|
+
return verifyStampEvidence(b, specVersionOk, errors, options);
|
|
1058
1059
|
}
|
|
1059
1060
|
|
|
1060
1061
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
1061
|
-
//
|
|
1062
|
-
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
1063
|
-
// body verdict undetermined) — not a failure.
|
|
1062
|
+
// An authentic wrapper over an unknown body: `incomplete`, not a failure.
|
|
1064
1063
|
errors.push(
|
|
1065
1064
|
`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
1066
|
-
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body
|
|
1065
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`,
|
|
1067
1066
|
);
|
|
1068
1067
|
return {
|
|
1069
|
-
status: "
|
|
1068
|
+
status: "incomplete",
|
|
1070
1069
|
specVersionOk,
|
|
1071
|
-
bodyHashOk,
|
|
1072
|
-
signatureOk,
|
|
1070
|
+
bodyHashOk: true,
|
|
1071
|
+
signatureOk: true,
|
|
1073
1072
|
bodyType,
|
|
1074
1073
|
onChainChecked: false,
|
|
1075
1074
|
checkpoints: [],
|
|
1076
1075
|
events: [],
|
|
1077
|
-
assertedStatus,
|
|
1078
1076
|
errors,
|
|
1079
1077
|
};
|
|
1080
1078
|
}
|
|
1081
1079
|
|
|
1082
|
-
return verifyAnchorTrace(b, specVersionOk,
|
|
1080
|
+
return verifyAnchorTrace(b, specVersionOk, errors, options);
|
|
1083
1081
|
}
|
|
1084
1082
|
|
|
1085
1083
|
/**
|
|
1086
|
-
* Recompute an export's source verdict (evidence-export.md §5 step 4).
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
* verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
|
|
1091
|
-
* its source verdict undetermined, so the export cannot reach `verified`. The
|
|
1092
|
-
* source wrapper is still checked first, because a forged source is a failure
|
|
1093
|
-
* whatever its body.
|
|
1084
|
+
* Recompute an export's source verdict (evidence-export.md §5 step 4). Only a
|
|
1085
|
+
* `sanning.anchor.trace/v3` source is recognised (§2.4); any other reads
|
|
1086
|
+
* undetermined, so the export cannot reach `verified`. The source wrapper is
|
|
1087
|
+
* checked first, because a forged source fails whatever its body.
|
|
1094
1088
|
*/
|
|
1095
1089
|
async function verifyExportSource(
|
|
1096
1090
|
source: unknown,
|
|
@@ -1100,9 +1094,8 @@ async function verifyExportSource(
|
|
|
1100
1094
|
}
|
|
1101
1095
|
|
|
1102
1096
|
/**
|
|
1103
|
-
* Index a source bundle's checkpoints by the key
|
|
1104
|
-
*
|
|
1105
|
-
* checkpoints an attestation can resolve (evidence-export.md §2.4).
|
|
1097
|
+
* Index a source bundle's checkpoints by derived `checkpoint_ref`, the key
|
|
1098
|
+
* attestations use (evidence-export.md §2.4).
|
|
1106
1099
|
*/
|
|
1107
1100
|
async function indexSourceCheckpoints(
|
|
1108
1101
|
bundle: EvidenceBundle,
|
|
@@ -1129,20 +1122,10 @@ async function indexSourceCheckpoints(
|
|
|
1129
1122
|
type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
|
|
1130
1123
|
|
|
1131
1124
|
/**
|
|
1132
|
-
* Resolve a checkpoint's join key and
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
* A checkpoint carrying a retired spelling beside the current one is two
|
|
1137
|
-
* answers to one question inside one signature. A reader that silently prefers
|
|
1138
|
-
* one gives a different answer from a reader that prefers the other, over the
|
|
1139
|
-
* same signed bytes, and both look like they verified. So `tx_id` and
|
|
1140
|
-
* `record_bytes` are rejected rather than resolved.
|
|
1141
|
-
*
|
|
1142
|
-
* ── Why the key is recomputed ──
|
|
1143
|
-
*
|
|
1144
|
-
* The key IS the bytes: relabelling a checkpoint and every event pointing at it
|
|
1145
|
-
* fails here, because nothing is taken from the label.
|
|
1125
|
+
* Resolve a checkpoint's join key and witness reference. The key is recomputed
|
|
1126
|
+
* from the bytes, never taken from the label. A retired spelling (`tx_id`,
|
|
1127
|
+
* `record_bytes`) beside the current one is two answers inside one signature,
|
|
1128
|
+
* so it is rejected rather than resolved.
|
|
1146
1129
|
*/
|
|
1147
1130
|
async function resolveCheckpointKey(
|
|
1148
1131
|
cp: AnyTraceCheckpoint,
|
|
@@ -1154,8 +1137,7 @@ async function resolveCheckpointKey(
|
|
|
1154
1137
|
errors: string[];
|
|
1155
1138
|
}> {
|
|
1156
1139
|
const errors: string[] = [];
|
|
1157
|
-
//
|
|
1158
|
-
// nothing there is "malformed": it is unexamined, which is not the same claim.
|
|
1140
|
+
// Early returns never look at `anchor_ref`, so it is unexamined, not malformed.
|
|
1159
1141
|
const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
|
|
1160
1142
|
|
|
1161
1143
|
if (cp.tx_id !== undefined) {
|
|
@@ -1196,33 +1178,12 @@ async function resolveCheckpointKey(
|
|
|
1196
1178
|
}
|
|
1197
1179
|
|
|
1198
1180
|
/**
|
|
1199
|
-
* Validate an optional `anchor_ref` (§5.1.1).
|
|
1181
|
+
* Validate an optional `anchor_ref` (§5.1.1): its shape, never its vocabulary.
|
|
1200
1182
|
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1205
|
-
* witness has therefore learned NOTHING about whether the evidence was
|
|
1206
|
-
* altered, and must not say otherwise. The shipped issuer did the opposite:
|
|
1207
|
-
* an unrecognised signature type returned `false` rather than `null`, mapped
|
|
1208
|
-
* to `tampered`, and was cached as permanent — a false accusation, produced
|
|
1209
|
-
* by a coverage gap, for a company whose product is telling people whether
|
|
1210
|
-
* records were altered.
|
|
1211
|
-
*
|
|
1212
|
-
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
1213
|
-
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
1214
|
-
* the reader, and only the first is the bundle's fault.
|
|
1215
|
-
*
|
|
1216
|
-
* ── Absent and malformed are not the same null (proof#120) ──
|
|
1217
|
-
*
|
|
1218
|
-
* Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
|
|
1219
|
-
* way downstream: an all-`unreachable` on-chain result, although no gateway was
|
|
1220
|
-
* asked in either case. For the ABSENT case that is at least the note
|
|
1221
|
-
* `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
|
|
1222
|
-
* For the MALFORMED case it is a manufactured outage: the bundle named a
|
|
1223
|
-
* witness, the name was unreadable, and the reader was told the public record
|
|
1224
|
-
* could not be reached. So the two come back as different facts, and `malformed`
|
|
1225
|
-
* carries the difference.
|
|
1183
|
+
* An unrecognised `kind` is not a failure: the offline verdict needs no witness,
|
|
1184
|
+
* so a reader that cannot resolve one has learned nothing about alteration and
|
|
1185
|
+
* must not accuse. A malformed `anchor_ref` is a defect in the bundle, and is
|
|
1186
|
+
* returned as `malformed` so it is never reported as an unreachable witness.
|
|
1226
1187
|
*/
|
|
1227
1188
|
function parseAnchorRef(
|
|
1228
1189
|
raw: unknown,
|
|
@@ -1262,7 +1223,6 @@ function parseAnchorRef(
|
|
|
1262
1223
|
async function verifyAnchorTrace(
|
|
1263
1224
|
b: EvidenceBundle,
|
|
1264
1225
|
specVersionOk: boolean,
|
|
1265
|
-
assertedStatus: string | null,
|
|
1266
1226
|
errors: string[],
|
|
1267
1227
|
options: VerifyEvidenceOptions,
|
|
1268
1228
|
): Promise<EvidenceBundleResult> {
|
|
@@ -1275,31 +1235,17 @@ async function verifyAnchorTrace(
|
|
|
1275
1235
|
const gateways = options.gateways ?? [];
|
|
1276
1236
|
const onChainChecked = gateways.length > 0;
|
|
1277
1237
|
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
// ── Why the join key is resolved BEFORE anything else ──
|
|
1281
|
-
//
|
|
1282
|
-
// `byKey` is what binds an event to the root its inclusion proof is checked
|
|
1283
|
-
// against. Get it wrong and an event proves inclusion in a tree it was never
|
|
1284
|
-
// in — the one failure in this file that produces a WRONG VERIFIED, not a
|
|
1285
|
-
// failed verify. Everything below is downstream of it.
|
|
1238
|
+
// Step 3: per checkpoint. `byKey` binds each event to the root its inclusion
|
|
1239
|
+
// is checked against; a wrong key here would yield a wrong `verified`.
|
|
1286
1240
|
const byKey = new Map<string, AnyTraceCheckpoint>();
|
|
1287
1241
|
const anchorByKey = new Map<string, AnchorRef | null>();
|
|
1288
1242
|
const checkpointResults: CheckpointResult[] = [];
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
// `errors` already states, not a new dimension of the published verdict, and
|
|
1292
|
-
// `CheckpointResult` is a shape downstream surfaces render.
|
|
1243
|
+
// Kept beside the results: an unreadable witness name is an input defect the
|
|
1244
|
+
// checkpoint's `errors` already states, not a verdict dimension.
|
|
1293
1245
|
const malformedAnchorRef = new Set<CheckpointResult>();
|
|
1294
1246
|
for (const rawCp of body.checkpoints as unknown[]) {
|
|
1295
|
-
// `checkpoints[]` is attacker-shaped
|
|
1296
|
-
//
|
|
1297
|
-
// caller a TypeError instead of a verdict, which an auditor cannot tell
|
|
1298
|
-
// apart from a broken tool. Graded and reported as "not a checkpoint"
|
|
1299
|
-
// rather than coerced into an empty one: probing the envelope layer with
|
|
1300
|
-
// nothing asks it a question about an object that was never an envelope.
|
|
1301
|
-
// Same treatment, same words, as the §5.3 path's `events[] entry is not an
|
|
1302
|
-
// object`.
|
|
1247
|
+
// `checkpoints[]` is attacker-shaped; a verifier never raises on it. A
|
|
1248
|
+
// non-object entry is reported as not a checkpoint, never coerced.
|
|
1303
1249
|
if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
|
|
1304
1250
|
checkpointResults.push({
|
|
1305
1251
|
checkpointRef: "",
|
|
@@ -1319,8 +1265,7 @@ async function verifyAnchorTrace(
|
|
|
1319
1265
|
let merkleRootOk = false;
|
|
1320
1266
|
let leafCountOk = false;
|
|
1321
1267
|
|
|
1322
|
-
//
|
|
1323
|
-
// is an input to identity, not just to the payload binding below.
|
|
1268
|
+
// The join key is derived from `event_bytes`, so decode them first.
|
|
1324
1269
|
let eventBytes: Uint8Array | undefined;
|
|
1325
1270
|
try {
|
|
1326
1271
|
if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
|
|
@@ -1330,15 +1275,9 @@ async function verifyAnchorTrace(
|
|
|
1330
1275
|
|
|
1331
1276
|
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
1332
1277
|
cpErrors.push(...keyed.errors);
|
|
1333
|
-
// A duplicate key is a
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
// never independently checked. Recording the finding and leaving `ok` true
|
|
1337
|
-
// let the bundle roll up to `verified` while carrying a checkpoint nothing
|
|
1338
|
-
// had verified: a false pass, which is what `evidence-bundle.md` §3
|
|
1339
|
-
// principle 3 exists to prevent (the displayed verdict must be the
|
|
1340
|
-
// recomputed one, and here the recomputation quietly dropped a finding it
|
|
1341
|
-
// had made).
|
|
1278
|
+
// A duplicate key is a hard failure: `byKey` keeps the first, so a second
|
|
1279
|
+
// checkpoint's identity would otherwise never be checked and the bundle
|
|
1280
|
+
// could roll up `verified` over it.
|
|
1342
1281
|
let duplicateKey = false;
|
|
1343
1282
|
if (keyed.key !== null) {
|
|
1344
1283
|
if (byKey.has(keyed.key)) {
|
|
@@ -1350,20 +1289,17 @@ async function verifyAnchorTrace(
|
|
|
1350
1289
|
}
|
|
1351
1290
|
}
|
|
1352
1291
|
|
|
1353
|
-
//
|
|
1354
|
-
// commitment — the record is the payload these external envelopes bind to).
|
|
1292
|
+
// The record is the payload these external-commitment envelopes bind to.
|
|
1355
1293
|
try {
|
|
1356
1294
|
const res = await verifyEnvelope(
|
|
1357
1295
|
cp.envelope as Envelope,
|
|
1358
1296
|
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1359
1297
|
);
|
|
1360
|
-
// A checkpoint must fully bind:
|
|
1361
|
-
//
|
|
1362
|
-
// anchor the events' inclusion claims to a trustworthy root.
|
|
1298
|
+
// A checkpoint must fully bind: an undetermined binding cannot anchor the
|
|
1299
|
+
// events' inclusion claims to a trustworthy root.
|
|
1363
1300
|
const hexError = envelopeHexError("checkpoint", cp.envelope);
|
|
1364
1301
|
envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
|
|
1365
|
-
//
|
|
1366
|
-
// not a failed check (proof#76).
|
|
1302
|
+
// A degenerate envelope is malformed input, not a failed check.
|
|
1367
1303
|
if (!isJsonObject(cp.envelope)) {
|
|
1368
1304
|
cpErrors.push("checkpoint envelope is not a JSON object");
|
|
1369
1305
|
} else if (hexError !== null) {
|
|
@@ -1372,16 +1308,12 @@ async function verifyAnchorTrace(
|
|
|
1372
1308
|
cpErrors.push("checkpoint envelope signature failed");
|
|
1373
1309
|
}
|
|
1374
1310
|
if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
|
|
1375
|
-
//
|
|
1376
|
-
// signature and binding; name it rather than fail with no reason.
|
|
1311
|
+
// Name a malformed skeleton field, which fails an otherwise valid envelope.
|
|
1377
1312
|
for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
|
|
1378
1313
|
const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
|
|
1379
1314
|
if (specFinding !== null) cpErrors.push(specFinding);
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
1383
|
-
// naming the wrong cause sends a reader to look for a field that is
|
|
1384
|
-
// sitting right there (proof#76).
|
|
1315
|
+
// Name why the binding is undetermined: absent bytes and unbindable bytes
|
|
1316
|
+
// are different causes.
|
|
1385
1317
|
if (res.payloadHashOk === null) {
|
|
1386
1318
|
cpErrors.push(
|
|
1387
1319
|
eventBytes === undefined
|
|
@@ -1393,8 +1325,7 @@ async function verifyAnchorTrace(
|
|
|
1393
1325
|
cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
|
|
1394
1326
|
}
|
|
1395
1327
|
|
|
1396
|
-
//
|
|
1397
|
-
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
1328
|
+
// Recompute: the committed record's merkle_root must equal the claimed one.
|
|
1398
1329
|
if (eventBytes !== undefined) {
|
|
1399
1330
|
try {
|
|
1400
1331
|
const record = jcsParse(eventBytes) as {
|
|
@@ -1408,12 +1339,9 @@ async function verifyAnchorTrace(
|
|
|
1408
1339
|
`committed=${jsonStr(committedRoot)}`,
|
|
1409
1340
|
);
|
|
1410
1341
|
}
|
|
1411
|
-
// §5.1
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
// the checkpoint, as a record that signs no merkle_root does. Read by
|
|
1415
|
-
// value through the one reader the inclusion compare and the
|
|
1416
|
-
// completeness notes share, so `3.0` and `3e0` are 3.
|
|
1342
|
+
// Fail closed (§5.1): every inclusion is held to the count the record
|
|
1343
|
+
// signs, so a record that signs no integer count fails the checkpoint.
|
|
1344
|
+
// Read by value through the shared reader, so `3.0` and `3e0` are 3.
|
|
1417
1345
|
leafCountOk = signedLeafCount(cp as { event_bytes?: unknown }) !== null;
|
|
1418
1346
|
if (!leafCountOk) cpErrors.push("checkpoint record leaf_count is not an integer");
|
|
1419
1347
|
} catch (e) {
|
|
@@ -1428,26 +1356,32 @@ async function verifyAnchorTrace(
|
|
|
1428
1356
|
merkleRootOk,
|
|
1429
1357
|
onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
|
|
1430
1358
|
onChain: null, // per-gateway (§4.2), filled in step 5
|
|
1431
|
-
// `
|
|
1432
|
-
//
|
|
1433
|
-
// can see rather than a coverage gap we cannot — and this kernel's
|
|
1434
|
-
// standing rule is that malformed is a failure while absent is
|
|
1435
|
-
// undetermined (the same rule disclosed `content` follows). Without
|
|
1436
|
-
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
1359
|
+
// A malformed `anchor_ref` is inside the signature: a visible defect,
|
|
1360
|
+
// which fails, while an absent one is undetermined.
|
|
1437
1361
|
ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
|
|
1438
1362
|
errors: cpErrors,
|
|
1363
|
+
anchorRefMalformed:
|
|
1364
|
+
keyed.anchorRefMalformed && envelopeOk && merkleRootOk && leafCountOk && !duplicateKey,
|
|
1439
1365
|
});
|
|
1440
1366
|
if (keyed.anchorRefMalformed) {
|
|
1441
1367
|
malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
|
|
1442
1368
|
}
|
|
1443
1369
|
}
|
|
1444
1370
|
|
|
1445
|
-
//
|
|
1371
|
+
// Every checkpoint ref the pack holds, derived or stated, compared as hex is. A ref
|
|
1372
|
+
// outside it names a checkpoint the pack does not hold, which is a stamp missing, never a
|
|
1373
|
+
// stamp proven wrong (section 5.1, as section 5.3.3 rule 1 reads a stamp).
|
|
1374
|
+
const heldRefs = new Set<string>([...byKey.keys()].map((k) => k.toLowerCase()));
|
|
1375
|
+
for (const raw of body.checkpoints as unknown[]) {
|
|
1376
|
+
const ref = isJsonObject(raw) ? (raw as { checkpoint_ref?: unknown }).checkpoint_ref : undefined;
|
|
1377
|
+
if (typeof ref === "string") heldRefs.add(ref.toLowerCase());
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Step 4: per event.
|
|
1446
1381
|
const eventResults: EventResult[] = [];
|
|
1382
|
+
const agentItems: (AgentHeldRecord | null)[] = [];
|
|
1447
1383
|
for (const rawEv of body.events as unknown[]) {
|
|
1448
|
-
//
|
|
1449
|
-
// already uses: a null or non-object entry is graded as not an event,
|
|
1450
|
-
// never raised on.
|
|
1384
|
+
// A non-object entry is graded as not an event, never raised on.
|
|
1451
1385
|
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1452
1386
|
eventResults.push({
|
|
1453
1387
|
eventId: "<unknown>",
|
|
@@ -1458,35 +1392,24 @@ async function verifyAnchorTrace(
|
|
|
1458
1392
|
contentOk: null,
|
|
1459
1393
|
ok: false,
|
|
1460
1394
|
errors: ["events[] entry is not an object"],
|
|
1395
|
+
agent: null,
|
|
1461
1396
|
});
|
|
1397
|
+
agentItems.push(null);
|
|
1462
1398
|
continue;
|
|
1463
1399
|
}
|
|
1464
1400
|
const ev = rawEv as TraceEvent;
|
|
1465
1401
|
const evErrors: string[] = [];
|
|
1466
1402
|
const eventId = getEventId(ev.envelope);
|
|
1467
1403
|
let envelopeOk = false;
|
|
1404
|
+
let envelopeFaults: EnvelopeFault[] = [];
|
|
1468
1405
|
let payloadBindingOk: boolean | null = null;
|
|
1469
1406
|
|
|
1470
|
-
// A retired field name
|
|
1471
|
-
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
1407
|
+
// A retired field name is a reject: ignored, it would read as withheld.
|
|
1472
1408
|
const legacyError = legacyEventBytesError(ev, "event", bodyType);
|
|
1473
1409
|
if (legacyError !== null) evErrors.push(legacyError);
|
|
1474
1410
|
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
1478
|
-
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
1479
|
-
// line down. So an event carrying garbage in place of its record used to
|
|
1480
|
-
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
1481
|
-
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
1482
|
-
// a record nobody could read. The only thing wrong with the pack was the
|
|
1483
|
-
// bytes, and it was the one thing the verdict did not say.
|
|
1484
|
-
//
|
|
1485
|
-
// A record the producer chose not to disclose is the customer exercising
|
|
1486
|
-
// minimum disclosure. A record that is present and unreadable is a defect
|
|
1487
|
-
// in the artifact, and this kernel's standing rule (the same one
|
|
1488
|
-
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
1489
|
-
// we can see fails.
|
|
1411
|
+
// Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
|
|
1412
|
+
// would otherwise look exactly like a withheld record and verify.
|
|
1490
1413
|
let eventBytes: Uint8Array | undefined;
|
|
1491
1414
|
let eventBytesMalformed = false;
|
|
1492
1415
|
try {
|
|
@@ -1495,10 +1418,10 @@ async function verifyAnchorTrace(
|
|
|
1495
1418
|
eventBytesMalformed = true;
|
|
1496
1419
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1497
1420
|
}
|
|
1498
|
-
const
|
|
1499
|
-
if (
|
|
1421
|
+
const notJson = recordNotJson(eventBytes);
|
|
1422
|
+
if (notJson !== undefined) {
|
|
1500
1423
|
eventBytesMalformed = true;
|
|
1501
|
-
evErrors.push(
|
|
1424
|
+
evErrors.push(notJson);
|
|
1502
1425
|
}
|
|
1503
1426
|
try {
|
|
1504
1427
|
const res = await verifyEnvelope(
|
|
@@ -1506,16 +1429,12 @@ async function verifyAnchorTrace(
|
|
|
1506
1429
|
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1507
1430
|
);
|
|
1508
1431
|
const hexError = envelopeHexError("event", ev.envelope);
|
|
1509
|
-
//
|
|
1432
|
+
// Tolerates an undetermined binding (withheld record).
|
|
1510
1433
|
envelopeOk = res.ok && hexError === null;
|
|
1434
|
+
envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
|
|
1511
1435
|
payloadBindingOk = res.payloadHashOk;
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
// binding was attempted. "signature failed" named a check that never ran,
|
|
1515
|
-
// and it was the only thing said about an event whose undetermined
|
|
1516
|
-
// binding then rendered as a WITHHELD record, a claim about the
|
|
1517
|
-
// producer's disclosure choice out of an input that says nothing about
|
|
1518
|
-
// the producer at all (proof#76).
|
|
1436
|
+
// No envelope, so no signature was checked: report it as malformed, not as
|
|
1437
|
+
// a failed signature or a withheld record.
|
|
1519
1438
|
if (!isJsonObject(ev.envelope)) {
|
|
1520
1439
|
evErrors.push("event envelope is not a JSON object");
|
|
1521
1440
|
} else if (hexError !== null) {
|
|
@@ -1524,27 +1443,27 @@ async function verifyAnchorTrace(
|
|
|
1524
1443
|
evErrors.push("event envelope signature failed");
|
|
1525
1444
|
}
|
|
1526
1445
|
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
1527
|
-
//
|
|
1528
|
-
// and binding (proof#95), so without this the event fails with no reason.
|
|
1446
|
+
// Name a malformed skeleton field, which fails an otherwise valid envelope.
|
|
1529
1447
|
for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
|
|
1448
|
+
if (res.errors.some((m) => m.startsWith("missing payload_hash"))) evErrors.push("event envelope missing payload_hash");
|
|
1530
1449
|
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
1531
1450
|
if (specFinding !== null) evErrors.push(specFinding);
|
|
1532
1451
|
} catch (e) {
|
|
1533
1452
|
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
1534
1453
|
}
|
|
1535
1454
|
|
|
1536
|
-
// Bind the event to exactly one checkpoint
|
|
1537
|
-
//
|
|
1455
|
+
// Bind the event to exactly one checkpoint, then verify RFC 9162 inclusion
|
|
1456
|
+
// against that checkpoint's merkle_root.
|
|
1538
1457
|
const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
// checkpoint nobody meant.
|
|
1458
|
+
// The retired `checkpoint_tx_id` spelling marks a mixed bundle; resolving
|
|
1459
|
+
// it could bind the event to an unintended checkpoint.
|
|
1542
1460
|
const keyField = "checkpoint_ref";
|
|
1543
1461
|
const wrongField = "checkpoint_tx_id";
|
|
1544
1462
|
const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
|
|
1545
1463
|
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
1546
1464
|
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
1547
1465
|
let cp: AnyTraceCheckpoint | undefined;
|
|
1466
|
+
let stampAbsent = false;
|
|
1548
1467
|
if (!incl || typeof incl !== "object") {
|
|
1549
1468
|
evErrors.push("event missing inclusion proof");
|
|
1550
1469
|
} else if (strayKey !== undefined) {
|
|
@@ -1555,7 +1474,14 @@ async function verifyAnchorTrace(
|
|
|
1555
1474
|
evErrors.push(`event inclusion missing ${keyField}`);
|
|
1556
1475
|
} else {
|
|
1557
1476
|
cp = byKey.get(rawKey);
|
|
1558
|
-
if (cp === undefined) {
|
|
1477
|
+
if (cp === undefined && !heldRefs.has(rawKey.toLowerCase())) {
|
|
1478
|
+
// The pack does not hold the checkpoint the record names: `witnessed` is not
|
|
1479
|
+
// proven (`missing`).
|
|
1480
|
+
stampAbsent = true;
|
|
1481
|
+
evErrors.push(
|
|
1482
|
+
`event inclusion.${keyField} ${JSON.stringify(rawKey)} names a checkpoint this pack does not hold`,
|
|
1483
|
+
);
|
|
1484
|
+
} else if (cp === undefined) {
|
|
1559
1485
|
evErrors.push(
|
|
1560
1486
|
`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`,
|
|
1561
1487
|
);
|
|
@@ -1564,22 +1490,12 @@ async function verifyAnchorTrace(
|
|
|
1564
1490
|
const checkpointBound = cp !== undefined;
|
|
1565
1491
|
|
|
1566
1492
|
let inclusionOk = false;
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
// bundle cannot verify. One reader of the signed count, shared with the
|
|
1574
|
-
// completeness notes, so the verdict and the grade never read two
|
|
1575
|
-
// different numbers.
|
|
1576
|
-
//
|
|
1577
|
-
// Every number here is read BY VALUE (`Number.isSafeInteger`), the model
|
|
1578
|
-
// JCS signs, and the Python kernel reads the same way (`_safe_integer`):
|
|
1579
|
-
// JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
|
|
1580
|
-
// same wrapper signature. A value that is not a safe integer is malformed,
|
|
1581
|
-
// with one sentence that prints no value, because the two kernels cannot
|
|
1582
|
-
// print `2.5`, `1e400` or `9007199254740993` the same way.
|
|
1493
|
+
// The tree walked must be the tree the checkpoint record signed (§5.1): an
|
|
1494
|
+
// RFC 9162 audit path is valid at more than one (leaf_index, leaf_count),
|
|
1495
|
+
// so a proof at (2, 3) would also verify as (1, 2). An unreadable signed
|
|
1496
|
+
// count already failed its checkpoint. Numbers are read by value, as JCS
|
|
1497
|
+
// signs them; a non-safe-integer is malformed, in a sentence that prints no
|
|
1498
|
+
// value because the kernels cannot print it alike.
|
|
1583
1499
|
const signedCount = cp ? signedLeafCount(cp as { event_bytes?: unknown }) : null;
|
|
1584
1500
|
if (incl && checkpointBound && cp) {
|
|
1585
1501
|
const statedCount = safeInteger(incl.leaf_count);
|
|
@@ -1600,19 +1516,14 @@ async function verifyAnchorTrace(
|
|
|
1600
1516
|
statedIndex,
|
|
1601
1517
|
statedCount,
|
|
1602
1518
|
(incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
1603
|
-
// No `?? ""` fallback
|
|
1604
|
-
// handed an invented
|
|
1605
|
-
// value and reports the decode failure, so substituting "" here had
|
|
1606
|
-
// the two kernels say different things about one bundle (TS: "the
|
|
1607
|
-
// proof did not reconstruct the checkpoint root", which is not what
|
|
1608
|
-
// happened: there was no claimed root to reconstruct).
|
|
1519
|
+
// No `?? ""` fallback: a checkpoint claiming no root must not be
|
|
1520
|
+
// handed an invented one.
|
|
1609
1521
|
hexToBytes(cp.merkle_root as string),
|
|
1610
1522
|
);
|
|
1611
1523
|
if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
1612
1524
|
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
// inclusion proof for an UNRELATED leaf could be smuggled in.
|
|
1525
|
+
// The claimed leaf_hash must hash the event's own signed envelope
|
|
1526
|
+
// (profile §6), or a proof for an unrelated leaf could be smuggled in.
|
|
1616
1527
|
const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
|
|
1617
1528
|
const expectedLeafHex = toHex(expectedLeaf);
|
|
1618
1529
|
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
@@ -1627,10 +1538,12 @@ async function verifyAnchorTrace(
|
|
|
1627
1538
|
}
|
|
1628
1539
|
}
|
|
1629
1540
|
|
|
1630
|
-
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
1631
1541
|
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
1632
1542
|
const contentOk = contentRes.contentOk;
|
|
1633
1543
|
evErrors.push(...contentRes.errors);
|
|
1544
|
+
agentItems.push(
|
|
1545
|
+
agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed),
|
|
1546
|
+
);
|
|
1634
1547
|
|
|
1635
1548
|
eventResults.push({
|
|
1636
1549
|
eventId,
|
|
@@ -1639,13 +1552,9 @@ async function verifyAnchorTrace(
|
|
|
1639
1552
|
inclusionOk,
|
|
1640
1553
|
checkpointBound,
|
|
1641
1554
|
contentOk,
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
//
|
|
1645
|
-
// withheld record or undisclosed content (binding/content null) does not
|
|
1646
|
-
// fail it.
|
|
1647
|
-
// `!eventBytesMalformed`: present-and-unreadable record bytes are a
|
|
1648
|
-
// defect in the artifact, never a disclosure choice (proof#85).
|
|
1555
|
+
// Ok when the envelope is authentic, inclusion reconstructs the root, the
|
|
1556
|
+
// checkpoint is present, and disclosed content did not mismatch. Withheld
|
|
1557
|
+
// or undisclosed content does not fail it; unreadable record bytes do.
|
|
1649
1558
|
ok:
|
|
1650
1559
|
envelopeOk &&
|
|
1651
1560
|
inclusionOk &&
|
|
@@ -1654,16 +1563,24 @@ async function verifyAnchorTrace(
|
|
|
1654
1563
|
legacyError === null &&
|
|
1655
1564
|
!eventBytesMalformed,
|
|
1656
1565
|
errors: evErrors,
|
|
1566
|
+
agent: null,
|
|
1567
|
+
envelopeFault: envelopeFaults[0] ?? null,
|
|
1568
|
+
envelopeFaults,
|
|
1569
|
+
recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
|
|
1570
|
+
contentUnsigned: contentOk === false && contentRes.unsigned,
|
|
1571
|
+
stampAbsent,
|
|
1572
|
+
ownFormFailed: legacyError !== null || eventBytesMalformed,
|
|
1657
1573
|
});
|
|
1658
1574
|
}
|
|
1659
1575
|
|
|
1660
|
-
|
|
1576
|
+
await attachAgentReadings(eventResults, agentItems);
|
|
1577
|
+
|
|
1578
|
+
// Step 5: optional on-chain re-fetch.
|
|
1661
1579
|
const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
|
|
1662
1580
|
let gatewayUnavailable = false;
|
|
1663
1581
|
let uncheckedKind = false;
|
|
1664
1582
|
let uncheckedId = false;
|
|
1665
|
-
// Simulated witnesses against
|
|
1666
|
-
// checkpoints that name one, as `refetchStamps` counts stamps.
|
|
1583
|
+
// Simulated witnesses against any other kind, as `refetchStamps` counts.
|
|
1667
1584
|
let simulated = 0;
|
|
1668
1585
|
let onChainKinds = 0;
|
|
1669
1586
|
if (onChainChecked) {
|
|
@@ -1674,21 +1591,11 @@ async function verifyAnchorTrace(
|
|
|
1674
1591
|
for (const cpr of checkpointResults) {
|
|
1675
1592
|
const cp = byKey.get(cpr.checkpointRef);
|
|
1676
1593
|
if (!cp) continue;
|
|
1677
|
-
//
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
// accusation this whole change is removing.
|
|
1683
|
-
// proof#120. An `anchor_ref` that failed structural validation is not
|
|
1684
|
-
// an absent one. Nothing was fetched, so the witness dimension is NOT
|
|
1685
|
-
// CHECKED — `onChain: null`, exactly as when the caller supplied no
|
|
1686
|
-
// adapter — and it must not raise `gatewayUnavailable`, which is what
|
|
1687
|
-
// puts "could not be re-fetched from the supplied gateways" on the
|
|
1688
|
-
// bundle. Reporting an outage nobody observed is a claim about the
|
|
1689
|
-
// public record made out of a defect in the artifact, and verify-page
|
|
1690
|
-
// renders it word for word. The run still fails: the structural error
|
|
1691
|
-
// is already in `cpr.errors` and already in `ok` above.
|
|
1594
|
+
// No `anchor_ref`: nothing to re-fetch. Absent (placed, not yet
|
|
1595
|
+
// witnessed) reports as unreachable, downgrading the run to partial and
|
|
1596
|
+
// never to a mismatch. Malformed reports as not checked (`onChain:
|
|
1597
|
+
// null`) and must not raise `gatewayUnavailable`: no outage was
|
|
1598
|
+
// observed, and the structural error already fails the run.
|
|
1692
1599
|
if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
|
|
1693
1600
|
cpr.onChain = null;
|
|
1694
1601
|
cpr.onChainOk = null;
|
|
@@ -1714,54 +1621,46 @@ async function verifyAnchorTrace(
|
|
|
1714
1621
|
if (outcome.unchecked === "kind") uncheckedKind = true;
|
|
1715
1622
|
if (outcome.unchecked === "id") uncheckedId = true;
|
|
1716
1623
|
if (outcome.error) cpr.errors.push(outcome.error);
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
// failure. A match (true) keeps it ok.
|
|
1722
|
-
// AND into the verdict already computed, never rebuild it from a
|
|
1723
|
-
// hand-listed set of dimensions. Rebuilding silently dropped every
|
|
1724
|
-
// term not in that list: the duplicate-key gate (proof#28) and the
|
|
1725
|
-
// `keyed.errors` term with it, so supplying a gateway turned a hard
|
|
1726
|
-
// reject back into `verified` and an UNREACHABLE gateway turned it
|
|
1727
|
-
// into `partial` (exit 3), which reads as a network problem an
|
|
1728
|
-
// operator retries. Neither is conditioned on a gateway by the spec:
|
|
1729
|
-
// `evidence-bundle.md` says two checkpoints carrying one key is a
|
|
1730
|
-
// hard reject, full stop. Written this way, a term added at
|
|
1731
|
-
// construction survives here by construction.
|
|
1624
|
+
// A mismatch fails the checkpoint; unreachable leaves it ok and
|
|
1625
|
+
// downgrades the run to partial. AND into the verdict already computed,
|
|
1626
|
+
// never rebuild it, so every construction-time term (the duplicate-key
|
|
1627
|
+
// gate included) survives.
|
|
1732
1628
|
cpr.ok = cpr.ok && cpr.onChainOk !== false;
|
|
1733
1629
|
}
|
|
1734
1630
|
}
|
|
1735
1631
|
}
|
|
1736
1632
|
|
|
1737
|
-
//
|
|
1633
|
+
// Rollup.
|
|
1738
1634
|
const allCheckpointsOk = checkpointResults.length > 0 && checkpointResults.every((c) => c.ok);
|
|
1739
1635
|
const allEventsOk = eventResults.every((e) => e.ok);
|
|
1740
1636
|
const anyHardFailure =
|
|
1741
|
-
checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok);
|
|
1637
|
+
checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok && !unproven(e));
|
|
1742
1638
|
const anyWithheldRecord = eventResults.some((e) => e.payloadBindingOk === null);
|
|
1743
|
-
|
|
1639
|
+
const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
|
|
1640
|
+
|
|
1641
|
+
// A lookup that did not settle is the witness line's to say, never the status's: the
|
|
1642
|
+
// witness is proven offline by the checkpoint and its inclusion proof, and only a lookup
|
|
1643
|
+
// that finds other bytes (a hard failure above) moves anything.
|
|
1644
|
+
pushRefetchGapNotes(errors, "checkpoints", {
|
|
1645
|
+
unavailable: gatewayUnavailable,
|
|
1646
|
+
kind: uncheckedKind,
|
|
1647
|
+
id: uncheckedId,
|
|
1648
|
+
});
|
|
1649
|
+
// A checkpoint nobody has witnessed yet (no anchor_ref) is placed, not witnessed: the
|
|
1650
|
+
// `witnessed` claim is not proven (evidence-bundle.md section 3), as for a stamp.
|
|
1651
|
+
const anyUnwitnessed = checkpointResults.some((c) => c.anchorRef === null);
|
|
1744
1652
|
let status: EvidenceStatus;
|
|
1745
1653
|
if (anyHardFailure) {
|
|
1746
1654
|
status = "failed";
|
|
1747
|
-
} else if (
|
|
1748
|
-
|
|
1749
|
-
// on-chain confirmation could not be made — the bundle is offline-sound,
|
|
1750
|
-
// its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
|
|
1751
|
-
status = "partial";
|
|
1752
|
-
pushRefetchGapNotes(errors, "checkpoints", {
|
|
1753
|
-
unavailable: gatewayUnavailable,
|
|
1754
|
-
kind: uncheckedKind,
|
|
1755
|
-
id: uncheckedId,
|
|
1756
|
-
});
|
|
1655
|
+
} else if (anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
|
|
1656
|
+
status = "incomplete";
|
|
1757
1657
|
} else if (anyWithheldRecord) {
|
|
1758
|
-
//
|
|
1759
|
-
|
|
1760
|
-
status = "partial";
|
|
1658
|
+
// Some record bytes were withheld, so their binding is not checked.
|
|
1659
|
+
status = "incomplete";
|
|
1761
1660
|
} else if (allCheckpointsOk && allEventsOk) {
|
|
1762
1661
|
status = "verified";
|
|
1763
1662
|
} else {
|
|
1764
|
-
status = "
|
|
1663
|
+
status = "incomplete";
|
|
1765
1664
|
}
|
|
1766
1665
|
|
|
1767
1666
|
return {
|
|
@@ -1770,63 +1669,22 @@ async function verifyAnchorTrace(
|
|
|
1770
1669
|
bodyHashOk: true,
|
|
1771
1670
|
signatureOk: true,
|
|
1772
1671
|
bodyType,
|
|
1773
|
-
//
|
|
1774
|
-
//
|
|
1775
|
-
// `refetchStamps`). The checkpoints stay not checked and the run partial.
|
|
1672
|
+
// Simulated-only witnesses looked nothing up on a chain, so the run is not
|
|
1673
|
+
// counted as checked on one.
|
|
1776
1674
|
onChainChecked: onChainChecked && !(simulated > 0 && onChainKinds === 0),
|
|
1777
1675
|
checkpoints: checkpointResults,
|
|
1778
1676
|
events: eventResults,
|
|
1779
|
-
assertedStatus,
|
|
1780
1677
|
errors,
|
|
1781
1678
|
};
|
|
1782
1679
|
}
|
|
1783
1680
|
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
// ── What this can prove, and what it deliberately does not claim ──
|
|
1788
|
-
//
|
|
1789
|
-
// A stamp is not a producer artifact. Under DL-52 the customer signs an
|
|
1790
|
-
// envelope and POSTs it; the commitment is witnessed later, on an interval, by
|
|
1791
|
-
// a stamp the customer never sees and holds no key for. So there is no
|
|
1792
|
-
// producer signature inside this body to check, and — this is the part worth
|
|
1793
|
-
// being precise about — there is no SECOND Sanning signature either. The
|
|
1794
|
-
// control plane stores the stamp's canonical bytes and hands them to a witness
|
|
1795
|
-
// as a signed chain data item; the signature lives on the chain, not in the
|
|
1796
|
-
// `stamps` table and not in this body.
|
|
1797
|
-
//
|
|
1798
|
-
// What "Sanning signed this" means for a stamp bundle is therefore exactly the
|
|
1799
|
-
// WRAPPER signature, verified generically before this function is reached. And
|
|
1800
|
-
// what makes the stamp evidence rather than an assertion is the witness, which
|
|
1801
|
-
// is on a chain and not checkable offline. Hence the rollup below: a stamp we
|
|
1802
|
-
// can read but nobody has witnessed holds the bundle at `partial`.
|
|
1803
|
-
//
|
|
1804
|
-
// So the checkable surface here is the stamp's INTERNAL consistency against
|
|
1805
|
-
// the one authority in the bundle — the witnessed bytes:
|
|
1806
|
-
//
|
|
1807
|
-
// 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
|
|
1808
|
-
// 2. Those bytes parse and re-canonicalize to themselves under JCS.
|
|
1809
|
-
// 3. Their `stamp_version` is the one this body_type pins.
|
|
1810
|
-
// 4. Every sub-tree range accounts for exactly one leaf of the stamp.
|
|
1811
|
-
//
|
|
1812
|
-
// (4) is the one that carries customer weight. R-8's whole per-agent story is
|
|
1813
|
-
// "prove this agent was under evidence over window W", answered by a
|
|
1814
|
-
// contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
|
|
1815
|
-
// not tile `[0, leaf_count)` describe a partition that does not exist, so a
|
|
1816
|
-
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
1817
|
-
// nothing else in the stack would ever notice.
|
|
1818
|
-
// ===========================================================================
|
|
1819
|
-
|
|
1820
|
-
// A JSON object, as opposed to null, an array, a string or a number. Used at
|
|
1821
|
-
// every point where a nested field MUST be an object before any check of it
|
|
1822
|
-
// means anything: probing a verifier with a non-object asks a question about
|
|
1823
|
-
// something that was never the thing, and the answer must say so rather than
|
|
1824
|
-
// name a check that never ran (proof#76).
|
|
1681
|
+
// A JSON object, as opposed to null, an array, a string or a number. A nested
|
|
1682
|
+
// field must be one before any check of it means anything.
|
|
1825
1683
|
function isJsonObject(v: unknown): v is Record<string, unknown> {
|
|
1826
1684
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1827
1685
|
}
|
|
1828
1686
|
|
|
1829
|
-
/** Lowercase hex, even length, non-empty.
|
|
1687
|
+
/** Lowercase hex, even length, non-empty. */
|
|
1830
1688
|
function isLowerHex(s: unknown, nibbles?: number): s is string {
|
|
1831
1689
|
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
|
|
1832
1690
|
if (nibbles !== undefined && s.length !== nibbles) return false;
|
|
@@ -1834,61 +1692,36 @@ function isLowerHex(s: unknown, nibbles?: number): s is string {
|
|
|
1834
1692
|
}
|
|
1835
1693
|
|
|
1836
1694
|
/**
|
|
1837
|
-
* A JSON number read
|
|
1838
|
-
* `
|
|
1839
|
-
* goes through (A38), mirrored by `_non_neg_integer` in
|
|
1840
|
-
* `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
|
|
1841
|
-
* JCS's own spelling of a count past 2**53 - 1, which Python refused.
|
|
1695
|
+
* A JSON number read by value as a non-negative safe integer, or null. Matches
|
|
1696
|
+
* Python's `_non_neg_integer`; `Number.isInteger` alone would accept `1e+21`.
|
|
1842
1697
|
*/
|
|
1843
1698
|
function nonNegInteger(n: unknown): number | null {
|
|
1844
1699
|
const v = safeInteger(n);
|
|
1845
1700
|
return v !== null && v >= 0 ? v : null;
|
|
1846
1701
|
}
|
|
1847
1702
|
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
* meaningful, so a bundle with two independent defects reports two — a
|
|
1853
|
-
* verifier that stops at the first one turns a second fix into a second
|
|
1854
|
-
* round-trip.
|
|
1855
|
-
*/
|
|
1856
|
-
// ===========================================================================
|
|
1857
|
-
// Disclosed-content binding, shared by every body that carries events.
|
|
1858
|
-
//
|
|
1859
|
-
// Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
|
|
1860
|
-
// rawLog -> content_hash link is the one step the chain
|
|
1861
|
-
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
1862
|
-
// not otherwise cover, and a second copy of it is a second place for the
|
|
1863
|
-
// never-fail-on-absent discipline to drift. The rules are unchanged from the
|
|
1864
|
-
// anchor-trace path this came from: undetermined (null) when nothing is
|
|
1865
|
-
// disclosed or nothing is committed; false ONLY on a real mismatch or an
|
|
1866
|
-
// in-body vs side-input disagreement.
|
|
1867
|
-
// ===========================================================================
|
|
1703
|
+
// Disclosed-content binding, shared by every body that carries events. The
|
|
1704
|
+
// raw log to `content_hash` link is the one step the signature chain does not
|
|
1705
|
+
// cover. Undetermined (null) when nothing is disclosed or committed; false only
|
|
1706
|
+
// on a real mismatch or an in-body vs side-input disagreement.
|
|
1868
1707
|
async function verifyDisclosedContent(
|
|
1869
1708
|
ev: { envelope?: unknown; content?: unknown },
|
|
1870
1709
|
eventId: string,
|
|
1871
1710
|
eventBytes: Uint8Array | undefined,
|
|
1872
1711
|
sideContent: VerifyEvidenceOptions["content"],
|
|
1873
|
-
): Promise<{ contentOk: boolean | null; errors: string[] }> {
|
|
1712
|
+
): Promise<{ contentOk: boolean | null; unsigned: boolean; errors: string[] }> {
|
|
1874
1713
|
const evErrors: string[] = [];
|
|
1875
1714
|
let contentOk: boolean | null = null;
|
|
1876
|
-
|
|
1877
|
-
//
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
1882
|
-
//
|
|
1883
|
-
//
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1886
|
-
// (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
|
|
1887
|
-
// hex. Anything short of that commits nothing this reader will bind to, so
|
|
1888
|
-
// the reading is undetermined and never true: a record dressed as a hand-off
|
|
1889
|
-
// record gains no binding from the dress, and a record outside §4.1 keeps its
|
|
1890
|
-
// `content_hash` exactly as before, whatever `message_hash` field it writes.
|
|
1891
|
-
// The Python twin is `evidence._evaluate_content`.
|
|
1715
|
+
// Whether a `false` rests on side bytes nobody signed: the side input checked alone, or
|
|
1716
|
+
// side bytes that differ from in-body bytes that do not fail on their own.
|
|
1717
|
+
let unsigned = false;
|
|
1718
|
+
|
|
1719
|
+
// Which hash binds depends on the record's structure (§5.1). A record §4.1
|
|
1720
|
+
// governs commits through `event.message_hash` (SHA-256 of the message's
|
|
1721
|
+
// JCS), and binds only when it is a conformant hand-off record and the value
|
|
1722
|
+
// is 64 lowercase hex characters; otherwise the reading is undetermined, never
|
|
1723
|
+
// true. Any other record binds through `content_hash`. Python's twin is
|
|
1724
|
+
// `_evaluate_content`.
|
|
1892
1725
|
const committed: string[] = [];
|
|
1893
1726
|
let recordContentHash: string | undefined;
|
|
1894
1727
|
let governed = false;
|
|
@@ -1922,8 +1755,8 @@ async function verifyDisclosedContent(
|
|
|
1922
1755
|
}
|
|
1923
1756
|
}
|
|
1924
1757
|
} catch {
|
|
1925
|
-
//
|
|
1926
|
-
//
|
|
1758
|
+
// Record not JSON: the payload binding reflects that; content stays
|
|
1759
|
+
// undetermined.
|
|
1927
1760
|
}
|
|
1928
1761
|
}
|
|
1929
1762
|
if (governed) {
|
|
@@ -1931,9 +1764,8 @@ async function verifyDisclosedContent(
|
|
|
1931
1764
|
} else if (recordContentHash !== undefined) {
|
|
1932
1765
|
committed.push(recordContentHash);
|
|
1933
1766
|
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1934
|
-
// Promoted fallback
|
|
1935
|
-
//
|
|
1936
|
-
// on adversarial input.
|
|
1767
|
+
// Promoted fallback, guarded: `contentHashes` would throw on a non-object
|
|
1768
|
+
// envelope.
|
|
1937
1769
|
for (const c of contentHashes(ev.envelope as Envelope)) committed.push(c.hash.toLowerCase());
|
|
1938
1770
|
}
|
|
1939
1771
|
|
|
@@ -1946,6 +1778,8 @@ async function verifyDisclosedContent(
|
|
|
1946
1778
|
evErrors.push(
|
|
1947
1779
|
"disclosed content disagreement: in-body events[].content and the supplied content differ",
|
|
1948
1780
|
);
|
|
1781
|
+
// Unsigned unless the in-body bytes, which the wrapper signs, fail on their own.
|
|
1782
|
+
unsigned = committed.length === 0 || committed.includes(await sha256Hex(inBody));
|
|
1949
1783
|
} else {
|
|
1950
1784
|
const disclosed = inBody ?? side;
|
|
1951
1785
|
if (disclosed === undefined || committed.length === 0) {
|
|
@@ -1955,14 +1789,15 @@ async function verifyDisclosedContent(
|
|
|
1955
1789
|
governed
|
|
1956
1790
|
? "disclosed content present but this hand-off record commits no " +
|
|
1957
1791
|
"message_hash a reader can bind to (not a conformant hand-off " +
|
|
1958
|
-
"record, or message_hash is not 64 lowercase hex) --
|
|
1792
|
+
"record, or message_hash is not 64 lowercase hex) -- not checked"
|
|
1959
1793
|
: "disclosed content present but no committed content_hash to bind to " +
|
|
1960
|
-
"(record withheld or record has no event.content_hash) --
|
|
1794
|
+
"(record withheld or record has no event.content_hash) -- not checked",
|
|
1961
1795
|
);
|
|
1962
1796
|
}
|
|
1963
1797
|
} else {
|
|
1964
1798
|
const got = await sha256Hex(disclosed);
|
|
1965
1799
|
contentOk = committed.includes(got);
|
|
1800
|
+
unsigned = !contentOk && inBody === undefined;
|
|
1966
1801
|
if (!contentOk) {
|
|
1967
1802
|
const fieldName = governed ? "message_hash" : "content_hash";
|
|
1968
1803
|
evErrors.push(
|
|
@@ -1972,28 +1807,23 @@ async function verifyDisclosedContent(
|
|
|
1972
1807
|
}
|
|
1973
1808
|
}
|
|
1974
1809
|
}
|
|
1975
|
-
return { contentOk, errors: evErrors };
|
|
1810
|
+
return { contentOk, unsigned, errors: evErrors };
|
|
1976
1811
|
}
|
|
1977
1812
|
|
|
1978
1813
|
/**
|
|
1979
1814
|
* One `stamps[]` entry's result, plus the witnessed bytes it was graded on.
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
1982
|
-
* whole stamp body again, and putting it back on the result would double every
|
|
1983
|
-
* serialized verdict to carry a copy of what the bundle already holds. It
|
|
1984
|
-
* exists so the optional on-chain re-fetch compares the chain against THE SAME
|
|
1985
|
-
* BYTES this function hashed to derive `stamp_ref`, rather than re-reading
|
|
1986
|
-
* `stamp_bytes` out of the body a second time — one value, so the graded bytes
|
|
1987
|
-
* and the compared bytes cannot come apart.
|
|
1988
|
-
*
|
|
1989
|
-
* Undefined exactly when `stamp_bytes` was unusable, in which case the entry
|
|
1990
|
-
* has already failed and there is nothing a gateway could confirm.
|
|
1815
|
+
* `witnessedHex` stays internal so the on-chain re-fetch compares the same
|
|
1816
|
+
* bytes that derived `stamp_ref`. Undefined when `stamp_bytes` was unusable.
|
|
1991
1817
|
*/
|
|
1992
1818
|
interface StampEntryOutcome {
|
|
1993
1819
|
result: StampResult;
|
|
1994
1820
|
witnessedHex?: string;
|
|
1995
1821
|
}
|
|
1996
1822
|
|
|
1823
|
+
/**
|
|
1824
|
+
* Parse and check one stamp's witnessed bytes. Errors accumulate wherever a
|
|
1825
|
+
* later check still means something, so independent defects are all reported.
|
|
1826
|
+
*/
|
|
1997
1827
|
async function verifyStampEntry(
|
|
1998
1828
|
raw: unknown,
|
|
1999
1829
|
seen: Set<string>,
|
|
@@ -2023,7 +1853,7 @@ async function verifyStampEntry(
|
|
|
2023
1853
|
}
|
|
2024
1854
|
const e = raw as Partial<StampEntry> & Record<string, unknown>;
|
|
2025
1855
|
|
|
2026
|
-
//
|
|
1856
|
+
// The witnessed bytes are the only authority in this body.
|
|
2027
1857
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
2028
1858
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
2029
1859
|
return { result: base };
|
|
@@ -2035,19 +1865,14 @@ async function verifyStampEntry(
|
|
|
2035
1865
|
errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
|
|
2036
1866
|
return { result: base };
|
|
2037
1867
|
}
|
|
2038
|
-
// Gated by `isLowerHex` above
|
|
2039
|
-
// the exact witnessed bytes — §5.2.2's "one authority in this body".
|
|
1868
|
+
// Gated by `isLowerHex` above: already the exact witnessed bytes' hex (§5.2.2).
|
|
2040
1869
|
const witnessedHex: string = e.stamp_bytes;
|
|
2041
1870
|
|
|
2042
|
-
// --- the derived identity -------------------------------------------------
|
|
2043
1871
|
const derived = await sha256Hex(bytes);
|
|
2044
1872
|
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
2045
1873
|
errors.push("stamp entry missing stamp_ref");
|
|
2046
1874
|
} else if (e.stamp_ref.toLowerCase() !== derived) {
|
|
2047
|
-
//
|
|
2048
|
-
// and the bytes were not. Either way the bundle names a stamp it is not
|
|
2049
|
-
// carrying, and quoting that name to a witness would resolve to something
|
|
2050
|
-
// else entirely.
|
|
1875
|
+
// Bytes and label disagree: the bundle names a stamp it is not carrying.
|
|
2051
1876
|
errors.push(
|
|
2052
1877
|
`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`,
|
|
2053
1878
|
);
|
|
@@ -2062,7 +1887,6 @@ async function verifyStampEntry(
|
|
|
2062
1887
|
}
|
|
2063
1888
|
}
|
|
2064
1889
|
|
|
2065
|
-
// --- the body itself ------------------------------------------------------
|
|
2066
1890
|
let body: StampBody;
|
|
2067
1891
|
try {
|
|
2068
1892
|
const parsed: unknown = jcsParse(bytes);
|
|
@@ -2071,10 +1895,8 @@ async function verifyStampEntry(
|
|
|
2071
1895
|
return { result: base, witnessedHex };
|
|
2072
1896
|
}
|
|
2073
1897
|
body = parsed as StampBody;
|
|
2074
|
-
//
|
|
2075
|
-
//
|
|
2076
|
-
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
2077
|
-
// of the stamp disagree with the one that was witnessed.
|
|
1898
|
+
// Must re-canonicalize to itself: a non-canonical body makes every
|
|
1899
|
+
// independent re-derivation disagree with what was witnessed.
|
|
2078
1900
|
if (jcs(body as unknown as Record<string, unknown>) !== decodeUtf8Strict(bytes)) {
|
|
2079
1901
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
2080
1902
|
return { result: base, witnessedHex };
|
|
@@ -2085,10 +1907,8 @@ async function verifyStampEntry(
|
|
|
2085
1907
|
}
|
|
2086
1908
|
|
|
2087
1909
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
2088
|
-
//
|
|
2089
|
-
//
|
|
2090
|
-
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
2091
|
-
// how a reader half-parses a shape it does not know.
|
|
1910
|
+
// The inner version must match the body type: two answers inside one
|
|
1911
|
+
// signature are rejected.
|
|
2092
1912
|
errors.push(
|
|
2093
1913
|
`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
|
|
2094
1914
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
@@ -2103,7 +1923,7 @@ async function verifyStampEntry(
|
|
|
2103
1923
|
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
2104
1924
|
base.bodyOk = false;
|
|
2105
1925
|
} else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
|
|
2106
|
-
//
|
|
1926
|
+
// The promise window uses the form `signed_at` pins (§5.2).
|
|
2107
1927
|
errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
|
|
2108
1928
|
base.bodyOk = false;
|
|
2109
1929
|
}
|
|
@@ -2125,30 +1945,26 @@ async function verifyStampEntry(
|
|
|
2125
1945
|
base.leafCount = stampCount;
|
|
2126
1946
|
}
|
|
2127
1947
|
|
|
2128
|
-
// --- the sub-tree ranges --------------------------------------------------
|
|
2129
1948
|
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
2130
1949
|
|
|
2131
|
-
//
|
|
2132
|
-
//
|
|
2133
|
-
|
|
2134
|
-
|
|
1950
|
+
// No malformed/absent split is needed: the re-fetch leaves `onChain` null
|
|
1951
|
+
// whenever there is no `AnchorRef`.
|
|
1952
|
+
const clean = errors.length === 0;
|
|
1953
|
+
const parsedRef = parseAnchorRef(e.anchor_ref, errors);
|
|
1954
|
+
base.anchorRef = parsedRef.ref;
|
|
2135
1955
|
|
|
2136
1956
|
base.ok =
|
|
2137
1957
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
1958
|
+
base.anchorRefMalformed =
|
|
1959
|
+
parsedRef.malformed && clean && base.stampRefOk && base.bodyOk && base.subtreesOk;
|
|
2138
1960
|
return { result: base, witnessedHex };
|
|
2139
1961
|
}
|
|
2140
1962
|
|
|
2141
1963
|
/**
|
|
2142
|
-
* The range arithmetic (§5.2)
|
|
2143
|
-
*
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2146
|
-
* ranges partition `[0, leaf_count)` exactly, by construction. A body whose
|
|
2147
|
-
* ranges do not is either not a stamp this stack built or one built wrong, and
|
|
2148
|
-
* in both cases the per-agent claim the ranges exist to support is unfounded.
|
|
2149
|
-
*
|
|
2150
|
-
* The sum is checked BEFORE the tiling and reports its own message, so a body
|
|
2151
|
-
* that is merely short does not get filed under a gap it does not have.
|
|
1964
|
+
* The range arithmetic (§5.2). Leaves are agent-major, so the ranges must
|
|
1965
|
+
* partition `[0, leaf_count)` exactly, or the per-agent slice claims they
|
|
1966
|
+
* support are unfounded. The sum is checked before the tiling so a short body
|
|
1967
|
+
* is not reported as a gap.
|
|
2152
1968
|
*/
|
|
2153
1969
|
function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boolean {
|
|
2154
1970
|
const raw = body.subtrees;
|
|
@@ -2172,9 +1988,8 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
|
|
|
2172
1988
|
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
2173
1989
|
shapeOk = false;
|
|
2174
1990
|
}
|
|
2175
|
-
// A zero-leaf
|
|
2176
|
-
//
|
|
2177
|
-
// up.
|
|
1991
|
+
// A zero-leaf range is a slice of nothing, and empty ranges could pad a
|
|
1992
|
+
// tiling that still adds up.
|
|
2178
1993
|
if (subCount === null || subCount === 0) {
|
|
2179
1994
|
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
2180
1995
|
shapeOk = false;
|
|
@@ -2198,9 +2013,7 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
|
|
|
2198
2013
|
const total = out.leafCount;
|
|
2199
2014
|
if (total === null) return false; // already reported by the caller
|
|
2200
2015
|
|
|
2201
|
-
// Summed exactly
|
|
2202
|
-
// and a float sum past 2**53 prints a different number than Python's
|
|
2203
|
-
// unbounded one in the sentence below.
|
|
2016
|
+
// Summed exactly: the sum can pass 2**53 and must print as Python's does.
|
|
2204
2017
|
const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
|
|
2205
2018
|
if (sum !== BigInt(total)) {
|
|
2206
2019
|
errors.push(
|
|
@@ -2223,10 +2036,21 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
|
|
|
2223
2036
|
return true;
|
|
2224
2037
|
}
|
|
2225
2038
|
|
|
2039
|
+
// `sanning.stamp.trace/v1`: the Sanning-signed interval stamp (§5.2).
|
|
2040
|
+
//
|
|
2041
|
+
// A stamp has no producer signature and no second Sanning signature in the
|
|
2042
|
+
// bundle: "Sanning signed this" is the wrapper signature, and the witness on a
|
|
2043
|
+
// chain is what makes it evidence. So an unwitnessed stamp holds the bundle at
|
|
2044
|
+
// `partial`. Offline, this checks the stamp against its witnessed bytes:
|
|
2045
|
+
//
|
|
2046
|
+
// 1. `stamp_ref` is SHA-256 of the decoded bytes.
|
|
2047
|
+
// 2. The bytes parse and re-canonicalize to themselves under JCS.
|
|
2048
|
+
// 3. Their `stamp_version` is the one this body type pins.
|
|
2049
|
+
// 4. The sub-tree ranges tile the stamp's leaves exactly: per-agent slice
|
|
2050
|
+
// claims rest on that, and nothing else in the stack would notice.
|
|
2226
2051
|
async function verifyStampTrace(
|
|
2227
2052
|
b: EvidenceBundle,
|
|
2228
2053
|
specVersionOk: boolean,
|
|
2229
|
-
assertedStatus: string | null,
|
|
2230
2054
|
errors: string[],
|
|
2231
2055
|
options: VerifyEvidenceOptions,
|
|
2232
2056
|
): Promise<EvidenceBundleResult> {
|
|
@@ -2244,13 +2068,8 @@ async function verifyStampTrace(
|
|
|
2244
2068
|
witnessed.push(outcome.witnessedHex);
|
|
2245
2069
|
}
|
|
2246
2070
|
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2249
|
-
// The SAME stamps[] array §5.3 carries, so it gets the same capability. The
|
|
2250
|
-
// alternative — a stamp that can be re-fetched inside one body and not the
|
|
2251
|
-
// other — asks every consumer to remember which wrapper it is holding before
|
|
2252
|
-
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
2253
|
-
// exception a reader eventually gets wrong.
|
|
2071
|
+
// The optional on-chain re-fetch (§5.2.4), shared with §5.3 so `onChain:
|
|
2072
|
+
// null` means the same thing in both bodies.
|
|
2254
2073
|
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
|
|
2255
2074
|
stamps,
|
|
2256
2075
|
witnessed,
|
|
@@ -2258,38 +2077,31 @@ async function verifyStampTrace(
|
|
|
2258
2077
|
errors,
|
|
2259
2078
|
);
|
|
2260
2079
|
|
|
2261
|
-
//
|
|
2080
|
+
// Rollup.
|
|
2262
2081
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
2263
2082
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
2264
2083
|
|
|
2084
|
+
// A lookup that did not settle is said here and moves nothing (the witness line says it).
|
|
2085
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
2086
|
+
unavailable: gatewayUnavailable,
|
|
2087
|
+
kind: uncheckedKind,
|
|
2088
|
+
id: uncheckedId,
|
|
2089
|
+
});
|
|
2265
2090
|
let status: EvidenceStatus;
|
|
2266
2091
|
if (anyHardFailure) {
|
|
2267
2092
|
status = "failed";
|
|
2268
2093
|
} else if (stamps.length === 0) {
|
|
2269
|
-
//
|
|
2270
|
-
|
|
2271
|
-
// does not have.
|
|
2272
|
-
status = "partial";
|
|
2094
|
+
// Asserts nothing: not a failure, but never `verified`.
|
|
2095
|
+
status = "incomplete";
|
|
2273
2096
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
2274
|
-
} else if (
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
kind: uncheckedKind,
|
|
2281
|
-
id: uncheckedId,
|
|
2282
|
-
});
|
|
2283
|
-
if (anyUnwitnessed) {
|
|
2284
|
-
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
2285
|
-
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
2286
|
-
// about our coverage and never an accusation about their evidence. It is
|
|
2287
|
-
// also not `verified`, because for a stamp the witness is the entire
|
|
2288
|
-
// independent part — unlike an anchor-trace checkpoint, which still
|
|
2289
|
-
// carries a producer signature and an inclusion proof that stand on
|
|
2290
|
-
// their own offline.
|
|
2097
|
+
} else if (anyUnwitnessed) {
|
|
2098
|
+
status = "incomplete";
|
|
2099
|
+
{
|
|
2100
|
+
// A stamp with no `anchor_ref` is sealed and unwitnessed: a statement
|
|
2101
|
+
// about Sanning's coverage, never an accusation. Not `verified` either:
|
|
2102
|
+
// for a stamp the witness is the whole independent part.
|
|
2291
2103
|
errors.push(
|
|
2292
|
-
"one or more stamps carries no anchor_ref
|
|
2104
|
+
"one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody",
|
|
2293
2105
|
);
|
|
2294
2106
|
}
|
|
2295
2107
|
} else {
|
|
@@ -2305,31 +2117,19 @@ async function verifyStampTrace(
|
|
|
2305
2117
|
onChainChecked,
|
|
2306
2118
|
checkpoints: [],
|
|
2307
2119
|
events: [],
|
|
2308
|
-
assertedStatus,
|
|
2309
2120
|
stamps,
|
|
2310
2121
|
errors,
|
|
2311
2122
|
};
|
|
2312
2123
|
}
|
|
2313
2124
|
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2319
|
-
// anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
|
|
2320
|
-
// `verifyDisclosedContent` — because a second copy of Merkle verification is a
|
|
2321
|
-
// second place for it to drift.
|
|
2322
|
-
//
|
|
2323
|
-
// What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
|
|
2324
|
-
// to a stamps[] entry and walks its audit path to the root committed INSIDE
|
|
2325
|
-
// that stamp's witnessed bytes. There is no `merkle_root` field on the event to
|
|
2326
|
-
// read instead — repeating the root beside its own authoritative bytes would be
|
|
2327
|
-
// two answers to one question inside one signature (§5.1.2).
|
|
2328
|
-
// ===========================================================================
|
|
2125
|
+
// `sanning.stamp.evidence/v2`: both halves, bound (evidence-bundle.md §5.3).
|
|
2126
|
+
// The stamp half is `verifyStampEntry`; the event half reuses the anchor-trace
|
|
2127
|
+
// primitives. Only the join differs: an event resolves `inclusion.stamp_ref`
|
|
2128
|
+
// and walks to the root inside that stamp's witnessed bytes. There is no
|
|
2129
|
+
// `merkle_root` on the event, which would be a second answer (§5.1.2).
|
|
2329
2130
|
async function verifyStampEvidence(
|
|
2330
2131
|
b: EvidenceBundle,
|
|
2331
2132
|
specVersionOk: boolean,
|
|
2332
|
-
assertedStatus: string | null,
|
|
2333
2133
|
errors: string[],
|
|
2334
2134
|
options: VerifyEvidenceOptions,
|
|
2335
2135
|
): Promise<EvidenceBundleResult> {
|
|
@@ -2342,7 +2142,7 @@ async function verifyStampEvidence(
|
|
|
2342
2142
|
);
|
|
2343
2143
|
}
|
|
2344
2144
|
|
|
2345
|
-
//
|
|
2145
|
+
// The stamp half.
|
|
2346
2146
|
const seen = new Set<string>();
|
|
2347
2147
|
const stamps: StampResult[] = [];
|
|
2348
2148
|
const witnessed: (string | undefined)[] = [];
|
|
@@ -2352,31 +2152,32 @@ async function verifyStampEvidence(
|
|
|
2352
2152
|
witnessed.push(outcome.witnessedHex);
|
|
2353
2153
|
}
|
|
2354
2154
|
|
|
2355
|
-
//
|
|
2356
|
-
//
|
|
2357
|
-
// verification is not a root anyone should walk a proof to, and admitting one
|
|
2358
|
-
// would let a broken stamp lend its authority to an event.
|
|
2155
|
+
// Only a stamp whose derived ref and witnessed bytes both checked out is
|
|
2156
|
+
// joinable: a failed stamp must not lend its root to an event.
|
|
2359
2157
|
const byRef = new Map<string, StampResult>();
|
|
2360
2158
|
for (const st of stamps) {
|
|
2361
2159
|
if (st.stampRef && st.stampRefOk && st.bodyOk) byRef.set(st.stampRef, st);
|
|
2362
2160
|
}
|
|
2161
|
+
// Every ref a stamp entry claims or derives. A ref outside it names a stamp the pack
|
|
2162
|
+
// does not hold, which is a stamp missing, never a stamp proven wrong (section 5.3.3
|
|
2163
|
+
// rule 1).
|
|
2164
|
+
const heldRefs = new Set<string>();
|
|
2165
|
+
for (const st of stamps) if (st.stampRef) heldRefs.add(st.stampRef);
|
|
2166
|
+
for (const raw of body.stamps as unknown[]) {
|
|
2167
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
2168
|
+
const claimed = (raw as Record<string, unknown>).stamp_ref;
|
|
2169
|
+
if (typeof claimed === "string") heldRefs.add(claimed);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2363
2172
|
|
|
2364
|
-
//
|
|
2173
|
+
// The event half.
|
|
2365
2174
|
const eventResults: EventResult[] = [];
|
|
2175
|
+
const agentItems: (AgentHeldRecord | null)[] = [];
|
|
2366
2176
|
for (const rawEv of body.events) {
|
|
2367
2177
|
const evErrors: string[] = [];
|
|
2368
|
-
//
|
|
2369
|
-
// stack trace instead of a verdict cannot tell a malformed pack from a
|
|
2370
|
-
// broken tool. `events[]` is attacker-shaped, so a null/non-object entry
|
|
2371
|
-
// is coerced to an empty event and fails through the normal path, exactly
|
|
2372
|
-
// as the Python kernel does. Found by adversarial review, not by the
|
|
2373
|
-
// cross-kernel leg — whose cases did not include one, and now do.
|
|
2178
|
+
// `events[]` is attacker-shaped; a verifier never raises on it.
|
|
2374
2179
|
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
2375
|
-
//
|
|
2376
|
-
// empty one: probing verifyEnvelope with nothing asks the envelope layer
|
|
2377
|
-
// a question about an object that was never an envelope, and the two
|
|
2378
|
-
// kernels answer it differently (undetermined vs failed) — a real
|
|
2379
|
-
// divergence, but not this body's to resolve. Reported separately.
|
|
2180
|
+
// Graded as not an event, never coerced to an empty one.
|
|
2380
2181
|
eventResults.push({
|
|
2381
2182
|
eventId: "<unknown>",
|
|
2382
2183
|
envelopeOk: false,
|
|
@@ -2386,34 +2187,23 @@ async function verifyStampEvidence(
|
|
|
2386
2187
|
contentOk: null,
|
|
2387
2188
|
ok: false,
|
|
2388
2189
|
errors: ["events[] entry is not an object"],
|
|
2190
|
+
agent: null,
|
|
2389
2191
|
});
|
|
2192
|
+
agentItems.push(null);
|
|
2390
2193
|
continue;
|
|
2391
2194
|
}
|
|
2392
2195
|
const ev = rawEv as StampEvidenceEvent;
|
|
2393
2196
|
const eventId = getEventId(ev.envelope);
|
|
2394
2197
|
let envelopeOk = false;
|
|
2198
|
+
let envelopeFaults: EnvelopeFault[] = [];
|
|
2395
2199
|
let payloadBindingOk: boolean | null = null;
|
|
2396
2200
|
|
|
2397
|
-
// A retired field name
|
|
2398
|
-
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
2201
|
+
// A retired field name is a reject: ignored, it would read as withheld.
|
|
2399
2202
|
const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
|
|
2400
2203
|
if (legacyError !== null) evErrors.push(legacyError);
|
|
2401
2204
|
|
|
2402
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
2405
|
-
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
2406
|
-
// line down. So an event carrying garbage in place of its record used to
|
|
2407
|
-
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
2408
|
-
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
2409
|
-
// a record nobody could read. The only thing wrong with the pack was the
|
|
2410
|
-
// bytes, and it was the one thing the verdict did not say.
|
|
2411
|
-
//
|
|
2412
|
-
// A record the producer chose not to disclose is the customer exercising
|
|
2413
|
-
// minimum disclosure. A record that is present and unreadable is a defect
|
|
2414
|
-
// in the artifact, and this kernel's standing rule (the same one
|
|
2415
|
-
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
2416
|
-
// we can see fails.
|
|
2205
|
+
// Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
|
|
2206
|
+
// would otherwise look exactly like a withheld record and verify.
|
|
2417
2207
|
let eventBytes: Uint8Array | undefined;
|
|
2418
2208
|
let eventBytesMalformed = false;
|
|
2419
2209
|
try {
|
|
@@ -2422,10 +2212,10 @@ async function verifyStampEvidence(
|
|
|
2422
2212
|
eventBytesMalformed = true;
|
|
2423
2213
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
2424
2214
|
}
|
|
2425
|
-
const
|
|
2426
|
-
if (
|
|
2215
|
+
const notJson = recordNotJson(eventBytes);
|
|
2216
|
+
if (notJson !== undefined) {
|
|
2427
2217
|
eventBytesMalformed = true;
|
|
2428
|
-
evErrors.push(
|
|
2218
|
+
evErrors.push(notJson);
|
|
2429
2219
|
}
|
|
2430
2220
|
try {
|
|
2431
2221
|
const res = await verifyEnvelope(
|
|
@@ -2433,16 +2223,12 @@ async function verifyStampEvidence(
|
|
|
2433
2223
|
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
2434
2224
|
);
|
|
2435
2225
|
const hexError = envelopeHexError("event", ev.envelope);
|
|
2436
|
-
//
|
|
2226
|
+
// Tolerates an undetermined binding (withheld record).
|
|
2437
2227
|
envelopeOk = res.ok && hexError === null;
|
|
2228
|
+
envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
|
|
2438
2229
|
payloadBindingOk = res.payloadHashOk;
|
|
2439
|
-
//
|
|
2440
|
-
//
|
|
2441
|
-
// binding was attempted. "signature failed" named a check that never ran,
|
|
2442
|
-
// and it was the only thing said about an event whose undetermined
|
|
2443
|
-
// binding then rendered as a WITHHELD record, a claim about the
|
|
2444
|
-
// producer's disclosure choice out of an input that says nothing about
|
|
2445
|
-
// the producer at all (proof#76).
|
|
2230
|
+
// No envelope, so no signature was checked: report it as malformed, not as
|
|
2231
|
+
// a failed signature or a withheld record.
|
|
2446
2232
|
if (!isJsonObject(ev.envelope)) {
|
|
2447
2233
|
evErrors.push("event envelope is not a JSON object");
|
|
2448
2234
|
} else if (hexError !== null) {
|
|
@@ -2451,24 +2237,23 @@ async function verifyStampEvidence(
|
|
|
2451
2237
|
evErrors.push("event envelope signature failed");
|
|
2452
2238
|
}
|
|
2453
2239
|
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
2454
|
-
//
|
|
2455
|
-
// and binding (proof#95), so without this the event fails with no reason.
|
|
2240
|
+
// Name a malformed skeleton field, which fails an otherwise valid envelope.
|
|
2456
2241
|
for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
|
|
2242
|
+
if (res.errors.some((m) => m.startsWith("missing payload_hash"))) evErrors.push("event envelope missing payload_hash");
|
|
2457
2243
|
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
2458
2244
|
if (specFinding !== null) evErrors.push(specFinding);
|
|
2459
2245
|
} catch (e) {
|
|
2460
2246
|
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
2461
2247
|
}
|
|
2462
2248
|
|
|
2463
|
-
//
|
|
2249
|
+
// The join (§5.3.3 rule 1).
|
|
2464
2250
|
const incl = ev.inclusion as (StampEvidenceInclusion & Record<string, unknown>) | undefined;
|
|
2465
2251
|
let st: StampResult | undefined;
|
|
2252
|
+
let stampAbsent = false;
|
|
2466
2253
|
if (!incl || typeof incl !== "object") {
|
|
2467
2254
|
evErrors.push("event missing inclusion proof");
|
|
2468
2255
|
} else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
|
|
2469
|
-
//
|
|
2470
|
-
// join key inside this body is a mixed bundle, and resolving it anyway is
|
|
2471
|
-
// how an event silently binds to something nobody meant (§5.1.2).
|
|
2256
|
+
// An anchor-trace join key inside this body is a mixed bundle (§5.1.2).
|
|
2472
2257
|
evErrors.push(
|
|
2473
2258
|
`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`,
|
|
2474
2259
|
);
|
|
@@ -2476,9 +2261,14 @@ async function verifyStampEvidence(
|
|
|
2476
2261
|
evErrors.push("event inclusion missing stamp_ref");
|
|
2477
2262
|
} else {
|
|
2478
2263
|
st = byRef.get(incl.stamp_ref);
|
|
2479
|
-
if (st === undefined) {
|
|
2480
|
-
//
|
|
2481
|
-
//
|
|
2264
|
+
if (st === undefined && !heldRefs.has(incl.stamp_ref)) {
|
|
2265
|
+
// The pack does not hold the stamp the record names: the inclusion has no root to
|
|
2266
|
+
// check against, so `witnessed` is not proven (`missing`).
|
|
2267
|
+
stampAbsent = true;
|
|
2268
|
+
evErrors.push(
|
|
2269
|
+
`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} names a stamp this pack does not hold`,
|
|
2270
|
+
);
|
|
2271
|
+
} else if (st === undefined) {
|
|
2482
2272
|
evErrors.push(
|
|
2483
2273
|
`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`,
|
|
2484
2274
|
);
|
|
@@ -2488,15 +2278,9 @@ async function verifyStampEvidence(
|
|
|
2488
2278
|
|
|
2489
2279
|
let inclusionOk = false;
|
|
2490
2280
|
if (incl && stampBound && st) {
|
|
2491
|
-
// Rule 3: the tree walked must be the tree the stamp committed to
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
// Read BY VALUE, as the anchor-trace path reads them (A38), and as the
|
|
2496
|
-
// Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
|
|
2497
|
-
// which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
|
|
2498
|
-
// signature. Anything that is not a safe integer is malformed, with a
|
|
2499
|
-
// sentence that prints no value.
|
|
2281
|
+
// Rule 3: the tree walked must be the tree the stamp committed to; RFC
|
|
2282
|
+
// 9162's path shape depends on the size. Read by value, as JCS signs it
|
|
2283
|
+
// (`3.0` is 3); anything not a safe integer is malformed.
|
|
2500
2284
|
const statedCount = safeInteger(incl.leaf_count);
|
|
2501
2285
|
const statedIndex = safeInteger(incl.leaf_index);
|
|
2502
2286
|
if (statedCount === null) {
|
|
@@ -2521,9 +2305,7 @@ async function verifyStampEvidence(
|
|
|
2521
2305
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
2522
2306
|
}
|
|
2523
2307
|
|
|
2524
|
-
//
|
|
2525
|
-
// own signed envelope bytes, or a valid proof for an UNRELATED leaf
|
|
2526
|
-
// could be smuggled in.
|
|
2308
|
+
// As §5.1: the claimed leaf must be this event's own signed envelope.
|
|
2527
2309
|
const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
|
|
2528
2310
|
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
2529
2311
|
inclusionOk = false;
|
|
@@ -2540,17 +2322,19 @@ async function verifyStampEvidence(
|
|
|
2540
2322
|
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
2541
2323
|
const contentOk = contentRes.contentOk;
|
|
2542
2324
|
evErrors.push(...contentRes.errors);
|
|
2325
|
+
agentItems.push(
|
|
2326
|
+
agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed),
|
|
2327
|
+
);
|
|
2543
2328
|
|
|
2544
2329
|
eventResults.push({
|
|
2545
2330
|
eventId,
|
|
2546
2331
|
envelopeOk,
|
|
2547
2332
|
payloadBindingOk,
|
|
2548
2333
|
inclusionOk,
|
|
2549
|
-
//
|
|
2334
|
+
// Here: whether `stamp_ref` resolved.
|
|
2550
2335
|
checkpointBound: stampBound,
|
|
2551
2336
|
contentOk,
|
|
2552
|
-
//
|
|
2553
|
-
// defect in the artifact, never a disclosure choice (proof#85).
|
|
2337
|
+
// Unreadable record bytes are a defect, never a disclosure choice.
|
|
2554
2338
|
ok:
|
|
2555
2339
|
envelopeOk &&
|
|
2556
2340
|
inclusionOk &&
|
|
@@ -2559,10 +2343,19 @@ async function verifyStampEvidence(
|
|
|
2559
2343
|
legacyError === null &&
|
|
2560
2344
|
!eventBytesMalformed,
|
|
2561
2345
|
errors: evErrors,
|
|
2346
|
+
agent: null,
|
|
2347
|
+
envelopeFault: envelopeFaults[0] ?? null,
|
|
2348
|
+
envelopeFaults,
|
|
2349
|
+
recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
|
|
2350
|
+
contentUnsigned: contentOk === false && contentRes.unsigned,
|
|
2351
|
+
stampAbsent,
|
|
2352
|
+
ownFormFailed: legacyError !== null || eventBytesMalformed,
|
|
2562
2353
|
});
|
|
2563
2354
|
}
|
|
2564
2355
|
|
|
2565
|
-
|
|
2356
|
+
await attachAgentReadings(eventResults, agentItems);
|
|
2357
|
+
|
|
2358
|
+
// The optional on-chain re-fetch (§5.2.4).
|
|
2566
2359
|
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
|
|
2567
2360
|
stamps,
|
|
2568
2361
|
witnessed,
|
|
@@ -2570,49 +2363,39 @@ async function verifyStampEvidence(
|
|
|
2570
2363
|
errors,
|
|
2571
2364
|
);
|
|
2572
2365
|
|
|
2573
|
-
//
|
|
2574
|
-
//
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
|
|
2366
|
+
// Rollup. Rule 4: a declared event must be checked, so an event that did
|
|
2367
|
+
// not verify fails the bundle.
|
|
2368
|
+
const anyHardFailure =
|
|
2369
|
+
stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok && !unproven(e));
|
|
2578
2370
|
const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
|
|
2371
|
+
const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
|
|
2579
2372
|
|
|
2373
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
2374
|
+
unavailable: gatewayUnavailable,
|
|
2375
|
+
kind: uncheckedKind,
|
|
2376
|
+
id: uncheckedId,
|
|
2377
|
+
});
|
|
2580
2378
|
let status: EvidenceStatus;
|
|
2581
2379
|
if (anyHardFailure) {
|
|
2582
2380
|
status = "failed";
|
|
2583
2381
|
} else if (stamps.length === 0 && eventResults.length === 0) {
|
|
2584
|
-
status = "
|
|
2382
|
+
status = "incomplete";
|
|
2585
2383
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
2586
|
-
} else if (
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
uncheckedId ||
|
|
2591
|
-
anyUnwitnessed
|
|
2592
|
-
) {
|
|
2593
|
-
// Each holds the bundle at `partial`, and EACH gets said. A pack with no
|
|
2594
|
-
// events, a stamp nobody witnessed and a witness we could not reach are
|
|
2595
|
-
// different gaps with different fixes, and an auditor told only about one
|
|
2596
|
-
// would read the others as fine. The order is fixed so the two kernels
|
|
2597
|
-
// emit the same list.
|
|
2598
|
-
status = "partial";
|
|
2384
|
+
} else if (eventResults.length === 0 || anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
|
|
2385
|
+
// Each gap holds the bundle at `incomplete` and each is named, in a fixed order
|
|
2386
|
+
// so both kernels emit the same list.
|
|
2387
|
+
status = "incomplete";
|
|
2599
2388
|
if (eventResults.length === 0) {
|
|
2600
|
-
//
|
|
2601
|
-
//
|
|
2389
|
+
// Section 5.3.2: an events-empty body is a section 5.2 stamp trace: incomplete, never
|
|
2390
|
+
// verified.
|
|
2602
2391
|
errors.push(
|
|
2603
2392
|
`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`,
|
|
2604
2393
|
);
|
|
2605
2394
|
}
|
|
2606
|
-
pushRefetchGapNotes(errors, "stamps", {
|
|
2607
|
-
unavailable: gatewayUnavailable,
|
|
2608
|
-
kind: uncheckedKind,
|
|
2609
|
-
id: uncheckedId,
|
|
2610
|
-
});
|
|
2611
2395
|
if (anyUnwitnessed) {
|
|
2612
|
-
//
|
|
2613
|
-
// the independent part, so the events resting on it inherit `partial`.
|
|
2396
|
+
// Section 5.3.3 rule 6: events resting on an unwitnessed stamp inherit `incomplete`.
|
|
2614
2397
|
errors.push(
|
|
2615
|
-
"one or more stamps carries no anchor_ref
|
|
2398
|
+
"one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody",
|
|
2616
2399
|
);
|
|
2617
2400
|
}
|
|
2618
2401
|
} else {
|
|
@@ -2628,7 +2411,6 @@ async function verifyStampEvidence(
|
|
|
2628
2411
|
onChainChecked,
|
|
2629
2412
|
checkpoints: [],
|
|
2630
2413
|
events: eventResults,
|
|
2631
|
-
assertedStatus,
|
|
2632
2414
|
stamps,
|
|
2633
2415
|
errors,
|
|
2634
2416
|
};
|
|
@@ -2636,37 +2418,10 @@ async function verifyStampEvidence(
|
|
|
2636
2418
|
|
|
2637
2419
|
/**
|
|
2638
2420
|
* The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
|
|
2639
|
-
*
|
|
2640
|
-
*
|
|
2641
|
-
*
|
|
2642
|
-
*
|
|
2643
|
-
* carry these outcomes would make a stamp indistinguishable from a checkpoint
|
|
2644
|
-
* in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
|
|
2645
|
-
* and §5.3.1 refused twice, most sharply in "a Sanning attestation
|
|
2646
|
-
* indistinguishable on the wire from a producer's". They are different
|
|
2647
|
-
* artifacts with different signers and different meanings when absent, and a
|
|
2648
|
-
* reader must never have to guess which one it is holding. So the stamp
|
|
2649
|
-
* results go on the stamps, and `checkpoints: []` keeps saying the true thing:
|
|
2650
|
-
* this body has none.
|
|
2651
|
-
*
|
|
2652
|
-
* ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
|
|
2653
|
-
*
|
|
2654
|
-
* The anchor-trace path synthesises an all-`unreachable` result for a
|
|
2655
|
-
* checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
|
|
2656
|
-
*
|
|
2657
|
-
* It would be untrue: nothing was asked of any gateway, so reporting that
|
|
2658
|
-
* every gateway failed to answer invents a network event. "Not checked" and
|
|
2659
|
-
* "we asked and nobody answered" are different facts, and collapsing them is
|
|
2660
|
-
* precisely the defect that had a surface reporting an unreachable gateway
|
|
2661
|
-
* when it had contacted none.
|
|
2662
|
-
*
|
|
2663
|
-
* And it would be load-bearing in the wrong direction. Over there the
|
|
2664
|
-
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
2665
|
-
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
2666
|
-
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
2667
|
-
* nothing to the status, which is `partial` either way, and would add an
|
|
2668
|
-
* unreachable-gateway note for a network event that never happened, based on
|
|
2669
|
-
* nothing but whether the caller happened to pass a gateway list.
|
|
2421
|
+
* Results land on the stamps and `checkpoints` stays empty: a stamp must never
|
|
2422
|
+
* be mistaken for a producer checkpoint. An unwitnessed stamp gets `onChain:
|
|
2423
|
+
* null`, not a synthesized all-unreachable result: no gateway was asked, and
|
|
2424
|
+
* §5.2.4 already holds it at `partial`.
|
|
2670
2425
|
*/
|
|
2671
2426
|
async function refetchStamps(
|
|
2672
2427
|
stamps: StampResult[],
|
|
@@ -2693,8 +2448,7 @@ async function refetchStamps(
|
|
|
2693
2448
|
for (let i = 0; i < stamps.length; i++) {
|
|
2694
2449
|
const st = stamps[i]!;
|
|
2695
2450
|
const witnessedHex = witnessed[i];
|
|
2696
|
-
// No witness named, or bytes
|
|
2697
|
-
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
2451
|
+
// No witness named, or unreadable bytes: nothing to re-fetch.
|
|
2698
2452
|
if (st.anchorRef === null || witnessedHex === undefined) continue;
|
|
2699
2453
|
if (st.anchorRef.kind === SIMULATED_WITNESS_KIND) simulated++;
|
|
2700
2454
|
else onChainKinds++;
|
|
@@ -2706,22 +2460,13 @@ async function refetchStamps(
|
|
|
2706
2460
|
if (outcome.unchecked === "kind") uncheckedKind = true;
|
|
2707
2461
|
if (outcome.unchecked === "id") uncheckedId = true;
|
|
2708
2462
|
if (outcome.error) st.errors.push(outcome.error);
|
|
2709
|
-
//
|
|
2710
|
-
//
|
|
2711
|
-
//
|
|
2712
|
-
// downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
|
|
2713
|
-
// changes nothing that was not already true offline.
|
|
2714
|
-
//
|
|
2715
|
-
// `st.ok` is kept and narrowed rather than recomputed: its offline form
|
|
2716
|
-
// includes `errors.length === 0`, and re-evaluating that AFTER pushing an
|
|
2717
|
-
// unreachable note would fail a stamp for a gateway being down.
|
|
2463
|
+
// As for a checkpoint: a mismatch fails the stamp, unreachable downgrades
|
|
2464
|
+
// the run to `partial`. Narrowed, not recomputed, so an unreachable note
|
|
2465
|
+
// does not fail the stamp.
|
|
2718
2466
|
st.ok = st.ok && st.onChainOk !== false;
|
|
2719
2467
|
}
|
|
2720
|
-
// A simulated witness
|
|
2721
|
-
//
|
|
2722
|
-
// It stays not checked like any kind this verifier has no adapter for, and
|
|
2723
|
-
// the run stays undetermined; what changes is that a run whose only
|
|
2724
|
-
// witnesses are simulated no longer says it checked anything on a chain.
|
|
2468
|
+
// A simulated witness has no chain to look up, so it is never counted as
|
|
2469
|
+
// checked on one; the run stays undetermined.
|
|
2725
2470
|
if (simulated > 0 && onChainKinds === 0) {
|
|
2726
2471
|
return { onChainChecked: false, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
2727
2472
|
}
|
|
@@ -2742,78 +2487,54 @@ interface StampRefetchSummary {
|
|
|
2742
2487
|
uncheckedId: boolean;
|
|
2743
2488
|
}
|
|
2744
2489
|
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
// the actual log bytes back had to know that and decode them itself —
|
|
2751
|
-
// re-implementing an encoding convention the kernel already applies internally.
|
|
2752
|
-
// A consumer that copied `content` VERBATIM into a file shipped packs that
|
|
2753
|
-
// failed verification on genuine, untampered evidence, because the hash of the
|
|
2754
|
-
// hex text is not the hash of the bytes it encodes.
|
|
2755
|
-
//
|
|
2756
|
-
// `disclosedContent` closes that gap with one rule: bytes come back only when
|
|
2757
|
-
// they are BOUND. It verifies the bundle first and hands back a decoded
|
|
2758
|
-
// Uint8Array only for events whose whole chain held AND whose disclosed bytes
|
|
2759
|
-
// hash to the committed `content_hash`. Everything else comes back as a status
|
|
2760
|
-
// with a reason and NO bytes — in particular a minimal-disclosure event reports
|
|
2761
|
-
// `not_disclosed` rather than empty bytes, because a caller that cannot tell
|
|
2762
|
-
// those apart writes an empty log file and believes it succeeded.
|
|
2763
|
-
// ===========================================================================
|
|
2490
|
+
// Ingest (evidence-bundle.md §5.1): `events[].content` rides as hex, and
|
|
2491
|
+
// `disclosedContent` hands back decoded bytes only when they are bound: the
|
|
2492
|
+
// event's chain held and the bytes hash to the committed `content_hash`.
|
|
2493
|
+
// Everything else returns a status with a reason and no bytes; minimal
|
|
2494
|
+
// disclosure is `not_disclosed`, never empty bytes.
|
|
2764
2495
|
|
|
2765
2496
|
// Per-event outcome of an ingest attempt. Only `verified` carries bytes.
|
|
2766
2497
|
export type DisclosureStatus =
|
|
2767
|
-
| "verified" //
|
|
2768
|
-
| "not_disclosed" // nothing disclosed
|
|
2769
|
-
| "undetermined" //
|
|
2770
|
-
| "failed"; // the event
|
|
2498
|
+
| "verified" // decoded and bound to the committed content_hash
|
|
2499
|
+
| "not_disclosed" // nothing disclosed: minimal disclosure
|
|
2500
|
+
| "undetermined" // disclosed, but nothing to bind to
|
|
2501
|
+
| "failed"; // the event did not verify, or the disclosure did not bind or decode
|
|
2771
2502
|
|
|
2772
2503
|
export interface DisclosedEventContent {
|
|
2773
2504
|
eventId: string;
|
|
2774
2505
|
status: DisclosureStatus;
|
|
2775
|
-
//
|
|
2776
|
-
// unbound or unverified disclosure is withheld, never handed back.
|
|
2506
|
+
// Non-null only when status is "verified": unbound bytes are never handed back.
|
|
2777
2507
|
bytes: Uint8Array | null;
|
|
2778
|
-
// Why,
|
|
2508
|
+
// Why, when bytes are absent for a reason other than non-disclosure.
|
|
2779
2509
|
errors: string[];
|
|
2780
2510
|
}
|
|
2781
2511
|
|
|
2782
2512
|
export interface DisclosedContentResult {
|
|
2783
2513
|
// The bundle-level rollup, verbatim from verifyEvidenceBundle.
|
|
2784
2514
|
status: EvidenceStatus;
|
|
2785
|
-
// The full
|
|
2786
|
-
// from this and materialize from `content` in the same pass.
|
|
2515
|
+
// The full result, so a caller verifies once and renders from it.
|
|
2787
2516
|
verification: EvidenceBundleResult;
|
|
2788
2517
|
// One entry per event in the bundle body, in body order.
|
|
2789
2518
|
events: DisclosedEventContent[];
|
|
2790
|
-
// event_id
|
|
2791
|
-
//
|
|
2792
|
-
// result.content)` is always safe to write straight to disk.
|
|
2519
|
+
// event_id to verified bytes. Only "verified" entries, so it is always safe
|
|
2520
|
+
// to write straight to disk.
|
|
2793
2521
|
content: Map<string, Uint8Array>;
|
|
2794
2522
|
// Bundle-level reasons no content could be materialized at all.
|
|
2795
2523
|
errors: string[];
|
|
2796
2524
|
}
|
|
2797
2525
|
|
|
2798
|
-
// How many
|
|
2799
|
-
//
|
|
2800
|
-
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
2801
|
-
// independently bounded rather than trusting that it ran first.
|
|
2526
|
+
// How many export wrappers deep the source bundle may nest. Bounded here
|
|
2527
|
+
// independently of the canonicalization depth bound.
|
|
2802
2528
|
const MAX_EXPORT_UNWRAP = 4;
|
|
2803
2529
|
|
|
2804
2530
|
/**
|
|
2805
|
-
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle
|
|
2806
|
-
*
|
|
2807
|
-
*
|
|
2808
|
-
*
|
|
2809
|
-
* bytes are returned ONLY when that verification bound them to the committed
|
|
2810
|
-
* `content_hash`. There is no path through this function that yields bytes
|
|
2811
|
-
* whose SHA-256 is not the commitment.
|
|
2531
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle.md
|
|
2532
|
+
* §5.1). The bundle is verified with {@link verifyEvidenceBundle}, and an
|
|
2533
|
+
* event's bytes are returned only when that verification bound them to the
|
|
2534
|
+
* committed `content_hash`.
|
|
2812
2535
|
*
|
|
2813
|
-
* Returns `Uint8Array`, never a string:
|
|
2814
|
-
*
|
|
2815
|
-
* such a string would produce different bytes and a different hash. Callers who
|
|
2816
|
-
* know their payload is text decode it themselves and own that assumption.
|
|
2536
|
+
* Returns `Uint8Array`, never a string: a UTF-8 decode is lossy and would
|
|
2537
|
+
* change the hash.
|
|
2817
2538
|
*/
|
|
2818
2539
|
export async function disclosedContent(
|
|
2819
2540
|
bundle: unknown,
|
|
@@ -2841,11 +2562,9 @@ export async function disclosedContent(
|
|
|
2841
2562
|
return empty();
|
|
2842
2563
|
}
|
|
2843
2564
|
|
|
2844
|
-
// Fail closed
|
|
2845
|
-
//
|
|
2846
|
-
//
|
|
2847
|
-
// only guarantee this function makes. A broken wrapper lands here too:
|
|
2848
|
-
// verifyEvidenceBundle returns zero event results, so nothing is emitted.
|
|
2565
|
+
// Fail closed unless the decoded body and the trusted results are the same
|
|
2566
|
+
// events in the same order. A broken wrapper yields zero results, so nothing
|
|
2567
|
+
// is emitted.
|
|
2849
2568
|
if (traceEvents.length !== verification.events.length) {
|
|
2850
2569
|
errors.push(
|
|
2851
2570
|
"the bundle did not verify far enough to materialize content: " +
|
|
@@ -2874,18 +2593,16 @@ export async function disclosedContent(
|
|
|
2874
2593
|
let status: DisclosureStatus;
|
|
2875
2594
|
|
|
2876
2595
|
if (!res.ok || res.contentOk === false) {
|
|
2877
|
-
// The event
|
|
2878
|
-
// unbound checkpoint) or its disclosure contradicts the commitment.
|
|
2596
|
+
// The event is not trustworthy, or its disclosure contradicts the commitment.
|
|
2879
2597
|
status = "failed";
|
|
2880
2598
|
evErrors.push(
|
|
2881
2599
|
res.contentOk === false
|
|
2882
|
-
? "disclosed content does not bind to the committed content_hash
|
|
2883
|
-
: "the event's own proof chain did not verify
|
|
2600
|
+
? "disclosed content does not bind to the committed content_hash; withheld"
|
|
2601
|
+
: "the event's own proof chain did not verify; nothing here is safe to materialize",
|
|
2884
2602
|
);
|
|
2885
2603
|
evErrors.push(...res.errors);
|
|
2886
2604
|
} else if (!selection.offered) {
|
|
2887
|
-
// Minimal disclosure:
|
|
2888
|
-
// Distinct from empty bytes, and never an error.
|
|
2605
|
+
// Minimal disclosure: distinct from empty bytes, and never an error.
|
|
2889
2606
|
status = "not_disclosed";
|
|
2890
2607
|
} else if (bytes === undefined) {
|
|
2891
2608
|
status = "failed";
|
|
@@ -2894,12 +2611,10 @@ export async function disclosedContent(
|
|
|
2894
2611
|
);
|
|
2895
2612
|
evErrors.push(...selection.errors);
|
|
2896
2613
|
} else if (res.contentOk !== true) {
|
|
2897
|
-
//
|
|
2898
|
-
// (withheld record, or a record committing no content_hash). Unbound
|
|
2899
|
-
// bytes are exactly what a consumer must not silently write out.
|
|
2614
|
+
// Nothing to check the bytes against: unbound bytes must not be written.
|
|
2900
2615
|
status = "undetermined";
|
|
2901
2616
|
evErrors.push(
|
|
2902
|
-
"disclosed bytes are not bound to any committed content_hash
|
|
2617
|
+
"disclosed bytes are not bound to any committed content_hash; withheld rather than returned",
|
|
2903
2618
|
);
|
|
2904
2619
|
evErrors.push(...res.errors);
|
|
2905
2620
|
} else {
|
|
@@ -2911,7 +2626,7 @@ export async function disclosedContent(
|
|
|
2911
2626
|
if (content.has(res.eventId)) {
|
|
2912
2627
|
errors.push(
|
|
2913
2628
|
`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
|
|
2914
|
-
"occurrence
|
|
2629
|
+
"occurrence; read events[] for every event",
|
|
2915
2630
|
);
|
|
2916
2631
|
} else {
|
|
2917
2632
|
content.set(res.eventId, bytes);
|
|
@@ -2928,35 +2643,18 @@ export async function disclosedContent(
|
|
|
2928
2643
|
return { status: verification.status, verification, events, content, errors };
|
|
2929
2644
|
}
|
|
2930
2645
|
|
|
2931
|
-
// The events a bundle carries,
|
|
2932
|
-
//
|
|
2933
|
-
//
|
|
2934
|
-
//
|
|
2935
|
-
//
|
|
2936
|
-
//
|
|
2937
|
-
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
2938
|
-
// `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
|
|
2939
|
-
// event shape.
|
|
2940
|
-
//
|
|
2941
|
-
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
2942
|
-
// again: two answers to "which events is this bundle actually about" inside one
|
|
2943
|
-
// kernel is two implementations of one rule, and the one that drifted would be
|
|
2944
|
-
// the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
|
|
2945
|
-
// same posture as `side-content.ts`, internal and shared.
|
|
2646
|
+
// The events a bundle carries, through any export wrappers to the inline
|
|
2647
|
+
// source, as `verifyEvidenceBundle` recurses, so they line up with its results.
|
|
2648
|
+
// Both event-carrying bodies (anchor trace and §5.3 stamp evidence). `undefined`
|
|
2649
|
+
// when the bundle carries no events this reader understands. Exported for
|
|
2650
|
+
// `chain.ts`, so there is one answer to which events a bundle is about; not
|
|
2651
|
+
// re-exported from `index.ts`.
|
|
2946
2652
|
export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
2947
2653
|
let cur: unknown = bundle;
|
|
2948
2654
|
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
2949
2655
|
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
|
|
2950
2656
|
const b = cur as EvidenceBundle;
|
|
2951
2657
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
2952
|
-
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
2953
|
-
// `sanning.stamp.evidence/v2` presents the SAME event shape (a
|
|
2954
|
-
// producer-signed envelope, optional `event_bytes`, an inclusion proof),
|
|
2955
|
-
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
2956
|
-
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
2957
|
-
// zero chains on every pack, however well-formed the links inside its
|
|
2958
|
-
// records were, so "is it in order?" read as NOT CHECKED on the one shape
|
|
2959
|
-
// that matters. The writers have been emitting those links the whole time.
|
|
2960
2658
|
if (
|
|
2961
2659
|
bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
2962
2660
|
bodyType === STAMP_EVIDENCE_BODY_TYPE
|
|
@@ -2973,26 +2671,20 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
|
2973
2671
|
return undefined;
|
|
2974
2672
|
}
|
|
2975
2673
|
|
|
2976
|
-
//
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
// + body_hash) has already verified (steps 1–2). This composes the existing
|
|
2980
|
-
// evidence path (recompute the inline source verdict) with the one new
|
|
2981
|
-
// primitive (embedded RSA-PSS operator attestations, verifyRsaPssSha256).
|
|
2982
|
-
// ===========================================================================
|
|
2674
|
+
// Attested-evidence-export verification (evidence-export.md §5, steps 3 to 9),
|
|
2675
|
+
// reached after the wrapper verified. Recomputes the inline source verdict and
|
|
2676
|
+
// checks the embedded RSA-PSS operator attestations.
|
|
2983
2677
|
async function verifyExportBody(
|
|
2984
2678
|
b: EvidenceBundle,
|
|
2985
2679
|
specVersionOk: boolean,
|
|
2986
|
-
assertedStatus: string | null,
|
|
2987
2680
|
errors: string[],
|
|
2988
2681
|
options: VerifyEvidenceOptions,
|
|
2989
2682
|
): Promise<EvidenceBundleResult> {
|
|
2990
|
-
//
|
|
2991
|
-
//
|
|
2992
|
-
// wrapper fields stay true (they DID verify) for any consumer that inspects
|
|
2993
|
-
// them; the CLI's malformed branch shows the errors and exits 2.
|
|
2683
|
+
// The wrapper verified but no verdict can be rendered: unreadable as an export.
|
|
2684
|
+
// The wrapper fields stay true, because they did verify.
|
|
2994
2685
|
const exportMalformed = (error: string): EvidenceBundleResult => ({
|
|
2995
|
-
status: "
|
|
2686
|
+
status: "incomplete",
|
|
2687
|
+
unreadable: true,
|
|
2996
2688
|
specVersionOk,
|
|
2997
2689
|
bodyHashOk: true,
|
|
2998
2690
|
signatureOk: true,
|
|
@@ -3000,7 +2692,6 @@ async function verifyExportBody(
|
|
|
3000
2692
|
onChainChecked: false,
|
|
3001
2693
|
checkpoints: [],
|
|
3002
2694
|
events: [],
|
|
3003
|
-
assertedStatus,
|
|
3004
2695
|
errors: [...errors, error],
|
|
3005
2696
|
});
|
|
3006
2697
|
|
|
@@ -3022,14 +2713,13 @@ async function verifyExportBody(
|
|
|
3022
2713
|
return exportMalformed("export body missing kernel_verdict");
|
|
3023
2714
|
}
|
|
3024
2715
|
|
|
3025
|
-
//
|
|
2716
|
+
// Step 3: source-bundle linkage.
|
|
3026
2717
|
const inline = body.source_bundle;
|
|
3027
2718
|
if (inline === undefined || inline === null) {
|
|
3028
|
-
//
|
|
3029
|
-
//
|
|
3030
|
-
// dependent checks are UNDETERMINED (exit 3), NOT a failure (§5 step 3).
|
|
2719
|
+
// An out-of-line source is not verifiable offline unless co-delivered: the
|
|
2720
|
+
// source-dependent checks are not checked, never failed.
|
|
3031
2721
|
return {
|
|
3032
|
-
status: "
|
|
2722
|
+
status: "incomplete",
|
|
3033
2723
|
specVersionOk,
|
|
3034
2724
|
bodyHashOk: true,
|
|
3035
2725
|
signatureOk: true,
|
|
@@ -3037,11 +2727,10 @@ async function verifyExportBody(
|
|
|
3037
2727
|
onChainChecked: false,
|
|
3038
2728
|
checkpoints: [],
|
|
3039
2729
|
events: [],
|
|
3040
|
-
assertedStatus,
|
|
3041
2730
|
errors: [
|
|
3042
2731
|
...errors,
|
|
3043
|
-
"source_bundle is not inline (source_bundle_ref)
|
|
3044
|
-
"source-dependent checks
|
|
2732
|
+
"source_bundle is not inline (source_bundle_ref): bytes unavailable offline; " +
|
|
2733
|
+
"source-dependent checks not checked",
|
|
3045
2734
|
],
|
|
3046
2735
|
};
|
|
3047
2736
|
}
|
|
@@ -3059,86 +2748,70 @@ async function verifyExportBody(
|
|
|
3059
2748
|
errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
|
|
3060
2749
|
}
|
|
3061
2750
|
|
|
3062
|
-
//
|
|
3063
|
-
// The existing evidence path over the inline source bundle — same primitives
|
|
3064
|
-
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
3065
|
-
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
2751
|
+
// Step 4: recompute the source verdict, including the optional re-fetch.
|
|
3066
2752
|
const sourceResult = await verifyExportSource(inline, options);
|
|
3067
2753
|
|
|
3068
|
-
//
|
|
2754
|
+
// For the attestation data_hash binding (§5 step 6c).
|
|
3069
2755
|
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
3070
2756
|
|
|
3071
|
-
//
|
|
2757
|
+
// Step 6: embedded attestation records.
|
|
3072
2758
|
const attestations: AttestationResult[] = [];
|
|
3073
2759
|
for (const rec of body.attestations) {
|
|
3074
2760
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
3075
2761
|
if (r.malformed !== undefined) return exportMalformed(r.malformed);
|
|
2762
|
+
if (typeof r.result.fieldError === "string") errors.push(r.result.fieldError);
|
|
3076
2763
|
attestations.push(r.result);
|
|
3077
2764
|
}
|
|
3078
2765
|
|
|
3079
|
-
// --- Build the recomputed §4 verdict object -------------------------------
|
|
3080
2766
|
const verdict = buildVerdictObject(sourceResult, attestations);
|
|
3081
2767
|
if (typeof b.generated_at === "string") verdict.as_of = b.generated_at;
|
|
3082
2768
|
|
|
3083
|
-
//
|
|
3084
|
-
//
|
|
3085
|
-
//
|
|
3086
|
-
//
|
|
3087
|
-
// merkle roots, and per-attestation signature/operator-address/data_hash
|
|
3088
|
-
// binding. The on-chain per-gateway dimension is environment/time-dependent —
|
|
3089
|
-
// the issuer observed it online at compose time; an offline verifier
|
|
3090
|
-
// legitimately sees `null` — so it is INFORMATIONAL and MUST NOT by itself
|
|
3091
|
-
// trigger an exit-1 tamper verdict (the verifier's own on-chain outcomes fold
|
|
3092
|
-
// in at step 7 only if it re-fetches). Advisory rollups (status / counts /
|
|
3093
|
-
// summary / as_of / custody_chain) are derived, not compared. See the §5
|
|
3094
|
-
// step-5 erratum flagged in the slice report.
|
|
2769
|
+
// Step 5: the cached `kernel_verdict` is never trusted, and agreement compares
|
|
2770
|
+
// only the deterministic, offline-recomputable dimensions. The on-chain
|
|
2771
|
+
// dimension depends on when and where it was observed, so it must not by
|
|
2772
|
+
// itself read as tamper. Derived rollups are not compared.
|
|
3095
2773
|
let verdictAgreementOk = false;
|
|
3096
2774
|
try {
|
|
3097
2775
|
const cached = projectVerdictForAgreement(body.kernel_verdict);
|
|
3098
2776
|
const fresh = projectVerdictForAgreement(verdict);
|
|
3099
|
-
|
|
2777
|
+
tolerateUnboundMessageContent(cached, fresh, sourceEventsGoverned(inline));
|
|
3100
2778
|
const cachedProjection = jcs(cached);
|
|
3101
2779
|
const freshProjection = jcs(fresh);
|
|
3102
2780
|
verdictAgreementOk = cachedProjection === freshProjection;
|
|
3103
2781
|
if (!verdictAgreementOk) {
|
|
3104
2782
|
errors.push(
|
|
3105
2783
|
"recomputed verdict disagrees with the cached kernel_verdict " +
|
|
3106
|
-
"(deterministic dimensions)
|
|
2784
|
+
"(deterministic dimensions); recompute-don't-trust",
|
|
3107
2785
|
);
|
|
3108
2786
|
}
|
|
3109
2787
|
} catch (e) {
|
|
3110
2788
|
errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
|
|
3111
2789
|
}
|
|
3112
2790
|
|
|
3113
|
-
//
|
|
3114
|
-
// (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
|
|
3115
|
-
// a checkpoint `mismatch` made it "failed"; an all-`unreachable` checkpoint
|
|
3116
|
-
// made it "partial" + an unreachable note → exit 3.)
|
|
2791
|
+
// Step 7 is already folded into `sourceResult.status` by the source recompute.
|
|
3117
2792
|
|
|
3118
|
-
//
|
|
3119
|
-
// exit-3 detection (which keys off "unreachable") — see them.
|
|
2793
|
+
// The CLI's exit-3 detection keys off "unreachable" in these notes.
|
|
3120
2794
|
for (const e of sourceResult.errors) errors.push(`source: ${e}`);
|
|
3121
2795
|
|
|
3122
|
-
//
|
|
2796
|
+
// Step 9: export rollup.
|
|
3123
2797
|
const anyAttestationFailed = attestations.some((a) => !a.ok);
|
|
3124
2798
|
let status: EvidenceStatus;
|
|
3125
2799
|
if (
|
|
3126
2800
|
!sourceLinkageOk ||
|
|
3127
2801
|
!verdictAgreementOk ||
|
|
3128
2802
|
sourceResult.status === "failed" ||
|
|
3129
|
-
sourceResult.
|
|
2803
|
+
sourceResult.unreadable === true ||
|
|
3130
2804
|
anyAttestationFailed
|
|
3131
2805
|
) {
|
|
3132
2806
|
status = "failed";
|
|
3133
|
-
} else if (sourceResult.status === "
|
|
3134
|
-
//
|
|
3135
|
-
|
|
3136
|
-
status = "partial";
|
|
2807
|
+
} else if (sourceResult.status === "incomplete") {
|
|
2808
|
+
// A source not fully checked (a withheld record). Never a failure.
|
|
2809
|
+
status = "incomplete";
|
|
3137
2810
|
} else {
|
|
3138
2811
|
status = "verified";
|
|
3139
2812
|
}
|
|
3140
2813
|
|
|
3141
|
-
// The
|
|
2814
|
+
// The displayed verdict reflects the full recompute, attestations included.
|
|
3142
2815
|
verdict.status = status;
|
|
3143
2816
|
verdict.summary = summarizeExport(status, sourceResult, attestations);
|
|
3144
2817
|
|
|
@@ -3149,11 +2822,9 @@ async function verifyExportBody(
|
|
|
3149
2822
|
signatureOk: true,
|
|
3150
2823
|
bodyType: EXPORT_BODY_TYPE,
|
|
3151
2824
|
onChainChecked: sourceResult.onChainChecked,
|
|
3152
|
-
// The
|
|
3153
|
-
// results, so the CLI's existing rendering shows them unchanged.
|
|
2825
|
+
// The recomputed source results, so the CLI renders them unchanged.
|
|
3154
2826
|
checkpoints: sourceResult.checkpoints,
|
|
3155
2827
|
events: sourceResult.events,
|
|
3156
|
-
assertedStatus,
|
|
3157
2828
|
export: {
|
|
3158
2829
|
sourceLinkageOk,
|
|
3159
2830
|
sourceStatus: sourceResult.status,
|
|
@@ -3161,33 +2832,26 @@ async function verifyExportBody(
|
|
|
3161
2832
|
attestations,
|
|
3162
2833
|
verdict,
|
|
3163
2834
|
status,
|
|
2835
|
+
// A source whose own wrapper time breaks its format is a source that does not follow
|
|
2836
|
+
// its format, as one that cannot be read is.
|
|
2837
|
+
sourceUnreadable:
|
|
2838
|
+
(sourceResult.unreadable === true || sourceResult.generatedAtMalformed === true) && sourceLinkageOk,
|
|
3164
2839
|
},
|
|
3165
2840
|
errors,
|
|
3166
2841
|
};
|
|
3167
2842
|
}
|
|
3168
2843
|
|
|
3169
|
-
//
|
|
3170
|
-
//
|
|
3171
|
-
//
|
|
3172
|
-
//
|
|
3173
|
-
//
|
|
3174
|
-
// step 5). Rather than reimplement that recompute, this reuses the EXACT verify
|
|
3175
|
-
// primitives verifyExportBody uses — verifyEvidenceBundle over the source,
|
|
3176
|
-
// verifyAttestation per record, then buildVerdictObject — so the composed cache
|
|
3177
|
-
// and the verifier's recompute cannot drift. Offline/pure: no gateways, so the
|
|
3178
|
-
// on-chain dimension is null (excluded from agreement anyway, §5 step 5).
|
|
3179
|
-
//
|
|
3180
|
-
// Throws on a malformed attestation record (unparseable RSA key, wrong
|
|
3181
|
-
// signature_alg, missing field) — a composer INPUT error the caller surfaces,
|
|
3182
|
-
// mirroring verifyExportBody's malformed→exit-2 for the same conditions.
|
|
3183
|
-
// ===========================================================================
|
|
2844
|
+
// Compose-side verdict recompute (evidence-export.md §5 steps 4 and 6). The
|
|
2845
|
+
// `proof export` composer embeds a cached `kernel_verdict` that must agree with
|
|
2846
|
+
// what `verifyExportBody` later recomputes, so it uses the same primitives.
|
|
2847
|
+
// Offline: the on-chain dimension is null. Throws on a malformed attestation
|
|
2848
|
+
// record, a composer input error.
|
|
3184
2849
|
export interface ComposeVerdictResult {
|
|
3185
|
-
// The §4 verdict
|
|
3186
|
-
//
|
|
2850
|
+
// The §4 verdict to embed as `body.kernel_verdict`, finalized as
|
|
2851
|
+
// `verifyExportBody` renders it.
|
|
3187
2852
|
verdict: VerdictObject;
|
|
3188
|
-
// The recomputed inline-source-bundle status (verifyEvidenceBundle).
|
|
3189
2853
|
sourceStatus: EvidenceStatus;
|
|
3190
|
-
// The export rollup
|
|
2854
|
+
// The export rollup (§5 step 9).
|
|
3191
2855
|
status: EvidenceStatus;
|
|
3192
2856
|
// Per-attestation results (each already bound to a source checkpoint).
|
|
3193
2857
|
attestations: AttestationResult[];
|
|
@@ -3201,15 +2865,15 @@ export async function recomputeExportVerdict(
|
|
|
3201
2865
|
): Promise<ComposeVerdictResult> {
|
|
3202
2866
|
const sourceResult = await verifyExportSource(sourceBundle, options);
|
|
3203
2867
|
|
|
3204
|
-
//
|
|
3205
|
-
// verifyExportBody, deliberately shared rather than restated.
|
|
2868
|
+
// The same index `verifyExportBody` builds (§5 step 6c).
|
|
3206
2869
|
const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
|
|
3207
2870
|
|
|
3208
2871
|
const attestations: AttestationResult[] = [];
|
|
3209
2872
|
for (const rec of attestationRecords) {
|
|
3210
2873
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
3211
|
-
|
|
3212
|
-
|
|
2874
|
+
const malformedField = r.malformed ?? r.result.fieldError ?? undefined;
|
|
2875
|
+
if (malformedField !== undefined) {
|
|
2876
|
+
throw new Error(`attestation record is malformed: ${malformedField}`);
|
|
3213
2877
|
}
|
|
3214
2878
|
attestations.push(r.result);
|
|
3215
2879
|
}
|
|
@@ -3217,20 +2881,18 @@ export async function recomputeExportVerdict(
|
|
|
3217
2881
|
const verdict = buildVerdictObject(sourceResult, attestations);
|
|
3218
2882
|
verdict.as_of = generatedAt;
|
|
3219
2883
|
|
|
3220
|
-
//
|
|
3221
|
-
//
|
|
3222
|
-
// there is no prior cached verdict to disagree with), so the only failure
|
|
3223
|
-
// inputs here are the recomputed source status and any failed attestation.
|
|
2884
|
+
// Linkage and agreement always hold at compose time, so only the source
|
|
2885
|
+
// status and the attestations can fail the rollup.
|
|
3224
2886
|
const anyAttestationFailed = attestations.some((a) => !a.ok);
|
|
3225
2887
|
let status: EvidenceStatus;
|
|
3226
2888
|
if (
|
|
3227
2889
|
sourceResult.status === "failed" ||
|
|
3228
|
-
sourceResult.
|
|
2890
|
+
sourceResult.unreadable === true ||
|
|
3229
2891
|
anyAttestationFailed
|
|
3230
2892
|
) {
|
|
3231
2893
|
status = "failed";
|
|
3232
|
-
} else if (sourceResult.status === "
|
|
3233
|
-
status = "
|
|
2894
|
+
} else if (sourceResult.status === "incomplete") {
|
|
2895
|
+
status = "incomplete";
|
|
3234
2896
|
} else {
|
|
3235
2897
|
status = "verified";
|
|
3236
2898
|
}
|
|
@@ -3240,19 +2902,10 @@ export async function recomputeExportVerdict(
|
|
|
3240
2902
|
return { verdict, sourceStatus: sourceResult.status, status, attestations };
|
|
3241
2903
|
}
|
|
3242
2904
|
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
// undetermined-tolerant and never gates.
|
|
3248
|
-
// The attestation payload fields evidence-export.md §3.1 marks Required, with
|
|
3249
|
-
// the type it gives each. A record whose payload lacks one, or carries another
|
|
3250
|
-
// type (null included), is malformed: §5 step 9 and §4.5 make "a missing
|
|
3251
|
-
// required field" an export the verifier cannot render (exit 2). §5 step 6
|
|
3252
|
-
// gates a WELL-FORMED record on signature, operator and data_hash only; these
|
|
3253
|
-
// checks decide whether the record is well-formed at all. `level` is the §3.1
|
|
3254
|
-
// enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
|
|
3255
|
-
// §3.1 clarification).
|
|
2905
|
+
// The payload fields evidence-export.md section 3.1 requires, with their types. Read only
|
|
2906
|
+
// under a signature that verifies: a record then missing one, or carrying another type
|
|
2907
|
+
// (null included), is one the issuer signed that does not follow its format (section 4.5,
|
|
2908
|
+
// `follows_rules` disproven). `level` is 1, 2 or 3; integers are non-negative safe integers.
|
|
3256
2909
|
const PAYLOAD_REQUIRED: readonly (readonly [string, "string" | "boolean" | "integer" | "level"])[] = [
|
|
3257
2910
|
["attested_at", "string"],
|
|
3258
2911
|
["tx_id", "string"],
|
|
@@ -3279,7 +2932,6 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
|
|
|
3279
2932
|
ok = typeof v === "boolean";
|
|
3280
2933
|
want = "a boolean";
|
|
3281
2934
|
} else if (kind === "integer") {
|
|
3282
|
-
// §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
|
|
3283
2935
|
ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
3284
2936
|
want = "a non-negative integer";
|
|
3285
2937
|
} else {
|
|
@@ -3288,13 +2940,17 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
|
|
|
3288
2940
|
}
|
|
3289
2941
|
if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
3290
2942
|
if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
|
|
3291
|
-
// evidence-export §3.1
|
|
2943
|
+
// The form `signed_at` pins (evidence-export.md §3.1).
|
|
3292
2944
|
return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
|
|
3293
2945
|
}
|
|
3294
2946
|
}
|
|
3295
2947
|
return null;
|
|
3296
2948
|
}
|
|
3297
2949
|
|
|
2950
|
+
// Verify one embedded attestation record (section 5 step 6). Returns `malformed` (exit 2)
|
|
2951
|
+
// where the signature cannot be checked (no payload, an unparseable key or signature);
|
|
2952
|
+
// otherwise `ok` gates on the signature, then the payload fields under it (`fieldError`),
|
|
2953
|
+
// then the operator address and data_hash. `subject_ref` never gates.
|
|
3298
2954
|
async function verifyAttestation(
|
|
3299
2955
|
rec: AttestationRecord,
|
|
3300
2956
|
sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
|
|
@@ -3341,17 +2997,14 @@ async function verifyAttestation(
|
|
|
3341
2997
|
if (typeof rec.signature !== "string") {
|
|
3342
2998
|
return { result, malformed: "attestation record missing signature" };
|
|
3343
2999
|
}
|
|
3344
|
-
const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
|
|
3345
|
-
if (fieldError !== null) return { result, malformed: fieldError };
|
|
3346
3000
|
const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
|
|
3347
3001
|
|
|
3348
3002
|
result.operator = typeof payload.operator === "string" ? payload.operator : "";
|
|
3349
3003
|
result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
|
|
3350
|
-
result.level =
|
|
3004
|
+
result.level = safeInteger(payload.level);
|
|
3351
3005
|
|
|
3352
|
-
// (a) RSA-PSS-SHA-256
|
|
3353
|
-
//
|
|
3354
|
-
// fails to verify returns false (→ exit 1).
|
|
3006
|
+
// (a) RSA-PSS-SHA-256 over JCS(payload). Malformed hex or an unimportable key
|
|
3007
|
+
// is malformed (exit 2); a signature that does not verify is false (exit 1).
|
|
3355
3008
|
let payloadBytes: Uint8Array;
|
|
3356
3009
|
try {
|
|
3357
3010
|
payloadBytes = utf8(jcs(payload));
|
|
@@ -3362,17 +3015,17 @@ async function verifyAttestation(
|
|
|
3362
3015
|
result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
|
|
3363
3016
|
if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
|
|
3364
3017
|
} catch {
|
|
3365
|
-
// A
|
|
3366
|
-
// `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
|
|
3367
|
-
// raises `malformed signature hex: ...`, so quoting either one split the
|
|
3368
|
-
// two kernels on a finding a holder can put in an export body it signs,
|
|
3369
|
-
// and an export pack's findings reach a case reading through `reasons`
|
|
3370
|
-
// (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
|
|
3371
|
-
// get this kernel's own words.
|
|
3018
|
+
// A fixed sentence, not the library's, so both kernels report one finding.
|
|
3372
3019
|
return { result, malformed: "attestation signature or key is malformed" };
|
|
3373
3020
|
}
|
|
3021
|
+
// The fields are read only under a signature that holds: over bytes the issuer did not
|
|
3022
|
+
// sign, a bad field says nothing the failed signature does not.
|
|
3023
|
+
if (result.signatureOk) {
|
|
3024
|
+
result.fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
|
|
3025
|
+
if (result.fieldError !== null) errs.push(result.fieldError);
|
|
3026
|
+
}
|
|
3374
3027
|
|
|
3375
|
-
// (b)
|
|
3028
|
+
// (b) base64url(SHA-256(modulus)) == payload.operator.
|
|
3376
3029
|
try {
|
|
3377
3030
|
const derived = await deriveOperatorAddress(pub.n);
|
|
3378
3031
|
result.operatorAddressBound = result.operator !== "" && derived === result.operator;
|
|
@@ -3385,10 +3038,8 @@ async function verifyAttestation(
|
|
|
3385
3038
|
return { result, malformed: `attestation operator key${thrownDetail(e)}` };
|
|
3386
3039
|
}
|
|
3387
3040
|
|
|
3388
|
-
// (c)
|
|
3389
|
-
//
|
|
3390
|
-
// against, §4.2), so the checkpoint's committed content hash is
|
|
3391
|
-
// SHA-256(JCS(checkpoint.envelope)).
|
|
3041
|
+
// (c) data_hash binding. The checkpoint's witnessed data is its uploaded
|
|
3042
|
+
// JCS(envelope), so the committed hash is SHA-256(JCS(checkpoint.envelope)).
|
|
3392
3043
|
const cp = sourceCheckpoints.get(result.checkpointRef);
|
|
3393
3044
|
result.checkpointResolved = cp !== undefined;
|
|
3394
3045
|
if (!cp) {
|
|
@@ -3411,21 +3062,20 @@ async function verifyAttestation(
|
|
|
3411
3062
|
}
|
|
3412
3063
|
}
|
|
3413
3064
|
|
|
3414
|
-
// (d) subject_ref (§3.2)
|
|
3415
|
-
// in v1); undetermined (null), never a hard failure.
|
|
3065
|
+
// (d) subject_ref (§3.2): well-formedness only; null, never a failure.
|
|
3416
3066
|
result.subjectRefOk = evaluateSubjectRef(payload.subject_ref, errs);
|
|
3417
3067
|
|
|
3418
3068
|
result.ok =
|
|
3419
3069
|
result.signatureOk &&
|
|
3070
|
+
(result.fieldError ?? null) === null &&
|
|
3420
3071
|
result.operatorAddressBound &&
|
|
3421
3072
|
result.dataHashBound &&
|
|
3422
3073
|
result.checkpointResolved;
|
|
3423
3074
|
return { result };
|
|
3424
3075
|
}
|
|
3425
3076
|
|
|
3426
|
-
// subject_ref (§3.2):
|
|
3427
|
-
//
|
|
3428
|
-
// Present + malformed → null + a surfaced note (never gates the attestation).
|
|
3077
|
+
// subject_ref (§3.2): null when absent, when well-formed with no side input to
|
|
3078
|
+
// compare against, and when malformed (with a note). Never gates.
|
|
3429
3079
|
function evaluateSubjectRef(subjectRef: unknown, errs: string[]): boolean | null {
|
|
3430
3080
|
if (subjectRef === undefined || subjectRef === null) return null;
|
|
3431
3081
|
if (typeof subjectRef !== "object" || Array.isArray(subjectRef)) {
|
|
@@ -3439,13 +3089,11 @@ function evaluateSubjectRef(subjectRef: unknown, errs: string[]): boolean | null
|
|
|
3439
3089
|
errs.push("subject_ref is present but not well-formed ({hash: sha256-hex, type: token})");
|
|
3440
3090
|
return null;
|
|
3441
3091
|
}
|
|
3442
|
-
return null;
|
|
3092
|
+
return null;
|
|
3443
3093
|
}
|
|
3444
3094
|
|
|
3445
|
-
// Build the §4 verdict object
|
|
3446
|
-
//
|
|
3447
|
-
// renderer/verify-API display; it is compared (over its deterministic subset)
|
|
3448
|
-
// to the cached body.kernel_verdict in step 5.
|
|
3095
|
+
// Build the §4 verdict object from the recomputed source result and the
|
|
3096
|
+
// attestations: what surfaces display, compared with the cached copy in step 5.
|
|
3449
3097
|
function buildVerdictObject(
|
|
3450
3098
|
source: EvidenceBundleResult,
|
|
3451
3099
|
attestations: AttestationResult[],
|
|
@@ -3496,7 +3144,7 @@ function buildVerdictObject(
|
|
|
3496
3144
|
|
|
3497
3145
|
return {
|
|
3498
3146
|
schema_version: "sanning.evidence.verdict/v1",
|
|
3499
|
-
status: source.
|
|
3147
|
+
status: source.unreadable === true ? "failed" : source.status,
|
|
3500
3148
|
counts: { verified, failed, undetermined },
|
|
3501
3149
|
events,
|
|
3502
3150
|
checkpoints,
|
|
@@ -3508,8 +3156,7 @@ function toVerdictOnChain(o: OnChainResult): VerdictOnChain {
|
|
|
3508
3156
|
return {
|
|
3509
3157
|
rollup: o.rollup,
|
|
3510
3158
|
on_chain_ok: o.onChainOk,
|
|
3511
|
-
//
|
|
3512
|
-
// enrichment (optional in the §4.4 schema) the offline verifier does not
|
|
3159
|
+
// `block_height` is issuer-side enrichment the offline verifier does not
|
|
3513
3160
|
// recompute.
|
|
3514
3161
|
per_gateway: o.perGateway.map((g) => ({ gateway: g.gateway, outcome: g.outcome })),
|
|
3515
3162
|
};
|
|
@@ -3517,16 +3164,13 @@ function toVerdictOnChain(o: OnChainResult): VerdictOnChain {
|
|
|
3517
3164
|
|
|
3518
3165
|
function eventVerdictStatus(e: EventResult): string {
|
|
3519
3166
|
if (!e.ok) return "failed";
|
|
3520
|
-
if (e.payloadBindingOk === null) return "
|
|
3167
|
+
if (e.payloadBindingOk === null) return "incomplete"; // withheld record
|
|
3521
3168
|
return "verified";
|
|
3522
3169
|
}
|
|
3523
3170
|
|
|
3524
|
-
// The §5 step
|
|
3525
|
-
//
|
|
3526
|
-
//
|
|
3527
|
-
// nullish normalization) — a structural difference surfaces as disagreement,
|
|
3528
|
-
// which is the correct tamper signal. Works on both the cached snake_case JSON
|
|
3529
|
-
// and the freshly built VerdictObject.
|
|
3171
|
+
// The §5 step 5 projection: only what an offline verifier deterministically
|
|
3172
|
+
// recomputes. Tolerates a garbled cached verdict: a structural difference
|
|
3173
|
+
// reads as disagreement, which is the right signal.
|
|
3530
3174
|
function projectVerdictForAgreement(v: unknown): {
|
|
3531
3175
|
schema_version: string | null;
|
|
3532
3176
|
events: { content_ok: boolean | null }[];
|
|
@@ -3553,7 +3197,7 @@ function projectVerdictForAgreement(v: unknown): {
|
|
|
3553
3197
|
return {
|
|
3554
3198
|
checkpoint_ref: cp.checkpoint_ref ?? null,
|
|
3555
3199
|
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
3556
|
-
// on_chain
|
|
3200
|
+
// on_chain omitted: environment and time dependent.
|
|
3557
3201
|
attestations: atts.map((a) => {
|
|
3558
3202
|
const at = (a ?? {}) as VerdictAttestation;
|
|
3559
3203
|
return {
|
|
@@ -3569,11 +3213,9 @@ function projectVerdictForAgreement(v: unknown): {
|
|
|
3569
3213
|
};
|
|
3570
3214
|
}
|
|
3571
3215
|
|
|
3572
|
-
// Per event of an export's inline
|
|
3573
|
-
//
|
|
3574
|
-
//
|
|
3575
|
-
// not JCS is not governed here either. The Python twin is
|
|
3576
|
-
// `evidence._source_events_governed`.
|
|
3216
|
+
// Per event of an export's inline source, in order: whether §4.1 governs its
|
|
3217
|
+
// record, decoded as the content binding decodes it. Python's twin is
|
|
3218
|
+
// `_source_events_governed`.
|
|
3577
3219
|
function sourceEventsGoverned(source: unknown): boolean[] {
|
|
3578
3220
|
if (source === null || typeof source !== "object" || Array.isArray(source)) return [];
|
|
3579
3221
|
const s = source as { body_type?: unknown; body?: unknown };
|
|
@@ -3605,17 +3247,13 @@ function sourceEventsGoverned(source: unknown): boolean[] {
|
|
|
3605
3247
|
});
|
|
3606
3248
|
}
|
|
3607
3249
|
|
|
3608
|
-
// The one difference §5 step 5
|
|
3609
|
-
//
|
|
3610
|
-
//
|
|
3611
|
-
//
|
|
3612
|
-
//
|
|
3613
|
-
//
|
|
3614
|
-
|
|
3615
|
-
// difference on a record outside §4.1 still disagree. Edits `fresh` in place
|
|
3616
|
-
// to match `cached` on those events. The Python twin is
|
|
3617
|
-
// `evidence._tolerate_pre_a20_content`.
|
|
3618
|
-
function toleratePreA20Content(
|
|
3250
|
+
// The one difference §5 step 5 tolerates: a cached `content_ok` of `null`
|
|
3251
|
+
// against a recomputed `true`, on an event whose record §4.1 governs. A composer
|
|
3252
|
+
// that could not bind hand-off messages claims less than the recompute proves,
|
|
3253
|
+
// which is not tamper. Only that direction; every other difference still
|
|
3254
|
+
// disagrees. Edits `fresh` in place. Python's twin is
|
|
3255
|
+
// `_tolerate_unbound_message_content`.
|
|
3256
|
+
function tolerateUnboundMessageContent(
|
|
3619
3257
|
cached: { events: { content_ok: boolean | null }[] },
|
|
3620
3258
|
fresh: { events: { content_ok: boolean | null }[] },
|
|
3621
3259
|
governed: boolean[],
|
|
@@ -3645,12 +3283,8 @@ function summarizeExport(
|
|
|
3645
3283
|
);
|
|
3646
3284
|
}
|
|
3647
3285
|
|
|
3648
|
-
// Re-fetch a checkpoint's
|
|
3649
|
-
//
|
|
3650
|
-
// worst-finding-wins. Unlike the pre-§4.2 first-responsive-wins collapse, this
|
|
3651
|
-
// records a per-gateway array so a confirm/mismatch/unreachable MIX across
|
|
3652
|
-
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
3653
|
-
// single-boolean exactly for a single gateway.
|
|
3286
|
+
// Re-fetch a checkpoint's bytes from each gateway and record each outcome
|
|
3287
|
+
// (evidence-export.md §4.2), then roll them up worst-finding-wins.
|
|
3654
3288
|
async function refetchCheckpoint(
|
|
3655
3289
|
cp: AnyTraceCheckpoint,
|
|
3656
3290
|
anchorRef: AnchorRef,
|
|
@@ -3659,44 +3293,22 @@ async function refetchCheckpoint(
|
|
|
3659
3293
|
): Promise<RefetchOutcome> {
|
|
3660
3294
|
let expectedHex: string | undefined;
|
|
3661
3295
|
try {
|
|
3662
|
-
//
|
|
3663
|
-
//
|
|
3664
|
-
// uploaded has to be recomputed from it. See `refetchStamp` for the case
|
|
3665
|
-
// where this step is not merely unnecessary but WRONG.
|
|
3296
|
+
// The bundle carries the envelope as an object, so its witnessed bytes are
|
|
3297
|
+
// recomputed as JCS. Stamps differ: see `refetchStamp`.
|
|
3666
3298
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
3667
3299
|
} catch {
|
|
3668
|
-
//
|
|
3669
|
-
// (we can never confirm bytes we cannot compute).
|
|
3300
|
+
// Bytes we cannot compute can never be confirmed: every gateway mismatches.
|
|
3670
3301
|
expectedHex = undefined;
|
|
3671
3302
|
}
|
|
3672
3303
|
|
|
3673
3304
|
return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
|
|
3674
3305
|
}
|
|
3675
3306
|
|
|
3676
|
-
// Re-fetch a
|
|
3677
|
-
//
|
|
3678
|
-
//
|
|
3679
|
-
//
|
|
3680
|
-
//
|
|
3681
|
-
// witnessed bytes are not in the bundle — only the object they were derived
|
|
3682
|
-
// from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
|
|
3683
|
-
// "lowercase hex of the exact JCS bytes that were handed to the witness", and
|
|
3684
|
-
// §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
|
|
3685
|
-
// are already the bytes, already hex, already lowercase (`isLowerHex` gated
|
|
3686
|
-
// them), and `stamp_ref` is their SHA-256.
|
|
3687
|
-
//
|
|
3688
|
-
// So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
|
|
3689
|
-
// defect, not a redundancy: it would compare the chain against bytes NOBODY
|
|
3690
|
-
// SIGNED. On a canonical body the two agree and the bug hides; on a body that
|
|
3691
|
-
// is not canonical — the exact case §5.2.2 makes the kernel check for, because
|
|
3692
|
-
// the plane replays these bytes as text precisely so a normalizing serializer
|
|
3693
|
-
// cannot alter them — a re-canonicalizing verifier reports `mismatch` against
|
|
3694
|
-
// a chain that faithfully holds what it was given. A false accusation, from
|
|
3695
|
-
// the verifier, about untampered evidence.
|
|
3696
|
-
//
|
|
3697
|
-
// The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
|
|
3698
|
-
// `stamp_ref`, threaded through rather than re-read from the body, so the bytes
|
|
3699
|
-
// graded and the bytes compared cannot drift apart.
|
|
3307
|
+
// Re-fetch a stamp's witnessed bytes (§5.2.2, §5.3.2). Unlike a checkpoint,
|
|
3308
|
+
// `stamp_bytes` already are the exact witnessed bytes, so they are compared
|
|
3309
|
+
// verbatim: re-canonicalizing would compare the chain against bytes nobody
|
|
3310
|
+
// signed and accuse a faithful chain of mismatch on a non-canonical body. The
|
|
3311
|
+
// hex is the same value `verifyStampEntry` hashed for `stamp_ref`.
|
|
3700
3312
|
async function refetchStamp(
|
|
3701
3313
|
witnessedHex: string,
|
|
3702
3314
|
anchorRef: AnchorRef,
|
|
@@ -3706,11 +3318,8 @@ async function refetchStamp(
|
|
|
3706
3318
|
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
3707
3319
|
}
|
|
3708
3320
|
|
|
3709
|
-
//
|
|
3710
|
-
// the
|
|
3711
|
-
// copied — a second rollup rule is a second place for "mismatch beats confirm
|
|
3712
|
-
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
3713
|
-
// The subject only ever selects wording; it never changes a verdict.
|
|
3321
|
+
// Shared by both paths: probe every gateway, roll up with `rollupOnChain`, and
|
|
3322
|
+
// phrase the finding. The subject selects wording only, never a verdict.
|
|
3714
3323
|
async function refetchAnchored(
|
|
3715
3324
|
subject: "checkpoint" | "stamp",
|
|
3716
3325
|
expectedHex: string | undefined,
|
|
@@ -3718,14 +3327,9 @@ async function refetchAnchored(
|
|
|
3718
3327
|
gateways: string[],
|
|
3719
3328
|
fetchImpl: typeof fetch,
|
|
3720
3329
|
): Promise<RefetchOutcome> {
|
|
3721
|
-
// Only a `kind`
|
|
3722
|
-
//
|
|
3723
|
-
// `
|
|
3724
|
-
// of any other kind is not an Arweave id, so whatever a gateway serves at
|
|
3725
|
-
// that path says nothing about the witness, and comparing it would turn an
|
|
3726
|
-
// unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
|
|
3727
|
-
// run goes `partial`: the caller asked for the witness check and it was not
|
|
3728
|
-
// made.
|
|
3330
|
+
// Only a `kind` with a witness adapter is re-fetched (§5.1.1: an unrecognised
|
|
3331
|
+
// kind must not fail verification). No fetch, `onChain: null`, and the run is
|
|
3332
|
+
// `partial`.
|
|
3729
3333
|
if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
|
|
3730
3334
|
return {
|
|
3731
3335
|
onChain: null,
|
|
@@ -3734,11 +3338,9 @@ async function refetchAnchored(
|
|
|
3734
3338
|
error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
|
|
3735
3339
|
};
|
|
3736
3340
|
}
|
|
3737
|
-
// The id becomes a URL path segment
|
|
3738
|
-
//
|
|
3739
|
-
//
|
|
3740
|
-
// `a?b`), and the two kernels would then request different URLs. Such an id
|
|
3741
|
-
// is never fetched and never failed: the witness is not checked.
|
|
3341
|
+
// The id becomes a URL path segment: anything but a 43-character base64url id
|
|
3342
|
+
// could escape the gateway's base path or make the kernels request different
|
|
3343
|
+
// URLs. Never fetched, never failed: the witness is not checked.
|
|
3742
3344
|
if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
|
|
3743
3345
|
return {
|
|
3744
3346
|
onChain: null,
|
|
@@ -3768,9 +3370,7 @@ async function refetchAnchored(
|
|
|
3768
3370
|
return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
|
|
3769
3371
|
}
|
|
3770
3372
|
|
|
3771
|
-
// The `anchor_ref.kind` values
|
|
3772
|
-
// (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
|
|
3773
|
-
// left unchecked by `refetchAnchored`, never probed.
|
|
3373
|
+
// The `anchor_ref.kind` values with a witness adapter (§5.1.1 registers `arweave`).
|
|
3774
3374
|
const WITNESS_ADAPTER_KINDS: ReadonlySet<string> = new Set(["arweave"]);
|
|
3775
3375
|
|
|
3776
3376
|
// The only `arweave` ids the adapter looks up: a 43-character base64url
|
|
@@ -3789,9 +3389,8 @@ interface RefetchOutcome {
|
|
|
3789
3389
|
error?: string;
|
|
3790
3390
|
}
|
|
3791
3391
|
|
|
3792
|
-
//
|
|
3793
|
-
//
|
|
3794
|
-
// CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
|
|
3392
|
+
// Notes for requested re-fetches that were not made. Each keeps "could not be
|
|
3393
|
+
// re-fetched", which both CLIs map to exit 3. Fixed order across kernels.
|
|
3795
3394
|
function pushRefetchGapNotes(
|
|
3796
3395
|
errors: string[],
|
|
3797
3396
|
subject: "stamps" | "checkpoints",
|
|
@@ -3812,8 +3411,7 @@ function pushRefetchGapNotes(
|
|
|
3812
3411
|
}
|
|
3813
3412
|
}
|
|
3814
3413
|
|
|
3815
|
-
// One gateway's outcome
|
|
3816
|
-
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
3414
|
+
// One gateway's outcome: confirm, mismatch, or unreachable.
|
|
3817
3415
|
async function probeGateway(
|
|
3818
3416
|
anchorRef: AnchorRef,
|
|
3819
3417
|
gateway: string,
|
|
@@ -3835,24 +3433,21 @@ async function probeGateway(
|
|
|
3835
3433
|
return "unreachable";
|
|
3836
3434
|
}
|
|
3837
3435
|
if (expectedHex === undefined) return "mismatch";
|
|
3838
|
-
// The on-chain bytes are the uploaded
|
|
3839
|
-
//
|
|
3840
|
-
//
|
|
3841
|
-
// the load-bearing check.
|
|
3436
|
+
// The on-chain bytes are the uploaded JCS bytes. The structural compare
|
|
3437
|
+
// tolerates a gateway that re-pretty-prints; the canonical match is the
|
|
3438
|
+
// load-bearing check.
|
|
3842
3439
|
if (toHex(onChainBytes) === expectedHex) return "confirm";
|
|
3843
3440
|
try {
|
|
3844
3441
|
const parsed = jcsParse(onChainBytes);
|
|
3845
3442
|
if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
|
|
3846
3443
|
} catch {
|
|
3847
|
-
//
|
|
3444
|
+
// Not parseable: a genuine mismatch.
|
|
3848
3445
|
}
|
|
3849
3446
|
return "mismatch";
|
|
3850
3447
|
}
|
|
3851
3448
|
|
|
3852
|
-
// Rollup
|
|
3853
|
-
//
|
|
3854
|
-
// true on confirm, null on all-unreachable. An empty gateway list is treated as
|
|
3855
|
-
// all-unreachable (no signal).
|
|
3449
|
+
// Rollup (§4.2): mismatch if any gateway mismatched, else confirm if any
|
|
3450
|
+
// confirmed, else unreachable. An empty list is all-unreachable.
|
|
3856
3451
|
function rollupOnChain(perGateway: PerGatewayOutcome[]): OnChainResult {
|
|
3857
3452
|
let rollup: OnChainOutcome = "unreachable";
|
|
3858
3453
|
if (perGateway.some((g) => g.outcome === "mismatch")) rollup = "mismatch";
|
|
@@ -3868,33 +3463,22 @@ function getEventId(env: unknown): string {
|
|
|
3868
3463
|
return "<unknown>";
|
|
3869
3464
|
}
|
|
3870
3465
|
|
|
3871
|
-
// The disclosed bytes offered for
|
|
3872
|
-
//
|
|
3873
|
-
//
|
|
3874
|
-
// the verifier hashed against the commitment rather than a second decode of the
|
|
3875
|
-
// same field that could drift from it.
|
|
3466
|
+
// The disclosed bytes offered for one event (§5.1), decoded once and shared by
|
|
3467
|
+
// the content check and `disclosedContent`, so a consumer receives exactly the
|
|
3468
|
+
// bytes the verifier hashed.
|
|
3876
3469
|
interface DisclosedSelection {
|
|
3877
3470
|
// In-body `events[].content` (the signed disclosure), when it decoded.
|
|
3878
3471
|
inBody?: Uint8Array;
|
|
3879
3472
|
// Out-of-band `options.content[eventId]`, when it decoded.
|
|
3880
3473
|
side?: Uint8Array;
|
|
3881
|
-
// A disclosure was
|
|
3882
|
-
//
|
|
3883
|
-
// materialize" from "disclosed, but unusable"; conflating those is how a
|
|
3884
|
-
// consumer writes an empty log file and believes it succeeded.
|
|
3474
|
+
// A disclosure was offered, in-body or side input, whether or not it
|
|
3475
|
+
// decoded: "nothing disclosed" and "disclosed but unusable" must stay apart.
|
|
3885
3476
|
offered: boolean;
|
|
3886
3477
|
// Decode failures, in the verifier's existing wording.
|
|
3887
3478
|
errors: string[];
|
|
3888
3479
|
}
|
|
3889
3480
|
|
|
3890
|
-
// The side-input presence rule lives in
|
|
3891
|
-
//
|
|
3892
|
-
// It used to live here as a private `lookupSideContent`, and `grading.ts`
|
|
3893
|
-
// carried a second, laxer answer to the same question (`hasEntry`: own +
|
|
3894
|
-
// enumerable, nothing more). On one and the same content map the grader said
|
|
3895
|
-
// PRESENT while this verifier said nothing was offered, and `gradePack` then
|
|
3896
|
-
// returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
|
|
3897
|
-
// and for what each clause of it cost.
|
|
3481
|
+
// The side-input presence rule lives in `./side-content.ts`, stated once.
|
|
3898
3482
|
|
|
3899
3483
|
function selectDisclosedBytes(
|
|
3900
3484
|
ev: TraceEvent,
|
|
@@ -3921,15 +3505,8 @@ function selectDisclosedBytes(
|
|
|
3921
3505
|
errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
|
|
3922
3506
|
}
|
|
3923
3507
|
}
|
|
3924
|
-
// `offered` comes from the
|
|
3925
|
-
// `value !== undefined
|
|
3926
|
-
// remove, and it was removed from ONE of the two operands. The left one
|
|
3927
|
-
// carried it unchanged: an in-body `"content": null` is `!== undefined`, so
|
|
3928
|
-
// the event was marked offered while no bytes were selected, and the kernel
|
|
3929
|
-
// split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
|
|
3930
|
-
// FAILED, on one bundle. `null` is how an optional field's absence
|
|
3931
|
-
// serializes; `inBodyContentOffered` states that once, beside the side-input
|
|
3932
|
-
// rule it mirrors.
|
|
3508
|
+
// `offered` comes from the same tests that selected the value, never from
|
|
3509
|
+
// `value !== undefined`: an in-body `null` is an absent optional field.
|
|
3933
3510
|
return {
|
|
3934
3511
|
inBody,
|
|
3935
3512
|
side,
|