@sanning/proof 0.9.1 → 0.10.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 +5 -5
- package/dist/agent-proof.js +5 -0
- package/dist/chain.js +4 -4
- package/dist/cli.js +13 -10
- package/dist/compose.js +3 -3
- package/dist/crypto.js +1 -1
- package/dist/evidence.d.ts +29 -54
- package/dist/evidence.js +349 -141
- package/dist/grading.d.ts +1 -1
- package/dist/grading.js +22 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -6
- package/dist/verifier.d.ts +1 -0
- package/dist/verifier.js +66 -4
- package/package.json +2 -2
- package/src/agent-proof.ts +3 -0
- package/src/chain.ts +4 -4
- package/src/cli.ts +13 -10
- package/src/compose.ts +4 -4
- package/src/crypto.ts +1 -1
- package/src/evidence.ts +418 -196
- package/src/grading.ts +28 -5
- package/src/index.ts +5 -8
- package/src/verifier.ts +67 -4
- package/vectors/README.md +1 -1
- package/vectors/chain-reading.json +4 -4
- 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
|
//
|
|
@@ -48,37 +48,73 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
|
|
|
48
48
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
49
49
|
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
50
50
|
// "undetermined" rather than "verified".
|
|
51
|
-
|
|
51
|
+
//
|
|
52
|
+
// The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
|
|
53
|
+
// `event_bytes` and moved every body that carries it to a new major. This
|
|
54
|
+
// kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
|
|
55
|
+
// `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
|
|
56
|
+
// types here and answer `partial`. DL-73 releases evidence made before the cut
|
|
57
|
+
// from any obligation to keep verifying, so there is no second field name to
|
|
58
|
+
// read and no dual-accept path.
|
|
59
|
+
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* defend (DL-30). Nothing new emits it.
|
|
62
|
+
* The name `event_bytes` replaced. Under the new majors a body entry that still
|
|
63
|
+
* carries it is a hard reject, never an ignored unknown field: ignored, it
|
|
64
|
+
* would read as a withheld record and the event would verify with nothing
|
|
65
|
+
* checked, the fail-open the new majors exist to prevent (governance.md §8,
|
|
66
|
+
* W-3).
|
|
60
67
|
*/
|
|
61
|
-
export const
|
|
68
|
+
export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Name an envelope the accept-set refused. Without this a bundle whose
|
|
72
|
+
* envelopes carry a retired profile major (a half-migrated producer still
|
|
73
|
+
* writing `sanning.events/v1`) fails with no reason at all. Spelled with
|
|
74
|
+
* `JSON.stringify` so both kernels write the same sentence.
|
|
75
|
+
*/
|
|
76
|
+
function specVersionFinding(
|
|
77
|
+
what: "checkpoint" | "event",
|
|
78
|
+
envelope: unknown,
|
|
79
|
+
res: { specVersionOk: boolean },
|
|
80
|
+
): string | null {
|
|
81
|
+
if (!isJsonObject(envelope) || res.specVersionOk) return null;
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
|
|
83
|
+
return `${what} envelope has no spec_version`;
|
|
84
|
+
}
|
|
85
|
+
const sv = (envelope as Record<string, unknown>).spec_version;
|
|
86
|
+
return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
|
|
87
|
+
}
|
|
62
88
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The reject for an entry that still carries `record_bytes`. One sentence,
|
|
91
|
+
* spelled the same in both kernels, so a negative vector can assert it.
|
|
92
|
+
*/
|
|
93
|
+
function legacyEventBytesError(
|
|
94
|
+
entry: object,
|
|
95
|
+
what: "checkpoint" | "event",
|
|
96
|
+
bodyType: string,
|
|
97
|
+
): string | null {
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD)) return null;
|
|
99
|
+
return (
|
|
100
|
+
`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
|
|
101
|
+
"(renamed event_bytes) -- one bundle speaks one vocabulary"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
68
104
|
|
|
69
105
|
// The issuer-composed attested-evidence-export body (specs/evidence-export.md
|
|
70
106
|
// §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
|
|
71
107
|
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
72
108
|
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
73
109
|
// verifyExportBody (§5).
|
|
74
|
-
export const EXPORT_BODY_TYPE = "sanning.evidence.export/
|
|
110
|
+
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
|
|
75
111
|
|
|
76
112
|
/**
|
|
77
113
|
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
78
114
|
*
|
|
79
115
|
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
80
116
|
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
81
|
-
* `sanning.events/
|
|
117
|
+
* `sanning.events/v2` envelope, and a stamp has no producer signature at all:
|
|
82
118
|
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
83
119
|
* The two rejected options were making `envelope` optional and synthesising an
|
|
84
120
|
* envelope that Sanning signs; the second would have made a Sanning
|
|
@@ -86,7 +122,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
86
122
|
* unrecoverable once witnessed.
|
|
87
123
|
*/
|
|
88
124
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
89
|
-
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/
|
|
125
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
|
|
90
126
|
|
|
91
127
|
/**
|
|
92
128
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
@@ -107,7 +143,6 @@ export interface EvidenceIssuer {
|
|
|
107
143
|
kind: string;
|
|
108
144
|
tenant_id?: string;
|
|
109
145
|
agent_id?: string;
|
|
110
|
-
producer_id?: string;
|
|
111
146
|
[k: string]: unknown;
|
|
112
147
|
}
|
|
113
148
|
|
|
@@ -136,13 +171,12 @@ export interface AnchorRef {
|
|
|
136
171
|
ref?: Record<string, unknown>;
|
|
137
172
|
}
|
|
138
173
|
|
|
139
|
-
// `sanning.anchor.trace/
|
|
174
|
+
// `sanning.anchor.trace/v3` body (§5.1). Uint8Array fields ride as lowercase hex.
|
|
140
175
|
export interface TraceCheckpoint {
|
|
141
176
|
/**
|
|
142
177
|
* 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.
|
|
178
|
+
* decoded `event_bytes`. Recomputed by the verifier, never taken from the
|
|
179
|
+
* label.
|
|
146
180
|
*/
|
|
147
181
|
checkpoint_ref: string;
|
|
148
182
|
/**
|
|
@@ -152,32 +186,24 @@ export interface TraceCheckpoint {
|
|
|
152
186
|
*/
|
|
153
187
|
anchor_ref?: AnchorRef;
|
|
154
188
|
envelope: Envelope;
|
|
155
|
-
|
|
189
|
+
event_bytes: string; // hex of JCS(checkpoint record)
|
|
156
190
|
merkle_root: string; // hex
|
|
157
191
|
}
|
|
158
192
|
|
|
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
193
|
export interface TraceInclusion {
|
|
168
194
|
leaf_hash: string; // hex
|
|
169
195
|
leaf_index: number;
|
|
170
196
|
leaf_count: number;
|
|
171
197
|
audit_path: string[]; // hex[]
|
|
172
|
-
/** Resolves against `checkpoints[].checkpoint_ref`.
|
|
198
|
+
/** Resolves against `checkpoints[].checkpoint_ref`. */
|
|
173
199
|
checkpoint_ref: string;
|
|
174
200
|
}
|
|
175
201
|
|
|
176
202
|
export interface TraceEvent {
|
|
177
203
|
envelope: Envelope;
|
|
178
|
-
|
|
204
|
+
event_bytes?: string; // hex of JCS(event record); omitted == record withheld
|
|
179
205
|
// Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 MUST equal the
|
|
180
|
-
// committed event.content_hash inside
|
|
206
|
+
// committed event.content_hash inside event_bytes (evidence-bundle §5.1).
|
|
181
207
|
// Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
|
|
182
208
|
content?: string;
|
|
183
209
|
inclusion: TraceInclusion;
|
|
@@ -246,7 +272,7 @@ export interface StampTraceBody {
|
|
|
246
272
|
stamps: StampEntry[];
|
|
247
273
|
}
|
|
248
274
|
|
|
249
|
-
// ---- `sanning.stamp.evidence/
|
|
275
|
+
// ---- `sanning.stamp.evidence/v2` wire types (evidence-bundle.md §5.3) -----
|
|
250
276
|
//
|
|
251
277
|
// Both halves of a DL-52 record, bound: the producer signs the envelope, and
|
|
252
278
|
// Sanning signs and witnesses the stamp whose root commits to it. Neither
|
|
@@ -266,7 +292,7 @@ export interface StampEvidenceInclusion {
|
|
|
266
292
|
|
|
267
293
|
export interface StampEvidenceEvent {
|
|
268
294
|
envelope: Envelope;
|
|
269
|
-
|
|
295
|
+
event_bytes?: string; // hex of JCS(event record); omitted == withheld
|
|
270
296
|
content?: string; // opt-in disclosed raw bytes, lowercase hex
|
|
271
297
|
inclusion: StampEvidenceInclusion;
|
|
272
298
|
}
|
|
@@ -327,23 +353,12 @@ export interface OnChainResult {
|
|
|
327
353
|
}
|
|
328
354
|
|
|
329
355
|
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
|
-
*/
|
|
356
|
+
/** The derived `checkpoint_ref`, empty when it could not be derived. */
|
|
337
357
|
checkpointRef: string;
|
|
338
358
|
/**
|
|
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.
|
|
359
|
+
* Where this checkpoint was witnessed. `null` when the checkpoint carries no
|
|
360
|
+
* `anchor_ref`: placed but not yet witnessed, which is a normal state and NOT
|
|
361
|
+
* a deficiency.
|
|
347
362
|
*/
|
|
348
363
|
anchorRef: AnchorRef | null;
|
|
349
364
|
// The checkpoint envelope verified (signature + payload binding to its record).
|
|
@@ -351,11 +366,15 @@ export interface CheckpointResult {
|
|
|
351
366
|
// The committed record's merkle_root equals the checkpoint's claimed merkle_root.
|
|
352
367
|
merkleRootOk: boolean;
|
|
353
368
|
// If gateways were supplied: the on-chain bytes at tx_id matched the envelope.
|
|
354
|
-
// null when no gateway re-fetch was requested
|
|
355
|
-
//
|
|
369
|
+
// null when no gateway re-fetch was requested, or when `anchor_ref.kind` has
|
|
370
|
+
// no witness adapter, or its id is not one the adapter accepts; the witness
|
|
371
|
+
// was not checked. Derived collapse of `onChain` (§4.2), retained so existing
|
|
372
|
+
// consumers keep the single-boolean field.
|
|
356
373
|
onChainOk: boolean | null;
|
|
357
374
|
// Per-gateway on-chain outcomes (§4.2), ADDITIVE to `onChainOk`. null when no
|
|
358
|
-
// gateway re-fetch was requested (offline-only verification)
|
|
375
|
+
// gateway re-fetch was requested (offline-only verification), or when
|
|
376
|
+
// `anchor_ref.kind` has no witness adapter, or its id is not one the adapter
|
|
377
|
+
// accepts; the witness was not checked.
|
|
359
378
|
onChain: OnChainResult | null;
|
|
360
379
|
ok: boolean;
|
|
361
380
|
errors: string[];
|
|
@@ -373,7 +392,7 @@ export interface EventResult {
|
|
|
373
392
|
// The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
|
|
374
393
|
checkpointBound: boolean;
|
|
375
394
|
// Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
|
|
376
|
-
// committed event.content_hash (in
|
|
395
|
+
// committed event.content_hash (in event_bytes), or, for a promoted
|
|
377
396
|
// envelope, a hash it commits to. true matched, false mismatch (→ event
|
|
378
397
|
// fails), null undetermined (nothing disclosed OR nothing committed OR record
|
|
379
398
|
// withheld — NOT a failure, mirroring payloadBindingOk).
|
|
@@ -435,12 +454,14 @@ export interface StampResult {
|
|
|
435
454
|
* ── The four states, and why none of them collapses ──
|
|
436
455
|
*
|
|
437
456
|
* `null` means not checked — no gateways were supplied, or this stamp names
|
|
438
|
-
* no witness to check
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
457
|
+
* no witness to check, or its `anchor_ref.kind` has no witness adapter, or
|
|
458
|
+
* its id is not one the adapter accepts; the witness was not checked. It is
|
|
459
|
+
* a statement about the RUN, never about the stamp. The other three are
|
|
460
|
+
* statements about the stamp and live in `onChain.rollup`: `confirm` (a
|
|
461
|
+
* gateway returned the witnessed bytes), `mismatch` (a gateway returned
|
|
462
|
+
* DIFFERENT bytes: a finding, and the only one of the four that fails
|
|
463
|
+
* anything), `unreachable` (we asked and nobody answered: an availability
|
|
464
|
+
* gap, never an accusation).
|
|
444
465
|
*
|
|
445
466
|
* Keeping "not checked" distinct from "unreachable" is the whole point. A
|
|
446
467
|
* surface handed a collapsed boolean cannot tell "no gateway could be
|
|
@@ -474,7 +495,7 @@ export interface EvidenceBundleResult {
|
|
|
474
495
|
events: EventResult[];
|
|
475
496
|
// The producer's asserted verdict.status (for display only — never trusted).
|
|
476
497
|
assertedStatus: string | null;
|
|
477
|
-
// Present ONLY for a `sanning.evidence.export/
|
|
498
|
+
// Present ONLY for a `sanning.evidence.export/v2` body (evidence-export.md §5):
|
|
478
499
|
// the export-specific dimensions (source linkage, verdict agreement, embedded
|
|
479
500
|
// attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
|
|
480
501
|
// above are the recomputed SOURCE-bundle results in that case. Absent for a
|
|
@@ -494,11 +515,7 @@ export interface EvidenceBundleResult {
|
|
|
494
515
|
// One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
|
|
495
516
|
// JCS(payload); the operator key is the operator's Arweave RSA wallet.
|
|
496
517
|
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
|
-
*/
|
|
518
|
+
/** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
|
|
502
519
|
checkpoint_ref: string;
|
|
503
520
|
payload: AttestationPayload;
|
|
504
521
|
signature_alg: string; // "rsa-pss-sha256"
|
|
@@ -689,7 +706,7 @@ function jsonStr(value: unknown): string {
|
|
|
689
706
|
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
690
707
|
}
|
|
691
708
|
|
|
692
|
-
// Verify a `sanning.evidence/v1` bundle
|
|
709
|
+
// Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
|
|
693
710
|
//
|
|
694
711
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
695
712
|
// rollup `status`. The producer's asserted `verdict.status` is surfaced
|
|
@@ -698,6 +715,16 @@ function jsonStr(value: unknown): string {
|
|
|
698
715
|
export async function verifyEvidenceBundle(
|
|
699
716
|
bundle: unknown,
|
|
700
717
|
options: VerifyEvidenceOptions = {},
|
|
718
|
+
): Promise<EvidenceBundleResult> {
|
|
719
|
+
return verifyBundle(bundle, options, false);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
723
|
+
// §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
|
|
724
|
+
async function verifyBundle(
|
|
725
|
+
bundle: unknown,
|
|
726
|
+
options: VerifyEvidenceOptions,
|
|
727
|
+
exportSource: boolean,
|
|
701
728
|
): Promise<EvidenceBundleResult> {
|
|
702
729
|
// --- Step 1: parse + reject unknown spec_version major ---------------------
|
|
703
730
|
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
@@ -781,6 +808,28 @@ export async function verifyEvidenceBundle(
|
|
|
781
808
|
// --- Body dispatch ---------------------------------------------------------
|
|
782
809
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
783
810
|
|
|
811
|
+
// Read as an export's source (evidence-export.md §2.4), the only body this
|
|
812
|
+
// kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
|
|
813
|
+
// verifies on its own, is undetermined here, after the wrapper checks above.
|
|
814
|
+
if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
815
|
+
errors.push(
|
|
816
|
+
`body_type ${JSON.stringify(bodyType)} is not an export source ` +
|
|
817
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
|
|
818
|
+
);
|
|
819
|
+
return {
|
|
820
|
+
status: "partial",
|
|
821
|
+
specVersionOk,
|
|
822
|
+
bodyHashOk,
|
|
823
|
+
signatureOk,
|
|
824
|
+
bodyType,
|
|
825
|
+
onChainChecked: false,
|
|
826
|
+
checkpoints: [],
|
|
827
|
+
events: [],
|
|
828
|
+
assertedStatus,
|
|
829
|
+
errors,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
784
833
|
// Attested-evidence-export body (evidence-export.md §5). The wrapper is
|
|
785
834
|
// authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
|
|
786
835
|
// hand off to the export verifier for steps 3–9.
|
|
@@ -800,13 +849,13 @@ export async function verifyEvidenceBundle(
|
|
|
800
849
|
return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
|
|
801
850
|
}
|
|
802
851
|
|
|
803
|
-
if (bodyType !== ANCHOR_TRACE_BODY_TYPE
|
|
852
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
804
853
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
805
854
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
806
855
|
// body verdict undetermined) — not a failure.
|
|
807
856
|
errors.push(
|
|
808
857
|
`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
809
|
-
`(only ${
|
|
858
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
|
|
810
859
|
);
|
|
811
860
|
return {
|
|
812
861
|
status: "partial",
|
|
@@ -822,108 +871,101 @@ export async function verifyEvidenceBundle(
|
|
|
822
871
|
};
|
|
823
872
|
}
|
|
824
873
|
|
|
825
|
-
return verifyAnchorTrace(b,
|
|
874
|
+
return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
826
875
|
}
|
|
827
876
|
|
|
828
877
|
/**
|
|
829
|
-
*
|
|
878
|
+
* Recompute an export's source verdict (evidence-export.md §5 step 4).
|
|
830
879
|
*
|
|
831
|
-
* The
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
880
|
+
* The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
|
|
881
|
+
* inline source body is unrecognised *as a source*, even one this kernel
|
|
882
|
+
* verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
|
|
883
|
+
* its source verdict undetermined, so the export cannot reach `verified`. The
|
|
884
|
+
* source wrapper is still checked first, because a forged source is a failure
|
|
885
|
+
* whatever its body.
|
|
886
|
+
*/
|
|
887
|
+
async function verifyExportSource(
|
|
888
|
+
source: unknown,
|
|
889
|
+
options: VerifyEvidenceOptions,
|
|
890
|
+
): Promise<EvidenceBundleResult> {
|
|
891
|
+
return verifyBundle(source, options, true);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Index a source bundle's checkpoints by the key its attestations use: the
|
|
896
|
+
* derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
|
|
897
|
+
* checkpoints an attestation can resolve (evidence-export.md §2.4).
|
|
836
898
|
*/
|
|
837
899
|
async function indexSourceCheckpoints(
|
|
838
900
|
bundle: EvidenceBundle,
|
|
839
901
|
): Promise<Map<string, AnyTraceCheckpoint>> {
|
|
840
902
|
const out = new Map<string, AnyTraceCheckpoint>();
|
|
903
|
+
if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE) return out;
|
|
841
904
|
const sb = bundle.body as AnchorTraceBody | undefined;
|
|
842
|
-
const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
843
905
|
if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
|
|
844
906
|
for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
|
|
845
907
|
if (!cp || typeof cp !== "object") continue;
|
|
846
|
-
let
|
|
908
|
+
let eventBytes: Uint8Array | undefined;
|
|
847
909
|
try {
|
|
848
|
-
if (typeof cp.
|
|
910
|
+
if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
|
|
849
911
|
} catch {
|
|
850
|
-
|
|
912
|
+
eventBytes = undefined;
|
|
851
913
|
}
|
|
852
|
-
const keyed = await resolveCheckpointKey(cp,
|
|
914
|
+
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
853
915
|
if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
|
|
854
916
|
}
|
|
855
917
|
return out;
|
|
856
918
|
}
|
|
857
919
|
|
|
858
|
-
/**
|
|
859
|
-
type AnyTraceCheckpoint = Partial<TraceCheckpoint> &
|
|
920
|
+
/** A checkpoint as it arrives, before any field is trusted. */
|
|
921
|
+
type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
|
|
860
922
|
|
|
861
923
|
/**
|
|
862
|
-
* Resolve a checkpoint's join key and its witness reference
|
|
863
|
-
* body form we are reading.
|
|
924
|
+
* Resolve a checkpoint's join key and its witness reference.
|
|
864
925
|
*
|
|
865
|
-
* ──
|
|
926
|
+
* ── Why a retired spelling is a reject rather than a preference ──
|
|
866
927
|
*
|
|
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.
|
|
928
|
+
* A checkpoint carrying a retired spelling beside the current one is two
|
|
929
|
+
* answers to one question inside one signature. A reader that silently prefers
|
|
930
|
+
* one gives a different answer from a reader that prefers the other, over the
|
|
931
|
+
* same signed bytes, and both look like they verified. So `tx_id` and
|
|
932
|
+
* `record_bytes` are rejected rather than resolved.
|
|
873
933
|
*
|
|
874
|
-
* ── Why
|
|
934
|
+
* ── Why the key is recomputed ──
|
|
875
935
|
*
|
|
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.
|
|
936
|
+
* The key IS the bytes: relabelling a checkpoint and every event pointing at it
|
|
937
|
+
* fails here, because nothing is taken from the label.
|
|
882
938
|
*/
|
|
883
939
|
async function resolveCheckpointKey(
|
|
884
940
|
cp: AnyTraceCheckpoint,
|
|
885
|
-
|
|
886
|
-
isV1: boolean,
|
|
941
|
+
eventBytes: Uint8Array | undefined,
|
|
887
942
|
): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
|
|
888
943
|
const errors: string[] = [];
|
|
889
944
|
|
|
890
|
-
if (isV1) {
|
|
891
|
-
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
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
|
-
}
|
|
907
|
-
|
|
908
945
|
if (cp.tx_id !== undefined) {
|
|
909
946
|
errors.push(
|
|
910
|
-
|
|
947
|
+
`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`,
|
|
911
948
|
);
|
|
912
949
|
return { key: null, anchorRef: null, errors };
|
|
913
950
|
}
|
|
951
|
+
const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
|
|
952
|
+
if (legacy !== null) {
|
|
953
|
+
errors.push(legacy);
|
|
954
|
+
return { key: null, anchorRef: null, errors };
|
|
955
|
+
}
|
|
914
956
|
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
915
957
|
errors.push("checkpoint missing checkpoint_ref");
|
|
916
958
|
return { key: null, anchorRef: null, errors };
|
|
917
959
|
}
|
|
918
|
-
if (
|
|
960
|
+
if (eventBytes === undefined) {
|
|
919
961
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
920
|
-
errors.push("checkpoint_ref cannot be verified --
|
|
962
|
+
errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
|
|
921
963
|
return { key: null, anchorRef: null, errors };
|
|
922
964
|
}
|
|
923
|
-
const derived = await sha256Hex(
|
|
965
|
+
const derived = await sha256Hex(eventBytes);
|
|
924
966
|
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
925
967
|
errors.push(
|
|
926
|
-
`checkpoint_ref does not match SHA-256(
|
|
968
|
+
`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
|
|
927
969
|
);
|
|
928
970
|
return { key: null, anchorRef: null, errors };
|
|
929
971
|
}
|
|
@@ -979,14 +1021,13 @@ function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
|
|
|
979
1021
|
|
|
980
1022
|
async function verifyAnchorTrace(
|
|
981
1023
|
b: EvidenceBundle,
|
|
982
|
-
bodyType: string,
|
|
983
1024
|
specVersionOk: boolean,
|
|
984
1025
|
assertedStatus: string | null,
|
|
985
1026
|
errors: string[],
|
|
986
1027
|
options: VerifyEvidenceOptions,
|
|
987
1028
|
): Promise<EvidenceBundleResult> {
|
|
988
1029
|
const body = b.body as AnchorTraceBody;
|
|
989
|
-
const
|
|
1030
|
+
const bodyType = ANCHOR_TRACE_BODY_TYPE;
|
|
990
1031
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
991
1032
|
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
992
1033
|
}
|
|
@@ -1032,16 +1073,16 @@ async function verifyAnchorTrace(
|
|
|
1032
1073
|
let envelopeOk = false;
|
|
1033
1074
|
let merkleRootOk = false;
|
|
1034
1075
|
|
|
1035
|
-
// Decode
|
|
1076
|
+
// Decode event_bytes first: in v2 the join key is DERIVED from it, so it
|
|
1036
1077
|
// is an input to identity, not just to the payload binding below.
|
|
1037
|
-
let
|
|
1078
|
+
let eventBytes: Uint8Array | undefined;
|
|
1038
1079
|
try {
|
|
1039
|
-
if (typeof cp.
|
|
1080
|
+
if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
|
|
1040
1081
|
} catch (e) {
|
|
1041
|
-
cpErrors.push(`checkpoint
|
|
1082
|
+
cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
|
|
1042
1083
|
}
|
|
1043
1084
|
|
|
1044
|
-
const keyed = await resolveCheckpointKey(cp,
|
|
1085
|
+
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
1045
1086
|
cpErrors.push(...keyed.errors);
|
|
1046
1087
|
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
1047
1088
|
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
@@ -1055,7 +1096,7 @@ async function verifyAnchorTrace(
|
|
|
1055
1096
|
let duplicateKey = false;
|
|
1056
1097
|
if (keyed.key !== null) {
|
|
1057
1098
|
if (byKey.has(keyed.key)) {
|
|
1058
|
-
cpErrors.push(`duplicate checkpoint
|
|
1099
|
+
cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
|
|
1059
1100
|
duplicateKey = true;
|
|
1060
1101
|
} else {
|
|
1061
1102
|
byKey.set(keyed.key, cp);
|
|
@@ -1068,7 +1109,7 @@ async function verifyAnchorTrace(
|
|
|
1068
1109
|
try {
|
|
1069
1110
|
const res = await verifyEnvelope(
|
|
1070
1111
|
cp.envelope as Envelope,
|
|
1071
|
-
|
|
1112
|
+
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1072
1113
|
);
|
|
1073
1114
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
1074
1115
|
// payload_hash. An undetermined binding (no record) is not enough to
|
|
@@ -1082,15 +1123,20 @@ async function verifyAnchorTrace(
|
|
|
1082
1123
|
cpErrors.push("checkpoint envelope signature failed");
|
|
1083
1124
|
}
|
|
1084
1125
|
if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
|
|
1126
|
+
// proof#95: a malformed skeleton field fails the envelope with a valid
|
|
1127
|
+
// signature and binding; name it rather than fail with no reason.
|
|
1128
|
+
for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
|
|
1129
|
+
const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
|
|
1130
|
+
if (specFinding !== null) cpErrors.push(specFinding);
|
|
1085
1131
|
// WHY the binding is undetermined decides which sentence is true.
|
|
1086
|
-
//
|
|
1132
|
+
// event_bytes really absent is one thing; event_bytes present and
|
|
1087
1133
|
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
1088
1134
|
// naming the wrong cause sends a reader to look for a field that is
|
|
1089
1135
|
// sitting right there (proof#76).
|
|
1090
1136
|
if (res.payloadHashOk === null) {
|
|
1091
1137
|
cpErrors.push(
|
|
1092
|
-
|
|
1093
|
-
? "checkpoint
|
|
1138
|
+
eventBytes === undefined
|
|
1139
|
+
? "checkpoint event_bytes missing -- root untrusted"
|
|
1094
1140
|
: "checkpoint record binding not checked: root untrusted",
|
|
1095
1141
|
);
|
|
1096
1142
|
}
|
|
@@ -1100,9 +1146,9 @@ async function verifyAnchorTrace(
|
|
|
1100
1146
|
|
|
1101
1147
|
// The committed record's merkle_root must equal the checkpoint's claimed
|
|
1102
1148
|
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
1103
|
-
if (
|
|
1149
|
+
if (eventBytes !== undefined) {
|
|
1104
1150
|
try {
|
|
1105
|
-
const record = JSON.parse(new TextDecoder().decode(
|
|
1151
|
+
const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
|
|
1106
1152
|
event?: { merkle_root?: unknown };
|
|
1107
1153
|
};
|
|
1108
1154
|
const committedRoot = record.event?.merkle_root;
|
|
@@ -1161,16 +1207,21 @@ async function verifyAnchorTrace(
|
|
|
1161
1207
|
let envelopeOk = false;
|
|
1162
1208
|
let payloadBindingOk: boolean | null = null;
|
|
1163
1209
|
|
|
1164
|
-
|
|
1210
|
+
// A retired field name under this major is a reject, never an ignored
|
|
1211
|
+
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
1212
|
+
const legacyError = legacyEventBytesError(ev, "event", bodyType);
|
|
1213
|
+
if (legacyError !== null) evErrors.push(legacyError);
|
|
1214
|
+
|
|
1215
|
+
let eventBytes: Uint8Array | undefined;
|
|
1165
1216
|
try {
|
|
1166
|
-
if (typeof ev.
|
|
1217
|
+
if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
|
|
1167
1218
|
} catch (e) {
|
|
1168
|
-
evErrors.push(`event
|
|
1219
|
+
evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
|
|
1169
1220
|
}
|
|
1170
1221
|
try {
|
|
1171
1222
|
const res = await verifyEnvelope(
|
|
1172
1223
|
ev.envelope,
|
|
1173
|
-
|
|
1224
|
+
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1174
1225
|
);
|
|
1175
1226
|
envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
|
|
1176
1227
|
payloadBindingOk = res.payloadHashOk;
|
|
@@ -1187,6 +1238,11 @@ async function verifyAnchorTrace(
|
|
|
1187
1238
|
evErrors.push("event envelope signature failed");
|
|
1188
1239
|
}
|
|
1189
1240
|
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
1241
|
+
// A malformed skeleton field fails the envelope with a valid signature
|
|
1242
|
+
// and binding (proof#95), so without this the event fails with no reason.
|
|
1243
|
+
for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
|
|
1244
|
+
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
1245
|
+
if (specFinding !== null) evErrors.push(specFinding);
|
|
1190
1246
|
} catch (e) {
|
|
1191
1247
|
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
1192
1248
|
}
|
|
@@ -1194,12 +1250,11 @@ async function verifyAnchorTrace(
|
|
|
1194
1250
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
1195
1251
|
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
1196
1252
|
const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1201
|
-
const
|
|
1202
|
-
const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
|
|
1253
|
+
// An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
|
|
1254
|
+
// bundle, and resolving it anyway is how an event silently binds to a
|
|
1255
|
+
// checkpoint nobody meant.
|
|
1256
|
+
const keyField = "checkpoint_ref";
|
|
1257
|
+
const wrongField = "checkpoint_tx_id";
|
|
1203
1258
|
const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
|
|
1204
1259
|
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
1205
1260
|
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
@@ -1257,7 +1312,7 @@ async function verifyAnchorTrace(
|
|
|
1257
1312
|
}
|
|
1258
1313
|
|
|
1259
1314
|
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
1260
|
-
const contentRes = await verifyDisclosedContent(ev, eventId,
|
|
1315
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
1261
1316
|
const contentOk = contentRes.contentOk;
|
|
1262
1317
|
evErrors.push(...contentRes.errors);
|
|
1263
1318
|
|
|
@@ -1273,7 +1328,7 @@ async function verifyAnchorTrace(
|
|
|
1273
1328
|
// present checkpoint, and its disclosed content did not MISMATCH. A
|
|
1274
1329
|
// withheld record or undisclosed content (binding/content null) does not
|
|
1275
1330
|
// fail it.
|
|
1276
|
-
ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
|
|
1331
|
+
ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
|
|
1277
1332
|
errors: evErrors,
|
|
1278
1333
|
});
|
|
1279
1334
|
}
|
|
@@ -1281,6 +1336,8 @@ async function verifyAnchorTrace(
|
|
|
1281
1336
|
// --- Step 5: optional on-chain re-fetch ------------------------------------
|
|
1282
1337
|
const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
|
|
1283
1338
|
let gatewayUnavailable = false;
|
|
1339
|
+
let uncheckedKind = false;
|
|
1340
|
+
let uncheckedId = false;
|
|
1284
1341
|
if (onChainChecked) {
|
|
1285
1342
|
if (typeof fetchImpl !== "function") {
|
|
1286
1343
|
gatewayUnavailable = true;
|
|
@@ -1308,8 +1365,10 @@ async function verifyAnchorTrace(
|
|
|
1308
1365
|
}
|
|
1309
1366
|
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
1310
1367
|
cpr.onChain = outcome.onChain;
|
|
1311
|
-
cpr.onChainOk = outcome.onChain
|
|
1368
|
+
cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
1312
1369
|
if (outcome.unavailable) gatewayUnavailable = true;
|
|
1370
|
+
if (outcome.unchecked === "kind") uncheckedKind = true;
|
|
1371
|
+
if (outcome.unchecked === "id") uncheckedId = true;
|
|
1313
1372
|
if (outcome.error) cpr.errors.push(outcome.error);
|
|
1314
1373
|
// Fold the on-chain dimension in: a MISMATCH (false) fails the
|
|
1315
1374
|
// checkpoint — the proof was not anchored as claimed. An UNREACHABLE
|
|
@@ -1341,12 +1400,16 @@ async function verifyAnchorTrace(
|
|
|
1341
1400
|
let status: EvidenceStatus;
|
|
1342
1401
|
if (anyHardFailure) {
|
|
1343
1402
|
status = "failed";
|
|
1344
|
-
} else if (gatewayUnavailable) {
|
|
1403
|
+
} else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
|
|
1345
1404
|
// Every offline check passed and nothing MISMATCHED, but a requested
|
|
1346
1405
|
// on-chain confirmation could not be made — the bundle is offline-sound,
|
|
1347
1406
|
// its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
|
|
1348
1407
|
status = "partial";
|
|
1349
|
-
errors
|
|
1408
|
+
pushRefetchGapNotes(errors, "checkpoints", {
|
|
1409
|
+
unavailable: gatewayUnavailable,
|
|
1410
|
+
kind: uncheckedKind,
|
|
1411
|
+
id: uncheckedId,
|
|
1412
|
+
});
|
|
1350
1413
|
} else if (anyWithheldRecord) {
|
|
1351
1414
|
// Everything that COULD be checked passed; some record bytes were withheld
|
|
1352
1415
|
// so their semantic binding is undetermined (not a failure) — "partial".
|
|
@@ -1438,7 +1501,7 @@ function isNonNegInt(n: unknown): n is number {
|
|
|
1438
1501
|
// ===========================================================================
|
|
1439
1502
|
// Disclosed-content binding, shared by every body that carries events.
|
|
1440
1503
|
//
|
|
1441
|
-
// Extracted for `sanning.stamp.evidence/
|
|
1504
|
+
// Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
|
|
1442
1505
|
// rawLog -> content_hash link is the one step the chain
|
|
1443
1506
|
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
1444
1507
|
// not otherwise cover, and a second copy of it is a second place for the
|
|
@@ -1450,7 +1513,7 @@ function isNonNegInt(n: unknown): n is number {
|
|
|
1450
1513
|
async function verifyDisclosedContent(
|
|
1451
1514
|
ev: { envelope?: unknown; content?: unknown },
|
|
1452
1515
|
eventId: string,
|
|
1453
|
-
|
|
1516
|
+
eventBytes: Uint8Array | undefined,
|
|
1454
1517
|
sideContent: VerifyEvidenceOptions["content"],
|
|
1455
1518
|
): Promise<{ contentOk: boolean | null; errors: string[] }> {
|
|
1456
1519
|
const evErrors: string[] = [];
|
|
@@ -1458,9 +1521,9 @@ async function verifyDisclosedContent(
|
|
|
1458
1521
|
|
|
1459
1522
|
const committed: string[] = [];
|
|
1460
1523
|
let recordContentHash: string | undefined;
|
|
1461
|
-
if (
|
|
1524
|
+
if (eventBytes !== undefined) {
|
|
1462
1525
|
try {
|
|
1463
|
-
const record = JSON.parse(new TextDecoder().decode(
|
|
1526
|
+
const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
|
|
1464
1527
|
event?: { content_hash?: unknown };
|
|
1465
1528
|
};
|
|
1466
1529
|
const ch = record.event?.content_hash;
|
|
@@ -1775,7 +1838,7 @@ async function verifyStampTrace(
|
|
|
1775
1838
|
// other — asks every consumer to remember which wrapper it is holding before
|
|
1776
1839
|
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
1777
1840
|
// exception a reader eventually gets wrong.
|
|
1778
|
-
const { onChainChecked, gatewayUnavailable } = await refetchStamps(
|
|
1841
|
+
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
|
|
1779
1842
|
stamps,
|
|
1780
1843
|
witnessed,
|
|
1781
1844
|
options,
|
|
@@ -1795,13 +1858,15 @@ async function verifyStampTrace(
|
|
|
1795
1858
|
// does not have.
|
|
1796
1859
|
status = "partial";
|
|
1797
1860
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1798
|
-
} else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1861
|
+
} else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
|
|
1799
1862
|
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
1800
1863
|
// for why one note is not enough when there are two different gaps.
|
|
1801
1864
|
status = "partial";
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1865
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
1866
|
+
unavailable: gatewayUnavailable,
|
|
1867
|
+
kind: uncheckedKind,
|
|
1868
|
+
id: uncheckedId,
|
|
1869
|
+
});
|
|
1805
1870
|
if (anyUnwitnessed) {
|
|
1806
1871
|
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1807
1872
|
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
@@ -1834,7 +1899,7 @@ async function verifyStampTrace(
|
|
|
1834
1899
|
}
|
|
1835
1900
|
|
|
1836
1901
|
// ===========================================================================
|
|
1837
|
-
// `sanning.stamp.evidence/
|
|
1902
|
+
// `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
|
|
1838
1903
|
//
|
|
1839
1904
|
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1840
1905
|
// the sub-tree partition check). The event half reuses the same primitives the
|
|
@@ -1912,16 +1977,21 @@ async function verifyStampEvidence(
|
|
|
1912
1977
|
let envelopeOk = false;
|
|
1913
1978
|
let payloadBindingOk: boolean | null = null;
|
|
1914
1979
|
|
|
1915
|
-
|
|
1980
|
+
// A retired field name under this major is a reject, never an ignored
|
|
1981
|
+
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
1982
|
+
const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
|
|
1983
|
+
if (legacyError !== null) evErrors.push(legacyError);
|
|
1984
|
+
|
|
1985
|
+
let eventBytes: Uint8Array | undefined;
|
|
1916
1986
|
try {
|
|
1917
|
-
if (typeof ev.
|
|
1987
|
+
if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
|
|
1918
1988
|
} catch (e) {
|
|
1919
|
-
evErrors.push(`event
|
|
1989
|
+
evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
|
|
1920
1990
|
}
|
|
1921
1991
|
try {
|
|
1922
1992
|
const res = await verifyEnvelope(
|
|
1923
1993
|
ev.envelope,
|
|
1924
|
-
|
|
1994
|
+
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1925
1995
|
);
|
|
1926
1996
|
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1927
1997
|
payloadBindingOk = res.payloadHashOk;
|
|
@@ -1938,6 +2008,11 @@ async function verifyStampEvidence(
|
|
|
1938
2008
|
evErrors.push("event envelope signature failed");
|
|
1939
2009
|
}
|
|
1940
2010
|
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
2011
|
+
// A malformed skeleton field fails the envelope with a valid signature
|
|
2012
|
+
// and binding (proof#95), so without this the event fails with no reason.
|
|
2013
|
+
for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
|
|
2014
|
+
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
2015
|
+
if (specFinding !== null) evErrors.push(specFinding);
|
|
1941
2016
|
} catch (e) {
|
|
1942
2017
|
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
1943
2018
|
}
|
|
@@ -2007,7 +2082,7 @@ async function verifyStampEvidence(
|
|
|
2007
2082
|
}
|
|
2008
2083
|
}
|
|
2009
2084
|
|
|
2010
|
-
const contentRes = await verifyDisclosedContent(ev, eventId,
|
|
2085
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
2011
2086
|
const contentOk = contentRes.contentOk;
|
|
2012
2087
|
evErrors.push(...contentRes.errors);
|
|
2013
2088
|
|
|
@@ -2019,13 +2094,13 @@ async function verifyStampEvidence(
|
|
|
2019
2094
|
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
2020
2095
|
checkpointBound: stampBound,
|
|
2021
2096
|
contentOk,
|
|
2022
|
-
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
|
|
2097
|
+
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
|
|
2023
2098
|
errors: evErrors,
|
|
2024
2099
|
});
|
|
2025
2100
|
}
|
|
2026
2101
|
|
|
2027
2102
|
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
2028
|
-
const { onChainChecked, gatewayUnavailable } = await refetchStamps(
|
|
2103
|
+
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
|
|
2029
2104
|
stamps,
|
|
2030
2105
|
witnessed,
|
|
2031
2106
|
options,
|
|
@@ -2045,16 +2120,31 @@ async function verifyStampEvidence(
|
|
|
2045
2120
|
} else if (stamps.length === 0 && eventResults.length === 0) {
|
|
2046
2121
|
status = "partial";
|
|
2047
2122
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
2048
|
-
} else if (
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2123
|
+
} else if (
|
|
2124
|
+
eventResults.length === 0 ||
|
|
2125
|
+
gatewayUnavailable ||
|
|
2126
|
+
uncheckedKind ||
|
|
2127
|
+
uncheckedId ||
|
|
2128
|
+
anyUnwitnessed
|
|
2129
|
+
) {
|
|
2130
|
+
// Each holds the bundle at `partial`, and EACH gets said. A pack with no
|
|
2131
|
+
// events, a stamp nobody witnessed and a witness we could not reach are
|
|
2132
|
+
// different gaps with different fixes, and an auditor told only about one
|
|
2133
|
+
// would read the others as fine. The order is fixed so the two kernels
|
|
2134
|
+
// emit the same list.
|
|
2054
2135
|
status = "partial";
|
|
2055
|
-
if (
|
|
2056
|
-
|
|
2136
|
+
if (eventResults.length === 0) {
|
|
2137
|
+
// §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
|
|
2138
|
+
// partial, never verified. It says nothing about any event.
|
|
2139
|
+
errors.push(
|
|
2140
|
+
`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`,
|
|
2141
|
+
);
|
|
2057
2142
|
}
|
|
2143
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
2144
|
+
unavailable: gatewayUnavailable,
|
|
2145
|
+
kind: uncheckedKind,
|
|
2146
|
+
id: uncheckedId,
|
|
2147
|
+
});
|
|
2058
2148
|
if (anyUnwitnessed) {
|
|
2059
2149
|
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
2060
2150
|
// the independent part, so the events resting on it inherit `partial`.
|
|
@@ -2120,18 +2210,21 @@ async function refetchStamps(
|
|
|
2120
2210
|
witnessed: (string | undefined)[],
|
|
2121
2211
|
options: VerifyEvidenceOptions,
|
|
2122
2212
|
errors: string[],
|
|
2123
|
-
): Promise<
|
|
2213
|
+
): Promise<StampRefetchSummary> {
|
|
2124
2214
|
const gateways = options.gateways ?? [];
|
|
2125
2215
|
const onChainChecked = gateways.length > 0;
|
|
2126
|
-
|
|
2216
|
+
const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
|
|
2217
|
+
if (!onChainChecked) return { onChainChecked, ...none };
|
|
2127
2218
|
|
|
2128
2219
|
const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
|
|
2129
2220
|
if (typeof fetchImpl !== "function") {
|
|
2130
2221
|
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
2131
|
-
return { onChainChecked, gatewayUnavailable: true };
|
|
2222
|
+
return { onChainChecked, ...none, gatewayUnavailable: true };
|
|
2132
2223
|
}
|
|
2133
2224
|
|
|
2134
2225
|
let gatewayUnavailable = false;
|
|
2226
|
+
let uncheckedKind = false;
|
|
2227
|
+
let uncheckedId = false;
|
|
2135
2228
|
for (let i = 0; i < stamps.length; i++) {
|
|
2136
2229
|
const st = stamps[i]!;
|
|
2137
2230
|
const witnessedHex = witnessed[i];
|
|
@@ -2141,8 +2234,10 @@ async function refetchStamps(
|
|
|
2141
2234
|
|
|
2142
2235
|
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
2143
2236
|
st.onChain = outcome.onChain;
|
|
2144
|
-
st.onChainOk = outcome.onChain
|
|
2237
|
+
st.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
2145
2238
|
if (outcome.unavailable) gatewayUnavailable = true;
|
|
2239
|
+
if (outcome.unchecked === "kind") uncheckedKind = true;
|
|
2240
|
+
if (outcome.unchecked === "id") uncheckedId = true;
|
|
2146
2241
|
if (outcome.error) st.errors.push(outcome.error);
|
|
2147
2242
|
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
2148
2243
|
// (false) fails the stamp — the chain does not hold what the bundle says
|
|
@@ -2155,7 +2250,14 @@ async function refetchStamps(
|
|
|
2155
2250
|
// unreachable note would fail a stamp for a gateway being down.
|
|
2156
2251
|
st.ok = st.ok && st.onChainOk !== false;
|
|
2157
2252
|
}
|
|
2158
|
-
return { onChainChecked, gatewayUnavailable };
|
|
2253
|
+
return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
interface StampRefetchSummary {
|
|
2257
|
+
onChainChecked: boolean;
|
|
2258
|
+
gatewayUnavailable: boolean;
|
|
2259
|
+
uncheckedKind: boolean;
|
|
2260
|
+
uncheckedId: boolean;
|
|
2159
2261
|
}
|
|
2160
2262
|
|
|
2161
2263
|
// ===========================================================================
|
|
@@ -2211,7 +2313,7 @@ export interface DisclosedContentResult {
|
|
|
2211
2313
|
errors: string[];
|
|
2212
2314
|
}
|
|
2213
2315
|
|
|
2214
|
-
// How many `sanning.evidence.export/
|
|
2316
|
+
// How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
|
|
2215
2317
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
2216
2318
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
2217
2319
|
// independently bounded rather than trusting that it ran first.
|
|
@@ -2250,7 +2352,7 @@ export async function disclosedContent(
|
|
|
2250
2352
|
const traceEvents = resolveTraceEvents(bundle);
|
|
2251
2353
|
if (traceEvents === undefined) {
|
|
2252
2354
|
errors.push(
|
|
2253
|
-
`no ${
|
|
2355
|
+
`no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
|
|
2254
2356
|
`body to materialize content from ` +
|
|
2255
2357
|
`(body_type ${JSON.stringify(verification.bodyType)})`,
|
|
2256
2358
|
);
|
|
@@ -2351,7 +2453,7 @@ export async function disclosedContent(
|
|
|
2351
2453
|
// a caller must distinguish from a body that has events and no links.
|
|
2352
2454
|
//
|
|
2353
2455
|
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
2354
|
-
// `sanning.stamp.evidence/
|
|
2456
|
+
// `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
|
|
2355
2457
|
// event shape.
|
|
2356
2458
|
//
|
|
2357
2459
|
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
@@ -2366,8 +2468,8 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
|
2366
2468
|
const b = cur as EvidenceBundle;
|
|
2367
2469
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
2368
2470
|
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
2369
|
-
// `sanning.stamp.evidence/
|
|
2370
|
-
// producer-signed envelope, optional `
|
|
2471
|
+
// `sanning.stamp.evidence/v2` presents the SAME event shape (a
|
|
2472
|
+
// producer-signed envelope, optional `event_bytes`, an inclusion proof),
|
|
2371
2473
|
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
2372
2474
|
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
2373
2475
|
// zero chains on every pack, however well-formed the links inside its
|
|
@@ -2375,7 +2477,6 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
|
2375
2477
|
// that matters. The writers have been emitting those links the whole time.
|
|
2376
2478
|
if (
|
|
2377
2479
|
bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
2378
|
-
bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
|
|
2379
2480
|
bodyType === STAMP_EVIDENCE_BODY_TYPE
|
|
2380
2481
|
) {
|
|
2381
2482
|
const body = b.body as AnchorTraceBody | null | undefined;
|
|
@@ -2480,7 +2581,7 @@ async function verifyExportBody(
|
|
|
2480
2581
|
// The existing evidence path over the inline source bundle — same primitives
|
|
2481
2582
|
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
2482
2583
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
2483
|
-
const sourceResult = await
|
|
2584
|
+
const sourceResult = await verifyExportSource(inline, options);
|
|
2484
2585
|
|
|
2485
2586
|
// Attestation data_hash binding (§5 6c).
|
|
2486
2587
|
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
@@ -2613,7 +2714,7 @@ export async function recomputeExportVerdict(
|
|
|
2613
2714
|
generatedAt: string,
|
|
2614
2715
|
options: VerifyEvidenceOptions = {},
|
|
2615
2716
|
): Promise<ComposeVerdictResult> {
|
|
2616
|
-
const sourceResult = await
|
|
2717
|
+
const sourceResult = await verifyExportSource(sourceBundle, options);
|
|
2617
2718
|
|
|
2618
2719
|
// Attestation data_hash binding (§5 6c) — the SAME index construction as
|
|
2619
2720
|
// verifyExportBody, deliberately shared rather than restated.
|
|
@@ -2659,6 +2760,52 @@ export async function recomputeExportVerdict(
|
|
|
2659
2760
|
// field; otherwise a per-dimension AttestationResult whose `ok` gates on the
|
|
2660
2761
|
// three binding checks (sig / operator-address / data_hash). subject_ref is
|
|
2661
2762
|
// undetermined-tolerant and never gates.
|
|
2763
|
+
// The attestation payload fields evidence-export.md §3.1 marks Required, with
|
|
2764
|
+
// the type it gives each. A record whose payload lacks one, or carries another
|
|
2765
|
+
// type (null included), is malformed: §5 step 9 and §4.5 make "a missing
|
|
2766
|
+
// required field" an export the verifier cannot render (exit 2). §5 step 6
|
|
2767
|
+
// gates a WELL-FORMED record on signature, operator and data_hash only; these
|
|
2768
|
+
// checks decide whether the record is well-formed at all. `level` is the §3.1
|
|
2769
|
+
// enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
|
|
2770
|
+
// §3.1 clarification).
|
|
2771
|
+
const PAYLOAD_REQUIRED: readonly (readonly [string, "string" | "boolean" | "integer" | "level"])[] = [
|
|
2772
|
+
["attested_at", "string"],
|
|
2773
|
+
["tx_id", "string"],
|
|
2774
|
+
["data_hash", "string"],
|
|
2775
|
+
["data_size", "integer"],
|
|
2776
|
+
["block_height", "integer"],
|
|
2777
|
+
["block_timestamp", "integer"],
|
|
2778
|
+
["operator", "string"],
|
|
2779
|
+
["owner_address", "string"],
|
|
2780
|
+
["gateway", "string"],
|
|
2781
|
+
["signature_verified", "boolean"],
|
|
2782
|
+
["level", "level"],
|
|
2783
|
+
];
|
|
2784
|
+
|
|
2785
|
+
function payloadFieldError(payload: Record<string, unknown>): string | null {
|
|
2786
|
+
for (const [name, kind] of PAYLOAD_REQUIRED) {
|
|
2787
|
+
const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
|
|
2788
|
+
let ok: boolean;
|
|
2789
|
+
let want: string;
|
|
2790
|
+
if (kind === "string") {
|
|
2791
|
+
ok = typeof v === "string";
|
|
2792
|
+
want = "a string";
|
|
2793
|
+
} else if (kind === "boolean") {
|
|
2794
|
+
ok = typeof v === "boolean";
|
|
2795
|
+
want = "a boolean";
|
|
2796
|
+
} else if (kind === "integer") {
|
|
2797
|
+
// §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
|
|
2798
|
+
ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
2799
|
+
want = "a non-negative integer";
|
|
2800
|
+
} else {
|
|
2801
|
+
ok = v === 1 || v === 2 || v === 3;
|
|
2802
|
+
want = "1, 2 or 3";
|
|
2803
|
+
}
|
|
2804
|
+
if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
2805
|
+
}
|
|
2806
|
+
return null;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2662
2809
|
async function verifyAttestation(
|
|
2663
2810
|
rec: AttestationRecord,
|
|
2664
2811
|
sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
|
|
@@ -2705,6 +2852,8 @@ async function verifyAttestation(
|
|
|
2705
2852
|
if (typeof rec.signature !== "string") {
|
|
2706
2853
|
return { result, malformed: "attestation record missing signature" };
|
|
2707
2854
|
}
|
|
2855
|
+
const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
|
|
2856
|
+
if (fieldError !== null) return { result, malformed: fieldError };
|
|
2708
2857
|
const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
|
|
2709
2858
|
|
|
2710
2859
|
result.operator = typeof payload.operator === "string" ? payload.operator : "";
|
|
@@ -2944,7 +3093,7 @@ async function refetchCheckpoint(
|
|
|
2944
3093
|
anchorRef: AnchorRef,
|
|
2945
3094
|
gateways: string[],
|
|
2946
3095
|
fetchImpl: typeof fetch,
|
|
2947
|
-
): Promise<
|
|
3096
|
+
): Promise<RefetchOutcome> {
|
|
2948
3097
|
let expectedHex: string | undefined;
|
|
2949
3098
|
try {
|
|
2950
3099
|
// A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
|
|
@@ -2990,7 +3139,7 @@ async function refetchStamp(
|
|
|
2990
3139
|
anchorRef: AnchorRef,
|
|
2991
3140
|
gateways: string[],
|
|
2992
3141
|
fetchImpl: typeof fetch,
|
|
2993
|
-
): Promise<
|
|
3142
|
+
): Promise<RefetchOutcome> {
|
|
2994
3143
|
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
2995
3144
|
}
|
|
2996
3145
|
|
|
@@ -3005,7 +3154,36 @@ async function refetchAnchored(
|
|
|
3005
3154
|
anchorRef: AnchorRef,
|
|
3006
3155
|
gateways: string[],
|
|
3007
3156
|
fetchImpl: typeof fetch,
|
|
3008
|
-
): Promise<
|
|
3157
|
+
): Promise<RefetchOutcome> {
|
|
3158
|
+
// Only a `kind` this kernel has a witness adapter for is re-fetched
|
|
3159
|
+
// (evidence-bundle.md §5.1: "where the verifier has an adapter for its
|
|
3160
|
+
// `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
|
|
3161
|
+
// of any other kind is not an Arweave id, so whatever a gateway serves at
|
|
3162
|
+
// that path says nothing about the witness, and comparing it would turn an
|
|
3163
|
+
// unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
|
|
3164
|
+
// run goes `partial`: the caller asked for the witness check and it was not
|
|
3165
|
+
// made.
|
|
3166
|
+
if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
|
|
3167
|
+
return {
|
|
3168
|
+
onChain: null,
|
|
3169
|
+
unavailable: false,
|
|
3170
|
+
unchecked: "kind",
|
|
3171
|
+
error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
// The id becomes a URL path segment. Anything but a 43-character base64url
|
|
3175
|
+
// transaction id can leave the gateway's base path once URL parsing
|
|
3176
|
+
// normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
|
|
3177
|
+
// `a?b`), and the two kernels would then request different URLs. Such an id
|
|
3178
|
+
// is never fetched and never failed: the witness is not checked.
|
|
3179
|
+
if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
|
|
3180
|
+
return {
|
|
3181
|
+
onChain: null,
|
|
3182
|
+
unavailable: false,
|
|
3183
|
+
unchecked: "id",
|
|
3184
|
+
error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3009
3187
|
const perGateway: PerGatewayOutcome[] = [];
|
|
3010
3188
|
for (const gw of gateways) {
|
|
3011
3189
|
perGateway.push({
|
|
@@ -3024,7 +3202,51 @@ async function refetchAnchored(
|
|
|
3024
3202
|
} else if (onChain.rollup === "unreachable") {
|
|
3025
3203
|
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
3026
3204
|
}
|
|
3027
|
-
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
3205
|
+
return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// The `anchor_ref.kind` values this kernel can re-fetch from a gateway
|
|
3209
|
+
// (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
|
|
3210
|
+
// left unchecked by `refetchAnchored`, never probed.
|
|
3211
|
+
const WITNESS_ADAPTER_KINDS: ReadonlySet<string> = new Set(["arweave"]);
|
|
3212
|
+
|
|
3213
|
+
// The only `arweave` ids the adapter looks up: a 43-character base64url
|
|
3214
|
+
// transaction or data-item id.
|
|
3215
|
+
const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
3216
|
+
|
|
3217
|
+
// Why a re-fetch was not made: `kind` (no adapter for the witness kind) or
|
|
3218
|
+
// `id` (an arweave id the adapter cannot look up). null when it was made.
|
|
3219
|
+
type Unchecked = "kind" | "id" | null;
|
|
3220
|
+
|
|
3221
|
+
interface RefetchOutcome {
|
|
3222
|
+
onChain: OnChainResult | null;
|
|
3223
|
+
// Every gateway was unreachable.
|
|
3224
|
+
unavailable: boolean;
|
|
3225
|
+
unchecked: Unchecked;
|
|
3226
|
+
error?: string;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// The bundle-level notes for a run where requested re-fetches were not made.
|
|
3230
|
+
// Each note keeps the phrase "could not be re-fetched", which is what both
|
|
3231
|
+
// CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
|
|
3232
|
+
function pushRefetchGapNotes(
|
|
3233
|
+
errors: string[],
|
|
3234
|
+
subject: "stamps" | "checkpoints",
|
|
3235
|
+
gaps: { unavailable: boolean; kind: boolean; id: boolean },
|
|
3236
|
+
): void {
|
|
3237
|
+
if (gaps.unavailable) {
|
|
3238
|
+
errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
|
|
3239
|
+
}
|
|
3240
|
+
if (gaps.kind) {
|
|
3241
|
+
errors.push(
|
|
3242
|
+
`one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`,
|
|
3243
|
+
);
|
|
3244
|
+
}
|
|
3245
|
+
if (gaps.id) {
|
|
3246
|
+
errors.push(
|
|
3247
|
+
`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`,
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3028
3250
|
}
|
|
3029
3251
|
|
|
3030
3252
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|