@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/dist/evidence.js CHANGED
@@ -61,6 +61,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
61
61
  * unrecoverable once witnessed.
62
62
  */
63
63
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
64
+ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
64
65
  /**
65
66
  * The version string INSIDE a witnessed stamp body — a different string from
66
67
  * the `body_type` above, and the two are easy to confuse.
@@ -193,7 +194,11 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
193
194
  // wrapper is authentic — and for this body that IS "Sanning signed it", since
194
195
  // a stamp carries no second signature of its own inside the bundle.
195
196
  if (bodyType === STAMP_TRACE_BODY_TYPE) {
196
- return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
197
+ return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
198
+ }
199
+ // The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
200
+ if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
201
+ return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
197
202
  }
198
203
  if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
199
204
  // The wrapper is authentic but we don't know this body's structure. Per
@@ -523,75 +528,10 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
523
528
  evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
524
529
  }
525
530
  }
526
- // Content (raw-log) binding — the rawLog → content_hash link the rest of
527
- // the chain (content_hash → record → payload_hash → signature → leaf →
528
- // root) already covers. The committed hash lives in the RECORD
529
- // (record.event.content_hash, minimal disclosure); for a promoted-disclosure
530
- // envelope it falls back to the envelope's own committed hashes
531
- // (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
532
- // and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
533
- // from the signed in-body `ev.content` FIRST, then the out-of-band
534
- // options.content side input. Undetermined (null) when nothing is disclosed
535
- // or nothing is committed; false ONLY on a real mismatch or an in-body vs
536
- // side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
537
- // discipline.
538
- let contentOk = null;
539
- {
540
- const committed = [];
541
- let recordContentHash;
542
- if (recordBytes !== undefined) {
543
- try {
544
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
545
- const ch = record.event?.content_hash;
546
- if (typeof ch === "string" && ch)
547
- recordContentHash = ch.toLowerCase();
548
- }
549
- catch {
550
- // record not JSON — the payload binding already reflects that; content
551
- // stays undetermined rather than throwing.
552
- }
553
- }
554
- if (recordContentHash !== undefined) {
555
- committed.push(recordContentHash);
556
- }
557
- else if (ev.envelope && typeof ev.envelope === "object") {
558
- // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
559
- // which throws on a null/non-object envelope — a verifier must never
560
- // raise on adversarial input (such an event already fails via envelopeOk).
561
- for (const c of contentHashes(ev.envelope))
562
- committed.push(c.hash.toLowerCase());
563
- }
564
- // Disclosed bytes, precedence: in-body (signed) → side input. A string
565
- // side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
566
- // selector so `disclosedContent` hands a consumer the very bytes bound
567
- // here — one decode, not two that can drift apart.
568
- const selection = selectDisclosedBytes(ev, eventId, options.content);
569
- const { inBody, side } = selection;
570
- evErrors.push(...selection.errors);
571
- if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
572
- // Two disclosures that disagree: a genuine conflict, not undetermined.
573
- contentOk = false;
574
- evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
575
- }
576
- else {
577
- const disclosed = inBody ?? side;
578
- if (disclosed === undefined || committed.length === 0) {
579
- contentOk = null;
580
- if (disclosed !== undefined && committed.length === 0) {
581
- evErrors.push("disclosed content present but no committed content_hash to bind to " +
582
- "(record withheld or record has no event.content_hash) — undetermined");
583
- }
584
- }
585
- else {
586
- const got = await sha256Hex(disclosed);
587
- contentOk = committed.includes(got);
588
- if (!contentOk) {
589
- evErrors.push("disclosed content does not match the committed content_hash: " +
590
- `sha256(disclosed)=${got} committed=${committed.join("|")}`);
591
- }
592
- }
593
- }
594
- }
531
+ // Content (raw-log) binding — shared with every other event-carrying body.
532
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
533
+ const contentOk = contentRes.contentOk;
534
+ evErrors.push(...contentRes.errors);
595
535
  eventResults.push({
596
536
  eventId,
597
537
  envelopeOk,
@@ -748,6 +688,72 @@ function isNonNegInt(n) {
748
688
  * verifier that stops at the first one turns a second fix into a second
749
689
  * round-trip.
750
690
  */
