@sanning/proof 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/v1` body — the
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
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
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 frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
33
- *
34
- * Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
35
- * before 2026-09-03 carries this shape, and those bundles are evidence — a
36
- * reader that stopped accepting them would destroy the record it exists to
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 ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
40
- /** Every anchor-trace body this kernel verifies. Order is newest-first. */
41
- export const ANCHOR_TRACE_BODY_TYPES = [
42
- ANCHOR_TRACE_BODY_TYPE,
43
- ANCHOR_TRACE_BODY_TYPE_V1,
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/v1";
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/v1` envelope, and a stamp has no producer signature at all —
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/v1";
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 carrying a `sanning.anchor.trace/v1` body.
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 && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
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 ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
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, bodyType, specVersionOk, assertedStatus, errors, options);
286
+ return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
236
287
  }
237
288
  /**
238
- * Index a source bundle's checkpoints by the key its attestations will use.
289
+ * Recompute an export's source verdict (evidence-export.md §5 step 4).
239
290
  *
240
- * The attestation join key follows the SOURCE bundle's own vocabulary — a
241
- * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
242
- * alternative (always deriving a v2-style key) would silently un-resolve every
243
- * attestation ever written against a v1 export: `data_hash_bound` would go
244
- * false, the export would stop reaching `verified`, and nothing would say why.
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 recordBytes;
316
+ let eventBytes;
256
317
  try {
257
- if (typeof cp.record_bytes === "string")
258
- recordBytes = hexToBytes(cp.record_bytes);
318
+ if (typeof cp.event_bytes === "string")
319
+ eventBytes = hexToBytes(cp.event_bytes);
259
320
  }
260
321
  catch {
261
- recordBytes = undefined;
322
+ eventBytes = undefined;
262
323
  }
263
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
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, for whichever
271
- * body form we are reading.
331
+ * Resolve a checkpoint's join key and its witness reference.
272
332
  *
273
- * ── The rule that matters, and why it is a reject rather than a preference ──
333
+ * ── Why a retired spelling is a reject rather than a preference ──
274
334
  *
275
- * A checkpoint carrying BOTH vocabularies is two answers to one question
276
- * inside one signature. A reader that silently prefers one gives a different
277
- * answer from a reader that prefers the other, over the same signed bytes —
278
- * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
279
- * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
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 v2's key is recomputed ──
341
+ * ── Why the key is recomputed ──
283
342
  *
284
- * In v1 the key was an asserted label matched against another asserted label;
285
- * nothing tied either to the bytes, so relabelling a checkpoint and every
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, recordBytes, isV1) {
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("sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary");
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 (recordBytes === undefined) {
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 -- record_bytes missing or not hex");
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(recordBytes);
366
+ const derived = await sha256Hex(eventBytes);
322
367
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
323
- errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
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, bodyType, specVersionOk, assertedStatus, errors, options) {
419
+ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options) {
375
420
  const body = b.body;
376
- const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
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 record_bytes first: in v2 the join key is DERIVED from it, so it
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 recordBytes;
466
+ let eventBytes;
422
467
  try {
423
- if (typeof cp.record_bytes === "string")
424
- recordBytes = hexToBytes(cp.record_bytes);
468
+ if (typeof cp.event_bytes === "string")
469
+ eventBytes = hexToBytes(cp.event_bytes);
425
470
  }
426
471
  catch (e) {
427
- cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
472
+ cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
428
473
  }
429
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
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 ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
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, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
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.
@@ -466,14 +511,22 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
466
511
  }
467
512
  if (res.payloadHashOk === false)
468
513
  cpErrors.push("checkpoint record does not bind to payload_hash");
514
+ // proof#95: a malformed skeleton field fails the envelope with a valid
515
+ // signature and binding; name it rather than fail with no reason.
516
+ for (const m of res.errors)
517
+ if (m.startsWith("malformed "))
518
+ cpErrors.push(`checkpoint envelope ${m}`);
519
+ const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
520
+ if (specFinding !== null)
521
+ cpErrors.push(specFinding);
469
522
  // WHY the binding is undetermined decides which sentence is true.
470
- // record_bytes really absent is one thing; record_bytes present and
523
+ // event_bytes really absent is one thing; event_bytes present and
471
524
  // unbindable (a degenerate envelope, unreadable hex) is another, and
472
525
  // naming the wrong cause sends a reader to look for a field that is
473
526
  // sitting right there (proof#76).
474
527
  if (res.payloadHashOk === null) {
475
- cpErrors.push(recordBytes === undefined
476
- ? "checkpoint record_bytes missing -- root untrusted"
528
+ cpErrors.push(eventBytes === undefined
529
+ ? "checkpoint event_bytes missing -- root untrusted"
477
530
  : "checkpoint record binding not checked: root untrusted");
478
531
  }
479
532
  }
@@ -482,9 +535,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
482
535
  }
483
536
  // The committed record's merkle_root must equal the checkpoint's claimed
484
537
  // merkle_root (recompute, don't trust the bundle's top-level claim).
485
- if (recordBytes !== undefined) {
538
+ if (eventBytes !== undefined) {
486
539
  try {
487
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
540
+ const record = JSON.parse(new TextDecoder().decode(eventBytes));
488
541
  const committedRoot = record.event?.merkle_root;
489
542
  merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
490
543
  if (!merkleRootOk) {
@@ -537,16 +590,21 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
537
590
  const eventId = getEventId(ev.envelope);
538
591
  let envelopeOk = false;
539
592
  let payloadBindingOk = null;
540
- let recordBytes;
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;
541
599
  try {
542
- if (typeof ev.record_bytes === "string")
543
- recordBytes = hexToBytes(ev.record_bytes);
600
+ if (typeof ev.event_bytes === "string")
601
+ eventBytes = hexToBytes(ev.event_bytes);
544
602
  }
545
603
  catch (e) {
546
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
604
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
547
605
  }
548
606
  try {
549
- const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
607
+ const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
550
608
  envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
551
609
  payloadBindingOk = res.payloadHashOk;
552
610
  // P-10: report what could not be determined, never a fault that was never
@@ -564,6 +622,14 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
564
622
  }
565
623
  if (res.payloadHashOk === false)
566
624
  evErrors.push("event record does not bind to payload_hash");
625
+ // A malformed skeleton field fails the envelope with a valid signature
626
+ // and binding (proof#95), so without this the event fails with no reason.
627
+ for (const m of res.errors)
628
+ if (m.startsWith("malformed "))
629
+ evErrors.push(`event envelope ${m}`);
630
+ const specFinding = specVersionFinding("event", ev.envelope, res);
631
+ if (specFinding !== null)
632
+ evErrors.push(specFinding);
567
633
  }
568
634
  catch (e) {
569
635
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -571,12 +637,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
571
637
  // Bind the event to exactly one checkpoint in the bundle, then verify
572
638
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
573
639
  const incl = ev.inclusion;
574
- // The join key is spelled differently in the two forms and MUST NOT be read
575
- // from the other one: an inclusion carrying v1's spelling inside a v2 body
576
- // is a mixed bundle, and resolving it anyway is how an event silently binds
577
- // to a checkpoint nobody meant.
578
- const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
579
- 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";
580
645
  const inclAsRecord = incl;
581
646
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
582
647
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
@@ -625,7 +690,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
625
690
  }
626
691
  }
627
692
  // Content (raw-log) binding — shared with every other event-carrying body.
628
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
693
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
629
694
  const contentOk = contentRes.contentOk;
630
695
  evErrors.push(...contentRes.errors);
631
696
  eventResults.push({
@@ -640,13 +705,15 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
640
705
  // present checkpoint, and its disclosed content did not MISMATCH. A
641
706
  // withheld record or undisclosed content (binding/content null) does not
642
707
  // fail it.
643
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
708
+ ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
644
709
  errors: evErrors,
645
710
  });
646
711
  }
647
712
  // --- Step 5: optional on-chain re-fetch ------------------------------------
648
713
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
649
714
  let gatewayUnavailable = false;
715
+ let uncheckedKind = false;
716
+ let uncheckedId = false;
650
717
  if (onChainChecked) {
651
718
  if (typeof fetchImpl !== "function") {
652
719
  gatewayUnavailable = true;
@@ -676,9 +743,13 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
676
743
  }
677
744
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
678
745
  cpr.onChain = outcome.onChain;
679
- cpr.onChainOk = outcome.onChain.onChainOk;
746
+ cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
680
747
  if (outcome.unavailable)
681
748
  gatewayUnavailable = true;
749
+ if (outcome.unchecked === "kind")
750
+ uncheckedKind = true;
751
+ if (outcome.unchecked === "id")
752
+ uncheckedId = true;
682
753
  if (outcome.error)
683
754
  cpr.errors.push(outcome.error);
684
755
  // Fold the on-chain dimension in: a MISMATCH (false) fails the
@@ -709,12 +780,16 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
709
780
  if (anyHardFailure) {
710
781
  status = "failed";
711
782
  }
712
- else if (gatewayUnavailable) {
783
+ else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
713
784
  // Every offline check passed and nothing MISMATCHED, but a requested
714
785
  // on-chain confirmation could not be made — the bundle is offline-sound,
715
786
  // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
716
787
  status = "partial";
717
- errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
788
+ pushRefetchGapNotes(errors, "checkpoints", {
789
+ unavailable: gatewayUnavailable,
790
+ kind: uncheckedKind,
791
+ id: uncheckedId,
792
+ });
718
793
  }
719
794
  else if (anyWithheldRecord) {
720
795
  // Everything that COULD be checked passed; some record bytes were withheld
@@ -805,7 +880,7 @@ function isNonNegInt(n) {
805
880
  // ===========================================================================
806
881
  // Disclosed-content binding, shared by every body that carries events.
807
882
  //
808
- // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
883
+ // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
809
884
  // rawLog -> content_hash link is the one step the chain
810
885
  // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
811
886
  // not otherwise cover, and a second copy of it is a second place for the
@@ -814,14 +889,14 @@ function isNonNegInt(n) {
814
889
  // disclosed or nothing is committed; false ONLY on a real mismatch or an
815
890
  // in-body vs side-input disagreement.
816
891
  // ===========================================================================
817
- async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
892
+ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
818
893
  const evErrors = [];
819
894
  let contentOk = null;
820
895
  const committed = [];
821
896
  let recordContentHash;
822
- if (recordBytes !== undefined) {
897
+ if (eventBytes !== undefined) {
823
898
  try {
824
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
899
+ const record = JSON.parse(new TextDecoder().decode(eventBytes));
825
900
  const ch = record.event?.content_hash;
826
901
  if (typeof ch === "string" && ch)
827
902
  recordContentHash = ch.toLowerCase();
@@ -1087,7 +1162,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1087
1162
  // other — asks every consumer to remember which wrapper it is holding before
1088
1163
  // it can trust `onChain: null`, and that is exactly the kind of per-body
1089
1164
  // exception a reader eventually gets wrong.
1090
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1165
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1091
1166
  // --- Rollup ----------------------------------------------------------------
1092
1167
  const anyHardFailure = stamps.some((s) => !s.ok);
1093
1168
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
@@ -1102,13 +1177,15 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1102
1177
  status = "partial";
1103
1178
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1104
1179
  }
1105
- else if (gatewayUnavailable || anyUnwitnessed) {
1180
+ else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1106
1181
  // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1107
1182
  // for why one note is not enough when there are two different gaps.
1108
1183
  status = "partial";
1109
- if (gatewayUnavailable) {
1110
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1111
- }
1184
+ pushRefetchGapNotes(errors, "stamps", {
1185
+ unavailable: gatewayUnavailable,
1186
+ kind: uncheckedKind,
1187
+ id: uncheckedId,
1188
+ });
1112
1189
  if (anyUnwitnessed) {
1113
1190
  // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1114
1191
  // sealed and unwitnessed: `retained-by-sanning`, which is a statement
@@ -1138,7 +1215,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1138
1215
  };
1139
1216
  }
1140
1217
  // ===========================================================================
1141
- // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1218
+ // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
1142
1219
  //
1143
1220
  // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1144
1221
  // the sub-tree partition check). The event half reuses the same primitives the
@@ -1207,16 +1284,21 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1207
1284
  const eventId = getEventId(ev.envelope);
1208
1285
  let envelopeOk = false;
1209
1286
  let payloadBindingOk = null;
1210
- let recordBytes;
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;
1211
1293
  try {
1212
- if (typeof ev.record_bytes === "string")
1213
- recordBytes = hexToBytes(ev.record_bytes);
1294
+ if (typeof ev.event_bytes === "string")
1295
+ eventBytes = hexToBytes(ev.event_bytes);
1214
1296
  }
1215
1297
  catch (e) {
1216
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1298
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1217
1299
  }
1218
1300
  try {
1219
- const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
1301
+ const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1220
1302
  envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1221
1303
  payloadBindingOk = res.payloadHashOk;
1222
1304
  // P-10: report what could not be determined, never a fault that was never
@@ -1234,6 +1316,14 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1234
1316
  }
1235
1317
  if (res.payloadHashOk === false)
1236
1318
  evErrors.push("event record does not bind to payload_hash");
1319
+ // A malformed skeleton field fails the envelope with a valid signature
1320
+ // and binding (proof#95), so without this the event fails with no reason.
1321
+ for (const m of res.errors)
1322
+ if (m.startsWith("malformed "))
1323
+ evErrors.push(`event envelope ${m}`);
1324
+ const specFinding = specVersionFinding("event", ev.envelope, res);
1325
+ if (specFinding !== null)
1326
+ evErrors.push(specFinding);
1237
1327
  }
1238
1328
  catch (e) {
1239
1329
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -1291,7 +1381,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1291
1381
  }
1292
1382
  }
1293
1383
  }
1294
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1384
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1295
1385
  const contentOk = contentRes.contentOk;
1296
1386
  evErrors.push(...contentRes.errors);
1297
1387
  eventResults.push({
@@ -1302,12 +1392,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1302
1392
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1303
1393
  checkpointBound: stampBound,
1304
1394
  contentOk,
1305
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1395
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
1306
1396
  errors: evErrors,
1307
1397
  });
1308
1398
  }
1309
1399
  // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1310
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1400
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1311
1401
  // --- Rollup ---------------------------------------------------------------
1312
1402
  // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1313
1403
  // is the false green this body exists to prevent, so an event that did not
@@ -1322,16 +1412,27 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1322
1412
  status = "partial";
1323
1413
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1324
1414
  }
1325
- else if (gatewayUnavailable || anyUnwitnessed) {
1326
- // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
1327
- // witnessed and a witness we could not reach are different gaps with
1328
- // different fixes — one waits for the interval, the other for the network —
1329
- // and an auditor told only about the second would read the first as fine.
1330
- // The order is fixed so the two kernels emit the same list.
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.
1331
1425
  status = "partial";
1332
- if (gatewayUnavailable) {
1333
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1334
- }
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
+ });
1335
1436
  if (anyUnwitnessed) {
1336
1437
  // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1337
1438
  // the independent part, so the events resting on it inherit `partial`.
@@ -1392,14 +1493,17 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1392
1493
  async function refetchStamps(stamps, witnessed, options, errors) {
1393
1494
  const gateways = options.gateways ?? [];
1394
1495
  const onChainChecked = gateways.length > 0;
1496
+ const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
1395
1497
  if (!onChainChecked)
1396
- return { onChainChecked, gatewayUnavailable: false };
1498
+ return { onChainChecked, ...none };
1397
1499
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1398
1500
  if (typeof fetchImpl !== "function") {
1399
1501
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
1400
- return { onChainChecked, gatewayUnavailable: true };
1502
+ return { onChainChecked, ...none, gatewayUnavailable: true };
1401
1503
  }
1402
1504
  let gatewayUnavailable = false;
1505
+ let uncheckedKind = false;
1506
+ let uncheckedId = false;
1403
1507
  for (let i = 0; i < stamps.length; i++) {
1404
1508
  const st = stamps[i];
1405
1509
  const witnessedHex = witnessed[i];
@@ -1409,9 +1513,13 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1409
1513
  continue;
1410
1514
  const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
1411
1515
  st.onChain = outcome.onChain;
1412
- st.onChainOk = outcome.onChain.onChainOk;
1516
+ st.onChainOk = outcome.onChain?.onChainOk ?? null;
1413
1517
  if (outcome.unavailable)
1414
1518
  gatewayUnavailable = true;
1519
+ if (outcome.unchecked === "kind")
1520
+ uncheckedKind = true;
1521
+ if (outcome.unchecked === "id")
1522
+ uncheckedId = true;
1415
1523
  if (outcome.error)
1416
1524
  st.errors.push(outcome.error);
1417
1525
  // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
@@ -1425,9 +1533,9 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1425
1533
  // unreachable note would fail a stamp for a gateway being down.
1426
1534
  st.ok = st.ok && st.onChainOk !== false;
1427
1535
  }
1428
- return { onChainChecked, gatewayUnavailable };
1536
+ return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
1429
1537
  }
1430
- // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
1538
+ // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
1431
1539
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
1432
1540
  // depth bound already rejects a pathological bundle, but the unwrap must be
1433
1541
  // independently bounded rather than trusting that it ran first.
@@ -1460,7 +1568,7 @@ export async function disclosedContent(bundle, options = {}) {
1460
1568
  });
1461
1569
  const traceEvents = resolveTraceEvents(bundle);
1462
1570
  if (traceEvents === undefined) {
1463
- errors.push(`no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
1571
+ errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
1464
1572
  `body to materialize content from ` +
1465
1573
  `(body_type ${JSON.stringify(verification.bodyType)})`);
1466
1574
  return empty();
@@ -1547,7 +1655,7 @@ export async function disclosedContent(bundle, options = {}) {
1547
1655
  // a caller must distinguish from a body that has events and no links.
1548
1656
  //
1549
1657
  // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
1550
- // `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
1658
+ // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
1551
1659
  // event shape.
1552
1660
  //
1553
1661
  // Exported for `chain.ts`, which needs the same descent and must not derive it
@@ -1563,15 +1671,14 @@ export function resolveTraceEvents(bundle) {
1563
1671
  const b = cur;
1564
1672
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1565
1673
  // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
1566
- // `sanning.stamp.evidence/v1` presents the SAME event shape (a
1567
- // producer-signed envelope, optional `record_bytes`, an inclusion proof),
1674
+ // `sanning.stamp.evidence/v2` presents the SAME event shape (a
1675
+ // producer-signed envelope, optional `event_bytes`, an inclusion proof),
1568
1676
  // and it is the HAND-OVER PACK: the only artifact an auditor is ever
1569
1677
  // handed. Dispatching on anchor-trace alone meant `readChains` reported
1570
1678
  // zero chains on every pack, however well-formed the links inside its
1571
1679
  // records were, so "is it in order?" read as NOT CHECKED on the one shape
1572
1680
  // that matters. The writers have been emitting those links the whole time.
1573
1681
  if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
1574
- bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
1575
1682
  bodyType === STAMP_EVIDENCE_BODY_TYPE) {
1576
1683
  const body = b.body;
1577
1684
  if (body === null || typeof body !== "object" || !Array.isArray(body.events))
@@ -1664,7 +1771,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1664
1771
  // The existing evidence path over the inline source bundle — same primitives
1665
1772
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
1666
1773
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
1667
- const sourceResult = await verifyEvidenceBundle(inline, options);
1774
+ const sourceResult = await verifyExportSource(inline, options);
1668
1775
  // Attestation data_hash binding (§5 6c).
1669
1776
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
1670
1777
  // --- Step 6: embedded attestation records ---------------------------------
@@ -1757,7 +1864,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1757
1864
  };
1758
1865
  }
1759
1866
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
1760
- const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
1867
+ const sourceResult = await verifyExportSource(sourceBundle, options);
1761
1868
  // Attestation data_hash binding (§5 6c) — the SAME index construction as
1762
1869
  // verifyExportBody, deliberately shared rather than restated.
1763
1870
  const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
@@ -1797,6 +1904,54 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
1797
1904
  // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
1798
1905
  // three binding checks (sig / operator-address / data_hash). subject_ref is
1799
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
+ }
1800
1955
  async function verifyAttestation(rec, sourceCheckpoints) {
1801
1956
  const errs = [];
1802
1957
  const result = {
@@ -1836,6 +1991,9 @@ async function verifyAttestation(rec, sourceCheckpoints) {
1836
1991
  if (typeof rec.signature !== "string") {
1837
1992
  return { result, malformed: "attestation record missing signature" };
1838
1993
  }
1994
+ const fieldError = payloadFieldError(payload);
1995
+ if (fieldError !== null)
1996
+ return { result, malformed: fieldError };
1839
1997
  const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
1840
1998
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
1841
1999
  result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
@@ -2101,6 +2259,35 @@ async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
2101
2259
  // beats unreachable" to drift, and the drifted copy is the one nobody reads.
2102
2260
  // The subject only ever selects wording; it never changes a verdict.
2103
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
+ }
2104
2291
  const perGateway = [];
2105
2292
  for (const gw of gateways) {
2106
2293
  perGateway.push({
@@ -2119,7 +2306,28 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
2119
2306
  else if (onChain.rollup === "unreachable") {
2120
2307
  error = `${subject} ${anchorRef.id} unreachable on all gateways`;
2121
2308
  }
2122
- 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
+ }
2123
2331
  }
2124
2332
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
2125
2333
  // (bytes disagree), or unreachable (no response / !ok / network error).