@sanning/proof 0.4.1 → 0.6.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
@@ -18,6 +18,7 @@
18
18
  import { deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
19
19
  import { leafHash, verifyInclusion } from "./merkle.js";
20
20
  import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, verifyEnvelope, } from "./verifier.js";
21
+ import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
21
22
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
22
23
  // deliberate one-entry addition HERE — same fail-closed discipline as the
23
24
  // envelope verifier's ACCEPTED_SPEC_MAJORS.
@@ -26,7 +27,21 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
26
27
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
27
28
  // still verify), but its body cannot be re-derived, so the per-body verdict is
28
29
  // "undetermined" rather than "verified".
29
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
30
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
31
+ /**
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.
38
+ */
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
+ ];
30
45
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
31
46
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
32
47
  // carries a cached verdict + the inline source bundle + embedded RSA-PSS
@@ -149,12 +164,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
149
164
  if (bodyType === EXPORT_BODY_TYPE) {
150
165
  return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
151
166
  }
152
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
167
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
153
168
  // The wrapper is authentic but we don't know this body's structure. Per
154
169
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
155
170
  // body verdict undetermined) — not a failure.
156
171
  errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
157
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
172
+ `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
158
173
  return {
159
174
  status: "partial",
160
175
  specVersionOk,
@@ -170,31 +185,168 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
170
185
  }
171
186
  return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
172
187
  }
188
+ /**
189
+ * Index a source bundle's checkpoints by the key its attestations will use.
190
+ *
191
+ * The attestation join key follows the SOURCE bundle's own vocabulary — a
192
+ * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
193
+ * alternative (always deriving a v2-style key) would silently un-resolve every
194
+ * attestation ever written against a v1 export: `data_hash_bound` would go
195
+ * false, the export would stop reaching `verified`, and nothing would say why.
196
+ */
197
+ async function indexSourceCheckpoints(bundle) {
198
+ const out = new Map();
199
+ const sb = bundle.body;
200
+ const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
201
+ if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
202
+ return out;
203
+ for (const cp of sb.checkpoints) {
204
+ if (!cp || typeof cp !== "object")
205
+ continue;
206
+ let recordBytes;
207
+ try {
208
+ if (typeof cp.record_bytes === "string")
209
+ recordBytes = hexToBytes(cp.record_bytes);
210
+ }
211
+ catch {
212
+ recordBytes = undefined;
213
+ }
214
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
215
+ if (keyed.key !== null && !out.has(keyed.key))
216
+ out.set(keyed.key, cp);
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Resolve a checkpoint's join key and its witness reference, for whichever
222
+ * body form we are reading.
223
+ *
224
+ * ── The rule that matters, and why it is a reject rather than a preference ──
225
+ *
226
+ * A checkpoint carrying BOTH vocabularies is two answers to one question
227
+ * inside one signature. A reader that silently prefers one gives a different
228
+ * answer from a reader that prefers the other, over the same signed bytes —
229
+ * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
230
+ * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
231
+ * resolved. This is cheap to enforce here and impossible to enforce later.
232
+ *
233
+ * ── Why v2's key is recomputed ──
234
+ *
235
+ * In v1 the key was an asserted label matched against another asserted label;
236
+ * nothing tied either to the bytes, so relabelling a checkpoint and every
237
+ * event pointing at it left a bundle that still verified. In v2 the key IS the
238
+ * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
239
+ * as it was — deliberately, because those bundles are already signed and
240
+ * re-interpreting them is not backwards compatibility.
241
+ */
242
+ async function resolveCheckpointKey(cp, recordBytes, isV1) {
243
+ const errors = [];
244
+ if (isV1) {
245
+ if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
246
+ errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
247
+ "one bundle speaks one vocabulary");
248
+ return { key: null, anchorRef: null, errors };
249
+ }
250
+ if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
251
+ errors.push("checkpoint missing tx_id");
252
+ return { key: null, anchorRef: null, errors };
253
+ }
254
+ // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
255
+ // through the same shape as v2 gives ONE reader-side vocabulary without
256
+ // touching a byte of the signed bundle.
257
+ return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
258
+ }
259
+ if (cp.tx_id !== undefined) {
260
+ errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary");
261
+ return { key: null, anchorRef: null, errors };
262
+ }
263
+ if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
264
+ errors.push("checkpoint missing checkpoint_ref");
265
+ return { key: null, anchorRef: null, errors };
266
+ }
267
+ if (recordBytes === undefined) {
268
+ // Without the bytes the key cannot be recomputed, so it cannot be trusted.
269
+ errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
270
+ return { key: null, anchorRef: null, errors };
271
+ }
272
+ const derived = await sha256Hex(recordBytes);
273
+ if (derived !== cp.checkpoint_ref.toLowerCase()) {
274
+ errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
275
+ return { key: null, anchorRef: null, errors };
276
+ }
277
+ const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
278
+ return { key: derived, anchorRef, errors };
279
+ }
280
+ /**
281
+ * Validate an optional `anchor_ref` (§5.1.1).
282
+ *
283
+ * ── An unrecognised `kind` is NOT a failure, and that is the point ──
284
+ *
285
+ * The offline verdict — signatures, payload binding, Merkle inclusion — is
286
+ * computed with no witness access at all. A reader that cannot resolve a
287
+ * witness has therefore learned NOTHING about whether the evidence was
288
+ * altered, and must not say otherwise. The shipped issuer did the opposite:
289
+ * an unrecognised signature type returned `false` rather than `null`, mapped
290
+ * to `tampered`, and was cached as permanent — a false accusation, produced
291
+ * by a coverage gap, for a company whose product is telling people whether
292
+ * records were altered.
293
+ *
294
+ * So this validates the SHAPE and never the vocabulary. A malformed
295
+ * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
296
+ * the reader, and only the first is the bundle's fault.
297
+ */
298
+ function parseAnchorRef(raw, errors) {
299
+ if (raw === undefined || raw === null)
300
+ return null; // placed, not yet witnessed
301
+ if (typeof raw !== "object" || Array.isArray(raw)) {
302
+ errors.push("anchor_ref must be an object");
303
+ return null;
304
+ }
305
+ const o = raw;
306
+ if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
307
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
308
+ return null;
309
+ }
310
+ if (typeof o.id !== "string" || o.id === "") {
311
+ errors.push("anchor_ref.id is missing or empty");
312
+ return null;
313
+ }
314
+ const ref = o.ref;
315
+ if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
316
+ errors.push("anchor_ref.ref must be an object when present");
317
+ return null;
318
+ }
319
+ return {
320
+ kind: o.kind,
321
+ id: o.id,
322
+ ...(ref === undefined ? {} : { ref: ref }),
323
+ };
324
+ }
173
325
  async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
174
326
  const body = b.body;
327
+ const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
175
328
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
176
- return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
329
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
177
330
  }
178
331
  const gateways = options.gateways ?? [];
179
332
  const onChainChecked = gateways.length > 0;
180
333
  // --- Step 3: per-checkpoint -------------------------------------------------
181
- const byTxId = new Map();
334
+ //
335
+ // ── Why the join key is resolved BEFORE anything else ──
336
+ //
337
+ // `byKey` is what binds an event to the root its inclusion proof is checked
338
+ // against. Get it wrong and an event proves inclusion in a tree it was never
339
+ // in — the one failure in this file that produces a WRONG VERIFIED, not a
340
+ // failed verify. Everything below is downstream of it.
341
+ const byKey = new Map();
342
+ const anchorByKey = new Map();
182
343
  const checkpointResults = [];
183
344
  for (const cp of body.checkpoints) {
184
345
  const cpErrors = [];
185
346
  let envelopeOk = false;
186
347
  let merkleRootOk = false;
187
- if (typeof cp.tx_id !== "string") {
188
- cpErrors.push("checkpoint missing tx_id");
189
- }
190
- else if (byTxId.has(cp.tx_id)) {
191
- cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
192
- }
193
- else {
194
- byTxId.set(cp.tx_id, cp);
195
- }
196
- // Verify the checkpoint envelope against its committed record (external
197
- // commitment — the record is the payload these external envelopes bind to).
348
+ // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
349
+ // is an input to identity, not just to the payload binding below.
198
350
  let recordBytes;
199
351
  try {
200
352
  if (typeof cp.record_bytes === "string")
@@ -203,6 +355,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
203
355
  catch (e) {
204
356
  cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
205
357
  }
358
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
359
+ cpErrors.push(...keyed.errors);
360
+ if (keyed.key !== null) {
361
+ if (byKey.has(keyed.key)) {
362
+ cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
363
+ }
364
+ else {
365
+ byKey.set(keyed.key, cp);
366
+ anchorByKey.set(keyed.key, keyed.anchorRef);
367
+ }
368
+ }
369
+ // Verify the checkpoint envelope against its committed record (external
370
+ // commitment — the record is the payload these external envelopes bind to).
206
371
  try {
207
372
  const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
208
373
  // A checkpoint must fully bind: signature + the committed record matching
@@ -235,12 +400,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
235
400
  }
236
401
  }
237
402
  checkpointResults.push({
238
- txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
403
+ checkpointRef: keyed.key ?? "",
404
+ anchorRef: keyed.anchorRef,
239
405
  envelopeOk,
240
406
  merkleRootOk,
241
407
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
242
408
  onChain: null, // per-gateway (§4.2), filled in step 5
243
- ok: envelopeOk && merkleRootOk,
409
+ // `keyed.errors` is load-bearing here, not decorative. A structurally
410
+ // invalid anchor_ref rides INSIDE the signature, so it is a defect we
411
+ // can see rather than a coverage gap we cannot — and this kernel's
412
+ // standing rule is that malformed is a failure while absent is
413
+ // undetermined (the same rule disclosed `content` follows). Without
414
+ // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
415
+ ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
244
416
  errors: cpErrors,
245
417
  });
246
418
  }
@@ -274,18 +446,36 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
274
446
  // Bind the event to exactly one checkpoint in the bundle, then verify
275
447
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
276
448
  const incl = ev.inclusion;
277
- const cp = incl && typeof incl.checkpoint_tx_id === "string" ? byTxId.get(incl.checkpoint_tx_id) : undefined;
278
- const checkpointBound = cp !== undefined;
449
+ // The join key is spelled differently in the two forms and MUST NOT be read
450
+ // from the other one: an inclusion carrying v1's spelling inside a v2 body
451
+ // is a mixed bundle, and resolving it anyway is how an event silently binds
452
+ // to a checkpoint nobody meant.
453
+ const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
454
+ const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
455
+ const inclAsRecord = incl;
456
+ const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
457
+ const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
458
+ let cp;
279
459
  if (!incl || typeof incl !== "object") {
280
460
  evErrors.push("event missing inclusion proof");
281
461
  }
282
- else if (!checkpointBound) {
283
- evErrors.push(`event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`);
462
+ else if (strayKey !== undefined) {
463
+ evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`);
464
+ }
465
+ else if (typeof rawKey !== "string") {
466
+ evErrors.push(`event inclusion missing ${keyField}`);
284
467
  }