691
+ // ===========================================================================
692
+ // Disclosed-content binding, shared by every body that carries events.
693
+ //
694
+ // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
695
+ // rawLog -> content_hash link is the one step the chain
696
+ // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
697
+ // not otherwise cover, and a second copy of it is a second place for the
698
+ // never-fail-on-absent discipline to drift. The rules are unchanged from the
699
+ // anchor-trace path this came from: undetermined (null) when nothing is
700
+ // disclosed or nothing is committed; false ONLY on a real mismatch or an
701
+ // in-body vs side-input disagreement.
702
+ // ===========================================================================
703
+ async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
704
+ const evErrors = [];
705
+ let contentOk = null;
706
+ const committed = [];
707
+ let recordContentHash;
708
+ if (recordBytes !== undefined) {
709
+ try {
710
+ const record = JSON.parse(new TextDecoder().decode(recordBytes));
711
+ const ch = record.event?.content_hash;
712
+ if (typeof ch === "string" && ch)
713
+ recordContentHash = ch.toLowerCase();
714
+ }
715
+ catch {
716
+ // record not JSON — the payload binding already reflects that; content
717
+ // stays undetermined rather than throwing.
718
+ }
719
+ }
720
+ if (recordContentHash !== undefined) {
721
+ committed.push(recordContentHash);
722
+ }
723
+ else if (ev.envelope && typeof ev.envelope === "object") {
724
+ // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
725
+ // which throws on a null/non-object envelope — a verifier must never raise
726
+ // on adversarial input.
727
+ for (const c of contentHashes(ev.envelope))
728
+ committed.push(c.hash.toLowerCase());
729
+ }
730
+ const selection = selectDisclosedBytes(ev, eventId, sideContent);
731
+ const { inBody, side } = selection;
732
+ evErrors.push(...selection.errors);
733
+ if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
734
+ contentOk = false;
735
+ evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
736
+ }
737
+ else {
738
+ const disclosed = inBody ?? side;
739
+ if (disclosed === undefined || committed.length === 0) {
740
+ contentOk = null;
741
+ if (disclosed !== undefined && committed.length === 0) {
742
+ evErrors.push("disclosed content present but no committed content_hash to bind to " +
743
+ "(record withheld or record has no event.content_hash) — undetermined");
744
+ }
745
+ }
746
+ else {
747
+ const got = await sha256Hex(disclosed);
748
+ contentOk = committed.includes(got);
749
+ if (!contentOk) {
750
+ evErrors.push("disclosed content does not match the committed content_hash: " +
751
+ `sha256(disclosed)=${got} committed=${committed.join("|")}`);
752
+ }
753
+ }
754
+ }
755
+ return { contentOk, errors: evErrors };
756
+ }
751
757
  async function verifyStampEntry(raw, seen) {
752
758
  const errors = [];
753
759
  const base = {
@@ -762,18 +768,20 @@ async function verifyStampEntry(raw, seen) {
762
768
  subtreesOk: false,
763
769
  subtrees: [],
764
770
  anchorRef: null,
771
+ onChain: null,
772
+ onChainOk: null,
765
773
  ok: false,
766
774
  errors,
767
775
  };
768
776
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
769
777
  errors.push("stamp entry is not an object");
770
- return base;
778
+ return { result: base };
771
779
  }
772
780
  const e = raw;
773
781
  // --- the witnessed bytes, which are the only authority in this body -------
774
782
  if (!isLowerHex(e.stamp_bytes)) {
775
783
  errors.push("stamp_bytes is missing or not lowercase hex");
776
- return base;
784
+ return { result: base };
777
785
  }
778
786
  let bytes;
779
787
  try {
@@ -781,8 +789,11 @@ async function verifyStampEntry(raw, seen) {
781
789
  }
782
790
  catch (err) {
783
791
  errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
784
- return base;
792
+ return { result: base };
785
793
  }
794
+ // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
795
+ // the exact witnessed bytes — §5.2.2's "one authority in this body".
796
+ const witnessedHex = e.stamp_bytes;
786
797
  // --- the derived identity -------------------------------------------------
787
798
  const derived = await sha256Hex(bytes);
788
799
  if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
@@ -812,7 +823,7 @@ async function verifyStampEntry(raw, seen) {
812
823
  const parsed = JSON.parse(new TextDecoder().decode(bytes));
813
824
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
814
825
  errors.push("stamp body is not a JSON object");
815
- return base;
826
+ return { result: base, witnessedHex };
816
827
  }
817
828
  body = parsed;
818
829
  // Re-canonicalize and compare. The plane replays these bytes verbatim on
@@ -821,12 +832,12 @@ async function verifyStampEntry(raw, seen) {
821
832
  // of the stamp disagree with the one that was witnessed.
822
833
  if (jcs(body) !== new TextDecoder().decode(bytes)) {
823
834
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
824
- return base;
835
+ return { result: base, witnessedHex };
825
836
  }
826
837
  }
827
838
  catch (err) {
828
839
  errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
829
- return base;
840
+ return { result: base, witnessedHex };
830
841
  }
831
842
  if (body.stamp_version !== STAMP_BODY_VERSION) {
832
843
  // A `sanning.stamp.trace/v1` body whose inner version is something else is
@@ -835,7 +846,7 @@ async function verifyStampEntry(raw, seen) {
835
846
  // how a reader half-parses a shape it does not know.
836
847
  errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
837
848
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
838
- return base;
849
+ return { result: base, witnessedHex };
839
850
  }
840
851
  base.stampVersion = body.stamp_version;
841
852
  base.bodyOk = true;
@@ -866,7 +877,7 @@ async function verifyStampEntry(raw, seen) {
866
877
  base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
867
878
  base.ok =
868
879
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
869
- return base;
880
+ return { result: base, witnessedHex };
870
881
  }
871
882
  /**
872
883
  * The range arithmetic (§5.2), and the reason it is a hard check.
@@ -942,16 +953,27 @@ function checkSubtrees(body, out, errors) {
942
953
  }
943
954
  return true;
944
955
  }
945
- async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
956
+ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
946
957
  const body = b.body;
947
958
  if (!Array.isArray(body?.stamps)) {
948
959
  return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
949
960
  }
950
961
  const seen = new Set();
951
962
  const stamps = [];
963
+ const witnessed = [];
952
964
  for (const entry of body.stamps) {
953
- stamps.push(await verifyStampEntry(entry, seen));
965
+ const outcome = await verifyStampEntry(entry, seen);
966
+ stamps.push(outcome.result);
967
+ witnessed.push(outcome.witnessedHex);
954
968
  }
969
+ // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
970
+ //
971
+ // The SAME stamps[] array §5.3 carries, so it gets the same capability. The
972
+ // alternative — a stamp that can be re-fetched inside one body and not the
973
+ // other — asks every consumer to remember which wrapper it is holding before
974
+ // it can trust `onChain: null`, and that is exactly the kind of per-body
975
+ // exception a reader eventually gets wrong.
976
+ const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
955
977
  // --- Rollup ----------------------------------------------------------------
956
978
  const anyHardFailure = stamps.some((s) => !s.ok);
957
979
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
@@ -966,15 +988,23 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
966
988
  status = "partial";
967
989
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
968
990
  }
969
- else if (anyUnwitnessed) {
970
- // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
971
- // sealed and unwitnessed: `retained-by-sanning`, which is a statement about
972
- // our coverage and never an accusation about their evidence. It is also not
973
- // `verified`, because for a stamp the witness is the entire independent
974
- // part — unlike an anchor-trace checkpoint, which still carries a producer
975
- // signature and an inclusion proof that stand on their own offline.
991
+ else if (gatewayUnavailable || anyUnwitnessed) {
992
+ // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
993
+ // for why one note is not enough when there are two different gaps.
976
994
  status = "partial";
977
- errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
995
+ if (gatewayUnavailable) {
996
+ errors.push("one or more stamps could not be re-fetched from the supplied gateways");
997
+ }
998
+ if (anyUnwitnessed) {
999
+ // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1000
+ // sealed and unwitnessed: `retained-by-sanning`, which is a statement
1001
+ // about our coverage and never an accusation about their evidence. It is
1002
+ // also not `verified`, because for a stamp the witness is the entire
1003
+ // independent part — unlike an anchor-trace checkpoint, which still
1004
+ // carries a producer signature and an inclusion proof that stand on
1005
+ // their own offline.
1006
+ errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
1007
+ }
978
1008
  }
979
1009
  else {
980
1010
  status = "verified";
@@ -985,7 +1015,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
985
1015
  bodyHashOk: true,
986
1016
  signatureOk: true,
987
1017
  bodyType: STAMP_TRACE_BODY_TYPE,
988
- onChainChecked: false,
1018
+ onChainChecked,
989
1019
  checkpoints: [],
990
1020
  events: [],
991
1021
  assertedStatus,
@@ -993,6 +1023,285 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
993
1023
  errors,
994
1024
  };
995
1025
  }
1026
+ // ===========================================================================
1027
+ // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1028
+ //
1029
+ // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1030
+ // the sub-tree partition check). The event half reuses the same primitives the
1031
+ // anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
1032
+ // `verifyDisclosedContent` — because a second copy of Merkle verification is a
1033
+ // second place for it to drift.
1034
+ //
1035
+ // What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
1036
+ // to a stamps[] entry and walks its audit path to the root committed INSIDE
1037
+ // that stamp's witnessed bytes. There is no `merkle_root` field on the event to
1038
+ // read instead — repeating the root beside its own authoritative bytes would be
1039
+ // two answers to one question inside one signature (§5.1.2).
1040
+ // ===========================================================================
1041
+ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
1042
+ const body = b.body;
1043
+ if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1044
+ return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
1045
+ }
1046
+ // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1047
+ const seen = new Set();
1048
+ const stamps = [];
1049
+ const witnessed = [];
1050
+ for (const entry of body.stamps) {
1051
+ const outcome = await verifyStampEntry(entry, seen);
1052
+ stamps.push(outcome.result);
1053
+ witnessed.push(outcome.witnessedHex);
1054
+ }
1055
+ // The join table. ONLY a stamp whose derived ref and witnessed bytes both
1056
+ // checked out is joinable: a root read out of bytes that failed their own
1057
+ // verification is not a root anyone should walk a proof to, and admitting one
1058
+ // would let a broken stamp lend its authority to an event.
1059
+ const byRef = new Map();
1060
+ for (const st of stamps) {
1061
+ if (st.stampRef && st.stampRefOk && st.bodyOk)
1062
+ byRef.set(st.stampRef, st);
1063
+ }
1064
+ // --- The event half -------------------------------------------------------
1065
+ const eventResults = [];
1066
+ for (const rawEv of body.events) {
1067
+ const evErrors = [];
1068
+ // A verifier must never RAISE on adversarial input — a caller handed a
1069
+ // stack trace instead of a verdict cannot tell a malformed pack from a
1070
+ // broken tool. `events[]` is attacker-shaped, so a null/non-object entry
1071
+ // is coerced to an empty event and fails through the normal path, exactly
1072
+ // as the Python kernel does. Found by adversarial review, not by the
1073
+ // cross-kernel leg — whose cases did not include one, and now do.
1074
+ if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
1075
+ // NOT an event. Graded and reported as such rather than coerced to an
1076
+ // empty one: probing verifyEnvelope with nothing asks the envelope layer
1077
+ // a question about an object that was never an envelope, and the two
1078
+ // kernels answer it differently (undetermined vs failed) — a real
1079
+ // divergence, but not this body's to resolve. Reported separately.
1080
+ eventResults.push({
1081
+ eventId: "<unknown>",
1082
+ envelopeOk: false,
1083
+ payloadBindingOk: null,
1084
+ inclusionOk: false,
1085
+ checkpointBound: false,
1086
+ contentOk: null,
1087
+ ok: false,
1088
+ errors: ["events[] entry is not an object"],
1089
+ });
1090
+ continue;
1091
+ }
1092
+ const ev = rawEv;
1093
+ const eventId = getEventId(ev.envelope);
1094
+ let envelopeOk = false;
1095
+ let payloadBindingOk = null;
1096
+ let recordBytes;
1097
+ try {
1098
+ if (typeof ev.record_bytes === "string")
1099
+ recordBytes = hexToBytes(ev.record_bytes);
1100
+ }
1101
+ catch (e) {
1102
+ evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1103
+ }
1104
+ try {
1105
+ const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
1106
+ envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1107
+ payloadBindingOk = res.payloadHashOk;
1108
+ if (!res.signatureOk)
1109
+ evErrors.push("event envelope signature failed");
1110
+ if (res.payloadHashOk === false)
1111
+ evErrors.push("event record does not bind to payload_hash");
1112
+ }
1113
+ catch (e) {
1114
+ evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1115
+ }
1116
+ // --- The join (§5.3.3 rule 1) -------------------------------------------
1117
+ const incl = ev.inclusion;
1118
+ let st;
1119
+ if (!incl || typeof incl !== "object") {
1120
+ evErrors.push("event missing inclusion proof");
1121
+ }
1122
+ else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
1123
+ // One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
1124
+ // join key inside this body is a mixed bundle, and resolving it anyway is
1125
+ // how an event silently binds to something nobody meant (§5.1.2).
1126
+ evErrors.push(`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`);
1127
+ }
1128
+ else if (typeof incl.stamp_ref !== "string") {
1129
+ evErrors.push("event inclusion missing stamp_ref");
1130
+ }
1131
+ else {
1132
+ st = byRef.get(incl.stamp_ref);
1133
+ if (st === undefined) {
1134
+ // Hard reject, never undetermined: without a resolved stamp the
1135
+ // inclusion proof has no root to check against at all.
1136
+ evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`);
1137
+ }
1138
+ }
1139
+ const stampBound = st !== undefined;
1140
+ let inclusionOk = false;
1141
+ if (incl && stampBound && st) {
1142
+ // Rule 3: the tree walked must be the tree the stamp committed to. A
1143
+ // proof walked against a different leaf_count is a proof about a
1144
+ // different tree, and RFC 9162's path shape depends on the size.
1145
+ if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
1146
+ evErrors.push(`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
1147
+ `leaf_count ${st.leafCount}`);
1148
+ }
1149
+ else {
1150
+ try {
1151
+ inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(st.merkleRoot ?? ""));
1152
+ if (!inclusionOk) {
1153
+ evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
1154
+ }
1155
+ // Defense in depth, as §5.1: the claimed leaf MUST be this event's
1156
+ // own signed envelope bytes, or a valid proof for an UNRELATED leaf
1157
+ // could be smuggled in.
1158
+ const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
1159
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1160
+ inclusionOk = false;
1161
+ evErrors.push("inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))");
1162
+ }
1163
+ }
1164
+ catch (e) {
1165
+ evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1166
+ }
1167
+ }
1168
+ }
1169
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1170
+ const contentOk = contentRes.contentOk;
1171
+ evErrors.push(...contentRes.errors);
1172
+ eventResults.push({
1173
+ eventId,
1174
+ envelopeOk,
1175
+ payloadBindingOk,
1176
+ inclusionOk,
1177
+ // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1178
+ checkpointBound: stampBound,
1179
+ contentOk,
1180
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1181
+ errors: evErrors,
1182
+ });
1183
+ }
1184
+ // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1185
+ const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1186
+ // --- Rollup ---------------------------------------------------------------
1187
+ // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1188
+ // is the false green this body exists to prevent, so an event that did not
1189
+ // verify fails the bundle — it is never rolled up as verified.
1190
+ const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
1191
+ const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
1192
+ let status;
1193
+ if (anyHardFailure) {
1194
+ status = "failed";
1195
+ }
1196
+ else if (stamps.length === 0 && eventResults.length === 0) {
1197
+ status = "partial";
1198
+ errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1199
+ }
1200
+ else if (gatewayUnavailable || anyUnwitnessed) {
1201
+ // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
1202
+ // witnessed and a witness we could not reach are different gaps with
1203
+ // different fixes — one waits for the interval, the other for the network —
1204
+ // and an auditor told only about the second would read the first as fine.
1205
+ // The order is fixed so the two kernels emit the same list.
1206
+ status = "partial";
1207
+ if (gatewayUnavailable) {
1208
+ errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1209
+ }
1210
+ if (anyUnwitnessed) {
1211
+ // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1212
+ // the independent part, so the events resting on it inherit `partial`.
1213
+ errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
1214
+ }
1215
+ }
1216
+ else {
1217
+ status = "verified";
1218
+ }
1219
+ return {
1220
+ status,
1221
+ specVersionOk,
1222
+ bodyHashOk: true,
1223
+ signatureOk: true,
1224
+ bodyType: STAMP_EVIDENCE_BODY_TYPE,
1225
+ onChainChecked,
1226
+ checkpoints: [],
1227
+ events: eventResults,
1228
+ assertedStatus,
1229
+ stamps,
1230
+ errors,
1231
+ };
1232
+ }
1233
+ /**
1234
+ * The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
1235
+ *
1236
+ * ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
1237
+ *
1238
+ * A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
1239
+ * carry these outcomes would make a stamp indistinguishable from a checkpoint
1240
+ * in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
1241
+ * and §5.3.1 refused twice, most sharply in "a Sanning attestation
1242
+ * indistinguishable on the wire from a producer's". They are different
1243
+ * artifacts with different signers and different meanings when absent, and a
1244
+ * reader must never have to guess which one it is holding. So the stamp
1245
+ * results go on the stamps, and `checkpoints: []` keeps saying the true thing:
1246
+ * this body has none.
1247
+ *
1248
+ * ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
1249
+ *
1250
+ * The anchor-trace path synthesises an all-`unreachable` result for a
1251
+ * checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
1252
+ *
1253
+ * It would be untrue: nothing was asked of any gateway, so reporting that
1254
+ * every gateway failed to answer invents a network event. "Not checked" and
1255
+ * "we asked and nobody answered" are different facts, and collapsing them is
1256
+ * precisely the defect that had a surface reporting an unreachable gateway
1257
+ * when it had contacted none.
1258
+ *
1259
+ * And it would be load-bearing in the wrong direction. Over there the
1260
+ * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
1261
+ * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
1262
+ * `partial` on its own, with a better error. Copying the synthesis would add
1263
+ * nothing to the status and would silently move the CLI exit code from 0 to 3
1264
+ * for an unwitnessed stamp — changing what an auditor's script does, based on
1265
+ * nothing but whether the caller happened to pass a gateway list.
1266
+ */
1267
+ async function refetchStamps(stamps, witnessed, options, errors) {
1268
+ const gateways = options.gateways ?? [];
1269
+ const onChainChecked = gateways.length > 0;
1270
+ if (!onChainChecked)
1271
+ return { onChainChecked, gatewayUnavailable: false };
1272
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1273
+ if (typeof fetchImpl !== "function") {
1274
+ errors.push("on-chain re-fetch requested but no fetch implementation is available");
1275
+ return { onChainChecked, gatewayUnavailable: true };
1276
+ }
1277
+ let gatewayUnavailable = false;
1278
+ for (let i = 0; i < stamps.length; i++) {
1279
+ const st = stamps[i];
1280
+ const witnessedHex = witnessed[i];
1281
+ // No witness named, or bytes we could not read: nothing to re-fetch, and
1282
+ // nothing a gateway could confirm or deny. Left as `onChain: null`.
1283
+ if (st.anchorRef === null || witnessedHex === undefined)
1284
+ continue;
1285
+ const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
1286
+ st.onChain = outcome.onChain;
1287
+ st.onChainOk = outcome.onChain.onChainOk;
1288
+ if (outcome.unavailable)
1289
+ gatewayUnavailable = true;
1290
+ if (outcome.error)
1291
+ st.errors.push(outcome.error);
1292
+ // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
1293
+ // (false) fails the stamp — the chain does not hold what the bundle says
1294
+ // was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
1295
+ // downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
1296
+ // changes nothing that was not already true offline.
1297
+ //
1298
+ // `st.ok` is kept and narrowed rather than recomputed: its offline form
1299
+ // includes `errors.length === 0`, and re-evaluating that AFTER pushing an
1300
+ // unreachable note would fail a stamp for a gateway being down.
1301
+ st.ok = st.ok && st.onChainOk !== false;
1302
+ }
1303
+ return { onChainChecked, gatewayUnavailable };
1304
+ }
996
1305
  // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
997
1306
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
998
1307
  // depth bound already rejects a pathological bundle, but the unwrap must be
@@ -1605,6 +1914,10 @@ function summarizeExport(status, source, attestations) {
1605
1914
  async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
1606
1915
  let expectedHex;
1607
1916
  try {
1917
+ // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
1918
+ // bundle carries the envelope as a JSON object, not as bytes, so what was
1919
+ // uploaded has to be recomputed from it. See `refetchStamp` for the case
1920
+ // where this step is not merely unnecessary but WRONG.
1608
1921
  expectedHex = toHex(utf8(jcs(cp.envelope)));
1609
1922
  }
1610
1923
  catch {
@@ -1612,6 +1925,41 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
1612
1925
  // (we can never confirm bytes we cannot compute).
1613
1926
  expectedHex = undefined;
1614
1927
  }
1928
+ return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
1929
+ }
1930
+ // Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
1931
+ //
1932
+ // ── The one step this path must NOT copy from the checkpoint path ──
1933
+ //
1934
+ // `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
1935
+ // witnessed bytes are not in the bundle — only the object they were derived
1936
+ // from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
1937
+ // "lowercase hex of the exact JCS bytes that were handed to the witness", and
1938
+ // §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
1939
+ // are already the bytes, already hex, already lowercase (`isLowerHex` gated
1940
+ // them), and `stamp_ref` is their SHA-256.
1941
+ //
1942
+ // So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
1943
+ // defect, not a redundancy: it would compare the chain against bytes NOBODY
1944
+ // SIGNED. On a canonical body the two agree and the bug hides; on a body that
1945
+ // is not canonical — the exact case §5.2.2 makes the kernel check for, because
1946
+ // the plane replays these bytes as text precisely so a normalizing serializer
1947
+ // cannot alter them — a re-canonicalizing verifier reports `mismatch` against
1948
+ // a chain that faithfully holds what it was given. A false accusation, from
1949
+ // the verifier, about untampered evidence.
1950
+ //
1951
+ // The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
1952
+ // `stamp_ref`, threaded through rather than re-read from the body, so the bytes
1953
+ // graded and the bytes compared cannot drift apart.
1954
+ async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
1955
+ return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
1956
+ }
1957
+ // The part both paths share: probe every gateway, roll the outcomes up under
1958
+ // the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
1959
+ // copied — a second rollup rule is a second place for "mismatch beats confirm
1960
+ // beats unreachable" to drift, and the drifted copy is the one nobody reads.
1961
+ // The subject only ever selects wording; it never changes a verdict.
1962
+ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
1615
1963
  const perGateway = [];
1616
1964
  for (const gw of gateways) {
1617
1965
  perGateway.push({
@@ -1622,10 +1970,13 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
1622
1970
  const onChain = rollupOnChain(perGateway);
1623
1971
  let error;
1624
1972
  if (onChain.rollup === "mismatch") {
1625
- error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
1973
+ error =
1974
+ subject === "checkpoint"
1975
+ ? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
1976
+ : `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
1626
1977
  }
1627
1978
  else if (onChain.rollup === "unreachable") {
1628
- error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
1979
+ error = `${subject} ${anchorRef.id} unreachable on all gateways`;
1629
1980
  }
1630
1981
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
1631
1982
  }