@sanning/proof 0.9.2 → 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/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 +334 -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.js +7 -3
- package/package.json +2 -2
- 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 +409 -196
- package/src/grading.ts +28 -5
- package/src/index.ts +5 -8
- package/src/verifier.ts +7 -3
- package/vectors/README.md +1 -1
- package/vectors/chain-reading.json +4 -4
- package/vectors/stamp-on-chain.json +3 -3
package/dist/evidence.js
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
|
//
|
|
@@ -27,33 +27,60 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
|
|
|
27
27
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
28
28
|
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
29
29
|
// "undetermined" rather than "verified".
|
|
30
|
-
|
|
30
|
+
//
|
|
31
|
+
// The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
|
|
32
|
+
// `event_bytes` and moved every body that carries it to a new major. This
|
|
33
|
+
// kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
|
|
34
|
+
// `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
|
|
35
|
+
// types here and answer `partial`. DL-73 releases evidence made before the cut
|
|
36
|
+
// from any obligation to keep verifying, so there is no second field name to
|
|
37
|
+
// read and no dual-accept path.
|
|
38
|
+
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
|
|
31
39
|
/**
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* defend (DL-30). Nothing new emits it.
|
|
40
|
+
* The name `event_bytes` replaced. Under the new majors a body entry that still
|
|
41
|
+
* carries it is a hard reject, never an ignored unknown field: ignored, it
|
|
42
|
+
* would read as a withheld record and the event would verify with nothing
|
|
43
|
+
* checked, the fail-open the new majors exist to prevent (governance.md §8,
|
|
44
|
+
* W-3).
|
|
38
45
|
*/
|
|
39
|
-
export const
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
|
|
47
|
+
/**
|
|
48
|
+
* Name an envelope the accept-set refused. Without this a bundle whose
|
|
49
|
+
* envelopes carry a retired profile major (a half-migrated producer still
|
|
50
|
+
* writing `sanning.events/v1`) fails with no reason at all. Spelled with
|
|
51
|
+
* `JSON.stringify` so both kernels write the same sentence.
|
|
52
|
+
*/
|
|
53
|
+
function specVersionFinding(what, envelope, res) {
|
|
54
|
+
if (!isJsonObject(envelope) || res.specVersionOk)
|
|
55
|
+
return null;
|
|
56
|
+
if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
|
|
57
|
+
return `${what} envelope has no spec_version`;
|
|
58
|
+
}
|
|
59
|
+
const sv = envelope.spec_version;
|
|
60
|
+
return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The reject for an entry that still carries `record_bytes`. One sentence,
|
|
64
|
+
* spelled the same in both kernels, so a negative vector can assert it.
|
|
65
|
+
*/
|
|
66
|
+
function legacyEventBytesError(entry, what, bodyType) {
|
|
67
|
+
if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD))
|
|
68
|
+
return null;
|
|
69
|
+
return (`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
|
|
70
|
+
"(renamed event_bytes) -- one bundle speaks one vocabulary");
|
|
71
|
+
}
|
|
45
72
|
// The issuer-composed attested-evidence-export body (specs/evidence-export.md
|
|
46
73
|
// §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
|
|
47
74
|
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
48
75
|
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
49
76
|
// verifyExportBody (§5).
|
|
50
|
-
export const EXPORT_BODY_TYPE = "sanning.evidence.export/
|
|
77
|
+
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
|
|
51
78
|
/**
|
|
52
79
|
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
53
80
|
*
|
|
54
81
|
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
55
82
|
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
56
|
-
* `sanning.events/
|
|
83
|
+
* `sanning.events/v2` envelope, and a stamp has no producer signature at all:
|
|
57
84
|
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
58
85
|
* The two rejected options were making `envelope` optional and synthesising an
|
|
59
86
|
* envelope that Sanning signs; the second would have made a Sanning
|
|
@@ -61,7 +88,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
61
88
|
* unrecoverable once witnessed.
|
|
62
89
|
*/
|
|
63
90
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
64
|
-
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/
|
|
91
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
|
|
65
92
|
/**
|
|
66
93
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
67
94
|
* the `body_type` above, and the two are easy to confuse.
|
|
@@ -116,13 +143,18 @@ function stringifyErr(e) {
|
|
|
116
143
|
function jsonStr(value) {
|
|
117
144
|
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
118
145
|
}
|
|
119
|
-
// Verify a `sanning.evidence/v1` bundle
|
|
146
|
+
// Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
|
|
120
147
|
//
|
|
121
148
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
122
149
|
// rollup `status`. The producer's asserted `verdict.status` is surfaced
|
|
123
150
|
// (`assertedStatus`) but NEVER trusted — the displayed status is the one this
|
|
124
151
|
// function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
|
|
125
152
|
export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
153
|
+
return verifyBundle(bundle, options, false);
|
|
154
|
+
}
|
|
155
|
+
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
156
|
+
// §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
|
|
157
|
+
async function verifyBundle(bundle, options, exportSource) {
|
|
126
158
|
// --- Step 1: parse + reject unknown spec_version major ---------------------
|
|
127
159
|
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
128
160
|
return malformed("evidence bundle is not a JSON object");
|
|
@@ -197,6 +229,25 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
197
229
|
}
|
|
198
230
|
// --- Body dispatch ---------------------------------------------------------
|
|
199
231
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
232
|
+
// Read as an export's source (evidence-export.md §2.4), the only body this
|
|
233
|
+
// kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
|
|
234
|
+
// verifies on its own, is undetermined here, after the wrapper checks above.
|
|
235
|
+
if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
236
|
+
errors.push(`body_type ${JSON.stringify(bodyType)} is not an export source ` +
|
|
237
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
|
|
238
|
+
return {
|
|
239
|
+
status: "partial",
|
|
240
|
+
specVersionOk,
|
|
241
|
+
bodyHashOk,
|
|
242
|
+
signatureOk,
|
|
243
|
+
bodyType,
|
|
244
|
+
onChainChecked: false,
|
|
245
|
+
checkpoints: [],
|
|
246
|
+
events: [],
|
|
247
|
+
assertedStatus,
|
|
248
|
+
errors,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
200
251
|
// Attested-evidence-export body (evidence-export.md §5). The wrapper is
|
|
201
252
|
// authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
|
|
202
253
|
// hand off to the export verifier for steps 3–9.
|
|
@@ -213,12 +264,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
213
264
|
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
214
265
|
return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
|
|
215
266
|
}
|
|
216
|
-
if (bodyType !== ANCHOR_TRACE_BODY_TYPE
|
|
267
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
217
268
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
218
269
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
219
270
|
// body verdict undetermined) — not a failure.
|
|
220
271
|
errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
221
|
-
`(only ${
|
|
272
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
|
|
222
273
|
return {
|
|
223
274
|
status: "partial",
|
|
224
275
|
specVersionOk,
|
|
@@ -232,95 +283,89 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
232
283
|
errors,
|
|
233
284
|
};
|
|
234
285
|
}
|
|
235
|
-
return verifyAnchorTrace(b,
|
|
286
|
+
return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
236
287
|
}
|
|
237
288
|
/**
|
|
238
|
-
*
|
|
289
|
+
* Recompute an export's source verdict (evidence-export.md §5 step 4).
|
|
239
290
|
*
|
|
240
|
-
* The
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
291
|
+
* The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
|
|
292
|
+
* inline source body is unrecognised *as a source*, even one this kernel
|
|
293
|
+
* verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
|
|
294
|
+
* its source verdict undetermined, so the export cannot reach `verified`. The
|
|
295
|
+
* source wrapper is still checked first, because a forged source is a failure
|
|
296
|
+
* whatever its body.
|
|
297
|
+
*/
|
|
298
|
+
async function verifyExportSource(source, options) {
|
|
299
|
+
return verifyBundle(source, options, true);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Index a source bundle's checkpoints by the key its attestations use: the
|
|
303
|
+
* derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
|
|
304
|
+
* checkpoints an attestation can resolve (evidence-export.md §2.4).
|
|
245
305
|
*/
|
|
246
306
|
async function indexSourceCheckpoints(bundle) {
|
|
247
307
|
const out = new Map();
|
|
308
|
+
if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE)
|
|
309
|
+
return out;
|
|
248
310
|
const sb = bundle.body;
|
|
249
|
-
const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
250
311
|
if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
|
|
251
312
|
return out;
|
|
252
313
|
for (const cp of sb.checkpoints) {
|
|
253
314
|
if (!cp || typeof cp !== "object")
|
|
254
315
|
continue;
|
|
255
|
-
let
|
|
316
|
+
let eventBytes;
|
|
256
317
|
try {
|
|
257
|
-
if (typeof cp.
|
|
258
|
-
|
|
318
|
+
if (typeof cp.event_bytes === "string")
|
|
319
|
+
eventBytes = hexToBytes(cp.event_bytes);
|
|
259
320
|
}
|
|
260
321
|
catch {
|
|
261
|
-
|
|
322
|
+
eventBytes = undefined;
|
|
262
323
|
}
|
|
263
|
-
const keyed = await resolveCheckpointKey(cp,
|
|
324
|
+
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
264
325
|
if (keyed.key !== null && !out.has(keyed.key))
|
|
265
326
|
out.set(keyed.key, cp);
|
|
266
327
|
}
|
|
267
328
|
return out;
|
|
268
329
|
}
|
|
269
330
|
/**
|
|
270
|
-
* Resolve a checkpoint's join key and its witness reference
|
|
271
|
-
* body form we are reading.
|
|
331
|
+
* Resolve a checkpoint's join key and its witness reference.
|
|
272
332
|
*
|
|
273
|
-
* ──
|
|
333
|
+
* ── Why a retired spelling is a reject rather than a preference ──
|
|
274
334
|
*
|
|
275
|
-
* A checkpoint carrying
|
|
276
|
-
* inside one signature. A reader that silently prefers
|
|
277
|
-
* answer from a reader that prefers the other, over the
|
|
278
|
-
* and both look like they verified. So
|
|
279
|
-
*
|
|
280
|
-
* resolved. This is cheap to enforce here and impossible to enforce later.
|
|
335
|
+
* A checkpoint carrying a retired spelling beside the current one is two
|
|
336
|
+
* answers to one question inside one signature. A reader that silently prefers
|
|
337
|
+
* one gives a different answer from a reader that prefers the other, over the
|
|
338
|
+
* same signed bytes, and both look like they verified. So `tx_id` and
|
|
339
|
+
* `record_bytes` are rejected rather than resolved.
|
|
281
340
|
*
|
|
282
|
-
* ── Why
|
|
341
|
+
* ── Why the key is recomputed ──
|
|
283
342
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* event pointing at it left a bundle that still verified. In v2 the key IS the
|
|
287
|
-
* bytes, so the same relabel fails here. v1's behaviour is preserved exactly
|
|
288
|
-
* as it was — deliberately, because those bundles are already signed and
|
|
289
|
-
* re-interpreting them is not backwards compatibility.
|
|
343
|
+
* The key IS the bytes: relabelling a checkpoint and every event pointing at it
|
|
344
|
+
* fails here, because nothing is taken from the label.
|
|
290
345
|
*/
|
|
291
|
-
async function resolveCheckpointKey(cp,
|
|
346
|
+
async function resolveCheckpointKey(cp, eventBytes) {
|
|
292
347
|
const errors = [];
|
|
293
|
-
if (isV1) {
|
|
294
|
-
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
295
|
-
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
|
|
296
|
-
"one bundle speaks one vocabulary");
|
|
297
|
-
return { key: null, anchorRef: null, errors };
|
|
298
|
-
}
|
|
299
|
-
if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
|
|
300
|
-
errors.push("checkpoint missing tx_id");
|
|
301
|
-
return { key: null, anchorRef: null, errors };
|
|
302
|
-
}
|
|
303
|
-
// v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
|
|
304
|
-
// through the same shape as v2 gives ONE reader-side vocabulary without
|
|
305
|
-
// touching a byte of the signed bundle.
|
|
306
|
-
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
307
|
-
}
|
|
308
348
|
if (cp.tx_id !== undefined) {
|
|
309
|
-
errors.push(
|
|
349
|
+
errors.push(`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`);
|
|
350
|
+
return { key: null, anchorRef: null, errors };
|
|
351
|
+
}
|
|
352
|
+
const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
|
|
353
|
+
if (legacy !== null) {
|
|
354
|
+
errors.push(legacy);
|
|
310
355
|
return { key: null, anchorRef: null, errors };
|
|
311
356
|
}
|
|
312
357
|
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
313
358
|
errors.push("checkpoint missing checkpoint_ref");
|
|
314
359
|
return { key: null, anchorRef: null, errors };
|
|
315
360
|
}
|
|
316
|
-
if (
|
|
361
|
+
if (eventBytes === undefined) {
|
|
317
362
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
318
|
-
errors.push("checkpoint_ref cannot be verified --
|
|
363
|
+
errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
|
|
319
364
|
return { key: null, anchorRef: null, errors };
|
|
320
365
|
}
|
|
321
|
-
const derived = await sha256Hex(
|
|
366
|
+
const derived = await sha256Hex(eventBytes);
|
|
322
367
|
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
323
|
-
errors.push(`checkpoint_ref does not match SHA-256(
|
|
368
|
+
errors.push(`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
|
|
324
369
|
return { key: null, anchorRef: null, errors };
|
|
325
370
|
}
|
|
326
371
|
const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
|
|
@@ -371,9 +416,9 @@ function parseAnchorRef(raw, errors) {
|
|
|
371
416
|
...(ref === undefined ? {} : { ref: ref }),
|
|
372
417
|
};
|
|
373
418
|
}
|
|
374
|
-
async function verifyAnchorTrace(b,
|
|
419
|
+
async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options) {
|
|
375
420
|
const body = b.body;
|
|
376
|
-
const
|
|
421
|
+
const bodyType = ANCHOR_TRACE_BODY_TYPE;
|
|
377
422
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
378
423
|
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
379
424
|
}
|
|
@@ -416,17 +461,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
416
461
|
const cpErrors = [];
|
|
417
462
|
let envelopeOk = false;
|
|
418
463
|
let merkleRootOk = false;
|
|
419
|
-
// Decode
|
|
464
|
+
// Decode event_bytes first: in v2 the join key is DERIVED from it, so it
|
|
420
465
|
// is an input to identity, not just to the payload binding below.
|
|
421
|
-
let
|
|
466
|
+
let eventBytes;
|
|
422
467
|
try {
|
|
423
|
-
if (typeof cp.
|
|
424
|
-
|
|
468
|
+
if (typeof cp.event_bytes === "string")
|
|
469
|
+
eventBytes = hexToBytes(cp.event_bytes);
|
|
425
470
|
}
|
|
426
471
|
catch (e) {
|
|
427
|
-
cpErrors.push(`checkpoint
|
|
472
|
+
cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
|
|
428
473
|
}
|
|
429
|
-
const keyed = await resolveCheckpointKey(cp,
|
|
474
|
+
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
430
475
|
cpErrors.push(...keyed.errors);
|
|
431
476
|
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
432
477
|
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
@@ -440,7 +485,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
440
485
|
let duplicateKey = false;
|
|
441
486
|
if (keyed.key !== null) {
|
|
442
487
|
if (byKey.has(keyed.key)) {
|
|
443
|
-
cpErrors.push(`duplicate checkpoint
|
|
488
|
+
cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
|
|
444
489
|
duplicateKey = true;
|
|
445
490
|
}
|
|
446
491
|
else {
|
|
@@ -451,7 +496,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
451
496
|
// Verify the checkpoint envelope against its committed record (external
|
|
452
497
|
// commitment — the record is the payload these external envelopes bind to).
|
|
453
498
|
try {
|
|
454
|
-
const res = await verifyEnvelope(cp.envelope,
|
|
499
|
+
const res = await verifyEnvelope(cp.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
455
500
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
456
501
|
// payload_hash. An undetermined binding (no record) is not enough to
|
|
457
502
|
// anchor the events' inclusion claims to a trustworthy root.
|
|
@@ -471,14 +516,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
471
516
|
for (const m of res.errors)
|
|
472
517
|
if (m.startsWith("malformed "))
|
|
473
518
|
cpErrors.push(`checkpoint envelope ${m}`);
|
|
519
|
+
const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
|
|
520
|
+
if (specFinding !== null)
|
|
521
|
+
cpErrors.push(specFinding);
|
|
474
522
|
// WHY the binding is undetermined decides which sentence is true.
|
|
475
|
-
//
|
|
523
|
+
// event_bytes really absent is one thing; event_bytes present and
|
|
476
524
|
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
477
525
|
// naming the wrong cause sends a reader to look for a field that is
|
|
478
526
|
// sitting right there (proof#76).
|
|
479
527
|
if (res.payloadHashOk === null) {
|
|
480
|
-
cpErrors.push(
|
|
481
|
-
? "checkpoint
|
|
528
|
+
cpErrors.push(eventBytes === undefined
|
|
529
|
+
? "checkpoint event_bytes missing -- root untrusted"
|
|
482
530
|
: "checkpoint record binding not checked: root untrusted");
|
|
483
531
|
}
|
|
484
532
|
}
|
|
@@ -487,9 +535,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
487
535
|
}
|
|
488
536
|
// The committed record's merkle_root must equal the checkpoint's claimed
|
|
489
537
|
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
490
|
-
if (
|
|
538
|
+
if (eventBytes !== undefined) {
|
|
491
539
|
try {
|
|
492
|
-
const record = JSON.parse(new TextDecoder().decode(
|
|
540
|
+
const record = JSON.parse(new TextDecoder().decode(eventBytes));
|
|
493
541
|
const committedRoot = record.event?.merkle_root;
|
|
494
542
|
merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
|
|
495
543
|
if (!merkleRootOk) {
|
|
@@ -542,16 +590,21 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
542
590
|
const eventId = getEventId(ev.envelope);
|
|
543
591
|
let envelopeOk = false;
|
|
544
592
|
let payloadBindingOk = null;
|
|
545
|
-
|
|
593
|
+
// A retired field name under this major is a reject, never an ignored
|
|
594
|
+
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
595
|
+
const legacyError = legacyEventBytesError(ev, "event", bodyType);
|
|
596
|
+
if (legacyError !== null)
|
|
597
|
+
evErrors.push(legacyError);
|
|
598
|
+
let eventBytes;
|
|
546
599
|
try {
|
|
547
|
-
if (typeof ev.
|
|
548
|
-
|
|
600
|
+
if (typeof ev.event_bytes === "string")
|
|
601
|
+
eventBytes = hexToBytes(ev.event_bytes);
|
|
549
602
|
}
|
|
550
603
|
catch (e) {
|
|
551
|
-
evErrors.push(`event
|
|
604
|
+
evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
|
|
552
605
|
}
|
|
553
606
|
try {
|
|
554
|
-
const res = await verifyEnvelope(ev.envelope,
|
|
607
|
+
const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
555
608
|
envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
|
|
556
609
|
payloadBindingOk = res.payloadHashOk;
|
|
557
610
|
// P-10: report what could not be determined, never a fault that was never
|
|
@@ -574,6 +627,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
574
627
|
for (const m of res.errors)
|
|
575
628
|
if (m.startsWith("malformed "))
|
|
576
629
|
evErrors.push(`event envelope ${m}`);
|
|
630
|
+
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
631
|
+
if (specFinding !== null)
|
|
632
|
+
evErrors.push(specFinding);
|
|
577
633
|
}
|
|
578
634
|
catch (e) {
|
|
579
635
|
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
@@ -581,12 +637,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
581
637
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
582
638
|
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
583
639
|
const incl = ev.inclusion;
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
|
|
640
|
+
// An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
|
|
641
|
+
// bundle, and resolving it anyway is how an event silently binds to a
|
|
642
|
+
// checkpoint nobody meant.
|
|
643
|
+
const keyField = "checkpoint_ref";
|
|
644
|
+
const wrongField = "checkpoint_tx_id";
|
|
590
645
|
const inclAsRecord = incl;
|
|
591
646
|
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
592
647
|
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
@@ -635,7 +690,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
635
690
|
}
|
|
636
691
|
}
|
|
637
692
|
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
638
|
-
const contentRes = await verifyDisclosedContent(ev, eventId,
|
|
693
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
639
694
|
const contentOk = contentRes.contentOk;
|
|
640
695
|
evErrors.push(...contentRes.errors);
|
|
641
696
|
eventResults.push({
|
|
@@ -650,13 +705,15 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
650
705
|
// present checkpoint, and its disclosed content did not MISMATCH. A
|
|
651
706
|
// withheld record or undisclosed content (binding/content null) does not
|
|
652
707
|
// fail it.
|
|
653
|
-
ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
|
|
708
|
+
ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
|
|
654
709
|
errors: evErrors,
|
|
655
710
|
});
|
|
656
711
|
}
|
|
657
712
|
// --- Step 5: optional on-chain re-fetch ------------------------------------
|
|
658
713
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
659
714
|
let gatewayUnavailable = false;
|
|
715
|
+
let uncheckedKind = false;
|
|
716
|
+
let uncheckedId = false;
|
|
660
717
|
if (onChainChecked) {
|
|
661
718
|
if (typeof fetchImpl !== "function") {
|
|
662
719
|
gatewayUnavailable = true;
|
|
@@ -686,9 +743,13 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
686
743
|
}
|
|
687
744
|
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
688
745
|
cpr.onChain = outcome.onChain;
|
|
689
|
-
cpr.onChainOk = outcome.onChain
|
|
746
|
+
cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
690
747
|
if (outcome.unavailable)
|
|
691
748
|
gatewayUnavailable = true;
|
|
749
|
+
if (outcome.unchecked === "kind")
|
|
750
|
+
uncheckedKind = true;
|
|
751
|
+
if (outcome.unchecked === "id")
|
|
752
|
+
uncheckedId = true;
|
|
692
753
|
if (outcome.error)
|
|
693
754
|
cpr.errors.push(outcome.error);
|
|
694
755
|
// Fold the on-chain dimension in: a MISMATCH (false) fails the
|
|
@@ -719,12 +780,16 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
719
780
|
if (anyHardFailure) {
|
|
720
781
|
status = "failed";
|
|
721
782
|
}
|
|
722
|
-
else if (gatewayUnavailable) {
|
|
783
|
+
else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
|
|
723
784
|
// Every offline check passed and nothing MISMATCHED, but a requested
|
|
724
785
|
// on-chain confirmation could not be made — the bundle is offline-sound,
|
|
725
786
|
// its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
|
|
726
787
|
status = "partial";
|
|
727
|
-
errors
|
|
788
|
+
pushRefetchGapNotes(errors, "checkpoints", {
|
|
789
|
+
unavailable: gatewayUnavailable,
|
|
790
|
+
kind: uncheckedKind,
|
|
791
|
+
id: uncheckedId,
|
|
792
|
+
});
|
|
728
793
|
}
|
|
729
794
|
else if (anyWithheldRecord) {
|
|
730
795
|
// Everything that COULD be checked passed; some record bytes were withheld
|
|
@@ -815,7 +880,7 @@ function isNonNegInt(n) {
|
|
|
815
880
|
// ===========================================================================
|
|
816
881
|
// Disclosed-content binding, shared by every body that carries events.
|
|
817
882
|
//
|
|
818
|
-
// Extracted for `sanning.stamp.evidence/
|
|
883
|
+
// Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
|
|
819
884
|
// rawLog -> content_hash link is the one step the chain
|
|
820
885
|
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
821
886
|
// not otherwise cover, and a second copy of it is a second place for the
|
|
@@ -824,14 +889,14 @@ function isNonNegInt(n) {
|
|
|
824
889
|
// disclosed or nothing is committed; false ONLY on a real mismatch or an
|
|
825
890
|
// in-body vs side-input disagreement.
|
|
826
891
|
// ===========================================================================
|
|
827
|
-
async function verifyDisclosedContent(ev, eventId,
|
|
892
|
+
async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
828
893
|
const evErrors = [];
|
|
829
894
|
let contentOk = null;
|
|
830
895
|
const committed = [];
|
|
831
896
|
let recordContentHash;
|
|
832
|
-
if (
|
|
897
|
+
if (eventBytes !== undefined) {
|
|
833
898
|
try {
|
|
834
|
-
const record = JSON.parse(new TextDecoder().decode(
|
|
899
|
+
const record = JSON.parse(new TextDecoder().decode(eventBytes));
|
|
835
900
|
const ch = record.event?.content_hash;
|
|
836
901
|
if (typeof ch === "string" && ch)
|
|
837
902
|
recordContentHash = ch.toLowerCase();
|
|
@@ -1097,7 +1162,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
|
|
|
1097
1162
|
// other — asks every consumer to remember which wrapper it is holding before
|
|
1098
1163
|
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
1099
1164
|
// exception a reader eventually gets wrong.
|
|
1100
|
-
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1165
|
+
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1101
1166
|
// --- Rollup ----------------------------------------------------------------
|
|
1102
1167
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
1103
1168
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
@@ -1112,13 +1177,15 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
|
|
|
1112
1177
|
status = "partial";
|
|
1113
1178
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1114
1179
|
}
|
|
1115
|
-
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1180
|
+
else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
|
|
1116
1181
|
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
1117
1182
|
// for why one note is not enough when there are two different gaps.
|
|
1118
1183
|
status = "partial";
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1184
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
1185
|
+
unavailable: gatewayUnavailable,
|
|
1186
|
+
kind: uncheckedKind,
|
|
1187
|
+
id: uncheckedId,
|
|
1188
|
+
});
|
|
1122
1189
|
if (anyUnwitnessed) {
|
|
1123
1190
|
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1124
1191
|
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
@@ -1148,7 +1215,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
|
|
|
1148
1215
|
};
|
|
1149
1216
|
}
|
|
1150
1217
|
// ===========================================================================
|
|
1151
|
-
// `sanning.stamp.evidence/
|
|
1218
|
+
// `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
|
|
1152
1219
|
//
|
|
1153
1220
|
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1154
1221
|
// the sub-tree partition check). The event half reuses the same primitives the
|
|
@@ -1217,16 +1284,21 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1217
1284
|
const eventId = getEventId(ev.envelope);
|
|
1218
1285
|
let envelopeOk = false;
|
|
1219
1286
|
let payloadBindingOk = null;
|
|
1220
|
-
|
|
1287
|
+
// A retired field name under this major is a reject, never an ignored
|
|
1288
|
+
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
1289
|
+
const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
|
|
1290
|
+
if (legacyError !== null)
|
|
1291
|
+
evErrors.push(legacyError);
|
|
1292
|
+
let eventBytes;
|
|
1221
1293
|
try {
|
|
1222
|
-
if (typeof ev.
|
|
1223
|
-
|
|
1294
|
+
if (typeof ev.event_bytes === "string")
|
|
1295
|
+
eventBytes = hexToBytes(ev.event_bytes);
|
|
1224
1296
|
}
|
|
1225
1297
|
catch (e) {
|
|
1226
|
-
evErrors.push(`event
|
|
1298
|
+
evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
|
|
1227
1299
|
}
|
|
1228
1300
|
try {
|
|
1229
|
-
const res = await verifyEnvelope(ev.envelope,
|
|
1301
|
+
const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
1230
1302
|
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1231
1303
|
payloadBindingOk = res.payloadHashOk;
|
|
1232
1304
|
// P-10: report what could not be determined, never a fault that was never
|
|
@@ -1249,6 +1321,9 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1249
1321
|
for (const m of res.errors)
|
|
1250
1322
|
if (m.startsWith("malformed "))
|
|
1251
1323
|
evErrors.push(`event envelope ${m}`);
|
|
1324
|
+
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
1325
|
+
if (specFinding !== null)
|
|
1326
|
+
evErrors.push(specFinding);
|
|
1252
1327
|
}
|
|
1253
1328
|
catch (e) {
|
|
1254
1329
|
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
@@ -1306,7 +1381,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1306
1381
|
}
|
|
1307
1382
|
}
|
|
1308
1383
|
}
|
|
1309
|
-
const contentRes = await verifyDisclosedContent(ev, eventId,
|
|
1384
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
1310
1385
|
const contentOk = contentRes.contentOk;
|
|
1311
1386
|
evErrors.push(...contentRes.errors);
|
|
1312
1387
|
eventResults.push({
|
|
@@ -1317,12 +1392,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1317
1392
|
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
1318
1393
|
checkpointBound: stampBound,
|
|
1319
1394
|
contentOk,
|
|
1320
|
-
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
|
|
1395
|
+
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
|
|
1321
1396
|
errors: evErrors,
|
|
1322
1397
|
});
|
|
1323
1398
|
}
|
|
1324
1399
|
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
1325
|
-
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1400
|
+
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1326
1401
|
// --- Rollup ---------------------------------------------------------------
|
|
1327
1402
|
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1328
1403
|
// is the false green this body exists to prevent, so an event that did not
|
|
@@ -1337,16 +1412,27 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1337
1412
|
status = "partial";
|
|
1338
1413
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1339
1414
|
}
|
|
1340
|
-
else if (
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
//
|
|
1415
|
+
else if (eventResults.length === 0 ||
|
|
1416
|
+
gatewayUnavailable ||
|
|
1417
|
+
uncheckedKind ||
|
|
1418
|
+
uncheckedId ||
|
|
1419
|
+
anyUnwitnessed) {
|
|
1420
|
+
// Each holds the bundle at `partial`, and EACH gets said. A pack with no
|
|
1421
|
+
// events, a stamp nobody witnessed and a witness we could not reach are
|
|
1422
|
+
// different gaps with different fixes, and an auditor told only about one
|
|
1423
|
+
// would read the others as fine. The order is fixed so the two kernels
|
|
1424
|
+
// emit the same list.
|
|
1346
1425
|
status = "partial";
|
|
1347
|
-
if (
|
|
1348
|
-
|
|
1349
|
-
|
|
1426
|
+
if (eventResults.length === 0) {
|
|
1427
|
+
// §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
|
|
1428
|
+
// partial, never verified. It says nothing about any event.
|
|
1429
|
+
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`);
|
|
1430
|
+
}
|
|
1431
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
1432
|
+
unavailable: gatewayUnavailable,
|
|
1433
|
+
kind: uncheckedKind,
|
|
1434
|
+
id: uncheckedId,
|
|
1435
|
+
});
|
|
1350
1436
|
if (anyUnwitnessed) {
|
|
1351
1437
|
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1352
1438
|
// the independent part, so the events resting on it inherit `partial`.
|
|
@@ -1407,14 +1493,17 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1407
1493
|
async function refetchStamps(stamps, witnessed, options, errors) {
|
|
1408
1494
|
const gateways = options.gateways ?? [];
|
|
1409
1495
|
const onChainChecked = gateways.length > 0;
|
|
1496
|
+
const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
|
|
1410
1497
|
if (!onChainChecked)
|
|
1411
|
-
return { onChainChecked,
|
|
1498
|
+
return { onChainChecked, ...none };
|
|
1412
1499
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1413
1500
|
if (typeof fetchImpl !== "function") {
|
|
1414
1501
|
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
1415
|
-
return { onChainChecked, gatewayUnavailable: true };
|
|
1502
|
+
return { onChainChecked, ...none, gatewayUnavailable: true };
|
|
1416
1503
|
}
|
|
1417
1504
|
let gatewayUnavailable = false;
|
|
1505
|
+
let uncheckedKind = false;
|
|
1506
|
+
let uncheckedId = false;
|
|
1418
1507
|
for (let i = 0; i < stamps.length; i++) {
|
|
1419
1508
|
const st = stamps[i];
|
|
1420
1509
|
const witnessedHex = witnessed[i];
|
|
@@ -1424,9 +1513,13 @@ async function refetchStamps(stamps, witnessed, options, errors) {
|
|
|
1424
1513
|
continue;
|
|
1425
1514
|
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
1426
1515
|
st.onChain = outcome.onChain;
|
|
1427
|
-
st.onChainOk = outcome.onChain
|
|
1516
|
+
st.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
1428
1517
|
if (outcome.unavailable)
|
|
1429
1518
|
gatewayUnavailable = true;
|
|
1519
|
+
if (outcome.unchecked === "kind")
|
|
1520
|
+
uncheckedKind = true;
|
|
1521
|
+
if (outcome.unchecked === "id")
|
|
1522
|
+
uncheckedId = true;
|
|
1430
1523
|
if (outcome.error)
|
|
1431
1524
|
st.errors.push(outcome.error);
|
|
1432
1525
|
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
@@ -1440,9 +1533,9 @@ async function refetchStamps(stamps, witnessed, options, errors) {
|
|
|
1440
1533
|
// unreachable note would fail a stamp for a gateway being down.
|
|
1441
1534
|
st.ok = st.ok && st.onChainOk !== false;
|
|
1442
1535
|
}
|
|
1443
|
-
return { onChainChecked, gatewayUnavailable };
|
|
1536
|
+
return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
1444
1537
|
}
|
|
1445
|
-
// How many `sanning.evidence.export/
|
|
1538
|
+
// How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
|
|
1446
1539
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
1447
1540
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
1448
1541
|
// independently bounded rather than trusting that it ran first.
|
|
@@ -1475,7 +1568,7 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
1475
1568
|
});
|
|
1476
1569
|
const traceEvents = resolveTraceEvents(bundle);
|
|
1477
1570
|
if (traceEvents === undefined) {
|
|
1478
|
-
errors.push(`no ${
|
|
1571
|
+
errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
|
|
1479
1572
|
`body to materialize content from ` +
|
|
1480
1573
|
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
1481
1574
|
return empty();
|
|
@@ -1562,7 +1655,7 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
1562
1655
|
// a caller must distinguish from a body that has events and no links.
|
|
1563
1656
|
//
|
|
1564
1657
|
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
1565
|
-
// `sanning.stamp.evidence/
|
|
1658
|
+
// `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
|
|
1566
1659
|
// event shape.
|
|
1567
1660
|
//
|
|
1568
1661
|
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
@@ -1578,15 +1671,14 @@ export function resolveTraceEvents(bundle) {
|
|
|
1578
1671
|
const b = cur;
|
|
1579
1672
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
1580
1673
|
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
1581
|
-
// `sanning.stamp.evidence/
|
|
1582
|
-
// producer-signed envelope, optional `
|
|
1674
|
+
// `sanning.stamp.evidence/v2` presents the SAME event shape (a
|
|
1675
|
+
// producer-signed envelope, optional `event_bytes`, an inclusion proof),
|
|
1583
1676
|
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
1584
1677
|
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
1585
1678
|
// zero chains on every pack, however well-formed the links inside its
|
|
1586
1679
|
// records were, so "is it in order?" read as NOT CHECKED on the one shape
|
|
1587
1680
|
// that matters. The writers have been emitting those links the whole time.
|
|
1588
1681
|
if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
1589
|
-
bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
|
|
1590
1682
|
bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
1591
1683
|
const body = b.body;
|
|
1592
1684
|
if (body === null || typeof body !== "object" || !Array.isArray(body.events))
|
|
@@ -1679,7 +1771,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
1679
1771
|
// The existing evidence path over the inline source bundle — same primitives
|
|
1680
1772
|
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
1681
1773
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
1682
|
-
const sourceResult = await
|
|
1774
|
+
const sourceResult = await verifyExportSource(inline, options);
|
|
1683
1775
|
// Attestation data_hash binding (§5 6c).
|
|
1684
1776
|
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
1685
1777
|
// --- Step 6: embedded attestation records ---------------------------------
|
|
@@ -1772,7 +1864,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
1772
1864
|
};
|
|
1773
1865
|
}
|
|
1774
1866
|
export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
|
|
1775
|
-
const sourceResult = await
|
|
1867
|
+
const sourceResult = await verifyExportSource(sourceBundle, options);
|
|
1776
1868
|
// Attestation data_hash binding (§5 6c) — the SAME index construction as
|
|
1777
1869
|
// verifyExportBody, deliberately shared rather than restated.
|
|
1778
1870
|
const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
|
|
@@ -1812,6 +1904,54 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
|
|
|
1812
1904
|
// field; otherwise a per-dimension AttestationResult whose `ok` gates on the
|
|
1813
1905
|
// three binding checks (sig / operator-address / data_hash). subject_ref is
|
|
1814
1906
|
// undetermined-tolerant and never gates.
|
|
1907
|
+
// The attestation payload fields evidence-export.md §3.1 marks Required, with
|
|
1908
|
+
// the type it gives each. A record whose payload lacks one, or carries another
|
|
1909
|
+
// type (null included), is malformed: §5 step 9 and §4.5 make "a missing
|
|
1910
|
+
// required field" an export the verifier cannot render (exit 2). §5 step 6
|
|
1911
|
+
// gates a WELL-FORMED record on signature, operator and data_hash only; these
|
|
1912
|
+
// checks decide whether the record is well-formed at all. `level` is the §3.1
|
|
1913
|
+
// enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
|
|
1914
|
+
// §3.1 clarification).
|
|
1915
|
+
const PAYLOAD_REQUIRED = [
|
|
1916
|
+
["attested_at", "string"],
|
|
1917
|
+
["tx_id", "string"],
|
|
1918
|
+
["data_hash", "string"],
|
|
1919
|
+
["data_size", "integer"],
|
|
1920
|
+
["block_height", "integer"],
|
|
1921
|
+
["block_timestamp", "integer"],
|
|
1922
|
+
["operator", "string"],
|
|
1923
|
+
["owner_address", "string"],
|
|
1924
|
+
["gateway", "string"],
|
|
1925
|
+
["signature_verified", "boolean"],
|
|
1926
|
+
["level", "level"],
|
|
1927
|
+
];
|
|
1928
|
+
function payloadFieldError(payload) {
|
|
1929
|
+
for (const [name, kind] of PAYLOAD_REQUIRED) {
|
|
1930
|
+
const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
|
|
1931
|
+
let ok;
|
|
1932
|
+
let want;
|
|
1933
|
+
if (kind === "string") {
|
|
1934
|
+
ok = typeof v === "string";
|
|
1935
|
+
want = "a string";
|
|
1936
|
+
}
|
|
1937
|
+
else if (kind === "boolean") {
|
|
1938
|
+
ok = typeof v === "boolean";
|
|
1939
|
+
want = "a boolean";
|
|
1940
|
+
}
|
|
1941
|
+
else if (kind === "integer") {
|
|
1942
|
+
// §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
|
|
1943
|
+
ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
1944
|
+
want = "a non-negative integer";
|
|
1945
|
+
}
|
|
1946
|
+
else {
|
|
1947
|
+
ok = v === 1 || v === 2 || v === 3;
|
|
1948
|
+
want = "1, 2 or 3";
|
|
1949
|
+
}
|
|
1950
|
+
if (!ok)
|
|
1951
|
+
return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
1952
|
+
}
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1815
1955
|
async function verifyAttestation(rec, sourceCheckpoints) {
|
|
1816
1956
|
const errs = [];
|
|
1817
1957
|
const result = {
|
|
@@ -1851,6 +1991,9 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
1851
1991
|
if (typeof rec.signature !== "string") {
|
|
1852
1992
|
return { result, malformed: "attestation record missing signature" };
|
|
1853
1993
|
}
|
|
1994
|
+
const fieldError = payloadFieldError(payload);
|
|
1995
|
+
if (fieldError !== null)
|
|
1996
|
+
return { result, malformed: fieldError };
|
|
1854
1997
|
const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
|
|
1855
1998
|
result.operator = typeof payload.operator === "string" ? payload.operator : "";
|
|
1856
1999
|
result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
|
|
@@ -2116,6 +2259,35 @@ async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
|
|
|
2116
2259
|
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
2117
2260
|
// The subject only ever selects wording; it never changes a verdict.
|
|
2118
2261
|
async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
|
|
2262
|
+
// Only a `kind` this kernel has a witness adapter for is re-fetched
|
|
2263
|
+
// (evidence-bundle.md §5.1: "where the verifier has an adapter for its
|
|
2264
|
+
// `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
|
|
2265
|
+
// of any other kind is not an Arweave id, so whatever a gateway serves at
|
|
2266
|
+
// that path says nothing about the witness, and comparing it would turn an
|
|
2267
|
+
// unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
|
|
2268
|
+
// run goes `partial`: the caller asked for the witness check and it was not
|
|
2269
|
+
// made.
|
|
2270
|
+
if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
|
|
2271
|
+
return {
|
|
2272
|
+
onChain: null,
|
|
2273
|
+
unavailable: false,
|
|
2274
|
+
unchecked: "kind",
|
|
2275
|
+
error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
// The id becomes a URL path segment. Anything but a 43-character base64url
|
|
2279
|
+
// transaction id can leave the gateway's base path once URL parsing
|
|
2280
|
+
// normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
|
|
2281
|
+
// `a?b`), and the two kernels would then request different URLs. Such an id
|
|
2282
|
+
// is never fetched and never failed: the witness is not checked.
|
|
2283
|
+
if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
|
|
2284
|
+
return {
|
|
2285
|
+
onChain: null,
|
|
2286
|
+
unavailable: false,
|
|
2287
|
+
unchecked: "id",
|
|
2288
|
+
error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2119
2291
|
const perGateway = [];
|
|
2120
2292
|
for (const gw of gateways) {
|
|
2121
2293
|
perGateway.push({
|
|
@@ -2134,7 +2306,28 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
|
|
|
2134
2306
|
else if (onChain.rollup === "unreachable") {
|
|
2135
2307
|
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
2136
2308
|
}
|
|
2137
|
-
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
2309
|
+
return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
|
|
2310
|
+
}
|
|
2311
|
+
// The `anchor_ref.kind` values this kernel can re-fetch from a gateway
|
|
2312
|
+
// (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
|
|
2313
|
+
// left unchecked by `refetchAnchored`, never probed.
|
|
2314
|
+
const WITNESS_ADAPTER_KINDS = new Set(["arweave"]);
|
|
2315
|
+
// The only `arweave` ids the adapter looks up: a 43-character base64url
|
|
2316
|
+
// transaction or data-item id.
|
|
2317
|
+
const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
2318
|
+
// The bundle-level notes for a run where requested re-fetches were not made.
|
|
2319
|
+
// Each note keeps the phrase "could not be re-fetched", which is what both
|
|
2320
|
+
// CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
|
|
2321
|
+
function pushRefetchGapNotes(errors, subject, gaps) {
|
|
2322
|
+
if (gaps.unavailable) {
|
|
2323
|
+
errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
|
|
2324
|
+
}
|
|
2325
|
+
if (gaps.kind) {
|
|
2326
|
+
errors.push(`one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`);
|
|
2327
|
+
}
|
|
2328
|
+
if (gaps.id) {
|
|
2329
|
+
errors.push(`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`);
|
|
2330
|
+
}
|
|
2138
2331
|
}
|
|
2139
2332
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
2140
2333
|
// (bytes disagree), or unreachable (no response / !ok / network error).
|