468
+ else {
469
+ cp = byKey.get(rawKey);
470
+ if (cp === undefined) {
471
+ evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
472
+ }
473
+ }
474
+ const checkpointBound = cp !== undefined;
285
475
  let inclusionOk = false;
286
476
  if (incl && checkpointBound && cp) {
287
477
  try {
288
- inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root));
478
+ inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root ?? ""));
289
479
  if (!inclusionOk)
290
480
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
291
481
  // Defense in depth: the claimed leaf_hash MUST be the hash of the
@@ -341,29 +531,12 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
341
531
  committed.push(c.hash.toLowerCase());
342
532
  }
343
533
  // Disclosed bytes, precedence: in-body (signed) → side input. A string
344
- // side input is hex; a Uint8Array is used as-is.
345
- let inBody;
346
- if (typeof ev.content === "string") {
347
- try {
348
- inBody = hexToBytes(ev.content);
349
- }
350
- catch (e) {
351
- evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
352
- }
353
- }
354
- let side;
355
- const sideRaw = options.content?.[eventId];
356
- if (sideRaw instanceof Uint8Array) {
357
- side = sideRaw;
358
- }
359
- else if (typeof sideRaw === "string") {
360
- try {
361
- side = hexToBytes(sideRaw);
362
- }
363
- catch (e) {
364
- evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
365
- }
366
- }
534
+ // side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
535
+ // selector so `disclosedContent` hands a consumer the very bytes bound
536
+ // here — one decode, not two that can drift apart.
537
+ const selection = selectDisclosedBytes(ev, eventId, options.content);
538
+ const { inBody, side } = selection;
539
+ evErrors.push(...selection.errors);
367
540
  if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
