@sanning/proof 0.7.0 → 0.8.1

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/src/evidence.ts CHANGED
@@ -86,6 +86,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
86
86
  * unrecoverable once witnessed.
87
87
  */
88
88
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
89
+ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
89
90
 
90
91
  /**
91
92
  * The version string INSIDE a witnessed stamp body — a different string from
@@ -245,6 +246,37 @@ export interface StampTraceBody {
245
246
  stamps: StampEntry[];
246
247
  }
247
248
 
249
+ // ---- `sanning.stamp.evidence/v1` wire types (evidence-bundle.md §5.3) -----
250
+ //
251
+ // Both halves of a DL-52 record, bound: the producer signs the envelope, and
252
+ // Sanning signs and witnesses the stamp whose root commits to it. Neither
253
+ // earlier body joins them, and a field added to §5.2 could not — a conformant
254
+ // reader MUST ignore fields it does not know (envelope-spec.md §2), so the
255
+ // join has to live in a body_type readers DISPATCH on. See §5.3.1.
256
+
257
+ /** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
258
+ export interface StampEvidenceInclusion {
259
+ leaf_hash: string; // hex
260
+ leaf_index: number;
261
+ leaf_count: number;
262
+ audit_path: string[]; // hex[]
263
+ /** Resolves against `stamps[].stamp_ref` in the SAME body. */
264
+ stamp_ref: string;
265
+ }
266
+
267
+ export interface StampEvidenceEvent {
268
+ envelope: Envelope;
269
+ record_bytes?: string; // hex of JCS(event record); omitted == withheld
270
+ content?: string; // opt-in disclosed raw bytes, lowercase hex
271
+ inclusion: StampEvidenceInclusion;
272
+ }
273
+
274
+ export interface StampEvidenceBody {
275
+ /** Byte-identical to §5.2.2 — one definition, one place to change. */
276
+ stamps: StampEntry[];
277
+ events: StampEvidenceEvent[];
278
+ }
279
+
248
280
  export interface EvidenceBundle {
249
281
  spec_version: string;
250
282
  body_type: string;
@@ -396,6 +428,35 @@ export interface StampResult {
396
428
  * carries it.
397
429
  */
398
430
  anchorRef: AnchorRef | null;
431
+ /**
432
+ * Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
433
+ * `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
434
+ *
435
+ * ── The four states, and why none of them collapses ──
436
+ *
437
+ * `null` means not checked — no gateways were supplied, or this stamp names
438
+ * no witness to check. It is a statement about the RUN, never about the
439
+ * stamp. The other three are statements about the stamp and live in
440
+ * `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
441
+ * `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
442
+ * one of the four that fails anything), `unreachable` (we asked and nobody
443
+ * answered — an availability gap, never an accusation).
444
+ *
445
+ * Keeping "not checked" distinct from "unreachable" is the whole point. A
446
+ * surface handed a collapsed boolean cannot tell "no gateway could be
447
+ * reached" from "no gateway was tried", and reported the first when the
448
+ * second was true — a false accusation manufactured by a missing state.
449
+ */
450
+ onChain: OnChainResult | null;
451
+ /**
452
+ * Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
453
+ * consumer reads one field the same way on both: `false` on mismatch, `true`
454
+ * on confirm, `null` on unreachable AND on not-checked.
455
+ *
456
+ * It is deliberately the LOSSY view — `null` is three-quarters of the state
457
+ * space here. Anything that needs to tell those apart reads `onChain`.
458
+ */
459
+ onChainOk: boolean | null;
399
460
  ok: boolean;
400
461
  errors: string[];
401
462
  }
@@ -717,7 +778,12 @@ export async function verifyEvidenceBundle(
717
778
  // wrapper is authentic — and for this body that IS "Sanning signed it", since
718
779
  // a stamp carries no second signature of its own inside the bundle.
719
780
  if (bodyType === STAMP_TRACE_BODY_TYPE) {
720
- return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
781
+ return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
782
+ }
783
+
784
+ // The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
785
+ if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
786
+ return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
721
787
  }
722
788
 
723
789
  if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
@@ -1091,79 +1157,10 @@ async function verifyAnchorTrace(
1091
1157
  }
1092
1158
  }
1093
1159
 
