@sanning/proof 0.5.0 → 0.7.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 +31 -4
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +351 -0
- package/dist/cli.js +253 -38
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +204 -7
- package/dist/evidence.js +621 -67
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +38 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/dist/verifier.js +46 -7
- package/package.json +2 -1
- package/src/chain.ts +460 -0
- package/src/cli.ts +324 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +875 -81
- package/src/grading.ts +623 -0
- package/src/index.ts +79 -0
- package/src/side-content.ts +119 -0
- package/src/verifier.ts +49 -7
- package/vectors/README.md +66 -0
- package/vectors/chain-reading.json +293 -0
- package/vectors/pack-grading.json +136 -0
package/src/evidence.ts
CHANGED
|
@@ -32,6 +32,11 @@ import {
|
|
|
32
32
|
jcs,
|
|
33
33
|
verifyEnvelope,
|
|
34
34
|
} from "./verifier.js";
|
|
35
|
+
import {
|
|
36
|
+
inBodyContentOffered,
|
|
37
|
+
sideContentEntry,
|
|
38
|
+
type SideEntry,
|
|
39
|
+
} from "./side-content.js";
|
|
35
40
|
import type { Envelope } from "./types.js";
|
|
36
41
|
|
|
37
42
|
// Only the evidence-bundle major(s) this kernel verifies. A new major is a
|
|
@@ -43,7 +48,23 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
|
|
|
43
48
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
44
49
|
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
45
50
|
// "undetermined" rather than "verified".
|
|
46
|
-
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/
|
|
51
|
+
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
|
|
55
|
+
*
|
|
56
|
+
* Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
|
|
57
|
+
* before 2026-09-03 carries this shape, and those bundles are evidence — a
|
|
58
|
+
* reader that stopped accepting them would destroy the record it exists to
|
|
59
|
+
* defend (DL-30). Nothing new emits it.
|
|
60
|
+
*/
|
|
61
|
+
export const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
|
|
62
|
+
|
|
63
|
+
/** Every anchor-trace body this kernel verifies. Order is newest-first. */
|
|
64
|
+
export const ANCHOR_TRACE_BODY_TYPES = [
|
|
65
|
+
ANCHOR_TRACE_BODY_TYPE,
|
|
66
|
+
ANCHOR_TRACE_BODY_TYPE_V1,
|
|
67
|
+
] as const;
|
|
47
68
|
|
|
48
69
|
// The issuer-composed attested-evidence-export body (specs/evidence-export.md
|
|
49
70
|
// §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
|
|
@@ -52,6 +73,33 @@ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
|
|
|
52
73
|
// verifyExportBody (§5).
|
|
53
74
|
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
54
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
78
|
+
*
|
|
79
|
+
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
80
|
+
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
81
|
+
* `sanning.events/v1` envelope, and a stamp has no producer signature at all —
|
|
82
|
+
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
83
|
+
* The two rejected options were making `envelope` optional and synthesising an
|
|
84
|
+
* envelope that Sanning signs; the second would have made a Sanning
|
|
85
|
+
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
86
|
+
* unrecoverable once witnessed.
|
|
87
|
+
*/
|
|
88
|
+
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The version string INSIDE a witnessed stamp body — a different string from
|
|
92
|
+
* the `body_type` above, and the two are easy to confuse.
|
|
93
|
+
*
|
|
94
|
+
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
95
|
+
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
96
|
+
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
97
|
+
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
98
|
+
* version to exactly this value: two answers to one question inside one
|
|
99
|
+
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
100
|
+
*/
|
|
101
|
+
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
102
|
+
|
|
55
103
|
// ---- Wire types (evidence-bundle.md §2 + §5.1) ------------------------------
|
|
56
104
|
|
|
57
105
|
export interface EvidenceIssuer {
|
|
@@ -70,20 +118,58 @@ export interface EvidenceVerdict {
|
|
|
70
118
|
[k: string]: unknown;
|
|
71
119
|
}
|
|
72
120
|
|
|
73
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Where a commitment was witnessed (evidence-bundle.md §5.1.1).
|
|
123
|
+
*
|
|
124
|
+
* The same three-part shape the control plane indexes (`kind` / one promoted
|
|
125
|
+
* `id` / a typed remainder), so the signed body, the index row and the SDK
|
|
126
|
+
* artifact stop being three vocabularies for one fact.
|
|
127
|
+
*
|
|
128
|
+
* NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
|
|
129
|
+
* and packing live in `ref`, opaque to every reader above this seam. A field
|
|
130
|
+
* that would need renaming for a second witness belongs in `ref` by definition.
|
|
131
|
+
*/
|
|
132
|
+
export interface AnchorRef {
|
|
133
|
+
kind: string;
|
|
134
|
+
id: string;
|
|
135
|
+
ref?: Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// `sanning.anchor.trace/v2` body (§5.1). Uint8Array fields ride as lowercase hex.
|
|
74
139
|
export interface TraceCheckpoint {
|
|
75
|
-
|
|
140
|
+
/**
|
|
141
|
+
* The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
|
|
142
|
+
* decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
|
|
143
|
+
* asserted label matched against another asserted label, so a producer could
|
|
144
|
+
* relabel a checkpoint and the bundle still verified.
|
|
145
|
+
*/
|
|
146
|
+
checkpoint_ref: string;
|
|
147
|
+
/**
|
|
148
|
+
* Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
|
|
149
|
+
* under DL-52 a checkpoint is routinely placed before it is witnessed, and a
|
|
150
|
+
* required field that must be filled dishonestly is a defect in the format.
|
|
151
|
+
*/
|
|
152
|
+
anchor_ref?: AnchorRef;
|
|
76
153
|
envelope: Envelope;
|
|
77
154
|
record_bytes: string; // hex of JCS(checkpoint record)
|
|
78
155
|
merkle_root: string; // hex
|
|
79
156
|
}
|
|
80
157
|
|
|
158
|
+
/** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
|
|
159
|
+
export interface TraceCheckpointV1 {
|
|
160
|
+
tx_id: string;
|
|
161
|
+
envelope: Envelope;
|
|
162
|
+
record_bytes: string;
|
|
163
|
+
merkle_root: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
81
166
|
export interface TraceInclusion {
|
|
82
167
|
leaf_hash: string; // hex
|
|
83
168
|
leaf_index: number;
|
|
84
169
|
leaf_count: number;
|
|
85
170
|
audit_path: string[]; // hex[]
|
|
86
|
-
checkpoint_tx_id
|
|
171
|
+
/** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
|
|
172
|
+
checkpoint_ref: string;
|
|
87
173
|
}
|
|
88
174
|
|
|
89
175
|
export interface TraceEvent {
|
|
@@ -101,6 +187,64 @@ export interface AnchorTraceBody {
|
|
|
101
187
|
events: TraceEvent[];
|
|
102
188
|
}
|
|
103
189
|
|
|
190
|
+
// ---- `sanning.stamp.trace/v1` wire types (evidence-bundle.md §5.2) ---------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One sub-tree of a stamp, as the WITNESSED bytes carry it.
|
|
194
|
+
*
|
|
195
|
+
* Keyed in the body by a blinded reference (an HMAC under a per-org secret we
|
|
196
|
+
* never publish), so a reader learns that the org runs N agents and their
|
|
197
|
+
* relative volumes and learns no name. The RANGE is deliberately in the clear:
|
|
198
|
+
* it is what makes a later per-agent slice claim checkable by a third party
|
|
199
|
+
* without Sanning, and hiding it would put us back in the trust path.
|
|
200
|
+
*/
|
|
201
|
+
export interface StampSubtree {
|
|
202
|
+
first_leaf: number;
|
|
203
|
+
leaf_count: number;
|
|
204
|
+
/** Hex RFC-9162 root over this sub-tree's leaves alone. */
|
|
205
|
+
root: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The stamp body as the control plane witnesses it
|
|
210
|
+
* (`control-plane/src/stamp-tree.ts`, `StampBody`).
|
|
211
|
+
*
|
|
212
|
+
* Never read from the bundle directly — it is parsed out of `stamp_bytes`,
|
|
213
|
+
* which is the only authority. Present as a type so the parse has a shape.
|
|
214
|
+
*/
|
|
215
|
+
export interface StampBody {
|
|
216
|
+
stamp_version: string;
|
|
217
|
+
/** The org, BLINDED. Not an org id, and not reversible without the secret. */
|
|
218
|
+
org_ref: string;
|
|
219
|
+
interval_start: string;
|
|
220
|
+
interval_end: string;
|
|
221
|
+
merkle_root: string;
|
|
222
|
+
leaf_count: number;
|
|
223
|
+
subtrees: Record<string, StampSubtree>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
|
|
227
|
+
export interface StampEntry {
|
|
228
|
+
/**
|
|
229
|
+
* The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
|
|
230
|
+
* DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
|
|
231
|
+
*/
|
|
232
|
+
stamp_ref: string;
|
|
233
|
+
/** Lowercase hex of the exact JCS bytes that were handed to the witness. */
|
|
234
|
+
stamp_bytes: string;
|
|
235
|
+
/**
|
|
236
|
+
* Where the stamp was witnessed, IF IT HAS BEEN (§5.1.1). Absent is honest:
|
|
237
|
+
* a sealed-but-unconfirmed stamp is witnessed by nobody, and an emitter that
|
|
238
|
+
* filled this in from a submission receipt would be claiming a chain
|
|
239
|
+
* position that does not exist yet.
|
|
240
|
+
*/
|
|
241
|
+
anchor_ref?: AnchorRef;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface StampTraceBody {
|
|
245
|
+
stamps: StampEntry[];
|
|
246
|
+
}
|
|
247
|
+
|
|
104
248
|
export interface EvidenceBundle {
|
|
105
249
|
spec_version: string;
|
|
106
250
|
body_type: string;
|
|
@@ -127,7 +271,7 @@ export type EvidenceStatus =
|
|
|
127
271
|
| "malformed"; // structurally unparseable / unknown major / body_hash mismatch
|
|
128
272
|
|
|
129
273
|
// Per-gateway on-chain outcome for one checkpoint tx (evidence-export.md §4.2).
|
|
130
|
-
// The on-chain re-fetch resolves each
|
|
274
|
+
// The on-chain re-fetch resolves each checkpoint's anchor_ref.id against EACH configured
|
|
131
275
|
// gateway and records that gateway's individual outcome:
|
|
132
276
|
// confirm — the gateway returned the tx and its bytes match the record.
|
|
133
277
|
// mismatch — the gateway returned the tx but its bytes disagree — a finding.
|
|
@@ -151,7 +295,25 @@ export interface OnChainResult {
|
|
|
151
295
|
}
|
|
152
296
|
|
|
153
297
|
export interface CheckpointResult {
|
|
154
|
-
|
|
298
|
+
/**
|
|
299
|
+
* The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
|
|
300
|
+
* `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
|
|
301
|
+
* Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
|
|
302
|
+
* that is what its own events resolve against and what an attestation
|
|
303
|
+
* written against it carries.
|
|
304
|
+
*/
|
|
305
|
+
checkpointRef: string;
|
|
306
|
+
/**
|
|
307
|
+
* Where this checkpoint was witnessed, normalised to one shape.
|
|
308
|
+
*
|
|
309
|
+
* `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
|
|
310
|
+
* witnessed, which is a normal state and NOT a deficiency. For a frozen v1
|
|
311
|
+
* checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
|
|
312
|
+
* is exactly what v1's spec said the field was ("the Arweave TX the
|
|
313
|
+
* checkpoint envelope was anchored as"). The synthesis is reader-side only
|
|
314
|
+
* and never re-enters a signed artifact or a verdict.
|
|
315
|
+
*/
|
|
316
|
+
anchorRef: AnchorRef | null;
|
|
155
317
|
// The checkpoint envelope verified (signature + payload binding to its record).
|
|
156
318
|
envelopeOk: boolean;
|
|
157
319
|
// The committed record's merkle_root equals the checkpoint's claimed merkle_root.
|
|
@@ -176,7 +338,7 @@ export interface EventResult {
|
|
|
176
338
|
payloadBindingOk: boolean | null;
|
|
177
339
|
// RFC 9162 inclusion of this event's leaf in its checkpoint's root.
|
|
178
340
|
inclusionOk: boolean;
|
|
179
|
-
// The inclusion's
|
|
341
|
+
// The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
|
|
180
342
|
checkpointBound: boolean;
|
|
181
343
|
// Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
|
|
182
344
|
// committed event.content_hash (in record_bytes) — or, for a promoted
|
|
@@ -188,6 +350,56 @@ export interface EventResult {
|
|
|
188
350
|
errors: string[];
|
|
189
351
|
}
|
|
190
352
|
|
|
353
|
+
/** One sub-tree's recomputed result (§5.2). */
|
|
354
|
+
export interface StampSubtreeResult {
|
|
355
|
+
/** The blinded key the body spells it under. Meaningless without the org's secret. */
|
|
356
|
+
ref: string;
|
|
357
|
+
firstLeaf: number;
|
|
358
|
+
leafCount: number;
|
|
359
|
+
root: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** One `stamps[]` entry's result (§5.2). */
|
|
363
|
+
export interface StampResult {
|
|
364
|
+
/** The stamp's derived identity, as the bundle spells it. "" when unusable. */
|
|
365
|
+
stampRef: string;
|
|
366
|
+
/** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
|
|
367
|
+
stampRefOk: boolean;
|
|
368
|
+
/**
|
|
369
|
+
* The witnessed bytes decoded, parsed, and re-canonicalized to themselves.
|
|
370
|
+
*
|
|
371
|
+
* The last clause is not pedantry. The plane stores these bytes as TEXT and
|
|
372
|
+
* replays them verbatim precisely because a round-trip through a normalizing
|
|
373
|
+
* serializer is not guaranteed to reproduce what was signed; a body that is
|
|
374
|
+
* not JCS-canonical is one that every re-deriving reader hashes differently.
|
|
375
|
+
*/
|
|
376
|
+
bodyOk: boolean;
|
|
377
|
+
stampVersion: string | null;
|
|
378
|
+
intervalStart: string | null;
|
|
379
|
+
intervalEnd: string | null;
|
|
380
|
+
merkleRoot: string | null;
|
|
381
|
+
leafCount: number | null;
|
|
382
|
+
/**
|
|
383
|
+
* Every sub-tree range accounts for exactly one leaf of the stamp: the
|
|
384
|
+
* ranges sorted by `first_leaf` tile `[0, leaf_count)` with no gap and no
|
|
385
|
+
* overlap, and their counts sum to `leaf_count`.
|
|
386
|
+
*/
|
|
387
|
+
subtreesOk: boolean;
|
|
388
|
+
subtrees: StampSubtreeResult[];
|
|
389
|
+
/**
|
|
390
|
+
* Where it was witnessed, or `null` for a stamp nothing has witnessed yet.
|
|
391
|
+
*
|
|
392
|
+
* `null` is NOT a failure and never an accusation — it is the honest state
|
|
393
|
+
* of every stamp for the first minutes of its life and the permanent state
|
|
394
|
+
* of every stamp when no witness is configured. It does hold the bundle at
|
|
395
|
+
* `partial`: a stamp is Sanning's own assertion until something independent
|
|
396
|
+
* carries it.
|
|
397
|
+
*/
|
|
398
|
+
anchorRef: AnchorRef | null;
|
|
399
|
+
ok: boolean;
|
|
400
|
+
errors: string[];
|
|
401
|
+
}
|
|
402
|
+
|
|
191
403
|
export interface EvidenceBundleResult {
|
|
192
404
|
status: EvidenceStatus;
|
|
193
405
|
// The wrapper: accepted major, body_hash recompute, Ed25519 wrapper signature.
|
|
@@ -207,6 +419,12 @@ export interface EvidenceBundleResult {
|
|
|
207
419
|
// above are the recomputed SOURCE-bundle results in that case. Absent for a
|
|
208
420
|
// plain anchor-trace bundle — additive, backward-compatible.
|
|
209
421
|
export?: ExportResult;
|
|
422
|
+
// Present ONLY for a `sanning.stamp.trace/v1` body (evidence-bundle.md §5.2):
|
|
423
|
+
// the per-stamp recomputed results. `checkpoints`/`events` stay empty for
|
|
424
|
+
// such a bundle — a stamp body carries neither, and reporting an empty list
|
|
425
|
+
// as "nothing was wrong" is the exact confusion `partial` exists to prevent.
|
|
426
|
+
// Absent for every other body — additive, backward-compatible.
|
|
427
|
+
stamps?: StampResult[];
|
|
210
428
|
errors: string[];
|
|
211
429
|
}
|
|
212
430
|
|
|
@@ -215,7 +433,12 @@ export interface EvidenceBundleResult {
|
|
|
215
433
|
// One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
|
|
216
434
|
// JCS(payload); the operator key is the operator's Arweave RSA wallet.
|
|
217
435
|
export interface AttestationRecord {
|
|
218
|
-
|
|
436
|
+
/**
|
|
437
|
+
* The source checkpoint this attestation binds to, spelled in the SOURCE
|
|
438
|
+
* bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
|
|
439
|
+
* for a frozen v1 one. Not re-derived across forms.
|
|
440
|
+
*/
|
|
441
|
+
checkpoint_ref: string;
|
|
219
442
|
payload: AttestationPayload;
|
|
220
443
|
signature_alg: string; // "rsa-pss-sha256"
|
|
221
444
|
public_key: { kty: "RSA"; n: string; e: string };
|
|
@@ -281,7 +504,7 @@ export interface VerdictAttestation {
|
|
|
281
504
|
}
|
|
282
505
|
|
|
283
506
|
export interface VerdictCheckpoint {
|
|
284
|
-
|
|
507
|
+
checkpoint_ref: string;
|
|
285
508
|
merkle_root_ok: boolean;
|
|
286
509
|
on_chain: VerdictOnChain | null;
|
|
287
510
|
attestations: VerdictAttestation[];
|
|
@@ -300,7 +523,7 @@ export interface VerdictObject {
|
|
|
300
523
|
}
|
|
301
524
|
|
|
302
525
|
export interface AttestationResult {
|
|
303
|
-
|
|
526
|
+
checkpointRef: string;
|
|
304
527
|
operator: string;
|
|
305
528
|
gateway: string | null;
|
|
306
529
|
// The RSA-PSS-SHA-256 signature over JCS(payload) verified (§5 step 6a).
|
|
@@ -310,7 +533,7 @@ export interface AttestationResult {
|
|
|
310
533
|
// payload.data_hash == the resolved source checkpoint's committed content hash
|
|
311
534
|
// (SHA-256(JCS(checkpoint.envelope))) (§5 step 6c).
|
|
312
535
|
dataHashBound: boolean;
|
|
313
|
-
//
|
|
536
|
+
// checkpoint_ref resolved to a present source checkpoint.
|
|
314
537
|
checkpointResolved: boolean;
|
|
315
538
|
level: number | null;
|
|
316
539
|
// subject_ref (§3.2) tri-state: null absent/undetermined, true/false only
|
|
@@ -340,7 +563,7 @@ export interface ExportResult {
|
|
|
340
563
|
}
|
|
341
564
|
|
|
342
565
|
export interface VerifyEvidenceOptions {
|
|
343
|
-
// Gateways to re-fetch each
|
|
566
|
+
// Gateways to re-fetch each anchor_ref.id from (first responsive wins).
|
|
344
567
|
// When supplied, the on-chain bytes MUST byte-match the bundle's checkpoint
|
|
345
568
|
// envelope, proving the proof is anchored, not just locally asserted.
|
|
346
569
|
gateways?: string[];
|
|
@@ -349,7 +572,15 @@ export interface VerifyEvidenceOptions {
|
|
|
349
572
|
// Uint8Array is used as-is; a string is parsed as lowercase hex. In-body
|
|
350
573
|
// `events[].content` (signed) takes precedence over this side input; if both
|
|
351
574
|
// are present for an event and disagree, the event fails (§5.1).
|
|
352
|
-
|
|
575
|
+
//
|
|
576
|
+
// A `Map` is the SAFE shape and the one to prefer — it is what
|
|
577
|
+
// `disclosedContent` hands back, so our output feeds straight back in, and a
|
|
578
|
+
// Map keys on the string itself: no prototype to inherit from and no
|
|
579
|
+
// `__proto__` write that silently lands somewhere else. The plain `Record` is
|
|
580
|
+
// still accepted (it was the original signature and remains supported), and
|
|
581
|
+
// the lookup guards it — see `lookupSideContent`. Widening is additive: every
|
|
582
|
+
// existing caller passing a Record keeps working unchanged.
|
|
583
|
+
content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
|
|
353
584
|
// Injectable fetch (Node >=18 global `fetch` by default) — test seam.
|
|
354
585
|
fetchImpl?: typeof fetch;
|
|
355
586
|
}
|
|
@@ -482,13 +713,20 @@ export async function verifyEvidenceBundle(
|
|
|
482
713
|
return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
|
|
483
714
|
}
|
|
484
715
|
|
|
485
|
-
|
|
716
|
+
// The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
|
|
717
|
+
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
718
|
+
// a stamp carries no second signature of its own inside the bundle.
|
|
719
|
+
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
720
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
486
724
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
487
725
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
488
726
|
// body verdict undetermined) — not a failure.
|
|
489
727
|
errors.push(
|
|
490
728
|
`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
491
|
-
`(only ${
|
|
729
|
+
`(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`,
|
|
492
730
|
);
|
|
493
731
|
return {
|
|
494
732
|
status: "partial",
|
|
@@ -507,6 +745,158 @@ export async function verifyEvidenceBundle(
|
|
|
507
745
|
return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
|
|
508
746
|
}
|
|
509
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Index a source bundle's checkpoints by the key its attestations will use.
|
|
750
|
+
*
|
|
751
|
+
* The attestation join key follows the SOURCE bundle's own vocabulary — a
|
|
752
|
+
* v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
|
|
753
|
+
* alternative (always deriving a v2-style key) would silently un-resolve every
|
|
754
|
+
* attestation ever written against a v1 export: `data_hash_bound` would go
|
|
755
|
+
* false, the export would stop reaching `verified`, and nothing would say why.
|
|
756
|
+
*/
|
|
757
|
+
async function indexSourceCheckpoints(
|
|
758
|
+
bundle: EvidenceBundle,
|
|
759
|
+
): Promise<Map<string, AnyTraceCheckpoint>> {
|
|
760
|
+
const out = new Map<string, AnyTraceCheckpoint>();
|
|
761
|
+
const sb = bundle.body as AnchorTraceBody | undefined;
|
|
762
|
+
const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
763
|
+
if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
|
|
764
|
+
for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
|
|
765
|
+
if (!cp || typeof cp !== "object") continue;
|
|
766
|
+
let recordBytes: Uint8Array | undefined;
|
|
767
|
+
try {
|
|
768
|
+
if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
|
|
769
|
+
} catch {
|
|
770
|
+
recordBytes = undefined;
|
|
771
|
+
}
|
|
772
|
+
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
773
|
+
if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
|
|
774
|
+
}
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Either shape of checkpoint, before the form is resolved. */
|
|
779
|
+
type AnyTraceCheckpoint = Partial<TraceCheckpoint> & Partial<TraceCheckpointV1>;
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Resolve a checkpoint's join key and its witness reference, for whichever
|
|
783
|
+
* body form we are reading.
|
|
784
|
+
*
|
|
785
|
+
* ── The rule that matters, and why it is a reject rather than a preference ──
|
|
786
|
+
*
|
|
787
|
+
* A checkpoint carrying BOTH vocabularies is two answers to one question
|
|
788
|
+
* inside one signature. A reader that silently prefers one gives a different
|
|
789
|
+
* answer from a reader that prefers the other, over the same signed bytes —
|
|
790
|
+
* and both look like they verified. So a v2 body carrying `tx_id`, or a v1
|
|
791
|
+
* body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
|
|
792
|
+
* resolved. This is cheap to enforce here and impossible to enforce later.
|
|
793
|
+
*
|
|
794
|
+
* ── Why v2's key is recomputed ──
|
|
795
|
+
*
|
|
796
|
+
* In v1 the key was an asserted label matched against another asserted label;
|
|
797
|
+
* nothing tied either to the bytes, so relabelling a checkpoint and every
|
|
798
|
+
* event pointing at it left a bundle that still verified. In v2 the key IS the
|
|
799
|
+
* bytes, so the same relabel fails here. v1's behaviour is preserved exactly
|
|
800
|
+
* as it was — deliberately, because those bundles are already signed and
|
|
801
|
+
* re-interpreting them is not backwards compatibility.
|
|
802
|
+
*/
|
|
803
|
+
async function resolveCheckpointKey(
|
|
804
|
+
cp: AnyTraceCheckpoint,
|
|
805
|
+
recordBytes: Uint8Array | undefined,
|
|
806
|
+
isV1: boolean,
|
|
807
|
+
): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
|
|
808
|
+
const errors: string[] = [];
|
|
809
|
+
|
|
810
|
+
if (isV1) {
|
|
811
|
+
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
812
|
+
errors.push(
|
|
813
|
+
"sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
|
|
814
|
+
"one bundle speaks one vocabulary",
|
|
815
|
+
);
|
|
816
|
+
return { key: null, anchorRef: null, errors };
|
|
817
|
+
}
|
|
818
|
+
if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
|
|
819
|
+
errors.push("checkpoint missing tx_id");
|
|
820
|
+
return { key: null, anchorRef: null, errors };
|
|
821
|
+
}
|
|
822
|
+
// v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
|
|
823
|
+
// through the same shape as v2 gives ONE reader-side vocabulary without
|
|
824
|
+
// touching a byte of the signed bundle.
|
|
825
|
+
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (cp.tx_id !== undefined) {
|
|
829
|
+
errors.push(
|
|
830
|
+
"sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary",
|
|
831
|
+
);
|
|
832
|
+
return { key: null, anchorRef: null, errors };
|
|
833
|
+
}
|
|
834
|
+
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
835
|
+
errors.push("checkpoint missing checkpoint_ref");
|
|
836
|
+
return { key: null, anchorRef: null, errors };
|
|
837
|
+
}
|
|
838
|
+
if (recordBytes === undefined) {
|
|
839
|
+
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
840
|
+
errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
|
|
841
|
+
return { key: null, anchorRef: null, errors };
|
|
842
|
+
}
|
|
843
|
+
const derived = await sha256Hex(recordBytes);
|
|
844
|
+
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
845
|
+
errors.push(
|
|
846
|
+
`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
|
|
847
|
+
);
|
|
848
|
+
return { key: null, anchorRef: null, errors };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
|
|
852
|
+
return { key: derived, anchorRef, errors };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Validate an optional `anchor_ref` (§5.1.1).
|
|
857
|
+
*
|
|
858
|
+
* ── An unrecognised `kind` is NOT a failure, and that is the point ──
|
|
859
|
+
*
|
|
860
|
+
* The offline verdict — signatures, payload binding, Merkle inclusion — is
|
|
861
|
+
* computed with no witness access at all. A reader that cannot resolve a
|
|
862
|
+
* witness has therefore learned NOTHING about whether the evidence was
|
|
863
|
+
* altered, and must not say otherwise. The shipped issuer did the opposite:
|
|
864
|
+
* an unrecognised signature type returned `false` rather than `null`, mapped
|
|
865
|
+
* to `tampered`, and was cached as permanent — a false accusation, produced
|
|
866
|
+
* by a coverage gap, for a company whose product is telling people whether
|
|
867
|
+
* records were altered.
|
|
868
|
+
*
|
|
869
|
+
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
870
|
+
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
871
|
+
* the reader, and only the first is the bundle's fault.
|
|
872
|
+
*/
|
|
873
|
+
function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
|
|
874
|
+
if (raw === undefined || raw === null) return null; // placed, not yet witnessed
|
|
875
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
876
|
+
errors.push("anchor_ref must be an object");
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
const o = raw as Record<string, unknown>;
|
|
880
|
+
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
881
|
+
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
if (typeof o.id !== "string" || o.id === "") {
|
|
885
|
+
errors.push("anchor_ref.id is missing or empty");
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const ref = o.ref;
|
|
889
|
+
if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
|
|
890
|
+
errors.push("anchor_ref.ref must be an object when present");
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
kind: o.kind,
|
|
895
|
+
id: o.id,
|
|
896
|
+
...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
510
900
|
async function verifyAnchorTrace(
|
|
511
901
|
b: EvidenceBundle,
|
|
512
902
|
bodyType: string,
|
|
@@ -516,40 +906,55 @@ async function verifyAnchorTrace(
|
|
|
516
906
|
options: VerifyEvidenceOptions,
|
|
517
907
|
): Promise<EvidenceBundleResult> {
|
|
518
908
|
const body = b.body as AnchorTraceBody;
|
|
909
|
+
const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
519
910
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
520
|
-
return malformed(
|
|
911
|
+
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
521
912
|
}
|
|
522
913
|
|
|
523
914
|
const gateways = options.gateways ?? [];
|
|
524
915
|
const onChainChecked = gateways.length > 0;
|
|
525
916
|
|
|
526
917
|
// --- Step 3: per-checkpoint -------------------------------------------------
|
|
527
|
-
|
|
918
|
+
//
|
|
919
|
+
// ── Why the join key is resolved BEFORE anything else ──
|
|
920
|
+
//
|
|
921
|
+
// `byKey` is what binds an event to the root its inclusion proof is checked
|
|
922
|
+
// against. Get it wrong and an event proves inclusion in a tree it was never
|
|
923
|
+
// in — the one failure in this file that produces a WRONG VERIFIED, not a
|
|
924
|
+
// failed verify. Everything below is downstream of it.
|
|
925
|
+
const byKey = new Map<string, AnyTraceCheckpoint>();
|
|
926
|
+
const anchorByKey = new Map<string, AnchorRef | null>();
|
|
528
927
|
const checkpointResults: CheckpointResult[] = [];
|
|
529
|
-
for (const cp of body.checkpoints) {
|
|
928
|
+
for (const cp of body.checkpoints as AnyTraceCheckpoint[]) {
|
|
530
929
|
const cpErrors: string[] = [];
|
|
531
930
|
let envelopeOk = false;
|
|
532
931
|
let merkleRootOk = false;
|
|
533
932
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
} else if (byTxId.has(cp.tx_id)) {
|
|
537
|
-
cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
|
|
538
|
-
} else {
|
|
539
|
-
byTxId.set(cp.tx_id, cp);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Verify the checkpoint envelope against its committed record (external
|
|
543
|
-
// commitment — the record is the payload these external envelopes bind to).
|
|
933
|
+
// Decode record_bytes first: in v2 the join key is DERIVED from it, so it
|
|
934
|
+
// is an input to identity, not just to the payload binding below.
|
|
544
935
|
let recordBytes: Uint8Array | undefined;
|
|
545
936
|
try {
|
|
546
937
|
if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
|
|
547
938
|
} catch (e) {
|
|
548
939
|
cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
|
|
549
940
|
}
|
|
941
|
+
|
|
942
|
+
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
943
|
+
cpErrors.push(...keyed.errors);
|
|
944
|
+
if (keyed.key !== null) {
|
|
945
|
+
if (byKey.has(keyed.key)) {
|
|
946
|
+
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
947
|
+
} else {
|
|
948
|
+
byKey.set(keyed.key, cp);
|
|
949
|
+
anchorByKey.set(keyed.key, keyed.anchorRef);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Verify the checkpoint envelope against its committed record (external
|
|
954
|
+
// commitment — the record is the payload these external envelopes bind to).
|
|
550
955
|
try {
|
|
551
956
|
const res = await verifyEnvelope(
|
|
552
|
-
cp.envelope,
|
|
957
|
+
cp.envelope as Envelope,
|
|
553
958
|
recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
|
|
554
959
|
);
|
|
555
960
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
@@ -583,12 +988,19 @@ async function verifyAnchorTrace(
|
|
|
583
988
|
}
|
|
584
989
|
|
|
585
990
|
checkpointResults.push({
|
|
586
|
-
|
|
991
|
+
checkpointRef: keyed.key ?? "",
|
|
992
|
+
anchorRef: keyed.anchorRef,
|
|
587
993
|
envelopeOk,
|
|
588
994
|
merkleRootOk,
|
|
589
995
|
onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
|
|
590
996
|
onChain: null, // per-gateway (§4.2), filled in step 5
|
|
591
|
-
|
|
997
|
+
// `keyed.errors` is load-bearing here, not decorative. A structurally
|
|
998
|
+
// invalid anchor_ref rides INSIDE the signature, so it is a defect we
|
|
999
|
+
// can see rather than a coverage gap we cannot — and this kernel's
|
|
1000
|
+
// standing rule is that malformed is a failure while absent is
|
|
1001
|
+
// undetermined (the same rule disclosed `content` follows). Without
|
|
1002
|
+
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
1003
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
592
1004
|
errors: cpErrors,
|
|
593
1005
|
});
|
|
594
1006
|
}
|
|
@@ -622,16 +1034,34 @@ async function verifyAnchorTrace(
|
|
|
622
1034
|
|
|
623
1035
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
624
1036
|
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
625
|
-
const incl = ev.inclusion;
|
|
626
|
-
|
|
627
|
-
|
|
1037
|
+
const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
|
|
1038
|
+
// The join key is spelled differently in the two forms and MUST NOT be read
|
|
1039
|
+
// from the other one: an inclusion carrying v1's spelling inside a v2 body
|
|
1040
|
+
// is a mixed bundle, and resolving it anyway is how an event silently binds
|
|
1041
|
+
// to a checkpoint nobody meant.
|
|
1042
|
+
const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
|
|
1043
|
+
const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
|
|
1044
|
+
const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
|
|
1045
|
+
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
1046
|
+
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
1047
|
+
let cp: AnyTraceCheckpoint | undefined;
|
|
628
1048
|
if (!incl || typeof incl !== "object") {
|
|
629
1049
|
evErrors.push("event missing inclusion proof");
|
|
630
|
-
} else if (
|
|
1050
|
+
} else if (strayKey !== undefined) {
|
|
631
1051
|
evErrors.push(
|
|
632
|
-
`event inclusion
|
|
1052
|
+
`event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`,
|
|
633
1053
|
);
|
|
1054
|
+
} else if (typeof rawKey !== "string") {
|
|
1055
|
+
evErrors.push(`event inclusion missing ${keyField}`);
|
|
1056
|
+
} else {
|
|
1057
|
+
cp = byKey.get(rawKey);
|
|
1058
|
+
if (cp === undefined) {
|
|
1059
|
+
evErrors.push(
|
|
1060
|
+
`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
634
1063
|
}
|
|
1064
|
+
const checkpointBound = cp !== undefined;
|
|
635
1065
|
|
|
636
1066
|
let inclusionOk = false;
|
|
637
1067
|
if (incl && checkpointBound && cp) {
|
|
@@ -641,7 +1071,7 @@ async function verifyAnchorTrace(
|
|
|
641
1071
|
incl.leaf_index,
|
|
642
1072
|
incl.leaf_count,
|
|
643
1073
|
(incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
644
|
-
hexToBytes(cp.merkle_root),
|
|
1074
|
+
hexToBytes(cp.merkle_root ?? ""),
|
|
645
1075
|
);
|
|
646
1076
|
if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
647
1077
|
|
|
@@ -761,9 +1191,26 @@ async function verifyAnchorTrace(
|
|
|
761
1191
|
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
762
1192
|
} else {
|
|
763
1193
|
for (const cpr of checkpointResults) {
|
|
764
|
-
const cp =
|
|
1194
|
+
const cp = byKey.get(cpr.checkpointRef);
|
|
765
1195
|
if (!cp) continue;
|
|
766
|
-
|
|
1196
|
+
// A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
|
|
1197
|
+
// that is the normal state inside a stamp interval — placed, readable,
|
|
1198
|
+
// not yet witnessed — so it reports as unreachable (which downgrades
|
|
1199
|
+
// the run to partial) and NEVER as a mismatch. Treating "we never
|
|
1200
|
+
// anchored it yet" as "the chain disagrees" would be the same false
|
|
1201
|
+
// accusation this whole change is removing.
|
|
1202
|
+
if (cpr.anchorRef === null) {
|
|
1203
|
+
gatewayUnavailable = true;
|
|
1204
|
+
cpr.onChain = {
|
|
1205
|
+
perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" as const })),
|
|
1206
|
+
rollup: "unreachable",
|
|
1207
|
+
onChainOk: null,
|
|
1208
|
+
};
|
|
1209
|
+
cpr.onChainOk = null;
|
|
1210
|
+
cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
767
1214
|
cpr.onChain = outcome.onChain;
|
|
768
1215
|
cpr.onChainOk = outcome.onChain.onChainOk;
|
|
769
1216
|
if (outcome.unavailable) gatewayUnavailable = true;
|
|
@@ -818,6 +1265,332 @@ async function verifyAnchorTrace(
|
|
|
818
1265
|
};
|
|
819
1266
|
}
|
|
820
1267
|
|
|
1268
|
+
// ===========================================================================
|
|
1269
|
+
// `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
|
|
1270
|
+
//
|
|
1271
|
+
// ── What this can prove, and what it deliberately does not claim ──
|
|
1272
|
+
//
|
|
1273
|
+
// A stamp is not a producer artifact. Under DL-52 the customer signs an
|
|
1274
|
+
// envelope and POSTs it; the commitment is witnessed later, on an interval, by
|
|
1275
|
+
// a stamp the customer never sees and holds no key for. So there is no
|
|
1276
|
+
// producer signature inside this body to check, and — this is the part worth
|
|
1277
|
+
// being precise about — there is no SECOND Sanning signature either. The
|
|
1278
|
+
// control plane stores the stamp's canonical bytes and hands them to a witness
|
|
1279
|
+
// as a signed chain data item; the signature lives on the chain, not in the
|
|
1280
|
+
// `stamps` table and not in this body.
|
|
1281
|
+
//
|
|
1282
|
+
// What "Sanning signed this" means for a stamp bundle is therefore exactly the
|
|
1283
|
+
// WRAPPER signature, verified generically before this function is reached. And
|
|
1284
|
+
// what makes the stamp evidence rather than an assertion is the witness, which
|
|
1285
|
+
// is on a chain and not checkable offline. Hence the rollup below: a stamp we
|
|
1286
|
+
// can read but nobody has witnessed holds the bundle at `partial`.
|
|
1287
|
+
//
|
|
1288
|
+
// So the checkable surface here is the stamp's INTERNAL consistency against
|
|
1289
|
+
// the one authority in the bundle — the witnessed bytes:
|
|
1290
|
+
//
|
|
1291
|
+
// 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
|
|
1292
|
+
// 2. Those bytes parse and re-canonicalize to themselves under JCS.
|
|
1293
|
+
// 3. Their `stamp_version` is the one this body_type pins.
|
|
1294
|
+
// 4. Every sub-tree range accounts for exactly one leaf of the stamp.
|
|
1295
|
+
//
|
|
1296
|
+
// (4) is the one that carries customer weight. R-8's whole per-agent story is
|
|
1297
|
+
// "prove this agent was under evidence over window W", answered by a
|
|
1298
|
+
// contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
|
|
1299
|
+
// not tile `[0, leaf_count)` describe a partition that does not exist, so a
|
|
1300
|
+
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
1301
|
+
// nothing else in the stack would ever notice.
|
|
1302
|
+
// ===========================================================================
|
|
1303
|
+
|
|
1304
|
+
/** Lowercase hex, even length, non-empty. The family's serialization rule. */
|
|
1305
|
+
function isLowerHex(s: unknown, nibbles?: number): s is string {
|
|
1306
|
+
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
|
|
1307
|
+
if (nibbles !== undefined && s.length !== nibbles) return false;
|
|
1308
|
+
return /^[0-9a-f]+$/.test(s);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function isNonNegInt(n: unknown): n is number {
|
|
1312
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 0;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Parse and check one stamp's witnessed bytes.
|
|
1317
|
+
*
|
|
1318
|
+
* Errors accumulate rather than short-circuit wherever a later check is still
|
|
1319
|
+
* meaningful, so a bundle with two independent defects reports two — a
|
|
1320
|
+
* verifier that stops at the first one turns a second fix into a second
|
|
1321
|
+
* round-trip.
|
|
1322
|
+
*/
|
|
1323
|
+
async function verifyStampEntry(
|
|
1324
|
+
raw: unknown,
|
|
1325
|
+
seen: Set<string>,
|
|
1326
|
+
): Promise<StampResult> {
|
|
1327
|
+
const errors: string[] = [];
|
|
1328
|
+
const base: StampResult = {
|
|
1329
|
+
stampRef: "",
|
|
1330
|
+
stampRefOk: false,
|
|
1331
|
+
bodyOk: false,
|
|
1332
|
+
stampVersion: null,
|
|
1333
|
+
intervalStart: null,
|
|
1334
|
+
intervalEnd: null,
|
|
1335
|
+
merkleRoot: null,
|
|
1336
|
+
leafCount: null,
|
|
1337
|
+
subtreesOk: false,
|
|
1338
|
+
subtrees: [],
|
|
1339
|
+
anchorRef: null,
|
|
1340
|
+
ok: false,
|
|
1341
|
+
errors,
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1345
|
+
errors.push("stamp entry is not an object");
|
|
1346
|
+
return base;
|
|
1347
|
+
}
|
|
1348
|
+
const e = raw as Partial<StampEntry> & Record<string, unknown>;
|
|
1349
|
+
|
|
1350
|
+
// --- the witnessed bytes, which are the only authority in this body -------
|
|
1351
|
+
if (!isLowerHex(e.stamp_bytes)) {
|
|
1352
|
+
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
1353
|
+
return base;
|
|
1354
|
+
}
|
|
1355
|
+
let bytes: Uint8Array;
|
|
1356
|
+
try {
|
|
1357
|
+
bytes = hexToBytes(e.stamp_bytes);
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
1360
|
+
return base;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// --- the derived identity -------------------------------------------------
|
|
1364
|
+
const derived = await sha256Hex(bytes);
|
|
1365
|
+
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
1366
|
+
errors.push("stamp entry missing stamp_ref");
|
|
1367
|
+
} else if (e.stamp_ref.toLowerCase() !== derived) {
|
|
1368
|
+
// The bytes were edited and the label was not — or the label was edited
|
|
1369
|
+
// and the bytes were not. Either way the bundle names a stamp it is not
|
|
1370
|
+
// carrying, and quoting that name to a witness would resolve to something
|
|
1371
|
+
// else entirely.
|
|
1372
|
+
errors.push(
|
|
1373
|
+
`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`,
|
|
1374
|
+
);
|
|
1375
|
+
} else {
|
|
1376
|
+
base.stampRefOk = true;
|
|
1377
|
+
base.stampRef = derived;
|
|
1378
|
+
if (seen.has(derived)) {
|
|
1379
|
+
errors.push(`duplicate stamp_ref ${derived}`);
|
|
1380
|
+
base.stampRefOk = false;
|
|
1381
|
+
} else {
|
|
1382
|
+
seen.add(derived);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// --- the body itself ------------------------------------------------------
|
|
1387
|
+
let body: StampBody;
|
|
1388
|
+
try {
|
|
1389
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
1390
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1391
|
+
errors.push("stamp body is not a JSON object");
|
|
1392
|
+
return base;
|
|
1393
|
+
}
|
|
1394
|
+
body = parsed as StampBody;
|
|
1395
|
+
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
1396
|
+
// every submit attempt for exactly this reason: a non-canonical body makes
|
|
1397
|
+
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
1398
|
+
// of the stamp disagree with the one that was witnessed.
|
|
1399
|
+
if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
|
|
1400
|
+
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1401
|
+
return base;
|
|
1402
|
+
}
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
1405
|
+
return base;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
1409
|
+
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
1410
|
+
// two answers to one question inside one signature. The body_type is the
|
|
1411
|
+
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
1412
|
+
// how a reader half-parses a shape it does not know.
|
|
1413
|
+
errors.push(
|
|
1414
|
+
`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
1415
|
+
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
1416
|
+
);
|
|
1417
|
+
return base;
|
|
1418
|
+
}
|
|
1419
|
+
base.stampVersion = body.stamp_version;
|
|
1420
|
+
base.bodyOk = true;
|
|
1421
|
+
|
|
1422
|
+
for (const f of ["org_ref", "interval_start", "interval_end"] as const) {
|
|
1423
|
+
if (typeof body[f] !== "string" || body[f] === "") {
|
|
1424
|
+
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
1425
|
+
base.bodyOk = false;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
1429
|
+
base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
|
|
1430
|
+
|
|
1431
|
+
if (!isLowerHex(body.merkle_root, 64)) {
|
|
1432
|
+
errors.push("stamp body merkle_root is not 64 lowercase hex characters");
|
|
1433
|
+
base.bodyOk = false;
|
|
1434
|
+
} else {
|
|
1435
|
+
base.merkleRoot = body.merkle_root;
|
|
1436
|
+
}
|
|
1437
|
+
if (!isNonNegInt(body.leaf_count)) {
|
|
1438
|
+
errors.push("stamp body leaf_count is not a non-negative integer");
|
|
1439
|
+
base.bodyOk = false;
|
|
1440
|
+
} else {
|
|
1441
|
+
base.leafCount = body.leaf_count;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// --- the sub-tree ranges --------------------------------------------------
|
|
1445
|
+
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
1446
|
+
|
|
1447
|
+
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
1448
|
+
|
|
1449
|
+
base.ok =
|
|
1450
|
+
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
1451
|
+
return base;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
1456
|
+
*
|
|
1457
|
+
* `buildStampTree` orders a stamp's leaves agent-major, which is the only
|
|
1458
|
+
* reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
|
|
1459
|
+
* ranges partition `[0, leaf_count)` exactly, by construction. A body whose
|
|
1460
|
+
* ranges do not is either not a stamp this stack built or one built wrong, and
|
|
1461
|
+
* in both cases the per-agent claim the ranges exist to support is unfounded.
|
|
1462
|
+
*
|
|
1463
|
+
* The sum is checked BEFORE the tiling and reports its own message, so a body
|
|
1464
|
+
* that is merely short does not get filed under a gap it does not have.
|
|
1465
|
+
*/
|
|
1466
|
+
function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boolean {
|
|
1467
|
+
const raw = body.subtrees;
|
|
1468
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1469
|
+
errors.push("stamp body subtrees is not an object");
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
let shapeOk = true;
|
|
1474
|
+
const subtrees: StampSubtreeResult[] = [];
|
|
1475
|
+
for (const [ref, v] of Object.entries(raw)) {
|
|
1476
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
1477
|
+
errors.push(`sub-tree ${ref} is not an object`);
|
|
1478
|
+
shapeOk = false;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const s = v as Partial<StampSubtree>;
|
|
1482
|
+
if (!isNonNegInt(s.first_leaf)) {
|
|
1483
|
+
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
1484
|
+
shapeOk = false;
|
|
1485
|
+
}
|
|
1486
|
+
// A zero-leaf sub-tree is not a slice of anything, and admitting one lets
|
|
1487
|
+
// an arbitrary number of empty ranges sit inside a tiling that still adds
|
|
1488
|
+
// up.
|
|
1489
|
+
if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
|
|
1490
|
+
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
1491
|
+
shapeOk = false;
|
|
1492
|
+
}
|
|
1493
|
+
if (!isLowerHex(s.root, 64)) {
|
|
1494
|
+
errors.push(`sub-tree ${ref} root is not 64 lowercase hex characters`);
|
|
1495
|
+
shapeOk = false;
|
|
1496
|
+
}
|
|
1497
|
+
if (!shapeOk) continue;
|
|
1498
|
+
subtrees.push({
|
|
1499
|
+
ref,
|
|
1500
|
+
firstLeaf: s.first_leaf as number,
|
|
1501
|
+
leafCount: s.leaf_count as number,
|
|
1502
|
+
root: s.root as string,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
subtrees.sort((a, b) => a.firstLeaf - b.firstLeaf);
|
|
1506
|
+
out.subtrees = subtrees;
|
|
1507
|
+
if (!shapeOk) return false;
|
|
1508
|
+
|
|
1509
|
+
const total = body.leaf_count;
|
|
1510
|
+
if (!isNonNegInt(total)) return false; // already reported by the caller
|
|
1511
|
+
|
|
1512
|
+
const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
|
|
1513
|
+
if (sum !== total) {
|
|
1514
|
+
errors.push(
|
|
1515
|
+
`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`,
|
|
1516
|
+
);
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
let next = 0;
|
|
1521
|
+
for (const s of subtrees) {
|
|
1522
|
+
if (s.firstLeaf !== next) {
|
|
1523
|
+
errors.push(
|
|
1524
|
+
`sub-tree ranges do not tile [0, ${total}): expected a range at leaf ` +
|
|
1525
|
+
`${next}, found one at ${s.firstLeaf}`,
|
|
1526
|
+
);
|
|
1527
|
+
return false;
|
|
1528
|
+
}
|
|
1529
|
+
next += s.leafCount;
|
|
1530
|
+
}
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
async function verifyStampTrace(
|
|
1535
|
+
b: EvidenceBundle,
|
|
1536
|
+
specVersionOk: boolean,
|
|
1537
|
+
assertedStatus: string | null,
|
|
1538
|
+
errors: string[],
|
|
1539
|
+
): Promise<EvidenceBundleResult> {
|
|
1540
|
+
const body = b.body as StampTraceBody;
|
|
1541
|
+
if (!Array.isArray(body?.stamps)) {
|
|
1542
|
+
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const seen = new Set<string>();
|
|
1546
|
+
const stamps: StampResult[] = [];
|
|
1547
|
+
for (const entry of body.stamps) {
|
|
1548
|
+
stamps.push(await verifyStampEntry(entry, seen));
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// --- Rollup ----------------------------------------------------------------
|
|
1552
|
+
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
1553
|
+
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
1554
|
+
|
|
1555
|
+
let status: EvidenceStatus;
|
|
1556
|
+
if (anyHardFailure) {
|
|
1557
|
+
status = "failed";
|
|
1558
|
+
} else if (stamps.length === 0) {
|
|
1559
|
+
// A bundle asserting nothing. Not a failure — nothing here is wrong — but
|
|
1560
|
+
// it must never read `verified`, which a caller would take as a witness it
|
|
1561
|
+
// does not have.
|
|
1562
|
+
status = "partial";
|
|
1563
|
+
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1564
|
+
} else if (anyUnwitnessed) {
|
|
1565
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1566
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement about
|
|
1567
|
+
// our coverage and never an accusation about their evidence. It is also not
|
|
1568
|
+
// `verified`, because for a stamp the witness is the entire independent
|
|
1569
|
+
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
1570
|
+
// signature and an inclusion proof that stand on their own offline.
|
|
1571
|
+
status = "partial";
|
|
1572
|
+
errors.push(
|
|
1573
|
+
"one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
|
|
1574
|
+
);
|
|
1575
|
+
} else {
|
|
1576
|
+
status = "verified";
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
return {
|
|
1580
|
+
status,
|
|
1581
|
+
specVersionOk,
|
|
1582
|
+
bodyHashOk: true,
|
|
1583
|
+
signatureOk: true,
|
|
1584
|
+
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
1585
|
+
onChainChecked: false,
|
|
1586
|
+
checkpoints: [],
|
|
1587
|
+
events: [],
|
|
1588
|
+
assertedStatus,
|
|
1589
|
+
stamps,
|
|
1590
|
+
errors,
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
821
1594
|
// ===========================================================================
|
|
822
1595
|
// Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
|
|
823
1596
|
//
|
|
@@ -910,7 +1683,7 @@ export async function disclosedContent(
|
|
|
910
1683
|
const traceEvents = resolveTraceEvents(bundle);
|
|
911
1684
|
if (traceEvents === undefined) {
|
|
912
1685
|
errors.push(
|
|
913
|
-
`no ${
|
|
1686
|
+
`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
|
|
914
1687
|
`(body_type ${JSON.stringify(verification.bodyType)})`,
|
|
915
1688
|
);
|
|
916
1689
|
return empty();
|
|
@@ -1007,13 +1780,19 @@ export async function disclosedContent(
|
|
|
1007
1780
|
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
1008
1781
|
// recurses into `source_bundle`, so the events line up with its results.
|
|
1009
1782
|
// `undefined` when there is no anchor-trace body to materialize from.
|
|
1010
|
-
|
|
1783
|
+
//
|
|
1784
|
+
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
1785
|
+
// again: two answers to "which events is this bundle actually about" inside one
|
|
1786
|
+
// kernel is two implementations of one rule, and the one that drifted would be
|
|
1787
|
+
// the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
|
|
1788
|
+
// same posture as `side-content.ts`, internal and shared.
|
|
1789
|
+
export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
1011
1790
|
let cur: unknown = bundle;
|
|
1012
1791
|
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
1013
1792
|
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
|
|
1014
1793
|
const b = cur as EvidenceBundle;
|
|
1015
1794
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
1016
|
-
if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
|
|
1795
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
1017
1796
|
const body = b.body as AnchorTraceBody | null | undefined;
|
|
1018
1797
|
if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
|
|
1019
1798
|
return body.events;
|
|
@@ -1118,14 +1897,8 @@ async function verifyExportBody(
|
|
|
1118
1897
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
1119
1898
|
const sourceResult = await verifyEvidenceBundle(inline, options);
|
|
1120
1899
|
|
|
1121
|
-
//
|
|
1122
|
-
const sourceCheckpoints =
|
|
1123
|
-
const sb = inline.body as AnchorTraceBody | undefined;
|
|
1124
|
-
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
1125
|
-
for (const cp of sb.checkpoints) {
|
|
1126
|
-
if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1900
|
+
// Attestation data_hash binding (§5 6c).
|
|
1901
|
+
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
1129
1902
|
|
|
1130
1903
|
// --- Step 6: embedded attestation records ---------------------------------
|
|
1131
1904
|
const attestations: AttestationResult[] = [];
|
|
@@ -1257,15 +2030,9 @@ export async function recomputeExportVerdict(
|
|
|
1257
2030
|
): Promise<ComposeVerdictResult> {
|
|
1258
2031
|
const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
|
|
1259
2032
|
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
const sourceCheckpoints =
|
|
1263
|
-
const sb = sourceBundle.body as AnchorTraceBody | undefined;
|
|
1264
|
-
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
1265
|
-
for (const cp of sb.checkpoints) {
|
|
1266
|
-
if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
2033
|
+
// Attestation data_hash binding (§5 6c) — the SAME index construction as
|
|
2034
|
+
// verifyExportBody, deliberately shared rather than restated.
|
|
2035
|
+
const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
|
|
1269
2036
|
|
|
1270
2037
|
const attestations: AttestationResult[] = [];
|
|
1271
2038
|
for (const rec of attestationRecords) {
|
|
@@ -1309,12 +2076,12 @@ export async function recomputeExportVerdict(
|
|
|
1309
2076
|
// undetermined-tolerant and never gates.
|
|
1310
2077
|
async function verifyAttestation(
|
|
1311
2078
|
rec: AttestationRecord,
|
|
1312
|
-
sourceCheckpoints: Map<string,
|
|
2079
|
+
sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
|
|
1313
2080
|
): Promise<{ result: AttestationResult; malformed?: string }> {
|
|
1314
2081
|
const errs: string[] = [];
|
|
1315
2082
|
const result: AttestationResult = {
|
|
1316
|
-
|
|
1317
|
-
rec && typeof rec.
|
|
2083
|
+
checkpointRef:
|
|
2084
|
+
rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
|
|
1318
2085
|
operator: "",
|
|
1319
2086
|
gateway: null,
|
|
1320
2087
|
signatureOk: false,
|
|
@@ -1392,11 +2159,11 @@ async function verifyAttestation(
|
|
|
1392
2159
|
// uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
|
|
1393
2160
|
// against, §4.2), so the checkpoint's committed content hash is
|
|
1394
2161
|
// SHA-256(JCS(checkpoint.envelope)).
|
|
1395
|
-
const cp = sourceCheckpoints.get(result.
|
|
2162
|
+
const cp = sourceCheckpoints.get(result.checkpointRef);
|
|
1396
2163
|
result.checkpointResolved = cp !== undefined;
|
|
1397
2164
|
if (!cp) {
|
|
1398
2165
|
errs.push(
|
|
1399
|
-
`attestation
|
|
2166
|
+
`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`,
|
|
1400
2167
|
);
|
|
1401
2168
|
} else {
|
|
1402
2169
|
try {
|
|
@@ -1455,7 +2222,7 @@ function buildVerdictObject(
|
|
|
1455
2222
|
): VerdictObject {
|
|
1456
2223
|
const attByCheckpoint = new Map<string, VerdictAttestation[]>();
|
|
1457
2224
|
for (const a of attestations) {
|
|
1458
|
-
const list = attByCheckpoint.get(a.
|
|
2225
|
+
const list = attByCheckpoint.get(a.checkpointRef) ?? [];
|
|
1459
2226
|
list.push({
|
|
1460
2227
|
operator: a.operator,
|
|
1461
2228
|
...(a.gateway ? { gateway: a.gateway } : {}),
|
|
@@ -1465,7 +2232,7 @@ function buildVerdictObject(
|
|
|
1465
2232
|
...(a.level !== null ? { level: a.level } : {}),
|
|
1466
2233
|
subject_ref_ok: a.subjectRefOk,
|
|
1467
2234
|
});
|
|
1468
|
-
attByCheckpoint.set(a.
|
|
2235
|
+
attByCheckpoint.set(a.checkpointRef, list);
|
|
1469
2236
|
}
|
|
1470
2237
|
|
|
1471
2238
|
const events: VerdictEvent[] = source.events.map((e) => ({
|
|
@@ -1478,10 +2245,10 @@ function buildVerdictObject(
|
|
|
1478
2245
|
}));
|
|
1479
2246
|
|
|
1480
2247
|
const checkpoints: VerdictCheckpoint[] = source.checkpoints.map((c) => ({
|
|
1481
|
-
|
|
2248
|
+
checkpoint_ref: c.checkpointRef,
|
|
1482
2249
|
merkle_root_ok: c.merkleRootOk,
|
|
1483
2250
|
on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
|
|
1484
|
-
attestations: attByCheckpoint.get(c.
|
|
2251
|
+
attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
|
|
1485
2252
|
}));
|
|
1486
2253
|
|
|
1487
2254
|
let verified = 0;
|
|
@@ -1550,7 +2317,7 @@ function projectVerdictForAgreement(v: unknown): unknown {
|
|
|
1550
2317
|
const cp = (c ?? {}) as VerdictCheckpoint;
|
|
1551
2318
|
const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
|
|
1552
2319
|
return {
|
|
1553
|
-
|
|
2320
|
+
checkpoint_ref: cp.checkpoint_ref ?? null,
|
|
1554
2321
|
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
1555
2322
|
// on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
|
|
1556
2323
|
attestations: atts.map((a) => {
|
|
@@ -1588,7 +2355,8 @@ function summarizeExport(
|
|
|
1588
2355
|
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
1589
2356
|
// single-boolean exactly for a single gateway.
|
|
1590
2357
|
async function refetchCheckpoint(
|
|
1591
|
-
cp:
|
|
2358
|
+
cp: AnyTraceCheckpoint,
|
|
2359
|
+
anchorRef: AnchorRef,
|
|
1592
2360
|
gateways: string[],
|
|
1593
2361
|
fetchImpl: typeof fetch,
|
|
1594
2362
|
): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
|
|
@@ -1603,15 +2371,18 @@ async function refetchCheckpoint(
|
|
|
1603
2371
|
|
|
1604
2372
|
const perGateway: PerGatewayOutcome[] = [];
|
|
1605
2373
|
for (const gw of gateways) {
|
|
1606
|
-
perGateway.push({
|
|
2374
|
+
perGateway.push({
|
|
2375
|
+
gateway: gw,
|
|
2376
|
+
outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
|
|
2377
|
+
});
|
|
1607
2378
|
}
|
|
1608
2379
|
|
|
1609
2380
|
const onChain = rollupOnChain(perGateway);
|
|
1610
2381
|
let error: string | undefined;
|
|
1611
2382
|
if (onChain.rollup === "mismatch") {
|
|
1612
|
-
error = `on-chain bytes at ${
|
|
2383
|
+
error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
|
|
1613
2384
|
} else if (onChain.rollup === "unreachable") {
|
|
1614
|
-
error = `checkpoint ${
|
|
2385
|
+
error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
|
|
1615
2386
|
}
|
|
1616
2387
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
1617
2388
|
}
|
|
@@ -1619,12 +2390,12 @@ async function refetchCheckpoint(
|
|
|
1619
2390
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
1620
2391
|
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
1621
2392
|
async function probeGateway(
|
|
1622
|
-
|
|
2393
|
+
anchorRef: AnchorRef,
|
|
1623
2394
|
gateway: string,
|
|
1624
2395
|
expectedHex: string | undefined,
|
|
1625
2396
|
fetchImpl: typeof fetch,
|
|
1626
2397
|
): Promise<OnChainOutcome> {
|
|
1627
|
-
const url = `${gateway.replace(/\/+$/, "")}/${
|
|
2398
|
+
const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
|
|
1628
2399
|
let resp: Awaited<ReturnType<typeof fetch>>;
|
|
1629
2400
|
try {
|
|
1630
2401
|
resp = await fetchImpl(url);
|
|
@@ -1691,6 +2462,15 @@ interface DisclosedSelection {
|
|
|
1691
2462
|
errors: string[];
|
|
1692
2463
|
}
|
|
1693
2464
|
|
|
2465
|
+
// The side-input presence rule lives in `side-content.ts`, stated ONCE.
|
|
2466
|
+
//
|
|
2467
|
+
// It used to live here as a private `lookupSideContent`, and `grading.ts`
|
|
2468
|
+
// carried a second, laxer answer to the same question (`hasEntry`: own +
|
|
2469
|
+
// enumerable, nothing more). On one and the same content map the grader said
|
|
2470
|
+
// PRESENT while this verifier said nothing was offered, and `gradePack` then
|
|
2471
|
+
// returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
|
|
2472
|
+
// and for what each clause of it cost.
|
|
2473
|
+
|
|
1694
2474
|
function selectDisclosedBytes(
|
|
1695
2475
|
ev: TraceEvent,
|
|
1696
2476
|
eventId: string,
|
|
@@ -1706,17 +2486,31 @@ function selectDisclosedBytes(
|
|
|
1706
2486
|
}
|
|
1707
2487
|
}
|
|
1708
2488
|
let side: Uint8Array | undefined;
|
|
1709
|
-
const
|
|
1710
|
-
if (
|
|
1711
|
-
side =
|
|
1712
|
-
} else if (typeof
|
|
2489
|
+
const sideEntry: SideEntry = sideContentEntry(sideContent, eventId);
|
|
2490
|
+
if (sideEntry.value instanceof Uint8Array) {
|
|
2491
|
+
side = sideEntry.value;
|
|
2492
|
+
} else if (typeof sideEntry.value === "string") {
|
|
1713
2493
|
try {
|
|
1714
|
-
side = hexToBytes(
|
|
2494
|
+
side = hexToBytes(sideEntry.value);
|
|
1715
2495
|
} catch (e) {
|
|
1716
2496
|
errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
1717
2497
|
}
|
|
1718
2498
|
}
|
|
1719
|
-
|
|
2499
|
+
// `offered` comes from the SAME test that produced the value, never from
|
|
2500
|
+
// `value !== undefined` — that is the conflation this whole guard exists to
|
|
2501
|
+
// remove, and it was removed from ONE of the two operands. The left one
|
|
2502
|
+
// carried it unchanged: an in-body `"content": null` is `!== undefined`, so
|
|
2503
|
+
// the event was marked offered while no bytes were selected, and the kernel
|
|
2504
|
+
// split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
|
|
2505
|
+
// FAILED, on one bundle. `null` is how an optional field's absence
|
|
2506
|
+
// serializes; `inBodyContentOffered` states that once, beside the side-input
|
|
2507
|
+
// rule it mirrors.
|
|
2508
|
+
return {
|
|
2509
|
+
inBody,
|
|
2510
|
+
side,
|
|
2511
|
+
offered: inBodyContentOffered(ev.content) || sideEntry.offered,
|
|
2512
|
+
errors,
|
|
2513
|
+
};
|
|
1720
2514
|
}
|
|
1721
2515
|
|
|
1722
2516
|
function toHex(bytes: Uint8Array): string {
|