368
541
  // Two disclosures that disagree: a genuine conflict, not undetermined.
369
542
  contentOk = false;
@@ -414,10 +587,27 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
414
587
  }
415
588
  else {
416
589
  for (const cpr of checkpointResults) {
417
- const cp = byTxId.get(cpr.txId);
590
+ const cp = byKey.get(cpr.checkpointRef);
418
591
  if (!cp)
419
592
  continue;
420
- const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
593
+ // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
594
+ // that is the normal state inside a stamp interval — placed, readable,
595
+ // not yet witnessed — so it reports as unreachable (which downgrades
596
+ // the run to partial) and NEVER as a mismatch. Treating "we never
597
+ // anchored it yet" as "the chain disagrees" would be the same false
598
+ // accusation this whole change is removing.
599
+ if (cpr.anchorRef === null) {
600
+ gatewayUnavailable = true;
601
+ cpr.onChain = {
602
+ perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" })),
603
+ rollup: "unreachable",
604
+ onChainOk: null,
605
+ };
606
+ cpr.onChainOk = null;
607
+ cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
608
+ continue;
609
+ }
610
+ const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
421
611
  cpr.onChain = outcome.onChain;
422
612
  cpr.onChainOk = outcome.onChain.onChainOk;
423
613
  if (outcome.unavailable)
@@ -473,6 +663,144 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
473
663
  errors,
474
664
  };
475
665
  }
