@sanning/proof 0.9.2 → 0.11.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 +7 -7
- package/dist/agent-proof.js +8 -2
- package/dist/case.d.ts +745 -0
- package/dist/case.js +1318 -0
- package/dist/chain.js +24 -5
- package/dist/cli.js +194 -37
- package/dist/compose.js +3 -3
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.js +30 -1
- package/dist/declaration.d.ts +284 -0
- package/dist/declaration.js +674 -0
- package/dist/evidence.d.ts +29 -54
- package/dist/evidence.js +622 -206
- package/dist/grading.d.ts +66 -5
- package/dist/grading.js +134 -9
- package/dist/handoff.d.ts +209 -0
- package/dist/handoff.js +598 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +56 -6
- package/dist/message-parts.d.ts +283 -0
- package/dist/message-parts.js +903 -0
- package/dist/verifier.d.ts +36 -0
- package/dist/verifier.js +157 -6
- package/package.json +2 -2
- package/src/agent-proof.ts +8 -2
- package/src/case.ts +1573 -0
- package/src/chain.ts +24 -5
- package/src/cli.ts +226 -39
- package/src/compose.ts +4 -4
- package/src/crypto.ts +29 -1
- package/src/declaration.ts +1005 -0
- package/src/evidence.ts +707 -259
- package/src/grading.ts +168 -11
- package/src/handoff.ts +674 -0
- package/src/index.ts +132 -8
- package/src/message-parts.ts +998 -0
- package/src/verifier.ts +157 -6
- package/vectors/README.md +37 -2
- package/vectors/chain-reading.json +4 -4
- package/vectors/jcs-reader.json +166 -0
- package/vectors/pack-grading.json +131 -3
- package/vectors/stamp-on-chain.json +3 -3
package/src/evidence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Evidence-bundle verification (sanning-io/proof specs/evidence-bundle.md).
|
|
2
2
|
//
|
|
3
3
|
// A `sanning.evidence/v1` bundle is a SIGNED wrapper around a producer-specific
|
|
4
|
-
// `body`. This module verifies the `sanning.anchor.trace/
|
|
4
|
+
// `body`. This module verifies the `sanning.anchor.trace/v3` body, the
|
|
5
5
|
// @sanning/anchor SDK trace: a self-verifying serialization of InclusionReceipt[]
|
|
6
6
|
// (per-event signed envelopes + their checkpoints + RFC 9162 inclusion proofs).
|
|
7
7
|
//
|
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
deriveOperatorAddress,
|
|
21
21
|
ed25519Verify,
|
|
22
22
|
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
|
+
isHexField,
|
|
23
28
|
sha256Hex,
|
|
24
29
|
utf8,
|
|
25
30
|
verifyRsaPssSha256,
|
|
@@ -30,6 +35,7 @@ import {
|
|
|
30
35
|
contentHashes,
|
|
31
36
|
exceedsDepth,
|
|
32
37
|
jcs,
|
|
38
|
+
jcsParse,
|
|
33
39
|
verifyEnvelope,
|
|
34
40
|
} from "./verifier.js";
|
|
35
41
|
import {
|
|
@@ -48,37 +54,73 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
|
|
|
48
54
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
49
55
|
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
50
56
|
// "undetermined" rather than "verified".
|
|
51
|
-
|
|
57
|
+
//
|
|
58
|
+
// The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
|
|
59
|
+
// `event_bytes` and moved every body that carries it to a new major. This
|
|
60
|
+
// kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
|
|
61
|
+
// `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
|
|
62
|
+
// types here and answer `partial`. DL-73 releases evidence made before the cut
|
|
63
|
+
// from any obligation to keep verifying, so there is no second field name to
|
|
64
|
+
// read and no dual-accept path.
|
|
65
|
+
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
|
|
52
66
|
|
|
53
67
|
/**
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
68
|
+
* The name `event_bytes` replaced. Under the new majors a body entry that still
|
|
69
|
+
* carries it is a hard reject, never an ignored unknown field: ignored, it
|
|
70
|
+
* would read as a withheld record and the event would verify with nothing
|
|
71
|
+
* checked, the fail-open the new majors exist to prevent (governance.md §8,
|
|
72
|
+
* W-3).
|
|
73
|
+
*/
|
|
74
|
+
export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Name an envelope the accept-set refused. Without this a bundle whose
|
|
78
|
+
* envelopes carry a retired profile major (a half-migrated producer still
|
|
79
|
+
* writing `sanning.events/v1`) fails with no reason at all. Spelled with
|
|
80
|
+
* `JSON.stringify` so both kernels write the same sentence.
|
|
60
81
|
*/
|
|
61
|
-
|
|
82
|
+
function specVersionFinding(
|
|
83
|
+
what: "checkpoint" | "event",
|
|
84
|
+
envelope: unknown,
|
|
85
|
+
res: { specVersionOk: boolean },
|
|
86
|
+
): string | null {
|
|
87
|
+
if (!isJsonObject(envelope) || res.specVersionOk) return null;
|
|
88
|
+
if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
|
|
89
|
+
return `${what} envelope has no spec_version`;
|
|
90
|
+
}
|
|
91
|
+
const sv = (envelope as Record<string, unknown>).spec_version;
|
|
92
|
+
return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
|
|
93
|
+
}
|
|
62
94
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The reject for an entry that still carries `record_bytes`. One sentence,
|
|
97
|
+
* spelled the same in both kernels, so a negative vector can assert it.
|
|
98
|
+
*/
|
|
99
|
+
function legacyEventBytesError(
|
|
100
|
+
entry: object,
|
|
101
|
+
what: "checkpoint" | "event",
|
|
102
|
+
bodyType: string,
|
|
103
|
+
): string | null {
|
|
104
|
+
if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD)) return null;
|
|
105
|
+
return (
|
|
106
|
+
`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
|
|
107
|
+
"(renamed event_bytes) -- one bundle speaks one vocabulary"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
68
110
|
|
|
69
111
|
// The issuer-composed attested-evidence-export body (specs/evidence-export.md
|
|
70
112
|
// §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
|
|
71
113
|
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
72
114
|
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
73
115
|
// verifyExportBody (§5).
|
|
74
|
-
export const EXPORT_BODY_TYPE = "sanning.evidence.export/
|
|
116
|
+
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
|
|
75
117
|
|
|
76
118
|
/**
|
|
77
119
|
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
78
120
|
*
|
|
79
121
|
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
80
122
|
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
81
|
-
* `sanning.events/
|
|
123
|
+
* `sanning.events/v2` envelope, and a stamp has no producer signature at all:
|
|
82
124
|
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
83
125
|
* The two rejected options were making `envelope` optional and synthesising an
|
|
84
126
|
* envelope that Sanning signs; the second would have made a Sanning
|
|
@@ -86,7 +128,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
86
128
|
* unrecoverable once witnessed.
|
|
87
129
|
*/
|
|
88
130
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
89
|
-
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/
|
|
131
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
|
|
90
132
|
|
|
91
133
|
/**
|
|
92
134
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
@@ -107,7 +149,6 @@ export interface EvidenceIssuer {
|
|
|
107
149
|
kind: string;
|
|
108
150
|
tenant_id?: string;
|
|
109
151
|
agent_id?: string;
|
|
110
|
-
producer_id?: string;
|
|
111
152
|
[k: string]: unknown;
|
|
112
153
|
}
|
|
113
154
|
|
|
@@ -136,13 +177,12 @@ export interface AnchorRef {
|
|
|
136
177
|
ref?: Record<string, unknown>;
|
|
137
178
|
}
|
|
138
179
|
|
|
139
|
-
// `sanning.anchor.trace/
|
|
180
|
+
// `sanning.anchor.trace/v3` body (§5.1). Uint8Array fields ride as lowercase hex.
|
|
140
181
|
export interface TraceCheckpoint {
|
|
141
182
|
/**
|
|
142
183
|
* The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
|
|
143
|
-
* decoded `
|
|
144
|
-
*
|
|
145
|
-
* relabel a checkpoint and the bundle still verified.
|
|
184
|
+
* decoded `event_bytes`. Recomputed by the verifier, never taken from the
|
|
185
|
+
* label.
|
|
146
186
|
*/
|
|
147
187
|
checkpoint_ref: string;
|
|
148
188
|
/**
|
|
@@ -152,32 +192,24 @@ export interface TraceCheckpoint {
|
|
|
152
192
|
*/
|
|
153
193
|
anchor_ref?: AnchorRef;
|
|
154
194
|
envelope: Envelope;
|
|
155
|
-
|
|
195
|
+
event_bytes: string; // hex of JCS(checkpoint record)
|
|
156
196
|
merkle_root: string; // hex
|
|
157
197
|
}
|
|
158
198
|
|
|
159
|
-
/** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
|
|
160
|
-
export interface TraceCheckpointV1 {
|
|
161
|
-
tx_id: string;
|
|
162
|
-
envelope: Envelope;
|
|
163
|
-
record_bytes: string;
|
|
164
|
-
merkle_root: string;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
199
|
export interface TraceInclusion {
|
|
168
200
|
leaf_hash: string; // hex
|
|
169
201
|
leaf_index: number;
|
|
170
202
|
leaf_count: number;
|
|
171
203
|
audit_path: string[]; // hex[]
|
|
172
|
-
/** Resolves against `checkpoints[].checkpoint_ref`.
|
|
204
|
+
/** Resolves against `checkpoints[].checkpoint_ref`. */
|
|
173
205
|
checkpoint_ref: string;
|
|
174
206
|
}
|
|
175
207
|
|
|
176
208
|
export interface TraceEvent {
|
|
177
209
|
envelope: Envelope;
|
|
178
|
-
|
|
210
|
+
event_bytes?: string; // hex of JCS(event record); omitted == record withheld
|
|
179
211
|
// Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 MUST equal the
|
|
180
|
-
// committed event.content_hash inside
|
|
212
|
+
// committed event.content_hash inside event_bytes (evidence-bundle §5.1).
|
|
181
213
|
// Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
|
|
182
214
|
content?: string;
|
|
183
215
|
inclusion: TraceInclusion;
|
|
@@ -246,7 +278,7 @@ export interface StampTraceBody {
|
|
|
246
278
|
stamps: StampEntry[];
|
|
247
279
|
}
|
|
248
280
|
|
|
249
|
-
// ---- `sanning.stamp.evidence/
|
|
281
|
+
// ---- `sanning.stamp.evidence/v2` wire types (evidence-bundle.md §5.3) -----
|
|
250
282
|
//
|
|
251
283
|
// Both halves of a DL-52 record, bound: the producer signs the envelope, and
|
|
252
284
|
// Sanning signs and witnesses the stamp whose root commits to it. Neither
|
|
@@ -266,7 +298,7 @@ export interface StampEvidenceInclusion {
|
|
|
266
298
|
|
|
267
299
|
export interface StampEvidenceEvent {
|
|
268
300
|
envelope: Envelope;
|
|
269
|
-
|
|
301
|
+
event_bytes?: string; // hex of JCS(event record); omitted == withheld
|
|
270
302
|
content?: string; // opt-in disclosed raw bytes, lowercase hex
|
|
271
303
|
inclusion: StampEvidenceInclusion;
|
|
272
304
|
}
|
|
@@ -327,23 +359,12 @@ export interface OnChainResult {
|
|
|
327
359
|
}
|
|
328
360
|
|
|
329
361
|
export interface CheckpointResult {
|
|
330
|
-
/**
|
|
331
|
-
* The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
|
|
332
|
-
* `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
|
|
333
|
-
* Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
|
|
334
|
-
* that is what its own events resolve against and what an attestation
|
|
335
|
-
* written against it carries.
|
|
336
|
-
*/
|
|
362
|
+
/** The derived `checkpoint_ref`, empty when it could not be derived. */
|
|
337
363
|
checkpointRef: string;
|
|
338
364
|
/**
|
|
339
|
-
* Where this checkpoint was witnessed
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
* witnessed, which is a normal state and NOT a deficiency. For a frozen v1
|
|
343
|
-
* checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
|
|
344
|
-
* is exactly what v1's spec said the field was ("the Arweave TX the
|
|
345
|
-
* checkpoint envelope was anchored as"). The synthesis is reader-side only
|
|
346
|
-
* and never re-enters a signed artifact or a verdict.
|
|
365
|
+
* Where this checkpoint was witnessed. `null` when the checkpoint carries no
|
|
366
|
+
* `anchor_ref`: placed but not yet witnessed, which is a normal state and NOT
|
|
367
|
+
* a deficiency.
|
|
347
368
|
*/
|
|
348
369
|
anchorRef: AnchorRef | null;
|
|
349
370
|
// The checkpoint envelope verified (signature + payload binding to its record).
|
|
@@ -351,11 +372,15 @@ export interface CheckpointResult {
|
|
|
351
372
|
// The committed record's merkle_root equals the checkpoint's claimed merkle_root.
|
|
352
373
|
merkleRootOk: boolean;
|
|
353
374
|
// If gateways were supplied: the on-chain bytes at tx_id matched the envelope.
|
|
354
|
-
// null when no gateway re-fetch was requested
|
|
355
|
-
//
|
|
375
|
+
// null when no gateway re-fetch was requested, or when `anchor_ref.kind` has
|
|
376
|
+
// no witness adapter, or its id is not one the adapter accepts; the witness
|
|
377
|
+
// was not checked. Derived collapse of `onChain` (§4.2), retained so existing
|
|
378
|
+
// consumers keep the single-boolean field.
|
|
356
379
|
onChainOk: boolean | null;
|
|
357
380
|
// Per-gateway on-chain outcomes (§4.2), ADDITIVE to `onChainOk`. null when no
|
|
358
|
-
// gateway re-fetch was requested (offline-only verification)
|
|
381
|
+
// gateway re-fetch was requested (offline-only verification), or when
|
|
382
|
+
// `anchor_ref.kind` has no witness adapter, or its id is not one the adapter
|
|
383
|
+
// accepts; the witness was not checked.
|
|
359
384
|
onChain: OnChainResult | null;
|
|
360
385
|
ok: boolean;
|
|
361
386
|
errors: string[];
|
|
@@ -373,7 +398,7 @@ export interface EventResult {
|
|
|
373
398
|
// The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
|
|
374
399
|
checkpointBound: boolean;
|
|
375
400
|
// Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
|
|
376
|
-
// committed event.content_hash (in
|
|
401
|
+
// committed event.content_hash (in event_bytes), or, for a promoted
|
|
377
402
|
// envelope, a hash it commits to. true matched, false mismatch (→ event
|
|
378
403
|
// fails), null undetermined (nothing disclosed OR nothing committed OR record
|
|
379
404
|
// withheld — NOT a failure, mirroring payloadBindingOk).
|
|
@@ -435,12 +460,14 @@ export interface StampResult {
|
|
|
435
460
|
* ── The four states, and why none of them collapses ──
|
|
436
461
|
*
|
|
437
462
|
* `null` means not checked — no gateways were supplied, or this stamp names
|
|
438
|
-
* no witness to check
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
463
|
+
* no witness to check, or its `anchor_ref.kind` has no witness adapter, or
|
|
464
|
+
* its id is not one the adapter accepts; the witness was not checked. It is
|
|
465
|
+
* a statement about the RUN, never about the stamp. The other three are
|
|
466
|
+
* statements about the stamp and live in `onChain.rollup`: `confirm` (a
|
|
467
|
+
* gateway returned the witnessed bytes), `mismatch` (a gateway returned
|
|
468
|
+
* DIFFERENT bytes: a finding, and the only one of the four that fails
|
|
469
|
+
* anything), `unreachable` (we asked and nobody answered: an availability
|
|
470
|
+
* gap, never an accusation).
|
|
444
471
|
*
|
|
445
472
|
* Keeping "not checked" distinct from "unreachable" is the whole point. A
|
|
446
473
|
* surface handed a collapsed boolean cannot tell "no gateway could be
|
|
@@ -474,7 +501,7 @@ export interface EvidenceBundleResult {
|
|
|
474
501
|
events: EventResult[];
|
|
475
502
|
// The producer's asserted verdict.status (for display only — never trusted).
|
|
476
503
|
assertedStatus: string | null;
|
|
477
|
-
// Present ONLY for a `sanning.evidence.export/
|
|
504
|
+
// Present ONLY for a `sanning.evidence.export/v2` body (evidence-export.md §5):
|
|
478
505
|
// the export-specific dimensions (source linkage, verdict agreement, embedded
|
|
479
506
|
// attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
|
|
480
507
|
// above are the recomputed SOURCE-bundle results in that case. Absent for a
|
|
@@ -494,11 +521,7 @@ export interface EvidenceBundleResult {
|
|
|
494
521
|
// One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
|
|
495
522
|
// JCS(payload); the operator key is the operator's Arweave RSA wallet.
|
|
496
523
|
export interface AttestationRecord {
|
|
497
|
-
/**
|
|
498
|
-
* The source checkpoint this attestation binds to, spelled in the SOURCE
|
|
499
|
-
* bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
|
|
500
|
-
* for a frozen v1 one. Not re-derived across forms.
|
|
501
|
-
*/
|
|
524
|
+
/** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
|
|
502
525
|
checkpoint_ref: string;
|
|
503
526
|
payload: AttestationPayload;
|
|
504
527
|
signature_alg: string; // "rsa-pss-sha256"
|
|
@@ -656,12 +679,23 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
|
656
679
|
});
|
|
657
680
|
}
|
|
658
681
|
|
|
659
|
-
|
|
682
|
+
/**
|
|
683
|
+
* A bundle this kernel cannot read.
|
|
684
|
+
*
|
|
685
|
+
* `wrapperOk` is true only where the wrapper signature and `body_hash` were
|
|
686
|
+
* CHECKED and held before the body was found unreadable. It is then reported
|
|
687
|
+
* as held, because it did: `signatureOk` and `bodyHashOk` are statements about
|
|
688
|
+
* the wrapper, and a body this kernel cannot parse does not unsign it. The
|
|
689
|
+
* case reader decides whose pack a bundle is on exactly those two fields
|
|
690
|
+
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
691
|
+
* broke its own body shape read as a forger.
|
|
692
|
+
*/
|
|
693
|
+
function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
|
|
660
694
|
return {
|
|
661
695
|
status: "malformed",
|
|
662
696
|
specVersionOk,
|
|
663
|
-
bodyHashOk:
|
|
664
|
-
signatureOk:
|
|
697
|
+
bodyHashOk: wrapperOk,
|
|
698
|
+
signatureOk: wrapperOk,
|
|
665
699
|
bodyType: null,
|
|
666
700
|
onChainChecked: false,
|
|
667
701
|
checkpoints: [],
|
|
@@ -671,8 +705,79 @@ function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
|
|
|
671
705
|
};
|
|
672
706
|
}
|
|
673
707
|
|
|
674
|
-
|
|
675
|
-
|
|
708
|
+
/**
|
|
709
|
+
* The prefixes of the exception text this kernel writes ITSELF, in the words
|
|
710
|
+
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
711
|
+
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
712
|
+
*/
|
|
713
|
+
const KERNEL_WORDING = ["hexToBytes: "];
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The detail a finding may append about a caught exception.
|
|
717
|
+
*
|
|
718
|
+
* THE RULE: a finding carries the text of a caught exception only where this
|
|
719
|
+
* kernel wrote that text. A finding travels into a case reading through `case`
|
|
720
|
+
* `reasons`, and both kernels promise one reading and one rendered sentence for
|
|
721
|
+
* one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
|
|
722
|
+
* not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
|
|
723
|
+
* for one pack anyone can build (gate 4 on proof#140, finding 1).
|
|
724
|
+
*
|
|
725
|
+
* Returns `": <text>"` for this kernel's own wording and the empty string for
|
|
726
|
+
* anything else. The label at the call site already names the check that
|
|
727
|
+
* failed and both kernels agree on it character for character; what is dropped
|
|
728
|
+
* is the host's parse offset, which no reader can act on and which was never
|
|
729
|
+
* the same in the two kernels.
|
|
730
|
+
*/
|
|
731
|
+
function thrownDetail(e: unknown): string {
|
|
732
|
+
const text = e instanceof Error ? e.message : String(e);
|
|
733
|
+
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
|
|
738
|
+
* is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
|
|
739
|
+
* eaten.
|
|
740
|
+
*
|
|
741
|
+
* ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
|
|
742
|
+
* every set of signed bytes in this kernel now; this function survives only for
|
|
743
|
+
* the canonicality comparison on the stamp path, which needs the DECODED TEXT
|
|
744
|
+
* of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
|
|
745
|
+
* performs exactly the decode `jcsParse` performs, so the comparison cannot
|
|
746
|
+
* read the bytes differently from the parse that produced `body`.
|
|
747
|
+
*
|
|
748
|
+
* `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
|
|
749
|
+
* every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
|
|
750
|
+
* one raw `0x80` inside a string value read `verified` here and `failed` there
|
|
751
|
+
* because of it (gate 4 on proof#140). The symmetric claim the first version of
|
|
752
|
+
* this comment made, that "Python's `json.loads` raises on both", is FALSE and
|
|
753
|
+
* is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
|
|
754
|
+
* too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
|
|
755
|
+
* is stated once, in `jcsParse`.
|
|
756
|
+
*/
|
|
757
|
+
function decodeUtf8Strict(bytes: Uint8Array): string {
|
|
758
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Name an envelope `public_key` or `signature` spelled outside the hex charset
|
|
763
|
+
* or at the wrong length (proof#142). This kernel's envelope layer already
|
|
764
|
+
* fails such an envelope, and the Python one's decoder accepts ASCII
|
|
765
|
+
* whitespace (proof#135), so both bundle verifiers name it here, in the same
|
|
766
|
+
* words, and the two kernels give one reading. Only a STRING is checked: a
|
|
767
|
+
* missing or non-string field is the envelope layer's to fail, and it does.
|
|
768
|
+
*/
|
|
769
|
+
function envelopeHexError(what: string, envelope: unknown): string | null {
|
|
770
|
+
if (!isJsonObject(envelope)) return null;
|
|
771
|
+
for (const [name, nibbles] of [
|
|
772
|
+
["public_key", 64],
|
|
773
|
+
["signature", 128],
|
|
774
|
+
] as const) {
|
|
775
|
+
const value = envelope[name];
|
|
776
|
+
if (typeof value === "string" && !isHexField(value, nibbles)) {
|
|
777
|
+
return `${what} envelope ${name} is not ${nibbles} hex characters`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
676
781
|
}
|
|
677
782
|
|
|
678
783
|
/**
|
|
@@ -689,7 +794,7 @@ function jsonStr(value: unknown): string {
|
|
|
689
794
|
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
690
795
|
}
|
|
691
796
|
|
|
692
|
-
// Verify a `sanning.evidence/v1` bundle
|
|
797
|
+
// Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
|
|
693
798
|
//
|
|
694
799
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
695
800
|
// rollup `status`. The producer's asserted `verdict.status` is surfaced
|
|
@@ -698,6 +803,16 @@ function jsonStr(value: unknown): string {
|
|
|
698
803
|
export async function verifyEvidenceBundle(
|
|
699
804
|
bundle: unknown,
|
|
700
805
|
options: VerifyEvidenceOptions = {},
|
|
806
|
+
): Promise<EvidenceBundleResult> {
|
|
807
|
+
return verifyBundle(bundle, options, false);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
811
|
+
// §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
|
|
812
|
+
async function verifyBundle(
|
|
813
|
+
bundle: unknown,
|
|
814
|
+
options: VerifyEvidenceOptions,
|
|
815
|
+
exportSource: boolean,
|
|
701
816
|
): Promise<EvidenceBundleResult> {
|
|
702
817
|
// --- Step 1: parse + reject unknown spec_version major ---------------------
|
|
703
818
|
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
@@ -737,26 +852,44 @@ export async function verifyEvidenceBundle(
|
|
|
737
852
|
// --- Step 2: verify the wrapper signature + recompute body_hash ------------
|
|
738
853
|
const errors: string[] = [];
|
|
739
854
|
|
|
855
|
+
// Every wrapper hex field is read STRICTLY, before any decoder sees it
|
|
856
|
+
// (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
|
|
857
|
+
// `signature` sits outside the signed scope, so one space in it, added by
|
|
858
|
+
// anyone and with no key, made a pack verify there and fail here. The same
|
|
859
|
+
// named failure in both kernels, for each field: `public_key` and
|
|
860
|
+
// `signature` are 64 and 128 hex characters in either case (proof#135,
|
|
861
|
+
// option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
|
|
862
|
+
// recomputed in and compared against.
|
|
740
863
|
let bodyHashOk = false;
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
864
|
+
if (!isHexField(b.body_hash, 64, true)) {
|
|
865
|
+
errors.push("wrapper body_hash is not 64 lowercase hex characters");
|
|
866
|
+
} else {
|
|
867
|
+
try {
|
|
868
|
+
const recomputed = await sha256Hex(utf8(jcs(b.body)));
|
|
869
|
+
bodyHashOk = recomputed === b.body_hash;
|
|
870
|
+
if (!bodyHashOk) {
|
|
871
|
+
errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
|
|
872
|
+
}
|
|
873
|
+
} catch (e) {
|
|
874
|
+
errors.push(`body canonicalization failed${thrownDetail(e)}`);
|
|
746
875
|
}
|
|
747
|
-
} catch (e) {
|
|
748
|
-
errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
|
|
749
876
|
}
|
|
750
877
|
|
|
751
878
|
let signatureOk = false;
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
879
|
+
const keyFormOk = isHexField(b.public_key, 64);
|
|
880
|
+
const signatureFormOk = isHexField(b.signature, 128);
|
|
881
|
+
if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
|
|
882
|
+
if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
|
|
883
|
+
if (keyFormOk && signatureFormOk) {
|
|
884
|
+
try {
|
|
885
|
+
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
|
|
886
|
+
co_signatures?: unknown;
|
|
887
|
+
};
|
|
888
|
+
signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
|
|
889
|
+
if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
|
|
890
|
+
} catch (e) {
|
|
891
|
+
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
892
|
+
}
|
|
760
893
|
}
|
|
761
894
|
|
|
762
895
|
const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
|
|
@@ -781,6 +914,28 @@ export async function verifyEvidenceBundle(
|
|
|
781
914
|
// --- Body dispatch ---------------------------------------------------------
|
|
782
915
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
783
916
|
|
|
917
|
+
// Read as an export's source (evidence-export.md §2.4), the only body this
|
|
918
|
+
// kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
|
|
919
|
+
// verifies on its own, is undetermined here, after the wrapper checks above.
|
|
920
|
+
if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
921
|
+
errors.push(
|
|
922
|
+
`body_type ${JSON.stringify(bodyType)} is not an export source ` +
|
|
923
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
|
|
924
|
+
);
|
|
925
|
+
return {
|
|
926
|
+
status: "partial",
|
|
927
|
+
specVersionOk,
|
|
928
|
+
bodyHashOk,
|
|
929
|
+
signatureOk,
|
|
930
|
+
bodyType,
|
|
931
|
+
onChainChecked: false,
|
|
932
|
+
checkpoints: [],
|
|
933
|
+
events: [],
|
|
934
|
+
assertedStatus,
|
|
935
|
+
errors,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
784
939
|
// Attested-evidence-export body (evidence-export.md §5). The wrapper is
|
|
785
940
|
// authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
|
|
786
941
|
// hand off to the export verifier for steps 3–9.
|
|
@@ -800,13 +955,13 @@ export async function verifyEvidenceBundle(
|
|
|
800
955
|
return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
|
|
801
956
|
}
|
|
802
957
|
|
|
803
|
-
if (bodyType !== ANCHOR_TRACE_BODY_TYPE
|
|
958
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
804
959
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
805
960
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
806
961
|
// body verdict undetermined) — not a failure.
|
|
807
962
|
errors.push(
|
|
808
963
|
`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
809
|
-
`(only ${
|
|
964
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
|
|
810
965
|
);
|
|
811
966
|
return {
|
|
812
967
|
status: "partial",
|
|
@@ -822,114 +977,120 @@ export async function verifyEvidenceBundle(
|
|
|
822
977
|
};
|
|
823
978
|
}
|
|
824
979
|
|
|
825
|
-
return verifyAnchorTrace(b,
|
|
980
|
+
return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
826
981
|
}
|
|
827
982
|
|
|
828
983
|
/**
|
|
829
|
-
*
|
|
984
|
+
* Recompute an export's source verdict (evidence-export.md §5 step 4).
|
|
830
985
|
*
|
|
831
|
-
* The
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
986
|
+
* The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
|
|
987
|
+
* inline source body is unrecognised *as a source*, even one this kernel
|
|
988
|
+
* verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
|
|
989
|
+
* its source verdict undetermined, so the export cannot reach `verified`. The
|
|
990
|
+
* source wrapper is still checked first, because a forged source is a failure
|
|
991
|
+
* whatever its body.
|
|
992
|
+
*/
|
|
993
|
+
async function verifyExportSource(
|
|
994
|
+
source: unknown,
|
|
995
|
+
options: VerifyEvidenceOptions,
|
|
996
|
+
): Promise<EvidenceBundleResult> {
|
|
997
|
+
return verifyBundle(source, options, true);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Index a source bundle's checkpoints by the key its attestations use: the
|
|
1002
|
+
* derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
|
|
1003
|
+
* checkpoints an attestation can resolve (evidence-export.md §2.4).
|
|
836
1004
|
*/
|
|
837
1005
|
async function indexSourceCheckpoints(
|
|
838
1006
|
bundle: EvidenceBundle,
|
|
839
1007
|
): Promise<Map<string, AnyTraceCheckpoint>> {
|
|
840
1008
|
const out = new Map<string, AnyTraceCheckpoint>();
|
|
1009
|
+
if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE) return out;
|
|
841
1010
|
const sb = bundle.body as AnchorTraceBody | undefined;
|
|
842
|
-
const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
843
1011
|
if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
|
|
844
1012
|
for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
|
|
845
1013
|
if (!cp || typeof cp !== "object") continue;
|
|
846
|
-
let
|
|
1014
|
+
let eventBytes: Uint8Array | undefined;
|
|
847
1015
|
try {
|
|
848
|
-
if (typeof cp.
|
|
1016
|
+
if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
|
|
849
1017
|
} catch {
|
|
850
|
-
|
|
1018
|
+
eventBytes = undefined;
|
|
851
1019
|
}
|
|
852
|
-
const keyed = await resolveCheckpointKey(cp,
|
|
1020
|
+
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
853
1021
|
if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
|
|
854
1022
|
}
|
|
855
1023
|
return out;
|
|
856
1024
|
}
|
|
857
1025
|
|
|
858
|
-
/**
|
|
859
|
-
type AnyTraceCheckpoint = Partial<TraceCheckpoint> &
|
|
1026
|
+
/** A checkpoint as it arrives, before any field is trusted. */
|
|
1027
|
+
type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
|
|
860
1028
|
|
|
861
1029
|
/**
|
|
862
|
-
* Resolve a checkpoint's join key and its witness reference
|
|
863
|
-
* body form we are reading.
|
|
1030
|
+
* Resolve a checkpoint's join key and its witness reference.
|
|
864
1031
|
*
|
|
865
|
-
* ──
|
|
1032
|
+
* ── Why a retired spelling is a reject rather than a preference ──
|
|
866
1033
|
*
|
|
867
|
-
* A checkpoint carrying
|
|
868
|
-
* inside one signature. A reader that silently prefers
|
|
869
|
-
* answer from a reader that prefers the other, over the
|
|
870
|
-
* and both look like they verified. So
|
|
871
|
-
*
|
|
872
|
-
* resolved. This is cheap to enforce here and impossible to enforce later.
|
|
1034
|
+
* A checkpoint carrying a retired spelling beside the current one is two
|
|
1035
|
+
* answers to one question inside one signature. A reader that silently prefers
|
|
1036
|
+
* one gives a different answer from a reader that prefers the other, over the
|
|
1037
|
+
* same signed bytes, and both look like they verified. So `tx_id` and
|
|
1038
|
+
* `record_bytes` are rejected rather than resolved.
|
|
873
1039
|
*
|
|
874
|
-
* ── Why
|
|
1040
|
+
* ── Why the key is recomputed ──
|
|
875
1041
|
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
* event pointing at it left a bundle that still verified. In v2 the key IS the
|
|
879
|
-
* bytes, so the same relabel fails here. v1's behaviour is preserved exactly
|
|
880
|
-
* as it was — deliberately, because those bundles are already signed and
|
|
881
|
-
* re-interpreting them is not backwards compatibility.
|
|
1042
|
+
* The key IS the bytes: relabelling a checkpoint and every event pointing at it
|
|
1043
|
+
* fails here, because nothing is taken from the label.
|
|
882
1044
|
*/
|
|
883
1045
|
async function resolveCheckpointKey(
|
|
884
1046
|
cp: AnyTraceCheckpoint,
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1047
|
+
eventBytes: Uint8Array | undefined,
|
|
1048
|
+
): Promise<{
|
|
1049
|
+
key: string | null;
|
|
1050
|
+
anchorRef: AnchorRef | null;
|
|
1051
|
+
anchorRefMalformed: boolean;
|
|
1052
|
+
errors: string[];
|
|
1053
|
+
}> {
|
|
888
1054
|
const errors: string[] = [];
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
errors.push(
|
|
893
|
-
"sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
|
|
894
|
-
"one bundle speaks one vocabulary",
|
|
895
|
-
);
|
|
896
|
-
return { key: null, anchorRef: null, errors };
|
|
897
|
-
}
|
|
898
|
-
if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
|
|
899
|
-
errors.push("checkpoint missing tx_id");
|
|
900
|
-
return { key: null, anchorRef: null, errors };
|
|
901
|
-
}
|
|
902
|
-
// v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
|
|
903
|
-
// through the same shape as v2 gives ONE reader-side vocabulary without
|
|
904
|
-
// touching a byte of the signed bundle.
|
|
905
|
-
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
906
|
-
}
|
|
1055
|
+
// Every early return below leaves before `anchor_ref` is ever looked at, so
|
|
1056
|
+
// nothing there is "malformed": it is unexamined, which is not the same claim.
|
|
1057
|
+
const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
|
|
907
1058
|
|
|
908
1059
|
if (cp.tx_id !== undefined) {
|
|
909
1060
|
errors.push(
|
|
910
|
-
|
|
1061
|
+
`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`,
|
|
911
1062
|
);
|
|
912
|
-
return
|
|
1063
|
+
return unexamined;
|
|
1064
|
+
}
|
|
1065
|
+
const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
|
|
1066
|
+
if (legacy !== null) {
|
|
1067
|
+
errors.push(legacy);
|
|
1068
|
+
return unexamined;
|
|
913
1069
|
}
|
|
914
1070
|
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
915
1071
|
errors.push("checkpoint missing checkpoint_ref");
|
|
916
|
-
return
|
|
1072
|
+
return unexamined;
|
|
917
1073
|
}
|
|
918
|
-
if (
|
|
1074
|
+
if (eventBytes === undefined) {
|
|
919
1075
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
920
|
-
errors.push("checkpoint_ref cannot be verified --
|
|
921
|
-
return
|
|
1076
|
+
errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
|
|
1077
|
+
return unexamined;
|
|
922
1078
|
}
|
|
923
|
-
const derived = await sha256Hex(
|
|
1079
|
+
const derived = await sha256Hex(eventBytes);
|
|
924
1080
|
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
925
1081
|
errors.push(
|
|
926
|
-
`checkpoint_ref does not match SHA-256(
|
|
1082
|
+
`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
|
|
927
1083
|
);
|
|
928
|
-
return
|
|
1084
|
+
return unexamined;
|
|
929
1085
|
}
|
|
930
1086
|
|
|
931
|
-
const
|
|
932
|
-
return {
|
|
1087
|
+
const parsed = parseAnchorRef(cp.anchor_ref, errors);
|
|
1088
|
+
return {
|
|
1089
|
+
key: derived,
|
|
1090
|
+
anchorRef: parsed.ref,
|
|
1091
|
+
anchorRefMalformed: parsed.malformed,
|
|
1092
|
+
errors,
|
|
1093
|
+
};
|
|
933
1094
|
}
|
|
934
1095
|
|
|
935
1096
|
/**
|
|
@@ -949,46 +1110,64 @@ async function resolveCheckpointKey(
|
|
|
949
1110
|
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
950
1111
|
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
951
1112
|
* the reader, and only the first is the bundle's fault.
|
|
1113
|
+
*
|
|
1114
|
+
* ── Absent and malformed are not the same null (proof#120) ──
|
|
1115
|
+
*
|
|
1116
|
+
* Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
|
|
1117
|
+
* way downstream: an all-`unreachable` on-chain result, although no gateway was
|
|
1118
|
+
* asked in either case. For the ABSENT case that is at least the note
|
|
1119
|
+
* `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
|
|
1120
|
+
* For the MALFORMED case it is a manufactured outage: the bundle named a
|
|
1121
|
+
* witness, the name was unreadable, and the reader was told the public record
|
|
1122
|
+
* could not be reached. So the two come back as different facts, and `malformed`
|
|
1123
|
+
* carries the difference.
|
|
952
1124
|
*/
|
|
953
|
-
function parseAnchorRef(
|
|
954
|
-
|
|
1125
|
+
function parseAnchorRef(
|
|
1126
|
+
raw: unknown,
|
|
1127
|
+
errors: string[],
|
|
1128
|
+
): { ref: AnchorRef | null; malformed: boolean } {
|
|
1129
|
+
if (raw === undefined || raw === null) {
|
|
1130
|
+
return { ref: null, malformed: false }; // placed, not yet witnessed
|
|
1131
|
+
}
|
|
955
1132
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
956
1133
|
errors.push("anchor_ref must be an object");
|
|
957
|
-
return null;
|
|
1134
|
+
return { ref: null, malformed: true };
|
|
958
1135
|
}
|
|
959
1136
|
const o = raw as Record<string, unknown>;
|
|
960
1137
|
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
961
1138
|
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
962
|
-
return null;
|
|
1139
|
+
return { ref: null, malformed: true };
|
|
963
1140
|
}
|
|
964
1141
|
if (typeof o.id !== "string" || o.id === "") {
|
|
965
1142
|
errors.push("anchor_ref.id is missing or empty");
|
|
966
|
-
return null;
|
|
1143
|
+
return { ref: null, malformed: true };
|
|
967
1144
|
}
|
|
968
1145
|
const ref = o.ref;
|
|
969
1146
|
if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
|
|
970
1147
|
errors.push("anchor_ref.ref must be an object when present");
|
|
971
|
-
return null;
|
|
1148
|
+
return { ref: null, malformed: true };
|
|
972
1149
|
}
|
|
973
1150
|
return {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1151
|
+
ref: {
|
|
1152
|
+
kind: o.kind,
|
|
1153
|
+
id: o.id,
|
|
1154
|
+
...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
|
|
1155
|
+
},
|
|
1156
|
+
malformed: false,
|
|
977
1157
|
};
|
|
978
1158
|
}
|
|
979
1159
|
|
|
980
1160
|
async function verifyAnchorTrace(
|
|
981
1161
|
b: EvidenceBundle,
|
|
982
|
-
bodyType: string,
|
|
983
1162
|
specVersionOk: boolean,
|
|
984
1163
|
assertedStatus: string | null,
|
|
985
1164
|
errors: string[],
|
|
986
1165
|
options: VerifyEvidenceOptions,
|
|
987
1166
|
): Promise<EvidenceBundleResult> {
|
|
988
1167
|
const body = b.body as AnchorTraceBody;
|
|
989
|
-
const
|
|
1168
|
+
const bodyType = ANCHOR_TRACE_BODY_TYPE;
|
|
990
1169
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
991
|
-
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
1170
|
+
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true, true);
|
|
992
1171
|
}
|
|
993
1172
|
|
|
994
1173
|
const gateways = options.gateways ?? [];
|
|
@@ -1005,6 +1184,11 @@ async function verifyAnchorTrace(
|
|
|
1005
1184
|
const byKey = new Map<string, AnyTraceCheckpoint>();
|
|
1006
1185
|
const anchorByKey = new Map<string, AnchorRef | null>();
|
|
1007
1186
|
const checkpointResults: CheckpointResult[] = [];
|
|
1187
|
+
// proof#120. Kept beside the results rather than on them: "the bundle named
|
|
1188
|
+
// a witness we could not read" is an input defect the checkpoint's own
|
|
1189
|
+
// `errors` already states, not a new dimension of the published verdict, and
|
|
1190
|
+
// `CheckpointResult` is a shape downstream surfaces render.
|
|
1191
|
+
const malformedAnchorRef = new Set<CheckpointResult>();
|
|
1008
1192
|
for (const rawCp of body.checkpoints as unknown[]) {
|
|
1009
1193
|
// `checkpoints[]` is attacker-shaped, and a verifier must never raise on
|
|
1010
1194
|
// adversarial input: a null entry used to reach `cp.tx_id` and hand the
|
|
@@ -1032,16 +1216,16 @@ async function verifyAnchorTrace(
|
|
|
1032
1216
|
let envelopeOk = false;
|
|
1033
1217
|
let merkleRootOk = false;
|
|
1034
1218
|
|
|
1035
|
-
// Decode
|
|
1219
|
+
// Decode event_bytes first: in v2 the join key is DERIVED from it, so it
|
|
1036
1220
|
// is an input to identity, not just to the payload binding below.
|
|
1037
|
-
let
|
|
1221
|
+
let eventBytes: Uint8Array | undefined;
|
|
1038
1222
|
try {
|
|
1039
|
-
if (typeof cp.
|
|
1223
|
+
if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
|
|
1040
1224
|
} catch (e) {
|
|
1041
|
-
cpErrors.push(`checkpoint
|
|
1225
|
+
cpErrors.push(`checkpoint event_bytes is not hex${thrownDetail(e)}`);
|
|
1042
1226
|
}
|
|
1043
1227
|
|
|
1044
|
-
const keyed = await resolveCheckpointKey(cp,
|
|
1228
|
+
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
1045
1229
|
cpErrors.push(...keyed.errors);
|
|
1046
1230
|
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
1047
1231
|
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
@@ -1055,7 +1239,7 @@ async function verifyAnchorTrace(
|
|
|
1055
1239
|
let duplicateKey = false;
|
|
1056
1240
|
if (keyed.key !== null) {
|
|
1057
1241
|
if (byKey.has(keyed.key)) {
|
|
1058
|
-
cpErrors.push(`duplicate checkpoint
|
|
1242
|
+
cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
|
|
1059
1243
|
duplicateKey = true;
|
|
1060
1244
|
} else {
|
|
1061
1245
|
byKey.set(keyed.key, cp);
|
|
@@ -1068,16 +1252,19 @@ async function verifyAnchorTrace(
|
|
|
1068
1252
|
try {
|
|
1069
1253
|
const res = await verifyEnvelope(
|
|
1070
1254
|
cp.envelope as Envelope,
|
|
1071
|
-
|
|
1255
|
+
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1072
1256
|
);
|
|
1073
1257
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
1074
1258
|
// payload_hash. An undetermined binding (no record) is not enough to
|
|
1075
1259
|
// anchor the events' inclusion claims to a trustworthy root.
|
|
1076
|
-
|
|
1260
|
+
const hexError = envelopeHexError("checkpoint", cp.envelope);
|
|
1261
|
+
envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
|
|
1077
1262
|
// See the event loop below: a degenerate envelope is a malformed input,
|
|
1078
1263
|
// not a failed check (proof#76).
|
|
1079
1264
|
if (!isJsonObject(cp.envelope)) {
|
|
1080
1265
|
cpErrors.push("checkpoint envelope is not a JSON object");
|
|
1266
|
+
} else if (hexError !== null) {
|
|
1267
|
+
cpErrors.push(hexError);
|
|
1081
1268
|
} else if (!res.signatureOk) {
|
|
1082
1269
|
cpErrors.push("checkpoint envelope signature failed");
|
|
1083
1270
|
}
|
|
@@ -1085,27 +1272,29 @@ async function verifyAnchorTrace(
|
|
|
1085
1272
|
// proof#95: a malformed skeleton field fails the envelope with a valid
|
|
1086
1273
|
// signature and binding; name it rather than fail with no reason.
|
|
1087
1274
|
for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
|
|
1275
|
+
const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
|
|
1276
|
+
if (specFinding !== null) cpErrors.push(specFinding);
|
|
1088
1277
|
// WHY the binding is undetermined decides which sentence is true.
|
|
1089
|
-
//
|
|
1278
|
+
// event_bytes really absent is one thing; event_bytes present and
|
|
1090
1279
|
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
1091
1280
|
// naming the wrong cause sends a reader to look for a field that is
|
|
1092
1281
|
// sitting right there (proof#76).
|
|
1093
1282
|
if (res.payloadHashOk === null) {
|
|
1094
1283
|
cpErrors.push(
|
|
1095
|
-
|
|
1096
|
-
? "checkpoint
|
|
1284
|
+
eventBytes === undefined
|
|
1285
|
+
? "checkpoint event_bytes missing -- root untrusted"
|
|
1097
1286
|
: "checkpoint record binding not checked: root untrusted",
|
|
1098
1287
|
);
|
|
1099
1288
|
}
|
|
1100
1289
|
} catch (e) {
|
|
1101
|
-
cpErrors.push(`checkpoint envelope verify error
|
|
1290
|
+
cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
|
|
1102
1291
|
}
|
|
1103
1292
|
|
|
1104
1293
|
// The committed record's merkle_root must equal the checkpoint's claimed
|
|
1105
1294
|
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
1106
|
-
if (
|
|
1295
|
+
if (eventBytes !== undefined) {
|
|
1107
1296
|
try {
|
|
1108
|
-
const record =
|
|
1297
|
+
const record = jcsParse(eventBytes) as {
|
|
1109
1298
|
event?: { merkle_root?: unknown };
|
|
1110
1299
|
};
|
|
1111
1300
|
const committedRoot = record.event?.merkle_root;
|
|
@@ -1117,7 +1306,7 @@ async function verifyAnchorTrace(
|
|
|
1117
1306
|
);
|
|
1118
1307
|
}
|
|
1119
1308
|
} catch (e) {
|
|
1120
|
-
cpErrors.push(`checkpoint record is not JSON
|
|
1309
|
+
cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
|
|
1121
1310
|
}
|
|
1122
1311
|
}
|
|
1123
1312
|
|
|
@@ -1137,6 +1326,9 @@ async function verifyAnchorTrace(
|
|
|
1137
1326
|
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
1138
1327
|
errors: cpErrors,
|
|
1139
1328
|
});
|
|
1329
|
+
if (keyed.anchorRefMalformed) {
|
|
1330
|
+
malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
|
|
1331
|
+
}
|
|
1140
1332
|
}
|
|
1141
1333
|
|
|
1142
1334
|
// --- Step 4: per-event ------------------------------------------------------
|
|
@@ -1164,18 +1356,42 @@ async function verifyAnchorTrace(
|
|
|
1164
1356
|
let envelopeOk = false;
|
|
1165
1357
|
let payloadBindingOk: boolean | null = null;
|
|
1166
1358
|
|
|
1167
|
-
|
|
1359
|
+
// A retired field name under this major is a reject, never an ignored
|
|
1360
|
+
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
1361
|
+
const legacyError = legacyEventBytesError(ev, "event", bodyType);
|
|
1362
|
+
if (legacyError !== null) evErrors.push(legacyError);
|
|
1363
|
+
|
|
1364
|
+
// MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
|
|
1365
|
+
//
|
|
1366
|
+
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
1367
|
+
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
1368
|
+
// line down. So an event carrying garbage in place of its record used to
|
|
1369
|
+
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
1370
|
+
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
1371
|
+
// a record nobody could read. The only thing wrong with the pack was the
|
|
1372
|
+
// bytes, and it was the one thing the verdict did not say.
|
|
1373
|
+
//
|
|
1374
|
+
// A record the producer chose not to disclose is the customer exercising
|
|
1375
|
+
// minimum disclosure. A record that is present and unreadable is a defect
|
|
1376
|
+
// in the artifact, and this kernel's standing rule (the same one
|
|
1377
|
+
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
1378
|
+
// we can see fails.
|
|
1379
|
+
let eventBytes: Uint8Array | undefined;
|
|
1380
|
+
let eventBytesMalformed = false;
|
|
1168
1381
|
try {
|
|
1169
|
-
if (typeof ev.
|
|
1382
|
+
if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
|
|
1170
1383
|
} catch (e) {
|
|
1171
|
-
|
|
1384
|
+
eventBytesMalformed = true;
|
|
1385
|
+
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1172
1386
|
}
|
|
1173
1387
|
try {
|
|
1174
1388
|
const res = await verifyEnvelope(
|
|
1175
1389
|
ev.envelope,
|
|
1176
|
-
|
|
1390
|
+
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1177
1391
|
);
|
|
1178
|
-
|
|
1392
|
+
const hexError = envelopeHexError("event", ev.envelope);
|
|
1393
|
+
// ok tolerates an undetermined binding (withheld record)
|
|
1394
|
+
envelopeOk = res.ok && hexError === null;
|
|
1179
1395
|
payloadBindingOk = res.payloadHashOk;
|
|
1180
1396
|
// P-10: report what could not be determined, never a fault that was never
|
|
1181
1397
|
// observed. There is no envelope here, so no signature was checked and no
|
|
@@ -1186,6 +1402,8 @@ async function verifyAnchorTrace(
|
|
|
1186
1402
|
// the producer at all (proof#76).
|
|
1187
1403
|
if (!isJsonObject(ev.envelope)) {
|
|
1188
1404
|
evErrors.push("event envelope is not a JSON object");
|
|
1405
|
+
} else if (hexError !== null) {
|
|
1406
|
+
evErrors.push(hexError);
|
|
1189
1407
|
} else if (!res.signatureOk) {
|
|
1190
1408
|
evErrors.push("event envelope signature failed");
|
|
1191
1409
|
}
|
|
@@ -1193,19 +1411,20 @@ async function verifyAnchorTrace(
|
|
|
1193
1411
|
// A malformed skeleton field fails the envelope with a valid signature
|
|
1194
1412
|
// and binding (proof#95), so without this the event fails with no reason.
|
|
1195
1413
|
for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
|
|
1414
|
+
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
1415
|
+
if (specFinding !== null) evErrors.push(specFinding);
|
|
1196
1416
|
} catch (e) {
|
|
1197
|
-
evErrors.push(`event envelope verify error
|
|
1417
|
+
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
1198
1418
|
}
|
|
1199
1419
|
|
|
1200
1420
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
1201
1421
|
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
1202
1422
|
const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
|
|
1207
|
-
const
|
|
1208
|
-
const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
|
|
1423
|
+
// An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
|
|
1424
|
+
// bundle, and resolving it anyway is how an event silently binds to a
|
|
1425
|
+
// checkpoint nobody meant.
|
|
1426
|
+
const keyField = "checkpoint_ref";
|
|
1427
|
+
const wrongField = "checkpoint_tx_id";
|
|
1209
1428
|
const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
|
|
1210
1429
|
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
1211
1430
|
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
@@ -1258,12 +1477,12 @@ async function verifyAnchorTrace(
|
|
|
1258
1477
|
);
|
|
1259
1478
|
}
|
|
1260
1479
|
} catch (e) {
|
|
1261
|
-
evErrors.push(`inclusion verify error
|
|
1480
|
+
evErrors.push(`inclusion verify error${thrownDetail(e)}`);
|
|
1262
1481
|
}
|
|
1263
1482
|
}
|
|
1264
1483
|
|
|
1265
1484
|
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
1266
|
-
const contentRes = await verifyDisclosedContent(ev, eventId,
|
|
1485
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
1267
1486
|
const contentOk = contentRes.contentOk;
|
|
1268
1487
|
evErrors.push(...contentRes.errors);
|
|
1269
1488
|
|
|
@@ -1279,7 +1498,15 @@ async function verifyAnchorTrace(
|
|
|
1279
1498
|
// present checkpoint, and its disclosed content did not MISMATCH. A
|
|
1280
1499
|
// withheld record or undisclosed content (binding/content null) does not
|
|
1281
1500
|
// fail it.
|
|
1282
|
-
|
|
1501
|
+
// `!eventBytesMalformed`: present-and-unreadable record bytes are a
|
|
1502
|
+
// defect in the artifact, never a disclosure choice (proof#85).
|
|
1503
|
+
ok:
|
|
1504
|
+
envelopeOk &&
|
|
1505
|
+
inclusionOk &&
|
|
1506
|
+
checkpointBound &&
|
|
1507
|
+
contentOk !== false &&
|
|
1508
|
+
legacyError === null &&
|
|
1509
|
+
!eventBytesMalformed,
|
|
1283
1510
|
errors: evErrors,
|
|
1284
1511
|
});
|
|
1285
1512
|
}
|
|
@@ -1287,6 +1514,8 @@ async function verifyAnchorTrace(
|
|
|
1287
1514
|
// --- Step 5: optional on-chain re-fetch ------------------------------------
|
|
1288
1515
|
const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
|
|
1289
1516
|
let gatewayUnavailable = false;
|
|
1517
|
+
let uncheckedKind = false;
|
|
1518
|
+
let uncheckedId = false;
|
|
1290
1519
|
if (onChainChecked) {
|
|
1291
1520
|
if (typeof fetchImpl !== "function") {
|
|
1292
1521
|
gatewayUnavailable = true;
|
|
@@ -1301,6 +1530,20 @@ async function verifyAnchorTrace(
|
|
|
1301
1530
|
// the run to partial) and NEVER as a mismatch. Treating "we never
|
|
1302
1531
|
// anchored it yet" as "the chain disagrees" would be the same false
|
|
1303
1532
|
// accusation this whole change is removing.
|
|
1533
|
+
// proof#120. An `anchor_ref` that failed structural validation is not
|
|
1534
|
+
// an absent one. Nothing was fetched, so the witness dimension is NOT
|
|
1535
|
+
// CHECKED — `onChain: null`, exactly as when the caller supplied no
|
|
1536
|
+
// adapter — and it must not raise `gatewayUnavailable`, which is what
|
|
1537
|
+
// puts "could not be re-fetched from the supplied gateways" on the
|
|
1538
|
+
// bundle. Reporting an outage nobody observed is a claim about the
|
|
1539
|
+
// public record made out of a defect in the artifact, and verify-page
|
|
1540
|
+
// renders it word for word. The run still fails: the structural error
|
|
1541
|
+
// is already in `cpr.errors` and already in `ok` above.
|
|
1542
|
+
if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
|
|
1543
|
+
cpr.onChain = null;
|
|
1544
|
+
cpr.onChainOk = null;
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1304
1547
|
if (cpr.anchorRef === null) {
|
|
1305
1548
|
gatewayUnavailable = true;
|
|
1306
1549
|
cpr.onChain = {
|
|
@@ -1314,8 +1557,10 @@ async function verifyAnchorTrace(
|
|
|
1314
1557
|
}
|
|
1315
1558
|
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
1316
1559
|
cpr.onChain = outcome.onChain;
|
|
1317
|
-
cpr.onChainOk = outcome.onChain
|
|
1560
|
+
cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
1318
1561
|
if (outcome.unavailable) gatewayUnavailable = true;
|
|
1562
|
+
if (outcome.unchecked === "kind") uncheckedKind = true;
|
|
1563
|
+
if (outcome.unchecked === "id") uncheckedId = true;
|
|
1319
1564
|
if (outcome.error) cpr.errors.push(outcome.error);
|
|
1320
1565
|
// Fold the on-chain dimension in: a MISMATCH (false) fails the
|
|
1321
1566
|
// checkpoint — the proof was not anchored as claimed. An UNREACHABLE
|
|
@@ -1347,12 +1592,16 @@ async function verifyAnchorTrace(
|
|
|
1347
1592
|
let status: EvidenceStatus;
|
|
1348
1593
|
if (anyHardFailure) {
|
|
1349
1594
|
status = "failed";
|
|
1350
|
-
} else if (gatewayUnavailable) {
|
|
1595
|
+
} else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
|
|
1351
1596
|
// Every offline check passed and nothing MISMATCHED, but a requested
|
|
1352
1597
|
// on-chain confirmation could not be made — the bundle is offline-sound,
|
|
1353
1598
|
// its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
|
|
1354
1599
|
status = "partial";
|
|
1355
|
-
errors
|
|
1600
|
+
pushRefetchGapNotes(errors, "checkpoints", {
|
|
1601
|
+
unavailable: gatewayUnavailable,
|
|
1602
|
+
kind: uncheckedKind,
|
|
1603
|
+
id: uncheckedId,
|
|
1604
|
+
});
|
|
1356
1605
|
} else if (anyWithheldRecord) {
|
|
1357
1606
|
// Everything that COULD be checked passed; some record bytes were withheld
|
|
1358
1607
|
// so their semantic binding is undetermined (not a failure) — "partial".
|
|
@@ -1444,7 +1693,7 @@ function isNonNegInt(n: unknown): n is number {
|
|
|
1444
1693
|
// ===========================================================================
|
|
1445
1694
|
// Disclosed-content binding, shared by every body that carries events.
|
|
1446
1695
|
//
|
|
1447
|
-
// Extracted for `sanning.stamp.evidence/
|
|
1696
|
+
// Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
|
|
1448
1697
|
// rawLog -> content_hash link is the one step the chain
|
|
1449
1698
|
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
1450
1699
|
// not otherwise cover, and a second copy of it is a second place for the
|
|
@@ -1456,7 +1705,7 @@ function isNonNegInt(n: unknown): n is number {
|
|
|
1456
1705
|
async function verifyDisclosedContent(
|
|
1457
1706
|
ev: { envelope?: unknown; content?: unknown },
|
|
1458
1707
|
eventId: string,
|
|
1459
|
-
|
|
1708
|
+
eventBytes: Uint8Array | undefined,
|
|
1460
1709
|
sideContent: VerifyEvidenceOptions["content"],
|
|
1461
1710
|
): Promise<{ contentOk: boolean | null; errors: string[] }> {
|
|
1462
1711
|
const evErrors: string[] = [];
|
|
@@ -1464,9 +1713,9 @@ async function verifyDisclosedContent(
|
|
|
1464
1713
|
|
|
1465
1714
|
const committed: string[] = [];
|
|
1466
1715
|
let recordContentHash: string | undefined;
|
|
1467
|
-
if (
|
|
1716
|
+
if (eventBytes !== undefined) {
|
|
1468
1717
|
try {
|
|
1469
|
-
const record =
|
|
1718
|
+
const record = jcsParse(eventBytes) as {
|
|
1470
1719
|
event?: { content_hash?: unknown };
|
|
1471
1720
|
};
|
|
1472
1721
|
const ch = record.event?.content_hash;
|
|
@@ -1575,7 +1824,7 @@ async function verifyStampEntry(
|
|
|
1575
1824
|
try {
|
|
1576
1825
|
bytes = hexToBytes(e.stamp_bytes);
|
|
1577
1826
|
} catch (err) {
|
|
1578
|
-
errors.push(`stamp_bytes is not hex
|
|
1827
|
+
errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
|
|
1579
1828
|
return { result: base };
|
|
1580
1829
|
}
|
|
1581
1830
|
// Gated by `isLowerHex` above, so this is already the exact lowercase hex of
|
|
@@ -1608,7 +1857,7 @@ async function verifyStampEntry(
|
|
|
1608
1857
|
// --- the body itself ------------------------------------------------------
|
|
1609
1858
|
let body: StampBody;
|
|
1610
1859
|
try {
|
|
1611
|
-
const parsed: unknown =
|
|
1860
|
+
const parsed: unknown = jcsParse(bytes);
|
|
1612
1861
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1613
1862
|
errors.push("stamp body is not a JSON object");
|
|
1614
1863
|
return { result: base, witnessedHex };
|
|
@@ -1618,12 +1867,12 @@ async function verifyStampEntry(
|
|
|
1618
1867
|
// every submit attempt for exactly this reason: a non-canonical body makes
|
|
1619
1868
|
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
1620
1869
|
// of the stamp disagree with the one that was witnessed.
|
|
1621
|
-
if (jcs(body as unknown as Record<string, unknown>) !==
|
|
1870
|
+
if (jcs(body as unknown as Record<string, unknown>) !== decodeUtf8Strict(bytes)) {
|
|
1622
1871
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1623
1872
|
return { result: base, witnessedHex };
|
|
1624
1873
|
}
|
|
1625
1874
|
} catch (err) {
|
|
1626
|
-
errors.push(`stamp body is not JSON
|
|
1875
|
+
errors.push(`stamp body is not JSON${thrownDetail(err)}`);
|
|
1627
1876
|
return { result: base, witnessedHex };
|
|
1628
1877
|
}
|
|
1629
1878
|
|
|
@@ -1666,7 +1915,10 @@ async function verifyStampEntry(
|
|
|
1666
1915
|
// --- the sub-tree ranges --------------------------------------------------
|
|
1667
1916
|
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
1668
1917
|
|
|
1669
|
-
|
|
1918
|
+
// A stamp needs no malformed/absent split: the §5.2 re-fetch path already
|
|
1919
|
+
// leaves `onChain` null whenever there is no `AnchorRef`, so it never
|
|
1920
|
+
// synthesised the outage proof#120 is about.
|
|
1921
|
+
base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
|
|
1670
1922
|
|
|
1671
1923
|
base.ok =
|
|
1672
1924
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
@@ -1762,7 +2014,7 @@ async function verifyStampTrace(
|
|
|
1762
2014
|
): Promise<EvidenceBundleResult> {
|
|
1763
2015
|
const body = b.body as StampTraceBody;
|
|
1764
2016
|
if (!Array.isArray(body?.stamps)) {
|
|
1765
|
-
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
2017
|
+
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
|
|
1766
2018
|
}
|
|
1767
2019
|
|
|
1768
2020
|
const seen = new Set<string>();
|
|
@@ -1781,7 +2033,7 @@ async function verifyStampTrace(
|
|
|
1781
2033
|
// other — asks every consumer to remember which wrapper it is holding before
|
|
1782
2034
|
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
1783
2035
|
// exception a reader eventually gets wrong.
|
|
1784
|
-
const { onChainChecked, gatewayUnavailable } = await refetchStamps(
|
|
2036
|
+
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
|
|
1785
2037
|
stamps,
|
|
1786
2038
|
witnessed,
|
|
1787
2039
|
options,
|
|
@@ -1801,13 +2053,15 @@ async function verifyStampTrace(
|
|
|
1801
2053
|
// does not have.
|
|
1802
2054
|
status = "partial";
|
|
1803
2055
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1804
|
-
} else if (gatewayUnavailable || anyUnwitnessed) {
|
|
2056
|
+
} else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
|
|
1805
2057
|
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
1806
2058
|
// for why one note is not enough when there are two different gaps.
|
|
1807
2059
|
status = "partial";
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2060
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
2061
|
+
unavailable: gatewayUnavailable,
|
|
2062
|
+
kind: uncheckedKind,
|
|
2063
|
+
id: uncheckedId,
|
|
2064
|
+
});
|
|
1811
2065
|
if (anyUnwitnessed) {
|
|
1812
2066
|
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1813
2067
|
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
@@ -1840,7 +2094,7 @@ async function verifyStampTrace(
|
|
|
1840
2094
|
}
|
|
1841
2095
|
|
|
1842
2096
|
// ===========================================================================
|
|
1843
|
-
// `sanning.stamp.evidence/
|
|
2097
|
+
// `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
|
|
1844
2098
|
//
|
|
1845
2099
|
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1846
2100
|
// the sub-tree partition check). The event half reuses the same primitives the
|
|
@@ -1863,7 +2117,11 @@ async function verifyStampEvidence(
|
|
|
1863
2117
|
): Promise<EvidenceBundleResult> {
|
|
1864
2118
|
const body = b.body as StampEvidenceBody;
|
|
1865
2119
|
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1866
|
-
return malformed(
|
|
2120
|
+
return malformed(
|
|
2121
|
+
`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`,
|
|
2122
|
+
true,
|
|
2123
|
+
true,
|
|
2124
|
+
);
|
|
1867
2125
|
}
|
|
1868
2126
|
|
|
1869
2127
|
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
@@ -1918,18 +2176,42 @@ async function verifyStampEvidence(
|
|
|
1918
2176
|
let envelopeOk = false;
|
|
1919
2177
|
let payloadBindingOk: boolean | null = null;
|
|
1920
2178
|
|
|
1921
|
-
|
|
2179
|
+
// A retired field name under this major is a reject, never an ignored
|
|
2180
|
+
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
2181
|
+
const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
|
|
2182
|
+
if (legacyError !== null) evErrors.push(legacyError);
|
|
2183
|
+
|
|
2184
|
+
// MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
|
|
2185
|
+
//
|
|
2186
|
+
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
2187
|
+
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
2188
|
+
// line down. So an event carrying garbage in place of its record used to
|
|
2189
|
+
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
2190
|
+
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
2191
|
+
// a record nobody could read. The only thing wrong with the pack was the
|
|
2192
|
+
// bytes, and it was the one thing the verdict did not say.
|
|
2193
|
+
//
|
|
2194
|
+
// A record the producer chose not to disclose is the customer exercising
|
|
2195
|
+
// minimum disclosure. A record that is present and unreadable is a defect
|
|
2196
|
+
// in the artifact, and this kernel's standing rule (the same one
|
|
2197
|
+
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
2198
|
+
// we can see fails.
|
|
2199
|
+
let eventBytes: Uint8Array | undefined;
|
|
2200
|
+
let eventBytesMalformed = false;
|
|
1922
2201
|
try {
|
|
1923
|
-
if (typeof ev.
|
|
2202
|
+
if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
|
|
1924
2203
|
} catch (e) {
|
|
1925
|
-
|
|
2204
|
+
eventBytesMalformed = true;
|
|
2205
|
+
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1926
2206
|
}
|
|
1927
2207
|
try {
|
|
1928
2208
|
const res = await verifyEnvelope(
|
|
1929
2209
|
ev.envelope,
|
|
1930
|
-
|
|
2210
|
+
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1931
2211
|
);
|
|
1932
|
-
|
|
2212
|
+
const hexError = envelopeHexError("event", ev.envelope);
|
|
2213
|
+
// tolerates an undetermined binding (withheld record)
|
|
2214
|
+
envelopeOk = res.ok && hexError === null;
|
|
1933
2215
|
payloadBindingOk = res.payloadHashOk;
|
|
1934
2216
|
// P-10: report what could not be determined, never a fault that was never
|
|
1935
2217
|
// observed. There is no envelope here, so no signature was checked and no
|
|
@@ -1940,6 +2222,8 @@ async function verifyStampEvidence(
|
|
|
1940
2222
|
// the producer at all (proof#76).
|
|
1941
2223
|
if (!isJsonObject(ev.envelope)) {
|
|
1942
2224
|
evErrors.push("event envelope is not a JSON object");
|
|
2225
|
+
} else if (hexError !== null) {
|
|
2226
|
+
evErrors.push(hexError);
|
|
1943
2227
|
} else if (!res.signatureOk) {
|
|
1944
2228
|
evErrors.push("event envelope signature failed");
|
|
1945
2229
|
}
|
|
@@ -1947,8 +2231,10 @@ async function verifyStampEvidence(
|
|
|
1947
2231
|
// A malformed skeleton field fails the envelope with a valid signature
|
|
1948
2232
|
// and binding (proof#95), so without this the event fails with no reason.
|
|
1949
2233
|
for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
|
|
2234
|
+
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
2235
|
+
if (specFinding !== null) evErrors.push(specFinding);
|
|
1950
2236
|
} catch (e) {
|
|
1951
|
-
evErrors.push(`event envelope verify error
|
|
2237
|
+
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
1952
2238
|
}
|
|
1953
2239
|
|
|
1954
2240
|
// --- The join (§5.3.3 rule 1) -------------------------------------------
|
|
@@ -2011,12 +2297,12 @@ async function verifyStampEvidence(
|
|
|
2011
2297
|
);
|
|
2012
2298
|
}
|
|
2013
2299
|
} catch (e) {
|
|
2014
|
-
evErrors.push(`inclusion verify error
|
|
2300
|
+
evErrors.push(`inclusion verify error${thrownDetail(e)}`);
|
|
2015
2301
|
}
|
|
2016
2302
|
}
|
|
2017
2303
|
}
|
|
2018
2304
|
|
|
2019
|
-
const contentRes = await verifyDisclosedContent(ev, eventId,
|
|
2305
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
2020
2306
|
const contentOk = contentRes.contentOk;
|
|
2021
2307
|
evErrors.push(...contentRes.errors);
|
|
2022
2308
|
|
|
@@ -2028,13 +2314,21 @@ async function verifyStampEvidence(
|
|
|
2028
2314
|
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
2029
2315
|
checkpointBound: stampBound,
|
|
2030
2316
|
contentOk,
|
|
2031
|
-
|
|
2317
|
+
// `!eventBytesMalformed`: present-and-unreadable record bytes are a
|
|
2318
|
+
// defect in the artifact, never a disclosure choice (proof#85).
|
|
2319
|
+
ok:
|
|
2320
|
+
envelopeOk &&
|
|
2321
|
+
inclusionOk &&
|
|
2322
|
+
stampBound &&
|
|
2323
|
+
contentOk !== false &&
|
|
2324
|
+
legacyError === null &&
|
|
2325
|
+
!eventBytesMalformed,
|
|
2032
2326
|
errors: evErrors,
|
|
2033
2327
|
});
|
|
2034
2328
|
}
|
|
2035
2329
|
|
|
2036
2330
|
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
2037
|
-
const { onChainChecked, gatewayUnavailable } = await refetchStamps(
|
|
2331
|
+
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
|
|
2038
2332
|
stamps,
|
|
2039
2333
|
witnessed,
|
|
2040
2334
|
options,
|
|
@@ -2054,16 +2348,31 @@ async function verifyStampEvidence(
|
|
|
2054
2348
|
} else if (stamps.length === 0 && eventResults.length === 0) {
|
|
2055
2349
|
status = "partial";
|
|
2056
2350
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
2057
|
-
} else if (
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2351
|
+
} else if (
|
|
2352
|
+
eventResults.length === 0 ||
|
|
2353
|
+
gatewayUnavailable ||
|
|
2354
|
+
uncheckedKind ||
|
|
2355
|
+
uncheckedId ||
|
|
2356
|
+
anyUnwitnessed
|
|
2357
|
+
) {
|
|
2358
|
+
// Each holds the bundle at `partial`, and EACH gets said. A pack with no
|
|
2359
|
+
// events, a stamp nobody witnessed and a witness we could not reach are
|
|
2360
|
+
// different gaps with different fixes, and an auditor told only about one
|
|
2361
|
+
// would read the others as fine. The order is fixed so the two kernels
|
|
2362
|
+
// emit the same list.
|
|
2063
2363
|
status = "partial";
|
|
2064
|
-
if (
|
|
2065
|
-
|
|
2364
|
+
if (eventResults.length === 0) {
|
|
2365
|
+
// §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
|
|
2366
|
+
// partial, never verified. It says nothing about any event.
|
|
2367
|
+
errors.push(
|
|
2368
|
+
`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`,
|
|
2369
|
+
);
|
|
2066
2370
|
}
|
|
2371
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
2372
|
+
unavailable: gatewayUnavailable,
|
|
2373
|
+
kind: uncheckedKind,
|
|
2374
|
+
id: uncheckedId,
|
|
2375
|
+
});
|
|
2067
2376
|
if (anyUnwitnessed) {
|
|
2068
2377
|
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
2069
2378
|
// the independent part, so the events resting on it inherit `partial`.
|
|
@@ -2129,18 +2438,21 @@ async function refetchStamps(
|
|
|
2129
2438
|
witnessed: (string | undefined)[],
|
|
2130
2439
|
options: VerifyEvidenceOptions,
|
|
2131
2440
|
errors: string[],
|
|
2132
|
-
): Promise<
|
|
2441
|
+
): Promise<StampRefetchSummary> {
|
|
2133
2442
|
const gateways = options.gateways ?? [];
|
|
2134
2443
|
const onChainChecked = gateways.length > 0;
|
|
2135
|
-
|
|
2444
|
+
const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
|
|
2445
|
+
if (!onChainChecked) return { onChainChecked, ...none };
|
|
2136
2446
|
|
|
2137
2447
|
const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
|
|
2138
2448
|
if (typeof fetchImpl !== "function") {
|
|
2139
2449
|
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
2140
|
-
return { onChainChecked, gatewayUnavailable: true };
|
|
2450
|
+
return { onChainChecked, ...none, gatewayUnavailable: true };
|
|
2141
2451
|
}
|
|
2142
2452
|
|
|
2143
2453
|
let gatewayUnavailable = false;
|
|
2454
|
+
let uncheckedKind = false;
|
|
2455
|
+
let uncheckedId = false;
|
|
2144
2456
|
for (let i = 0; i < stamps.length; i++) {
|
|
2145
2457
|
const st = stamps[i]!;
|
|
2146
2458
|
const witnessedHex = witnessed[i];
|
|
@@ -2150,8 +2462,10 @@ async function refetchStamps(
|
|
|
2150
2462
|
|
|
2151
2463
|
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
2152
2464
|
st.onChain = outcome.onChain;
|
|
2153
|
-
st.onChainOk = outcome.onChain
|
|
2465
|
+
st.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
2154
2466
|
if (outcome.unavailable) gatewayUnavailable = true;
|
|
2467
|
+
if (outcome.unchecked === "kind") uncheckedKind = true;
|
|
2468
|
+
if (outcome.unchecked === "id") uncheckedId = true;
|
|
2155
2469
|
if (outcome.error) st.errors.push(outcome.error);
|
|
2156
2470
|
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
2157
2471
|
// (false) fails the stamp — the chain does not hold what the bundle says
|
|
@@ -2164,7 +2478,14 @@ async function refetchStamps(
|
|
|
2164
2478
|
// unreachable note would fail a stamp for a gateway being down.
|
|
2165
2479
|
st.ok = st.ok && st.onChainOk !== false;
|
|
2166
2480
|
}
|
|
2167
|
-
return { onChainChecked, gatewayUnavailable };
|
|
2481
|
+
return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
interface StampRefetchSummary {
|
|
2485
|
+
onChainChecked: boolean;
|
|
2486
|
+
gatewayUnavailable: boolean;
|
|
2487
|
+
uncheckedKind: boolean;
|
|
2488
|
+
uncheckedId: boolean;
|
|
2168
2489
|
}
|
|
2169
2490
|
|
|
2170
2491
|
// ===========================================================================
|
|
@@ -2220,7 +2541,7 @@ export interface DisclosedContentResult {
|
|
|
2220
2541
|
errors: string[];
|
|
2221
2542
|
}
|
|
2222
2543
|
|
|
2223
|
-
// How many `sanning.evidence.export/
|
|
2544
|
+
// How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
|
|
2224
2545
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
2225
2546
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
2226
2547
|
// independently bounded rather than trusting that it ran first.
|
|
@@ -2259,7 +2580,7 @@ export async function disclosedContent(
|
|
|
2259
2580
|
const traceEvents = resolveTraceEvents(bundle);
|
|
2260
2581
|
if (traceEvents === undefined) {
|
|
2261
2582
|
errors.push(
|
|
2262
|
-
`no ${
|
|
2583
|
+
`no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
|
|
2263
2584
|
`body to materialize content from ` +
|
|
2264
2585
|
`(body_type ${JSON.stringify(verification.bodyType)})`,
|
|
2265
2586
|
);
|
|
@@ -2360,7 +2681,7 @@ export async function disclosedContent(
|
|
|
2360
2681
|
// a caller must distinguish from a body that has events and no links.
|
|
2361
2682
|
//
|
|
2362
2683
|
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
2363
|
-
// `sanning.stamp.evidence/
|
|
2684
|
+
// `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
|
|
2364
2685
|
// event shape.
|
|
2365
2686
|
//
|
|
2366
2687
|
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
@@ -2375,8 +2696,8 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
|
2375
2696
|
const b = cur as EvidenceBundle;
|
|
2376
2697
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
2377
2698
|
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
2378
|
-
// `sanning.stamp.evidence/
|
|
2379
|
-
// producer-signed envelope, optional `
|
|
2699
|
+
// `sanning.stamp.evidence/v2` presents the SAME event shape (a
|
|
2700
|
+
// producer-signed envelope, optional `event_bytes`, an inclusion proof),
|
|
2380
2701
|
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
2381
2702
|
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
2382
2703
|
// zero chains on every pack, however well-formed the links inside its
|
|
@@ -2384,7 +2705,6 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
|
2384
2705
|
// that matters. The writers have been emitting those links the whole time.
|
|
2385
2706
|
if (
|
|
2386
2707
|
bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
2387
|
-
bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
|
|
2388
2708
|
bodyType === STAMP_EVIDENCE_BODY_TYPE
|
|
2389
2709
|
) {
|
|
2390
2710
|
const body = b.body as AnchorTraceBody | null | undefined;
|
|
@@ -2482,14 +2802,14 @@ async function verifyExportBody(
|
|
|
2482
2802
|
);
|
|
2483
2803
|
}
|
|
2484
2804
|
} catch (e) {
|
|
2485
|
-
errors.push(`source_bundle canonicalization failed
|
|
2805
|
+
errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
|
|
2486
2806
|
}
|
|
2487
2807
|
|
|
2488
2808
|
// --- Step 4: recompute the source verdict ---------------------------------
|
|
2489
2809
|
// The existing evidence path over the inline source bundle — same primitives
|
|
2490
2810
|
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
2491
2811
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
2492
|
-
const sourceResult = await
|
|
2812
|
+
const sourceResult = await verifyExportSource(inline, options);
|
|
2493
2813
|
|
|
2494
2814
|
// Attestation data_hash binding (§5 6c).
|
|
2495
2815
|
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
@@ -2530,7 +2850,7 @@ async function verifyExportBody(
|
|
|
2530
2850
|
);
|
|
2531
2851
|
}
|
|
2532
2852
|
} catch (e) {
|
|
2533
|
-
errors.push(`verdict agreement comparison failed
|
|
2853
|
+
errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
|
|
2534
2854
|
}
|
|
2535
2855
|
|
|
2536
2856
|
// --- Step 7 already folded in by the source recompute ---------------------
|
|
@@ -2622,7 +2942,7 @@ export async function recomputeExportVerdict(
|
|
|
2622
2942
|
generatedAt: string,
|
|
2623
2943
|
options: VerifyEvidenceOptions = {},
|
|
2624
2944
|
): Promise<ComposeVerdictResult> {
|
|
2625
|
-
const sourceResult = await
|
|
2945
|
+
const sourceResult = await verifyExportSource(sourceBundle, options);
|
|
2626
2946
|
|
|
2627
2947
|
// Attestation data_hash binding (§5 6c) — the SAME index construction as
|
|
2628
2948
|
// verifyExportBody, deliberately shared rather than restated.
|
|
@@ -2668,6 +2988,52 @@ export async function recomputeExportVerdict(
|
|
|
2668
2988
|
// field; otherwise a per-dimension AttestationResult whose `ok` gates on the
|
|
2669
2989
|
// three binding checks (sig / operator-address / data_hash). subject_ref is
|
|
2670
2990
|
// undetermined-tolerant and never gates.
|
|
2991
|
+
// The attestation payload fields evidence-export.md §3.1 marks Required, with
|
|
2992
|
+
// the type it gives each. A record whose payload lacks one, or carries another
|
|
2993
|
+
// type (null included), is malformed: §5 step 9 and §4.5 make "a missing
|
|
2994
|
+
// required field" an export the verifier cannot render (exit 2). §5 step 6
|
|
2995
|
+
// gates a WELL-FORMED record on signature, operator and data_hash only; these
|
|
2996
|
+
// checks decide whether the record is well-formed at all. `level` is the §3.1
|
|
2997
|
+
// enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
|
|
2998
|
+
// §3.1 clarification).
|
|
2999
|
+
const PAYLOAD_REQUIRED: readonly (readonly [string, "string" | "boolean" | "integer" | "level"])[] = [
|
|
3000
|
+
["attested_at", "string"],
|
|
3001
|
+
["tx_id", "string"],
|
|
3002
|
+
["data_hash", "string"],
|
|
3003
|
+
["data_size", "integer"],
|
|
3004
|
+
["block_height", "integer"],
|
|
3005
|
+
["block_timestamp", "integer"],
|
|
3006
|
+
["operator", "string"],
|
|
3007
|
+
["owner_address", "string"],
|
|
3008
|
+
["gateway", "string"],
|
|
3009
|
+
["signature_verified", "boolean"],
|
|
3010
|
+
["level", "level"],
|
|
3011
|
+
];
|
|
3012
|
+
|
|
3013
|
+
function payloadFieldError(payload: Record<string, unknown>): string | null {
|
|
3014
|
+
for (const [name, kind] of PAYLOAD_REQUIRED) {
|
|
3015
|
+
const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
|
|
3016
|
+
let ok: boolean;
|
|
3017
|
+
let want: string;
|
|
3018
|
+
if (kind === "string") {
|
|
3019
|
+
ok = typeof v === "string";
|
|
3020
|
+
want = "a string";
|
|
3021
|
+
} else if (kind === "boolean") {
|
|
3022
|
+
ok = typeof v === "boolean";
|
|
3023
|
+
want = "a boolean";
|
|
3024
|
+
} else if (kind === "integer") {
|
|
3025
|
+
// §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
|
|
3026
|
+
ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
3027
|
+
want = "a non-negative integer";
|
|
3028
|
+
} else {
|
|
3029
|
+
ok = v === 1 || v === 2 || v === 3;
|
|
3030
|
+
want = "1, 2 or 3";
|
|
3031
|
+
}
|
|
3032
|
+
if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
3033
|
+
}
|
|
3034
|
+
return null;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2671
3037
|
async function verifyAttestation(
|
|
2672
3038
|
rec: AttestationRecord,
|
|
2673
3039
|
sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
|
|
@@ -2714,6 +3080,8 @@ async function verifyAttestation(
|
|
|
2714
3080
|
if (typeof rec.signature !== "string") {
|
|
2715
3081
|
return { result, malformed: "attestation record missing signature" };
|
|
2716
3082
|
}
|
|
3083
|
+
const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
|
|
3084
|
+
if (fieldError !== null) return { result, malformed: fieldError };
|
|
2717
3085
|
const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
|
|
2718
3086
|
|
|
2719
3087
|
result.operator = typeof payload.operator === "string" ? payload.operator : "";
|
|
@@ -2727,13 +3095,20 @@ async function verifyAttestation(
|
|
|
2727
3095
|
try {
|
|
2728
3096
|
payloadBytes = utf8(jcs(payload));
|
|
2729
3097
|
} catch (e) {
|
|
2730
|
-
return { result, malformed: `attestation payload canonicalization failed
|
|
3098
|
+
return { result, malformed: `attestation payload canonicalization failed${thrownDetail(e)}` };
|
|
2731
3099
|
}
|
|
2732
3100
|
try {
|
|
2733
3101
|
result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
|
|
2734
3102
|
if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
|
|
2735
|
-
} catch
|
|
2736
|
-
|
|
3103
|
+
} catch {
|
|
3104
|
+
// A FIXED sentence, not the library's. This kernel throws
|
|
3105
|
+
// `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
|
|
3106
|
+
// raises `malformed signature hex: ...`, so quoting either one split the
|
|
3107
|
+
// two kernels on a finding a holder can put in an export body it signs,
|
|
3108
|
+
// and an export pack's findings reach a case reading through `reasons`
|
|
3109
|
+
// (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
|
|
3110
|
+
// get this kernel's own words.
|
|
3111
|
+
return { result, malformed: "attestation signature or key is malformed" };
|
|
2737
3112
|
}
|
|
2738
3113
|
|
|
2739
3114
|
// (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
|
|
@@ -2746,7 +3121,7 @@ async function verifyAttestation(
|
|
|
2746
3121
|
);
|
|
2747
3122
|
}
|
|
2748
3123
|
} catch (e) {
|
|
2749
|
-
return { result, malformed: `attestation operator key
|
|
3124
|
+
return { result, malformed: `attestation operator key${thrownDetail(e)}` };
|
|
2750
3125
|
}
|
|
2751
3126
|
|
|
2752
3127
|
// (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
|
|
@@ -2771,7 +3146,7 @@ async function verifyAttestation(
|
|
|
2771
3146
|
);
|
|
2772
3147
|
}
|
|
2773
3148
|
} catch (e) {
|
|
2774
|
-
errs.push(`cannot recompute checkpoint content hash
|
|
3149
|
+
errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
|
|
2775
3150
|
}
|
|
2776
3151
|
}
|
|
2777
3152
|
|
|
@@ -2953,7 +3328,7 @@ async function refetchCheckpoint(
|
|
|
2953
3328
|
anchorRef: AnchorRef,
|
|
2954
3329
|
gateways: string[],
|
|
2955
3330
|
fetchImpl: typeof fetch,
|
|
2956
|
-
): Promise<
|
|
3331
|
+
): Promise<RefetchOutcome> {
|
|
2957
3332
|
let expectedHex: string | undefined;
|
|
2958
3333
|
try {
|
|
2959
3334
|
// A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
|
|
@@ -2999,7 +3374,7 @@ async function refetchStamp(
|
|
|
2999
3374
|
anchorRef: AnchorRef,
|
|
3000
3375
|
gateways: string[],
|
|
3001
3376
|
fetchImpl: typeof fetch,
|
|
3002
|
-
): Promise<
|
|
3377
|
+
): Promise<RefetchOutcome> {
|
|
3003
3378
|
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
3004
3379
|
}
|
|
3005
3380
|
|
|
@@ -3014,7 +3389,36 @@ async function refetchAnchored(
|
|
|
3014
3389
|
anchorRef: AnchorRef,
|
|
3015
3390
|
gateways: string[],
|
|
3016
3391
|
fetchImpl: typeof fetch,
|
|
3017
|
-
): Promise<
|
|
3392
|
+
): Promise<RefetchOutcome> {
|
|
3393
|
+
// Only a `kind` this kernel has a witness adapter for is re-fetched
|
|
3394
|
+
// (evidence-bundle.md §5.1: "where the verifier has an adapter for its
|
|
3395
|
+
// `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
|
|
3396
|
+
// of any other kind is not an Arweave id, so whatever a gateway serves at
|
|
3397
|
+
// that path says nothing about the witness, and comparing it would turn an
|
|
3398
|
+
// unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
|
|
3399
|
+
// run goes `partial`: the caller asked for the witness check and it was not
|
|
3400
|
+
// made.
|
|
3401
|
+
if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
|
|
3402
|
+
return {
|
|
3403
|
+
onChain: null,
|
|
3404
|
+
unavailable: false,
|
|
3405
|
+
unchecked: "kind",
|
|
3406
|
+
error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3409
|
+
// The id becomes a URL path segment. Anything but a 43-character base64url
|
|
3410
|
+
// transaction id can leave the gateway's base path once URL parsing
|
|
3411
|
+
// normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
|
|
3412
|
+
// `a?b`), and the two kernels would then request different URLs. Such an id
|
|
3413
|
+
// is never fetched and never failed: the witness is not checked.
|
|
3414
|
+
if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
|
|
3415
|
+
return {
|
|
3416
|
+
onChain: null,
|
|
3417
|
+
unavailable: false,
|
|
3418
|
+
unchecked: "id",
|
|
3419
|
+
error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
|
|
3420
|
+
};
|
|
3421
|
+
}
|
|
3018
3422
|
const perGateway: PerGatewayOutcome[] = [];
|
|
3019
3423
|
for (const gw of gateways) {
|
|
3020
3424
|
perGateway.push({
|
|
@@ -3033,7 +3437,51 @@ async function refetchAnchored(
|
|
|
3033
3437
|
} else if (onChain.rollup === "unreachable") {
|
|
3034
3438
|
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
3035
3439
|
}
|
|
3036
|
-
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
3440
|
+
return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
// The `anchor_ref.kind` values this kernel can re-fetch from a gateway
|
|
3444
|
+
// (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
|
|
3445
|
+
// left unchecked by `refetchAnchored`, never probed.
|
|
3446
|
+
const WITNESS_ADAPTER_KINDS: ReadonlySet<string> = new Set(["arweave"]);
|
|
3447
|
+
|
|
3448
|
+
// The only `arweave` ids the adapter looks up: a 43-character base64url
|
|
3449
|
+
// transaction or data-item id.
|
|
3450
|
+
const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
3451
|
+
|
|
3452
|
+
// Why a re-fetch was not made: `kind` (no adapter for the witness kind) or
|
|
3453
|
+
// `id` (an arweave id the adapter cannot look up). null when it was made.
|
|
3454
|
+
type Unchecked = "kind" | "id" | null;
|
|
3455
|
+
|
|
3456
|
+
interface RefetchOutcome {
|
|
3457
|
+
onChain: OnChainResult | null;
|
|
3458
|
+
// Every gateway was unreachable.
|
|
3459
|
+
unavailable: boolean;
|
|
3460
|
+
unchecked: Unchecked;
|
|
3461
|
+
error?: string;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
// The bundle-level notes for a run where requested re-fetches were not made.
|
|
3465
|
+
// Each note keeps the phrase "could not be re-fetched", which is what both
|
|
3466
|
+
// CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
|
|
3467
|
+
function pushRefetchGapNotes(
|
|
3468
|
+
errors: string[],
|
|
3469
|
+
subject: "stamps" | "checkpoints",
|
|
3470
|
+
gaps: { unavailable: boolean; kind: boolean; id: boolean },
|
|
3471
|
+
): void {
|
|
3472
|
+
if (gaps.unavailable) {
|
|
3473
|
+
errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
|
|
3474
|
+
}
|
|
3475
|
+
if (gaps.kind) {
|
|
3476
|
+
errors.push(
|
|
3477
|
+
`one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`,
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
if (gaps.id) {
|
|
3481
|
+
errors.push(
|
|
3482
|
+
`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`,
|
|
3483
|
+
);
|
|
3484
|
+
}
|
|
3037
3485
|
}
|
|
3038
3486
|
|
|
3039
3487
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
@@ -3065,7 +3513,7 @@ async function probeGateway(
|
|
|
3065
3513
|
// the load-bearing check.
|
|
3066
3514
|
if (toHex(onChainBytes) === expectedHex) return "confirm";
|
|
3067
3515
|
try {
|
|
3068
|
-
const parsed =
|
|
3516
|
+
const parsed = jcsParse(onChainBytes);
|
|
3069
3517
|
if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
|
|
3070
3518
|
} catch {
|
|
3071
3519
|
// not JSON / not parseable — a genuine mismatch
|
|
@@ -3131,7 +3579,7 @@ function selectDisclosedBytes(
|
|
|
3131
3579
|
try {
|
|
3132
3580
|
inBody = hexToBytes(ev.content);
|
|
3133
3581
|
} catch (e) {
|
|
3134
|
-
errors.push(`event content is not hex
|
|
3582
|
+
errors.push(`event content is not hex${thrownDetail(e)}`);
|
|
3135
3583
|
}
|
|
3136
3584
|
}
|
|
3137
3585
|
let side: Uint8Array | undefined;
|
|
@@ -3142,7 +3590,7 @@ function selectDisclosedBytes(
|
|
|
3142
3590
|
try {
|
|
3143
3591
|
side = hexToBytes(sideEntry.value);
|
|
3144
3592
|
} catch (e) {
|
|
3145
|
-
errors.push(`supplied content for ${eventId} is not hex
|
|
3593
|
+
errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
|
|
3146
3594
|
}
|
|
3147
3595
|
}
|
|
3148
3596
|
// `offered` comes from the SAME test that produced the value, never from
|