1094
- // Content (raw-log) binding — the rawLog → content_hash link the rest of
1095
- // the chain (content_hash → record → payload_hash → signature → leaf →
1096
- // root) already covers. The committed hash lives in the RECORD
1097
- // (record.event.content_hash, minimal disclosure); for a promoted-disclosure
1098
- // envelope it falls back to the envelope's own committed hashes
1099
- // (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
1100
- // and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
1101
- // from the signed in-body `ev.content` FIRST, then the out-of-band
1102
- // options.content side input. Undetermined (null) when nothing is disclosed
1103
- // or nothing is committed; false ONLY on a real mismatch or an in-body vs
1104
- // side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
1105
- // discipline.
1106
- let contentOk: boolean | null = null;
1107
- {
1108
- const committed: string[] = [];
1109
- let recordContentHash: string | undefined;
1110
- if (recordBytes !== undefined) {
1111
- try {
1112
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1113
- event?: { content_hash?: unknown };
1114
- };
1115
- const ch = record.event?.content_hash;
1116
- if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
1117
- } catch {
1118
- // record not JSON — the payload binding already reflects that; content
1119
- // stays undetermined rather than throwing.
1120
- }
1121
- }
1122
- if (recordContentHash !== undefined) {
1123
- committed.push(recordContentHash);
1124
- } else if (ev.envelope && typeof ev.envelope === "object") {
1125
- // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
1126
- // which throws on a null/non-object envelope — a verifier must never
1127
- // raise on adversarial input (such an event already fails via envelopeOk).
1128
- for (const c of contentHashes(ev.envelope)) committed.push(c.hash.toLowerCase());
1129
- }
1130
-
1131
- // Disclosed bytes, precedence: in-body (signed) → side input. A string
1132
- // side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
1133
- // selector so `disclosedContent` hands a consumer the very bytes bound
1134
- // here — one decode, not two that can drift apart.
1135
- const selection = selectDisclosedBytes(ev, eventId, options.content);
1136
- const { inBody, side } = selection;
1137
- evErrors.push(...selection.errors);
1138
-
1139
- if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
1140
- // Two disclosures that disagree: a genuine conflict, not undetermined.
1141
- contentOk = false;
1142
- evErrors.push(
1143
- "disclosed content disagreement: in-body events[].content and the supplied content differ",
1144
- );
1145
- } else {
1146
- const disclosed = inBody ?? side;
1147
- if (disclosed === undefined || committed.length === 0) {
1148
- contentOk = null;
1149
- if (disclosed !== undefined && committed.length === 0) {
1150
- evErrors.push(
1151
- "disclosed content present but no committed content_hash to bind to " +
1152
- "(record withheld or record has no event.content_hash) — undetermined",
1153
- );
1154
- }
1155
- } else {
1156
- const got = await sha256Hex(disclosed);
1157
- contentOk = committed.includes(got);
1158
- if (!contentOk) {
1159
- evErrors.push(
1160
- "disclosed content does not match the committed content_hash: " +
1161
- `sha256(disclosed)=${got} committed=${committed.join("|")}`,
1162
- );
1163
- }
1164
- }
1165
- }
1166
- }
1160
+ // Content (raw-log) binding — shared with every other event-carrying body.
1161
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1162
+ const contentOk = contentRes.contentOk;
1163
+ evErrors.push(...contentRes.errors);
1167
1164
 