666
+ // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
667
+ // nested before this path gives up. verifyEvidenceBundle's canonicalization
668
+ // depth bound already rejects a pathological bundle, but the unwrap must be
669
+ // independently bounded rather than trusting that it ran first.
670
+ const MAX_EXPORT_UNWRAP = 4;
671
+ /**
672
+ * Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
673
+ *
674
+ * Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
675
+ * (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
676
+ * bytes are returned ONLY when that verification bound them to the committed
677
+ * `content_hash`. There is no path through this function that yields bytes
678
+ * whose SHA-256 is not the commitment.
679
+ *
680
+ * Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
681
+ * UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
682
+ * such a string would produce different bytes and a different hash. Callers who
683
+ * know their payload is text decode it themselves and own that assumption.
684
+ */
685
+ export async function disclosedContent(bundle, options = {}) {
686
+ const verification = await verifyEvidenceBundle(bundle, options);
687
+ const events = [];
688
+ const content = new Map();
689
+ const errors = [];
690
+ const empty = () => ({
691
+ status: verification.status,
692
+ verification,
693
+ events: [],
694
+ content: new Map(),
695
+ errors: [...errors, ...verification.errors],
696
+ });
697
+ const traceEvents = resolveTraceEvents(bundle);
698
+ if (traceEvents === undefined) {
699
+ errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
700
+ `(body_type ${JSON.stringify(verification.bodyType)})`);
701
+ return empty();
702
+ }
703
+ // Fail closed on any drift between the body we are decoding and the results
704
+ // we are trusting. Both must be the same events in the same order, or the
705
+ // "these bytes are the bytes that verified" guarantee is void — which is the
706
+ // only guarantee this function makes. A broken wrapper lands here too:
707
+ // verifyEvidenceBundle returns zero event results, so nothing is emitted.
708
+ if (traceEvents.length !== verification.events.length) {
709
+ errors.push("the bundle did not verify far enough to materialize content: " +
710
+ `${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`);
711
+ return empty();
712
+ }
713
+ for (let i = 0; i < traceEvents.length; i++) {
714
+ const te = traceEvents[i];
715
+ const envelope = te !== null && typeof te === "object" ? te.envelope : undefined;
716
+ if (getEventId(envelope) !== verification.events[i].eventId) {
717
+ errors.push(`event alignment drift at index ${i}: the body event and its verification result ` +
718
+ "are not the same event; refusing to hand back any content");
719
+ return empty();
720
+ }
721
+ }
722
+ for (let i = 0; i < traceEvents.length; i++) {
723
+ const ev = traceEvents[i];
724
+ const res = verification.events[i];
725
+ const selection = selectDisclosedBytes(ev, res.eventId, options.content);
726
+ const bytes = selection.inBody ?? selection.side;
727
+ const evErrors = [];
728
+ let status;
729
+ if (!res.ok || res.contentOk === false) {
730
+ // The event itself is not trustworthy (bad signature, broken inclusion,
731
+ // unbound checkpoint) or its disclosure contradicts the commitment.
732
+ status = "failed";
733
+ evErrors.push(res.contentOk === false
734
+ ? "disclosed content does not bind to the committed content_hash — withheld"
735
+ : "the event's own proof chain did not verify — nothing here is safe to materialize");
736
+ evErrors.push(...res.errors);
737
+ }
738
+ else if (!selection.offered) {
739
+ // Minimal disclosure: the committed hash is there, the bytes are not.
740
+ // Distinct from empty bytes, and never an error.
741
+ status = "not_disclosed";
742
+ }
743
+ else if (bytes === undefined) {
744
+ status = "failed";
745
+ evErrors.push("content was disclosed for this event but is not a decodable lowercase-hex string");
746
+ evErrors.push(...selection.errors);
747
+ }
748
+ else if (res.contentOk !== true) {
749
+ // Disclosed and decodable, but there is no commitment to check it against
750
+ // (withheld record, or a record committing no content_hash). Unbound
751
+ // bytes are exactly what a consumer must not silently write out.
752
+ status = "undetermined";
753
+ evErrors.push("disclosed bytes are not bound to any committed content_hash — withheld rather than returned");
754
+ evErrors.push(...res.errors);
755
+ }
756
+ else {
757
+ status = "verified";
758
+ }
759
+ const verified = status === "verified" && bytes !== undefined;
760
+ if (verified) {
761
+ if (content.has(res.eventId)) {
762
+ errors.push(`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
763
+ "occurrence — read events[] for every event");
764
+ }
765
+ else {
766
+ content.set(res.eventId, bytes);
767
+ }
768
+ }
769
+ events.push({
770
+ eventId: res.eventId,
771
+ status,
772
+ bytes: verified ? bytes : null,
773
+ errors: evErrors,
774
+ });
775
+ }
776
+ return { status: verification.status, verification, events, content, errors };
777
+ }
778
+ // The anchor-trace events a bundle carries, unwrapping any evidence-export
779
+ // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
780
+ // recurses into `source_bundle`, so the events line up with its results.
781
+ // `undefined` when there is no anchor-trace body to materialize from.
782
+ function resolveTraceEvents(bundle) {
783
+ let cur = bundle;
784
+ for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
785
+ if (cur === null || typeof cur !== "object" || Array.isArray(cur))
786
+ return undefined;
787
+ const b = cur;
788
+ const bodyType = typeof b.body_type === "string" ? b.body_type : null;
789
+ if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
790
+ const body = b.body;
791
+ if (body === null || typeof body !== "object" || !Array.isArray(body.events))
792
+ return undefined;
793
+ return body.events;
794
+ }
795
+ if (bodyType !== EXPORT_BODY_TYPE)
796
+ return undefined;
797
+ const body = b.body;
798
+ if (body === null || typeof body !== "object")
799
+ return undefined;
800
+ cur = body.source_bundle;
801
+ }
802
+ return undefined;
803
+ }
476
804
  // ===========================================================================
477
805
  // Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
478
806
  // Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
@@ -551,15 +879,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
551
879
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
552
880
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
553
881
  const sourceResult = await verifyEvidenceBundle(inline, options);
554
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c).
555
- const sourceCheckpoints = new Map();
556
- const sb = inline.body;
557
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
558
- for (const cp of sb.checkpoints) {
559
- if (cp && typeof cp.tx_id === "string")
560
- sourceCheckpoints.set(cp.tx_id, cp);
561
- }
562
- }
882
+ // Attestation data_hash binding (§5 6c).
883
+ const sourceCheckpoints = await indexSourceCheckpoints(inline);
563
884
  // --- Step 6: embedded attestation records ---------------------------------
564
885
  const attestations = [];
565
886
  for (const rec of body.attestations) {
@@ -651,16 +972,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
651
972
  }
652
973
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
653
974
  const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
654
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c) —
655
- // identical to verifyExportBody's map construction.
656
- const sourceCheckpoints = new Map();
657
- const sb = sourceBundle.body;
658
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
659
- for (const cp of sb.checkpoints) {
660
- if (cp && typeof cp.tx_id === "string")
661
- sourceCheckpoints.set(cp.tx_id, cp);
662
- }
663
- }
975
+ // Attestation data_hash binding (§5 6c) — the SAME index construction as
976
+ // verifyExportBody, deliberately shared rather than restated.
977
+ const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
664
978
  const attestations = [];
665
979
  for (const rec of attestationRecords) {
666
980
  const r = await verifyAttestation(rec, sourceCheckpoints);
@@ -700,7 +1014,7 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
700
1014
  async function verifyAttestation(rec, sourceCheckpoints) {
701
1015
  const errs = [];
702
1016
  const result = {
703
- checkpointTxId: rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
1017
+ checkpointRef: rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
704
1018
  operator: "",
705
1019
  gateway: null,
706
1020
  signatureOk: false,
@@ -773,10 +1087,10 @@ async function verifyAttestation(rec, sourceCheckpoints) {
773
1087
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
774
1088
  // against, §4.2), so the checkpoint's committed content hash is
775
1089
  // SHA-256(JCS(checkpoint.envelope)).
776
- const cp = sourceCheckpoints.get(result.checkpointTxId);
1090
+ const cp = sourceCheckpoints.get(result.checkpointRef);
777
1091
  result.checkpointResolved = cp !== undefined;
778
1092
  if (!cp) {
779
- errs.push(`attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`);
1093
+ errs.push(`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`);
780
1094
  }
781
1095
  else {
782
1096
  try {
@@ -828,7 +1142,7 @@ function evaluateSubjectRef(subjectRef, errs) {
828
1142
  function buildVerdictObject(source, attestations) {
829
1143
  const attByCheckpoint = new Map();
830
1144
  for (const a of attestations) {
831
- const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
1145
+ const list = attByCheckpoint.get(a.checkpointRef) ?? [];
832
1146
  list.push({
833
1147
  operator: a.operator,
834
1148
  ...(a.gateway ? { gateway: a.gateway } : {}),
@@ -838,7 +1152,7 @@ function buildVerdictObject(source, attestations) {
838
1152
  ...(a.level !== null ? { level: a.level } : {}),
839
1153
  subject_ref_ok: a.subjectRefOk,
840
1154
  });
841
- attByCheckpoint.set(a.checkpointTxId, list);
1155
+ attByCheckpoint.set(a.checkpointRef, list);
842
1156
  }
843
1157
  const events = source.events.map((e) => ({
844
1158
  event_id: e.eventId,
@@ -849,10 +1163,10 @@ function buildVerdictObject(source, attestations) {
849
1163
  status: eventVerdictStatus(e),
850
1164
  }));
851
1165
  const checkpoints = source.checkpoints.map((c) => ({
852
- checkpoint_tx_id: c.txId,
1166
+ checkpoint_ref: c.checkpointRef,
853
1167
  merkle_root_ok: c.merkleRootOk,
854
1168
  on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
855
- attestations: attByCheckpoint.get(c.txId) ?? [],
1169
+ attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
856
1170
  }));
857
1171
  let verified = 0;
858
1172
  let failed = 0;
@@ -923,7 +1237,7 @@ function projectVerdictForAgreement(v) {
923
1237
  const cp = (c ?? {});
924
1238
  const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
925
1239
  return {
926
- checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
1240
+ checkpoint_ref: cp.checkpoint_ref ?? null,
927
1241
  merkle_root_ok: cp.merkle_root_ok ?? null,
928
1242
  // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
929
1243
  attestations: atts.map((a) => {
@@ -952,7 +1266,7 @@ function summarizeExport(status, source, attestations) {
952
1266
  // records a per-gateway array so a confirm/mismatch/unreachable MIX across
953
1267
  // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
954
1268
  // single-boolean exactly for a single gateway.
955
- async function refetchCheckpoint(cp, gateways, fetchImpl) {
1269
+ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
956
1270
  let expectedHex;
957
1271
  try {
958
1272
  expectedHex = toHex(utf8(jcs(cp.envelope)));
@@ -964,22 +1278,25 @@ async function refetchCheckpoint(cp, gateways, fetchImpl) {
964
1278
  }
965
1279
  const perGateway = [];
966
1280
  for (const gw of gateways) {
967
- perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
1281
+ perGateway.push({
1282
+ gateway: gw,
1283
+ outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
1284
+ });
968
1285
  }
969
1286
  const onChain = rollupOnChain(perGateway);
970
1287
  let error;
971
1288
  if (onChain.rollup === "mismatch") {
972
- error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
1289
+ error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
973
1290
  }
974
1291
  else if (onChain.rollup === "unreachable") {
975
- error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
1292
+ error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
976
1293
  }
977
1294
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
978
1295
  }
979
1296
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
980
1297
  // (bytes disagree), or unreachable (no response / !ok / network error).
981
- async function probeGateway(cp, gateway, expectedHex, fetchImpl) {
982
- const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
1298
+ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
1299
+ const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
983
1300
  let resp;
984
1301
  try {
985
1302
  resp = await fetchImpl(url);
@@ -1033,6 +1350,54 @@ function getEventId(env) {
1033
1350
  }
1034
1351
  return "<unknown>";
1035
1352
  }
1353
+ // The side-input presence rule lives in `side-content.ts`, stated ONCE.
1354
+ //
1355
+ // It used to live here as a private `lookupSideContent`, and `grading.ts`
1356
+ // carried a second, laxer answer to the same question (`hasEntry`: own +
1357
+ // enumerable, nothing more). On one and the same content map the grader said
1358
+ // PRESENT while this verifier said nothing was offered, and `gradePack` then
1359
+ // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
1360
+ // and for what each clause of it cost.
1361
+ function selectDisclosedBytes(ev, eventId, sideContent) {
1362
+ const errors = [];
1363
+ let inBody;
1364
+ if (typeof ev.content === "string") {
1365
+ try {
1366
+ inBody = hexToBytes(ev.content);
1367
+ }
1368
+ catch (e) {
1369
+ errors.push(`event content is not hex: ${stringifyErr(e)}`);
1370
+ }
1371
+ }
1372
+ let side;
1373
+ const sideEntry = sideContentEntry(sideContent, eventId);
1374
+ if (sideEntry.value instanceof Uint8Array) {
1375
+ side = sideEntry.value;
1376
+ }
1377
+ else if (typeof sideEntry.value === "string") {
1378
+ try {
1379
+ side = hexToBytes(sideEntry.value);
1380
+ }
1381
+ catch (e) {
1382
+ errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
1383
+ }
1384
+ }
1385
+ // `offered` comes from the SAME test that produced the value, never from
1386
+ // `value !== undefined` — that is the conflation this whole guard exists to
1387
+ // remove, and it was removed from ONE of the two operands. The left one
1388
+ // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
1389
+ // the event was marked offered while no bytes were selected, and the kernel
1390
+ // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
1391
+ // FAILED, on one bundle. `null` is how an optional field's absence
1392
+ // serializes; `inBodyContentOffered` states that once, beside the side-input
1393
+ // rule it mirrors.
1394
+ return {
1395
+ inBody,
1396
+ side,
1397
+ offered: inBodyContentOffered(ev.content) || sideEntry.offered,
1398
+ errors,
1399
+ };
1400
+ }
1036
1401
  function toHex(bytes) {
1037
1402
  let out = "";
1038
1403
  for (const x of bytes)