1168
1165
  eventResults.push({
1169
1166
  eventId,
@@ -1320,10 +1317,106 @@ function isNonNegInt(n: unknown): n is number {
1320
1317
  * verifier that stops at the first one turns a second fix into a second
1321
1318
  * round-trip.
1322
1319
  */
1320
+ // ===========================================================================
1321
+ // Disclosed-content binding, shared by every body that carries events.
1322
+ //
1323
+ // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
1324
+ // rawLog -> content_hash link is the one step the chain
1325
+ // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1326
+ // not otherwise cover, and a second copy of it is a second place for the
1327
+ // never-fail-on-absent discipline to drift. The rules are unchanged from the
1328
+ // anchor-trace path this came from: undetermined (null) when nothing is
1329
+ // disclosed or nothing is committed; false ONLY on a real mismatch or an
1330
+ // in-body vs side-input disagreement.
1331
+ // ===========================================================================
1332
+ async function verifyDisclosedContent(
1333
+ ev: { envelope?: unknown; content?: unknown },
1334
+ eventId: string,
1335
+ recordBytes: Uint8Array | undefined,
1336
+ sideContent: VerifyEvidenceOptions["content"],
1337
+ ): Promise<{ contentOk: boolean | null; errors: string[] }> {
1338
+ const evErrors: string[] = [];
1339
+ let contentOk: boolean | null = null;
1340
+
1341
+ const committed: string[] = [];
1342
+ let recordContentHash: string | undefined;
1343
+ if (recordBytes !== undefined) {
1344
+ try {
1345
+ const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1346
+ event?: { content_hash?: unknown };
1347
+ };
1348
+ const ch = record.event?.content_hash;
1349
+ if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
1350
+ } catch {
1351
+ // record not JSON — the payload binding already reflects that; content
1352
+ // stays undetermined rather than throwing.
1353
+ }
1354
+ }
1355
+ if (recordContentHash !== undefined) {
1356
+ committed.push(recordContentHash);
1357
+ } else if (ev.envelope && typeof ev.envelope === "object") {
1358
+ // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
1359
+ // which throws on a null/non-object envelope — a verifier must never raise
1360
+ // on adversarial input.
1361
+ for (const c of contentHashes(ev.envelope as Envelope)) committed.push(c.hash.toLowerCase());
1362
+ }
1363
+
1364
+ const selection = selectDisclosedBytes(ev as TraceEvent, eventId, sideContent);
1365
+ const { inBody, side } = selection;
1366
+ evErrors.push(...selection.errors);
1367
+
1368
+ if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
1369
+ contentOk = false;
1370
+ evErrors.push(
1371
+ "disclosed content disagreement: in-body events[].content and the supplied content differ",
1372
+ );
1373
+ } else {
1374
+ const disclosed = inBody ?? side;
1375
+ if (disclosed === undefined || committed.length === 0) {
1376
+ contentOk = null;
1377
+ if (disclosed !== undefined && committed.length === 0) {
1378
+ evErrors.push(
1379
+ "disclosed content present but no committed content_hash to bind to " +
1380
+ "(record withheld or record has no event.content_hash) — undetermined",
1381
+ );
1382
+ }
1383
+ } else {
1384
+ const got = await sha256Hex(disclosed);
1385
+ contentOk = committed.includes(got);
1386
+ if (!contentOk) {
1387
+ evErrors.push(
1388
+ "disclosed content does not match the committed content_hash: " +
1389
+ `sha256(disclosed)=${got} committed=${committed.join("|")}`,
1390
+ );
1391
+ }
1392
+ }
1393
+ }
1394
+ return { contentOk, errors: evErrors };
1395
+ }
1396
+
1397
+ /**
1398
+ * One `stamps[]` entry's result, plus the witnessed bytes it was graded on.
1399
+ *
1400
+ * `witnessedHex` is internal and never surfaces on `StampResult`: it is the
1401
+ * whole stamp body again, and putting it back on the result would double every
1402
+ * serialized verdict to carry a copy of what the bundle already holds. It
1403
+ * exists so the optional on-chain re-fetch compares the chain against THE SAME
1404
+ * BYTES this function hashed to derive `stamp_ref`, rather than re-reading
1405
+ * `stamp_bytes` out of the body a second time — one value, so the graded bytes
1406
+ * and the compared bytes cannot come apart.
1407
+ *
1408
+ * Undefined exactly when `stamp_bytes` was unusable, in which case the entry
1409
+ * has already failed and there is nothing a gateway could confirm.
1410
+ */
1411
+ interface StampEntryOutcome {
1412
+ result: StampResult;
1413
+ witnessedHex?: string;
1414
+ }
1415
+
1323
1416
  async function verifyStampEntry(
1324
1417
  raw: unknown,
1325
1418
  seen: Set<string>,
1326
- ): Promise<StampResult> {
1419
+ ): Promise<StampEntryOutcome> {
1327
1420
  const errors: string[] = [];
1328
1421
  const base: StampResult = {
1329
1422
  stampRef: "",
@@ -1337,28 +1430,33 @@ async function verifyStampEntry(
1337
1430
  subtreesOk: false,
1338
1431
  subtrees: [],
1339
1432
  anchorRef: null,
1433
+ onChain: null,
1434
+ onChainOk: null,
1340
1435
  ok: false,
1341
1436
  errors,
1342
1437
  };
1343
1438
 
1344
1439
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1345
1440
  errors.push("stamp entry is not an object");
1346
- return base;
1441
+ return { result: base };
1347
1442
  }
1348
1443
  const e = raw as Partial<StampEntry> & Record<string, unknown>;
1349
1444
 
1350
1445
  // --- the witnessed bytes, which are the only authority in this body -------
1351
1446
  if (!isLowerHex(e.stamp_bytes)) {
1352
1447
  errors.push("stamp_bytes is missing or not lowercase hex");
1353
- return base;
1448
+ return { result: base };
1354
1449
  }
1355
1450
  let bytes: Uint8Array;
1356
1451
  try {
1357
1452
  bytes = hexToBytes(e.stamp_bytes);
1358
1453
  } catch (err) {
1359
1454
  errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1360
- return base;
1455
+ return { result: base };
1361
1456
  }
1457
+ // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
1458
+ // the exact witnessed bytes — §5.2.2's "one authority in this body".
1459
+ const witnessedHex: string = e.stamp_bytes;
1362
1460
 
1363
1461
  // --- the derived identity -------------------------------------------------
1364
1462
  const derived = await sha256Hex(bytes);
@@ -1389,7 +1487,7 @@ async function verifyStampEntry(
1389
1487
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1390
1488
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1391
1489
  errors.push("stamp body is not a JSON object");
1392
- return base;
1490
+ return { result: base, witnessedHex };
1393
1491
  }
1394
1492
  body = parsed as StampBody;
1395
1493
  // Re-canonicalize and compare. The plane replays these bytes verbatim on
@@ -1398,11 +1496,11 @@ async function verifyStampEntry(
1398
1496
  // of the stamp disagree with the one that was witnessed.
1399
1497
  if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
1400
1498
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1401
- return base;
1499
+ return { result: base, witnessedHex };
1402
1500
  }
1403
1501
  } catch (err) {
1404
1502
  errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1405
- return base;
1503
+ return { result: base, witnessedHex };
1406
1504
  }
1407
1505
 
1408
1506
  if (body.stamp_version !== STAMP_BODY_VERSION) {
@@ -1414,7 +1512,7 @@ async function verifyStampEntry(
1414
1512
  `unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
1415
1513
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
1416
1514
  );
1417
- return base;
1515
+ return { result: base, witnessedHex };
1418
1516
  }
1419
1517
  base.stampVersion = body.stamp_version;
1420
1518
  base.bodyOk = true;
@@ -1448,7 +1546,7 @@ async function verifyStampEntry(
1448
1546
 
1449
1547
  base.ok =
1450
1548
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1451
- return base;
1549
+ return { result: base, witnessedHex };
1452
1550
  }
1453
1551
 
1454
1552
  /**
@@ -1536,6 +1634,7 @@ async function verifyStampTrace(
1536
1634
  specVersionOk: boolean,
1537
1635
  assertedStatus: string | null,
1538
1636
  errors: string[],
1637
+ options: VerifyEvidenceOptions,
1539
1638
  ): Promise<EvidenceBundleResult> {
1540
1639
  const body = b.body as StampTraceBody;
1541
1640
  if (!Array.isArray(body?.stamps)) {
@@ -1544,10 +1643,27 @@ async function verifyStampTrace(
1544
1643
 
1545
1644
  const seen = new Set<string>();
1546
1645
  const stamps: StampResult[] = [];
1646
+ const witnessed: (string | undefined)[] = [];
1547
1647
  for (const entry of body.stamps) {
1548
- stamps.push(await verifyStampEntry(entry, seen));
1648
+ const outcome = await verifyStampEntry(entry, seen);
1649
+ stamps.push(outcome.result);
1650
+ witnessed.push(outcome.witnessedHex);
1549
1651
  }
1550
1652
 
1653
+ // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1654
+ //
1655
+ // The SAME stamps[] array §5.3 carries, so it gets the same capability. The
1656
+ // alternative — a stamp that can be re-fetched inside one body and not the
1657
+ // other — asks every consumer to remember which wrapper it is holding before
1658
+ // it can trust `onChain: null`, and that is exactly the kind of per-body
1659
+ // exception a reader eventually gets wrong.
1660
+ const { onChainChecked, gatewayUnavailable } = await refetchStamps(
1661
+ stamps,
1662
+ witnessed,
1663
+ options,
1664
+ errors,
1665
+ );
1666
+
1551
1667
  // --- Rollup ----------------------------------------------------------------
1552
1668
  const anyHardFailure = stamps.some((s) => !s.ok);
1553
1669
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
@@ -1561,17 +1677,25 @@ async function verifyStampTrace(
1561
1677
  // does not have.
1562
1678
  status = "partial";
1563
1679
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1564
- } else if (anyUnwitnessed) {
1565
- // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1566
- // sealed and unwitnessed: `retained-by-sanning`, which is a statement about
1567
- // our coverage and never an accusation about their evidence. It is also not
1568
- // `verified`, because for a stamp the witness is the entire independent
1569
- // part — unlike an anchor-trace checkpoint, which still carries a producer
1570
- // signature and an inclusion proof that stand on their own offline.
1680
+ } else if (gatewayUnavailable || anyUnwitnessed) {
1681
+ // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1682
+ // for why one note is not enough when there are two different gaps.
1571
1683
  status = "partial";
1572
- errors.push(
1573
- "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1574
- );
1684
+ if (gatewayUnavailable) {
1685
+ errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1686
+ }
1687
+ if (anyUnwitnessed) {
1688
+ // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1689
+ // sealed and unwitnessed: `retained-by-sanning`, which is a statement
1690
+ // about our coverage and never an accusation about their evidence. It is
1691
+ // also not `verified`, because for a stamp the witness is the entire
1692
+ // independent part — unlike an anchor-trace checkpoint, which still
1693
+ // carries a producer signature and an inclusion proof that stand on
1694
+ // their own offline.
1695
+ errors.push(
1696
+ "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1697
+ );
1698
+ }
1575
1699
  } else {
1576
1700
  status = "verified";
1577
1701
  }
@@ -1582,7 +1706,7 @@ async function verifyStampTrace(
1582
1706
  bodyHashOk: true,
1583
1707
  signatureOk: true,
1584
1708
  bodyType: STAMP_TRACE_BODY_TYPE,
1585
- onChainChecked: false,
1709
+ onChainChecked,
1586
1710
  checkpoints: [],
1587
1711
  events: [],
1588
1712
  assertedStatus,
@@ -1591,6 +1715,320 @@ async function verifyStampTrace(
1591
1715
  };
1592
1716
  }
1593
1717
 
1718
+ // ===========================================================================
1719
+ // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1720
+ //
1721
+ // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1722
+ // the sub-tree partition check). The event half reuses the same primitives the
1723
+ // anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
1724
+ // `verifyDisclosedContent` — because a second copy of Merkle verification is a
1725
+ // second place for it to drift.
1726
+ //
1727
+ // What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
1728
+ // to a stamps[] entry and walks its audit path to the root committed INSIDE
1729
+ // that stamp's witnessed bytes. There is no `merkle_root` field on the event to
1730
+ // read instead — repeating the root beside its own authoritative bytes would be
1731
+ // two answers to one question inside one signature (§5.1.2).
1732
+ // ===========================================================================
1733
+ async function verifyStampEvidence(
1734
+ b: EvidenceBundle,
1735
+ specVersionOk: boolean,
1736
+ assertedStatus: string | null,
1737
+ errors: string[],
1738
+ options: VerifyEvidenceOptions,
1739
+ ): Promise<EvidenceBundleResult> {
1740
+ const body = b.body as StampEvidenceBody;
1741
+ if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1742
+ return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
1743
+ }
1744
+
1745
+ // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1746
+ const seen = new Set<string>();
1747
+ const stamps: StampResult[] = [];
1748
+ const witnessed: (string | undefined)[] = [];
1749
+ for (const entry of body.stamps) {
1750
+ const outcome = await verifyStampEntry(entry, seen);
1751
+ stamps.push(outcome.result);
1752
+ witnessed.push(outcome.witnessedHex);
1753
+ }
1754
+
1755
+ // The join table. ONLY a stamp whose derived ref and witnessed bytes both
1756
+ // checked out is joinable: a root read out of bytes that failed their own
1757
+ // verification is not a root anyone should walk a proof to, and admitting one
1758
+ // would let a broken stamp lend its authority to an event.
1759
+ const byRef = new Map<string, StampResult>();
1760
+ for (const st of stamps) {
1761
+ if (st.stampRef && st.stampRefOk && st.bodyOk) byRef.set(st.stampRef, st);
1762
+ }
1763
+
1764
+ // --- The event half -------------------------------------------------------
1765
+ const eventResults: EventResult[] = [];
1766
+ for (const rawEv of body.events) {
1767
+ const evErrors: string[] = [];
1768
+ // A verifier must never RAISE on adversarial input — a caller handed a
1769
+ // stack trace instead of a verdict cannot tell a malformed pack from a
1770
+ // broken tool. `events[]` is attacker-shaped, so a null/non-object entry
1771
+ // is coerced to an empty event and fails through the normal path, exactly
1772
+ // as the Python kernel does. Found by adversarial review, not by the
1773
+ // cross-kernel leg — whose cases did not include one, and now do.
1774
+ if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
1775
+ // NOT an event. Graded and reported as such rather than coerced to an
1776
+ // empty one: probing verifyEnvelope with nothing asks the envelope layer
1777
+ // a question about an object that was never an envelope, and the two
1778
+ // kernels answer it differently (undetermined vs failed) — a real
1779
+ // divergence, but not this body's to resolve. Reported separately.
1780
+ eventResults.push({
1781
+ eventId: "<unknown>",
1782
+ envelopeOk: false,
1783
+ payloadBindingOk: null,
1784
+ inclusionOk: false,
1785
+ checkpointBound: false,
1786
+ contentOk: null,
1787
+ ok: false,
1788
+ errors: ["events[] entry is not an object"],
1789
+ });
1790
+ continue;
1791
+ }
1792
+ const ev = rawEv as StampEvidenceEvent;
1793
+ const eventId = getEventId(ev.envelope);
1794
+ let envelopeOk = false;
1795
+ let payloadBindingOk: boolean | null = null;
1796
+
1797
+ let recordBytes: Uint8Array | undefined;
1798
+ try {
1799
+ if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1800
+ } catch (e) {
1801
+ evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1802
+ }
1803
+ try {
1804
+ const res = await verifyEnvelope(
1805
+ ev.envelope,
1806
+ recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1807
+ );
1808
+ envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1809
+ payloadBindingOk = res.payloadHashOk;
1810
+ if (!res.signatureOk) evErrors.push("event envelope signature failed");
1811
+ if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1812
+ } catch (e) {
1813
+ evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1814
+ }
1815
+
1816
+ // --- The join (§5.3.3 rule 1) -------------------------------------------
1817
+ const incl = ev.inclusion as (StampEvidenceInclusion & Record<string, unknown>) | undefined;
1818
+ let st: StampResult | undefined;
1819
+ if (!incl || typeof incl !== "object") {
1820
+ evErrors.push("event missing inclusion proof");
1821
+ } else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
1822
+ // One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
1823
+ // join key inside this body is a mixed bundle, and resolving it anyway is
1824
+ // how an event silently binds to something nobody meant (§5.1.2).
1825
+ evErrors.push(
1826
+ `event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`,
1827
+ );
1828
+ } else if (typeof incl.stamp_ref !== "string") {
1829
+ evErrors.push("event inclusion missing stamp_ref");
1830
+ } else {
1831
+ st = byRef.get(incl.stamp_ref);
1832
+ if (st === undefined) {
1833
+ // Hard reject, never undetermined: without a resolved stamp the
1834
+ // inclusion proof has no root to check against at all.
1835
+ evErrors.push(
1836
+ `event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`,
1837
+ );
1838
+ }
1839
+ }
1840
+ const stampBound = st !== undefined;
1841
+
1842
+ let inclusionOk = false;
1843
+ if (incl && stampBound && st) {
1844
+ // Rule 3: the tree walked must be the tree the stamp committed to. A
1845
+ // proof walked against a different leaf_count is a proof about a
1846
+ // different tree, and RFC 9162's path shape depends on the size.
1847
+ if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
1848
+ evErrors.push(
1849
+ `inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
1850
+ `leaf_count ${st.leafCount}`,
1851
+ );
1852
+ } else {
1853
+ try {
1854
+ inclusionOk = await verifyInclusion(
1855
+ hexToBytes(incl.leaf_hash),
1856
+ incl.leaf_index,
1857
+ incl.leaf_count,
1858
+ (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1859
+ hexToBytes(st.merkleRoot ?? ""),
1860
+ );
1861
+ if (!inclusionOk) {
1862
+ evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
1863
+ }
1864
+
1865
+ // Defense in depth, as §5.1: the claimed leaf MUST be this event's
1866
+ // own signed envelope bytes, or a valid proof for an UNRELATED leaf
1867
+ // could be smuggled in.
1868
+ const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
1869
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1870
+ inclusionOk = false;
1871
+ evErrors.push(
1872
+ "inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))",
1873
+ );
1874
+ }
1875
+ } catch (e) {
1876
+ evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1877
+ }
1878
+ }
1879
+ }
1880
+
1881
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1882
+ const contentOk = contentRes.contentOk;
1883
+ evErrors.push(...contentRes.errors);
1884
+
1885
+ eventResults.push({
1886
+ eventId,
1887
+ envelopeOk,
1888
+ payloadBindingOk,
1889
+ inclusionOk,
1890
+ // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1891
+ checkpointBound: stampBound,
1892
+ contentOk,
1893
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1894
+ errors: evErrors,
1895
+ });
1896
+ }
1897
+
1898
+ // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1899
+ const { onChainChecked, gatewayUnavailable } = await refetchStamps(
1900
+ stamps,
1901
+ witnessed,
1902
+ options,
1903
+ errors,
1904
+ );
1905
+
1906
+ // --- Rollup ---------------------------------------------------------------
1907
+ // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1908
+ // is the false green this body exists to prevent, so an event that did not
1909
+ // verify fails the bundle — it is never rolled up as verified.
1910
+ const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
1911
+ const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
1912
+
1913
+ let status: EvidenceStatus;
1914
+ if (anyHardFailure) {
1915
+ status = "failed";
1916
+ } else if (stamps.length === 0 && eventResults.length === 0) {
1917
+ status = "partial";
1918
+ errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1919
+ } else if (gatewayUnavailable || anyUnwitnessed) {
1920
+ // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
1921
+ // witnessed and a witness we could not reach are different gaps with
1922
+ // different fixes — one waits for the interval, the other for the network —
1923
+ // and an auditor told only about the second would read the first as fine.
1924
+ // The order is fixed so the two kernels emit the same list.
1925
+ status = "partial";
1926
+ if (gatewayUnavailable) {
1927
+ errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1928
+ }
1929
+ if (anyUnwitnessed) {
1930
+ // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1931
+ // the independent part, so the events resting on it inherit `partial`.
1932
+ errors.push(
1933
+ "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1934
+ );
1935
+ }
1936
+ } else {
1937
+ status = "verified";
1938
+ }
1939
+
1940
+ return {
1941
+ status,
1942
+ specVersionOk,
1943
+ bodyHashOk: true,
1944
+ signatureOk: true,
1945
+ bodyType: STAMP_EVIDENCE_BODY_TYPE,
1946
+ onChainChecked,
1947
+ checkpoints: [],
1948
+ events: eventResults,
1949
+ assertedStatus,
1950
+ stamps,
1951
+ errors,
1952
+ };
1953
+ }
1954
+
1955
+ /**
1956
+ * The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
1957
+ *
1958
+ * ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
1959
+ *
1960
+ * A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
1961
+ * carry these outcomes would make a stamp indistinguishable from a checkpoint
1962
+ * in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
1963
+ * and §5.3.1 refused twice, most sharply in "a Sanning attestation
1964
+ * indistinguishable on the wire from a producer's". They are different
1965
+ * artifacts with different signers and different meanings when absent, and a
1966
+ * reader must never have to guess which one it is holding. So the stamp
1967
+ * results go on the stamps, and `checkpoints: []` keeps saying the true thing:
1968
+ * this body has none.
1969
+ *
1970
+ * ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
1971
+ *
1972
+ * The anchor-trace path synthesises an all-`unreachable` result for a
1973
+ * checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
1974
+ *
1975
+ * It would be untrue: nothing was asked of any gateway, so reporting that
1976
+ * every gateway failed to answer invents a network event. "Not checked" and
1977
+ * "we asked and nobody answered" are different facts, and collapsing them is
1978
+ * precisely the defect that had a surface reporting an unreachable gateway
1979
+ * when it had contacted none.
1980
+ *
1981
+ * And it would be load-bearing in the wrong direction. Over there the
1982
+ * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
1983
+ * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
1984
+ * `partial` on its own, with a better error. Copying the synthesis would add
1985
+ * nothing to the status and would silently move the CLI exit code from 0 to 3
1986
+ * for an unwitnessed stamp — changing what an auditor's script does, based on
1987
+ * nothing but whether the caller happened to pass a gateway list.
1988
+ */
1989
+ async function refetchStamps(
1990
+ stamps: StampResult[],
1991
+ witnessed: (string | undefined)[],
1992
+ options: VerifyEvidenceOptions,
1993
+ errors: string[],
1994
+ ): Promise<{ onChainChecked: boolean; gatewayUnavailable: boolean }> {
1995
+ const gateways = options.gateways ?? [];
1996
+ const onChainChecked = gateways.length > 0;
1997
+ if (!onChainChecked) return { onChainChecked, gatewayUnavailable: false };
1998
+
1999
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
2000
+ if (typeof fetchImpl !== "function") {
2001
+ errors.push("on-chain re-fetch requested but no fetch implementation is available");
2002
+ return { onChainChecked, gatewayUnavailable: true };
2003
+ }
2004
+
2005
+ let gatewayUnavailable = false;
2006
+ for (let i = 0; i < stamps.length; i++) {
2007
+ const st = stamps[i]!;
2008
+ const witnessedHex = witnessed[i];
2009
+ // No witness named, or bytes we could not read: nothing to re-fetch, and
2010
+ // nothing a gateway could confirm or deny. Left as `onChain: null`.
2011
+ if (st.anchorRef === null || witnessedHex === undefined) continue;
2012
+
2013
+ const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
2014
+ st.onChain = outcome.onChain;
2015
+ st.onChainOk = outcome.onChain.onChainOk;
2016
+ if (outcome.unavailable) gatewayUnavailable = true;
2017
+ if (outcome.error) st.errors.push(outcome.error);
2018
+ // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
2019
+ // (false) fails the stamp — the chain does not hold what the bundle says
2020
+ // was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
2021
+ // downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
2022
+ // changes nothing that was not already true offline.
2023
+ //
2024
+ // `st.ok` is kept and narrowed rather than recomputed: its offline form
2025
+ // includes `errors.length === 0`, and re-evaluating that AFTER pushing an
2026
+ // unreachable note would fail a stamp for a gateway being down.
2027
+ st.ok = st.ok && st.onChainOk !== false;
2028
+ }
2029
+ return { onChainChecked, gatewayUnavailable };
2030
+ }
2031
+
1594
2032
  // ===========================================================================
1595
2033
  // Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
1596
2034
  //
@@ -2362,6 +2800,10 @@ async function refetchCheckpoint(
2362
2800
  ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
2363
2801
  let expectedHex: string | undefined;
2364
2802
  try {
2803
+ // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
2804
+ // bundle carries the envelope as a JSON object, not as bytes, so what was
2805
+ // uploaded has to be recomputed from it. See `refetchStamp` for the case
2806
+ // where this step is not merely unnecessary but WRONG.
2365
2807
  expectedHex = toHex(utf8(jcs(cp.envelope)));
2366
2808
  } catch {
2367
2809
  // Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
@@ -2369,6 +2811,54 @@ async function refetchCheckpoint(
2369
2811
  expectedHex = undefined;
2370
2812
  }
2371
2813
 
2814
+ return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
2815
+ }
2816
+
2817
+ // Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
2818
+ //
2819
+ // ── The one step this path must NOT copy from the checkpoint path ──
2820
+ //
2821
+ // `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
2822
+ // witnessed bytes are not in the bundle — only the object they were derived
2823
+ // from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
2824
+ // "lowercase hex of the exact JCS bytes that were handed to the witness", and
2825
+ // §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
2826
+ // are already the bytes, already hex, already lowercase (`isLowerHex` gated
2827
+ // them), and `stamp_ref` is their SHA-256.
2828
+ //
2829
+ // So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
2830
+ // defect, not a redundancy: it would compare the chain against bytes NOBODY
2831
+ // SIGNED. On a canonical body the two agree and the bug hides; on a body that
2832
+ // is not canonical — the exact case §5.2.2 makes the kernel check for, because
2833
+ // the plane replays these bytes as text precisely so a normalizing serializer
2834
+ // cannot alter them — a re-canonicalizing verifier reports `mismatch` against
2835
+ // a chain that faithfully holds what it was given. A false accusation, from
2836
+ // the verifier, about untampered evidence.
2837
+ //
2838
+ // The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
2839
+ // `stamp_ref`, threaded through rather than re-read from the body, so the bytes
2840
+ // graded and the bytes compared cannot drift apart.
2841
+ async function refetchStamp(
2842
+ witnessedHex: string,
2843
+ anchorRef: AnchorRef,
2844
+ gateways: string[],
2845
+ fetchImpl: typeof fetch,
2846
+ ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
2847
+ return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
2848
+ }
2849
+
2850
+ // The part both paths share: probe every gateway, roll the outcomes up under
2851
+ // the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
2852
+ // copied — a second rollup rule is a second place for "mismatch beats confirm
2853
+ // beats unreachable" to drift, and the drifted copy is the one nobody reads.
2854
+ // The subject only ever selects wording; it never changes a verdict.
2855
+ async function refetchAnchored(
2856
+ subject: "checkpoint" | "stamp",
2857
+ expectedHex: string | undefined,
2858
+ anchorRef: AnchorRef,
2859
+ gateways: string[],
2860
+ fetchImpl: typeof fetch,
2861
+ ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
2372
2862
  const perGateway: PerGatewayOutcome[] = [];
2373
2863
  for (const gw of gateways) {
2374
2864
  perGateway.push({
@@ -2380,9 +2870,12 @@ async function refetchCheckpoint(
2380
2870
  const onChain = rollupOnChain(perGateway);
2381
2871
  let error: string | undefined;
2382
2872
  if (onChain.rollup === "mismatch") {
2383
- error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
2873
+ error =
2874
+ subject === "checkpoint"
2875
+ ? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
2876
+ : `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
2384
2877
  } else if (onChain.rollup === "unreachable") {
2385
- error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
2878
+ error = `${subject} ${anchorRef.id} unreachable on all gateways`;
2386
2879
  }
2387
2880
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
2388
2881
  }