@sanning/proof 0.7.0 → 0.8.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/chain.js CHANGED
@@ -143,7 +143,15 @@ function adapterLinkage(record) {
143
143
  // here: nothing to resolve is a beginning.
144
144
  const prev = str(fields.prev_event_id);
145
145
  const seqRaw = fields.seq;
146
- const seq = Number.isInteger(seqRaw) ? seqRaw : null;
146
+ // SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
147
+ // survive `JSON.parse` intact — `9007199254740993` arrives as
148
+ // `...992` — while Python's parser keeps it exactly, so the two kernels
149
+ // read DIFFERENT sequence numbers off identical bytes and report gaps of
150
+ // different widths. Measured at 2^53+1: a `chain-gap` count of
151
+ // 9007199254740990 here against 9007199254740992 there (proof#49). A value
152
+ // this kernel cannot represent is one it cannot read, which is the same
153
+ // answer it gives every other unreadable field: no sequence, take no part.
154
+ const seq = Number.isSafeInteger(seqRaw) ? seqRaw : null;
147
155
  return { namespace, chainKey, prev, seq };
148
156
  }
149
157
  return null;
@@ -226,6 +234,20 @@ function byChainOrder(a, b) {
226
234
  return a.seq - b.seq;
227
235
  return a.order - b.order;
228
236
  }
237
+ /**
238
+ * The smallest slot between the lowest and highest occupied ones that no record
239
+ * occupies, found by walking the SORTED occupants rather than the range they
240
+ * span. See the call site: the range's width is chosen by whoever wrote the
241
+ * bundle.
242
+ */
243
+ function firstMissingSlot(present) {
244
+ const ordered = [...present].sort((a, b) => a - b);
245
+ for (let i = 0; i < ordered.length - 1; i++) {
246
+ if (ordered[i + 1] > ordered[i] + 1)
247
+ return ordered[i] + 1;
248
+ }
249
+ return null;
250
+ }
229
251
  function findingsFor(group) {
230
252
  const { members, chainKey, source, publicKey } = group;
231
253
  const of = members.length;
@@ -283,18 +305,23 @@ function findingsFor(group) {
283
305
  const present = new Set(sequenced.map((m) => m.seq));
284
306
  const low = Math.min(...present);
285
307
  const high = Math.max(...present);
286
- const missing = [];
287
- for (let s = low; s <= high; s++)
288
- if (!present.has(s))
289
- missing.push(s);
290
- if (missing.length > 0) {
291
- const after = sequenced.filter((m) => m.seq > missing[0]).sort(byChainOrder)[0];
308
+ // Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
309
+ // two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
310
+ // high; s++)` spin for days — a verifier that HANGS on adversarial input is
311
+ // worse than one that raises, and this reader promises neither (proof#49).
312
+ // The present set is a subset of [low, high], so the absent slots are the
313
+ // width minus the occupants, and the first of them is found by walking the
314
+ // occupants in order.
315
+ const missingCount = high - low + 1 - present.size;
316
+ const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
317
+ if (firstMissing !== null) {
318
+ const after = sequenced.filter((m) => m.seq > firstMissing).sort(byChainOrder)[0];
292
319
  findings.push({
293
320
  kind: "chain-gap",
294
321
  ...base,
295
- count: missing.length,
322
+ count: missingCount,
296
323
  eventIds: after === undefined ? [] : [after.eventId],
297
- firstMissingSeq: missing[0],
324
+ firstMissingSeq: firstMissing,
298
325
  });
299
326
  }
300
327
  }
@@ -311,7 +338,18 @@ function findingsFor(group) {
311
338
  export async function readChains(bundle) {
312
339
  const groups = await groupChains(bundle);
313
340
  const order = (g) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
314
- groups.sort((a, b) => order(a).localeCompare(order(b)));
341
+ // Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
342
+ // text, and `localeCompare` with no locale argument answers from the host's
343
+ // ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
344
+ // code-wise comparison puts it before, and it can differ between two
345
+ // TypeScript installs, let alone between kernels. The Python reader sorts the
346
+ // same key encoded as `utf-16-be`, which is exactly this comparison — so the
347
+ // two kernels emit findings in one order (proof#49).
348
+ groups.sort((a, b) => {
349
+ const x = order(a);
350
+ const y = order(b);
351
+ return x < y ? -1 : x > y ? 1 : 0;
352
+ });
315
353
  return {
316
354
  chains: groups.map((g) => ({
317
355
  chainKey: g.chainKey,
@@ -25,6 +25,7 @@ export declare const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
25
25
  * unrecoverable once witnessed.
26
26
  */
27
27
  export declare const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
28
+ export declare const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
28
29
  /**
29
30
  * The version string INSIDE a witnessed stamp body — a different string from
30
31
  * the `body_type` above, and the two are easy to confuse.
@@ -162,6 +163,26 @@ export interface StampEntry {
162
163
  export interface StampTraceBody {
163
164
  stamps: StampEntry[];
164
165
  }
166
+ /** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
167
+ export interface StampEvidenceInclusion {
168
+ leaf_hash: string;
169
+ leaf_index: number;
170
+ leaf_count: number;
171
+ audit_path: string[];
172
+ /** Resolves against `stamps[].stamp_ref` in the SAME body. */
173
+ stamp_ref: string;
174
+ }
175
+ export interface StampEvidenceEvent {
176
+ envelope: Envelope;
177
+ record_bytes?: string;
178
+ content?: string;
179
+ inclusion: StampEvidenceInclusion;
180
+ }
181
+ export interface StampEvidenceBody {
182
+ /** Byte-identical to §5.2.2 — one definition, one place to change. */
183
+ stamps: StampEntry[];
184
+ events: StampEvidenceEvent[];
185
+ }
165
186
  export interface EvidenceBundle {
166
187
  spec_version: string;
167
188
  body_type: string;
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.
@@ -195,6 +196,10 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
195
196
  if (bodyType === STAMP_TRACE_BODY_TYPE) {
196
197
  return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
197
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);
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
200
205
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
@@ -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 = {
@@ -993,6 +999,197 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
993
999
  errors,
994
1000
  };
995
1001
  }
1002
+ // ===========================================================================
1003
+ // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1004
+ //
1005
+ // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1006
+ // the sub-tree partition check). The event half reuses the same primitives the
1007
+ // anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
1008
+ // `verifyDisclosedContent` — because a second copy of Merkle verification is a
1009
+ // second place for it to drift.
1010
+ //
1011
+ // What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
1012
+ // to a stamps[] entry and walks its audit path to the root committed INSIDE
1013
+ // that stamp's witnessed bytes. There is no `merkle_root` field on the event to
1014
+ // read instead — repeating the root beside its own authoritative bytes would be
1015
+ // two answers to one question inside one signature (§5.1.2).
1016
+ // ===========================================================================
1017
+ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
1018
+ const body = b.body;
1019
+ if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1020
+ return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
1021
+ }
1022
+ // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1023
+ const seen = new Set();
1024
+ const stamps = [];
1025
+ for (const entry of body.stamps)
1026
+ stamps.push(await verifyStampEntry(entry, seen));
1027
+ // The join table. ONLY a stamp whose derived ref and witnessed bytes both
1028
+ // checked out is joinable: a root read out of bytes that failed their own
1029
+ // verification is not a root anyone should walk a proof to, and admitting one
1030
+ // would let a broken stamp lend its authority to an event.
1031
+ const byRef = new Map();
1032
+ for (const st of stamps) {
1033
+ if (st.stampRef && st.stampRefOk && st.bodyOk)
1034
+ byRef.set(st.stampRef, st);
1035
+ }
1036
+ // --- The event half -------------------------------------------------------
1037
+ const eventResults = [];
1038
+ for (const rawEv of body.events) {
1039
+ const evErrors = [];
1040
+ // A verifier must never RAISE on adversarial input — a caller handed a
1041
+ // stack trace instead of a verdict cannot tell a malformed pack from a
1042
+ // broken tool. `events[]` is attacker-shaped, so a null/non-object entry
1043
+ // is coerced to an empty event and fails through the normal path, exactly
1044
+ // as the Python kernel does. Found by adversarial review, not by the
1045
+ // cross-kernel leg — whose cases did not include one, and now do.
1046
+ if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
1047
+ // NOT an event. Graded and reported as such rather than coerced to an
1048
+ // empty one: probing verifyEnvelope with nothing asks the envelope layer
1049
+ // a question about an object that was never an envelope, and the two
1050
+ // kernels answer it differently (undetermined vs failed) — a real
1051
+ // divergence, but not this body's to resolve. Reported separately.
1052
+ eventResults.push({
1053
+ eventId: "<unknown>",
1054
+ envelopeOk: false,
1055
+ payloadBindingOk: null,
1056
+ inclusionOk: false,
1057
+ checkpointBound: false,
1058
+ contentOk: null,
1059
+ ok: false,
1060
+ errors: ["events[] entry is not an object"],
1061
+ });
1062
+ continue;
1063
+ }
1064
+ const ev = rawEv;
1065
+ const eventId = getEventId(ev.envelope);
1066
+ let envelopeOk = false;
1067
+ let payloadBindingOk = null;
1068
+ let recordBytes;
1069
+ try {
1070
+ if (typeof ev.record_bytes === "string")
1071
+ recordBytes = hexToBytes(ev.record_bytes);
1072
+ }
1073
+ catch (e) {
1074
+ evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1075
+ }
1076
+ try {
1077
+ const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
1078
+ envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1079
+ payloadBindingOk = res.payloadHashOk;
1080
+ if (!res.signatureOk)
1081
+ evErrors.push("event envelope signature failed");
1082
+ if (res.payloadHashOk === false)
1083
+ evErrors.push("event record does not bind to payload_hash");
1084
+ }
1085
+ catch (e) {
1086
+ evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1087
+ }
1088
+ // --- The join (§5.3.3 rule 1) -------------------------------------------
1089
+ const incl = ev.inclusion;
1090
+ let st;
1091
+ if (!incl || typeof incl !== "object") {
1092
+ evErrors.push("event missing inclusion proof");
1093
+ }
1094
+ else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
1095
+ // One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
1096
+ // join key inside this body is a mixed bundle, and resolving it anyway is
1097
+ // how an event silently binds to something nobody meant (§5.1.2).
1098
+ evErrors.push(`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`);
1099
+ }
1100
+ else if (typeof incl.stamp_ref !== "string") {
1101
+ evErrors.push("event inclusion missing stamp_ref");
1102
+ }
1103
+ else {
1104
+ st = byRef.get(incl.stamp_ref);
1105
+ if (st === undefined) {
1106
+ // Hard reject, never undetermined: without a resolved stamp the
1107
+ // inclusion proof has no root to check against at all.
1108
+ evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`);
1109
+ }
1110
+ }
1111
+ const stampBound = st !== undefined;
1112
+ let inclusionOk = false;
1113
+ if (incl && stampBound && st) {
1114
+ // Rule 3: the tree walked must be the tree the stamp committed to. A
1115
+ // proof walked against a different leaf_count is a proof about a
1116
+ // different tree, and RFC 9162's path shape depends on the size.
1117
+ if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
1118
+ evErrors.push(`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
1119
+ `leaf_count ${st.leafCount}`);
1120
+ }
1121
+ else {
1122
+ try {
1123
+ inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(st.merkleRoot ?? ""));
1124
+ if (!inclusionOk) {
1125
+ evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
1126
+ }
1127
+ // Defense in depth, as §5.1: the claimed leaf MUST be this event's
1128
+ // own signed envelope bytes, or a valid proof for an UNRELATED leaf
1129
+ // could be smuggled in.
1130
+ const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
1131
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1132
+ inclusionOk = false;
1133
+ evErrors.push("inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))");
1134
+ }
1135
+ }
1136
+ catch (e) {
1137
+ evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1138
+ }
1139
+ }
1140
+ }
1141
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1142
+ const contentOk = contentRes.contentOk;
1143
+ evErrors.push(...contentRes.errors);
1144
+ eventResults.push({
1145
+ eventId,
1146
+ envelopeOk,
1147
+ payloadBindingOk,
1148
+ inclusionOk,
1149
+ // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1150
+ checkpointBound: stampBound,
1151
+ contentOk,
1152
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1153
+ errors: evErrors,
1154
+ });
1155
+ }
1156
+ // --- Rollup ---------------------------------------------------------------
1157
+ // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1158
+ // is the false green this body exists to prevent, so an event that did not
1159
+ // verify fails the bundle — it is never rolled up as verified.
1160
+ const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
1161
+ const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
1162
+ let status;
1163
+ if (anyHardFailure) {
1164
+ status = "failed";
1165
+ }
1166
+ else if (stamps.length === 0 && eventResults.length === 0) {
1167
+ status = "partial";
1168
+ errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1169
+ }
1170
+ else if (anyUnwitnessed) {
1171
+ // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1172
+ // the independent part, so the events resting on it inherit `partial`.
1173
+ status = "partial";
1174
+ errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
1175
+ }
1176
+ else {
1177
+ status = "verified";
1178
+ }
1179
+ return {
1180
+ status,
1181
+ specVersionOk,
1182
+ bodyHashOk: true,
1183
+ signatureOk: true,
1184
+ bodyType: STAMP_EVIDENCE_BODY_TYPE,
1185
+ onChainChecked: false,
1186
+ checkpoints: [],
1187
+ events: eventResults,
1188
+ assertedStatus,
1189
+ stamps,
1190
+ errors,
1191
+ };
1192
+ }
996
1193
  // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
997
1194
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
998
1195
  // depth bound already rejects a pathological bundle, but the unwrap must be
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyE
2
2
  export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
3
3
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
4
4
  export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
5
- export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
6
- export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
5
+ export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
6
+ export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, StampEvidenceBody, StampEvidenceEvent, StampEvidenceInclusion, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
7
7
  export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
8
8
  export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
9
9
  export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
34
34
  // files that failed verification on genuine evidence. This hands back DECODED
35
35
  // bytes, and only for events whose verification bound them to the committed
36
36
  // content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
37
- export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
37
+ export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
38
38
  // Pack grading (src/grading.ts): what a verification SURFACE may claim about a
39
39
  // pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
40
40
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/chain.ts CHANGED
@@ -236,7 +236,15 @@ function adapterLinkage(
236
236
  // here: nothing to resolve is a beginning.
237
237
  const prev = str(fields.prev_event_id);
238
238
  const seqRaw = fields.seq;
239
- const seq = Number.isInteger(seqRaw) ? (seqRaw as number) : null;
239
+ // SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
240
+ // survive `JSON.parse` intact — `9007199254740993` arrives as
241
+ // `...992` — while Python's parser keeps it exactly, so the two kernels
242
+ // read DIFFERENT sequence numbers off identical bytes and report gaps of
243
+ // different widths. Measured at 2^53+1: a `chain-gap` count of
244
+ // 9007199254740990 here against 9007199254740992 there (proof#49). A value
245
+ // this kernel cannot represent is one it cannot read, which is the same
246
+ // answer it gives every other unreadable field: no sequence, take no part.
247
+ const seq = Number.isSafeInteger(seqRaw) ? (seqRaw as number) : null;
240
248
  return { namespace, chainKey, prev, seq };
241
249
  }
242
250
  return null;
@@ -322,6 +330,20 @@ function byChainOrder(a: Member, b: Member): number {
322
330
  return a.order - b.order;
323
331
  }
324
332
 
333
+ /**
334
+ * The smallest slot between the lowest and highest occupied ones that no record
335
+ * occupies, found by walking the SORTED occupants rather than the range they
336
+ * span. See the call site: the range's width is chosen by whoever wrote the
337
+ * bundle.
338
+ */
339
+ function firstMissingSlot(present: Set<number>): number | null {
340
+ const ordered = [...present].sort((a, b) => a - b);
341
+ for (let i = 0; i < ordered.length - 1; i++) {
342
+ if (ordered[i + 1]! > ordered[i]! + 1) return ordered[i]! + 1;
343
+ }
344
+ return null;
345
+ }
346
+
325
347
  function findingsFor(group: Group): ChainFinding[] {
326
348
  const { members, chainKey, source, publicKey } = group;
327
349
  const of = members.length;
@@ -383,16 +405,23 @@ function findingsFor(group: Group): ChainFinding[] {
383
405
  const present = new Set(sequenced.map((m) => m.seq as number));
384
406
  const low = Math.min(...present);
385
407
  const high = Math.max(...present);
386
- const missing: number[] = [];
387
- for (let s = low; s <= high; s++) if (!present.has(s)) missing.push(s);
388
- if (missing.length > 0) {
389
- const after = sequenced.filter((m) => (m.seq as number) > missing[0]!).sort(byChainOrder)[0];
408
+ // Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
409
+ // two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
410
+ // high; s++)` spin for days — a verifier that HANGS on adversarial input is
411
+ // worse than one that raises, and this reader promises neither (proof#49).
412
+ // The present set is a subset of [low, high], so the absent slots are the
413
+ // width minus the occupants, and the first of them is found by walking the
414
+ // occupants in order.
415
+ const missingCount = high - low + 1 - present.size;
416
+ const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
417
+ if (firstMissing !== null) {
418
+ const after = sequenced.filter((m) => (m.seq as number) > firstMissing).sort(byChainOrder)[0];
390
419
  findings.push({
391
420
  kind: "chain-gap",
392
421
  ...base,
393
- count: missing.length,
422
+ count: missingCount,
394
423
  eventIds: after === undefined ? [] : [after.eventId],
395
- firstMissingSeq: missing[0]!,
424
+ firstMissingSeq: firstMissing,
396
425
  });
397
426
  }
398
427
  }
@@ -411,7 +440,18 @@ function findingsFor(group: Group): ChainFinding[] {
411
440
  export async function readChains(bundle: unknown): Promise<ChainReading> {
412
441
  const groups = await groupChains(bundle);
413
442
  const order = (g: Group) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
414
- groups.sort((a, b) => order(a).localeCompare(order(b)));
443
+ // Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
444
+ // text, and `localeCompare` with no locale argument answers from the host's
445
+ // ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
446
+ // code-wise comparison puts it before, and it can differ between two
447
+ // TypeScript installs, let alone between kernels. The Python reader sorts the
448
+ // same key encoded as `utf-16-be`, which is exactly this comparison — so the
449
+ // two kernels emit findings in one order (proof#49).
450
+ groups.sort((a, b) => {
451
+ const x = order(a);
452
+ const y = order(b);
453
+ return x < y ? -1 : x > y ? 1 : 0;
454
+ });
415
455
 
416
456
  return {
417
457
  chains: groups.map((g) => ({
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;
@@ -720,6 +752,11 @@ export async function verifyEvidenceBundle(
720
752
  return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
721
753
  }
722
754
 
755
+ // The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
756
+ if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
757
+ return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
758
+ }
759
+
723
760
  if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
724
761
  // The wrapper is authentic but we don't know this body's structure. Per
725
762
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
@@ -1091,79 +1128,10 @@ async function verifyAnchorTrace(
1091
1128
  }
1092
1129
  }
1093
1130
 
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
- }
1131
+ // Content (raw-log) binding — shared with every other event-carrying body.
1132
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1133
+ const contentOk = contentRes.contentOk;
1134
+ evErrors.push(...contentRes.errors);
1167
1135
 
1168
1136
  eventResults.push({
1169
1137
  eventId,
@@ -1320,6 +1288,83 @@ function isNonNegInt(n: unknown): n is number {
1320
1288
  * verifier that stops at the first one turns a second fix into a second
1321
1289
  * round-trip.
1322
1290
  */
1291
+ // ===========================================================================
1292
+ // Disclosed-content binding, shared by every body that carries events.
1293
+ //
1294
+ // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
1295
+ // rawLog -> content_hash link is the one step the chain
1296
+ // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1297
+ // not otherwise cover, and a second copy of it is a second place for the
1298
+ // never-fail-on-absent discipline to drift. The rules are unchanged from the
1299
+ // anchor-trace path this came from: undetermined (null) when nothing is
1300
+ // disclosed or nothing is committed; false ONLY on a real mismatch or an
1301
+ // in-body vs side-input disagreement.
1302
+ // ===========================================================================
1303
+ async function verifyDisclosedContent(
1304
+ ev: { envelope?: unknown; content?: unknown },
1305
+ eventId: string,
1306
+ recordBytes: Uint8Array | undefined,
1307
+ sideContent: VerifyEvidenceOptions["content"],
1308
+ ): Promise<{ contentOk: boolean | null; errors: string[] }> {
1309
+ const evErrors: string[] = [];
1310
+ let contentOk: boolean | null = null;
1311
+
1312
+ const committed: string[] = [];
1313
+ let recordContentHash: string | undefined;
1314
+ if (recordBytes !== undefined) {
1315
+ try {
1316
+ const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1317
+ event?: { content_hash?: unknown };
1318
+ };
1319
+ const ch = record.event?.content_hash;
1320
+ if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
1321
+ } catch {
1322
+ // record not JSON — the payload binding already reflects that; content
1323
+ // stays undetermined rather than throwing.
1324
+ }
1325
+ }
1326
+ if (recordContentHash !== undefined) {
1327
+ committed.push(recordContentHash);
1328
+ } else if (ev.envelope && typeof ev.envelope === "object") {
1329
+ // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
1330
+ // which throws on a null/non-object envelope — a verifier must never raise
1331
+ // on adversarial input.
1332
+ for (const c of contentHashes(ev.envelope as Envelope)) committed.push(c.hash.toLowerCase());
1333
+ }
1334
+
1335
+ const selection = selectDisclosedBytes(ev as TraceEvent, eventId, sideContent);
1336
+ const { inBody, side } = selection;
1337
+ evErrors.push(...selection.errors);
1338
+
1339
+ if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
1340
+ contentOk = false;
1341
+ evErrors.push(
1342
+ "disclosed content disagreement: in-body events[].content and the supplied content differ",
1343
+ );
1344
+ } else {
1345
+ const disclosed = inBody ?? side;
1346
+ if (disclosed === undefined || committed.length === 0) {
1347
+ contentOk = null;
1348
+ if (disclosed !== undefined && committed.length === 0) {
1349
+ evErrors.push(
1350
+ "disclosed content present but no committed content_hash to bind to " +
1351
+ "(record withheld or record has no event.content_hash) — undetermined",
1352
+ );
1353
+ }
1354
+ } else {
1355
+ const got = await sha256Hex(disclosed);
1356
+ contentOk = committed.includes(got);
1357
+ if (!contentOk) {
1358
+ evErrors.push(
1359
+ "disclosed content does not match the committed content_hash: " +
1360
+ `sha256(disclosed)=${got} committed=${committed.join("|")}`,
1361
+ );
1362
+ }
1363
+ }
1364
+ }
1365
+ return { contentOk, errors: evErrors };
1366
+ }
1367
+
1323
1368
  async function verifyStampEntry(
1324
1369
  raw: unknown,
1325
1370
  seen: Set<string>,
@@ -1591,6 +1636,220 @@ async function verifyStampTrace(
1591
1636
  };
1592
1637
  }
1593
1638
 
1639
+ // ===========================================================================
1640
+ // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1641
+ //
1642
+ // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1643
+ // the sub-tree partition check). The event half reuses the same primitives the
1644
+ // anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
1645
+ // `verifyDisclosedContent` — because a second copy of Merkle verification is a
1646
+ // second place for it to drift.
1647
+ //
1648
+ // What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
1649
+ // to a stamps[] entry and walks its audit path to the root committed INSIDE
1650
+ // that stamp's witnessed bytes. There is no `merkle_root` field on the event to
1651
+ // read instead — repeating the root beside its own authoritative bytes would be
1652
+ // two answers to one question inside one signature (§5.1.2).
1653
+ // ===========================================================================
1654
+ async function verifyStampEvidence(
1655
+ b: EvidenceBundle,
1656
+ specVersionOk: boolean,
1657
+ assertedStatus: string | null,
1658
+ errors: string[],
1659
+ options: VerifyEvidenceOptions,
1660
+ ): Promise<EvidenceBundleResult> {
1661
+ const body = b.body as StampEvidenceBody;
1662
+ if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1663
+ return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
1664
+ }
1665
+
1666
+ // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1667
+ const seen = new Set<string>();
1668
+ const stamps: StampResult[] = [];
1669
+ for (const entry of body.stamps) stamps.push(await verifyStampEntry(entry, seen));
1670
+
1671
+ // The join table. ONLY a stamp whose derived ref and witnessed bytes both
1672
+ // checked out is joinable: a root read out of bytes that failed their own
1673
+ // verification is not a root anyone should walk a proof to, and admitting one
1674
+ // would let a broken stamp lend its authority to an event.
1675
+ const byRef = new Map<string, StampResult>();
1676
+ for (const st of stamps) {
1677
+ if (st.stampRef && st.stampRefOk && st.bodyOk) byRef.set(st.stampRef, st);
1678
+ }
1679
+
1680
+ // --- The event half -------------------------------------------------------
1681
+ const eventResults: EventResult[] = [];
1682
+ for (const rawEv of body.events) {
1683
+ const evErrors: string[] = [];
1684
+ // A verifier must never RAISE on adversarial input — a caller handed a
1685
+ // stack trace instead of a verdict cannot tell a malformed pack from a
1686
+ // broken tool. `events[]` is attacker-shaped, so a null/non-object entry
1687
+ // is coerced to an empty event and fails through the normal path, exactly
1688
+ // as the Python kernel does. Found by adversarial review, not by the
1689
+ // cross-kernel leg — whose cases did not include one, and now do.
1690
+ if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
1691
+ // NOT an event. Graded and reported as such rather than coerced to an
1692
+ // empty one: probing verifyEnvelope with nothing asks the envelope layer
1693
+ // a question about an object that was never an envelope, and the two
1694
+ // kernels answer it differently (undetermined vs failed) — a real
1695
+ // divergence, but not this body's to resolve. Reported separately.
1696
+ eventResults.push({
1697
+ eventId: "<unknown>",
1698
+ envelopeOk: false,
1699
+ payloadBindingOk: null,
1700
+ inclusionOk: false,
1701
+ checkpointBound: false,
1702
+ contentOk: null,
1703
+ ok: false,
1704
+ errors: ["events[] entry is not an object"],
1705
+ });
1706
+ continue;
1707
+ }
1708
+ const ev = rawEv as StampEvidenceEvent;
1709
+ const eventId = getEventId(ev.envelope);
1710
+ let envelopeOk = false;
1711
+ let payloadBindingOk: boolean | null = null;
1712
+
1713
+ let recordBytes: Uint8Array | undefined;
1714
+ try {
1715
+ if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1716
+ } catch (e) {
1717
+ evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1718
+ }
1719
+ try {
1720
+ const res = await verifyEnvelope(
1721
+ ev.envelope,
1722
+ recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1723
+ );
1724
+ envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1725
+ payloadBindingOk = res.payloadHashOk;
1726
+ if (!res.signatureOk) evErrors.push("event envelope signature failed");
1727
+ if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1728
+ } catch (e) {
1729
+ evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1730
+ }
1731
+
1732
+ // --- The join (§5.3.3 rule 1) -------------------------------------------
1733
+ const incl = ev.inclusion as (StampEvidenceInclusion & Record<string, unknown>) | undefined;
1734
+ let st: StampResult | undefined;
1735
+ if (!incl || typeof incl !== "object") {
1736
+ evErrors.push("event missing inclusion proof");
1737
+ } else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
1738
+ // One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
1739
+ // join key inside this body is a mixed bundle, and resolving it anyway is
1740
+ // how an event silently binds to something nobody meant (§5.1.2).
1741
+ evErrors.push(
1742
+ `event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`,
1743
+ );
1744
+ } else if (typeof incl.stamp_ref !== "string") {
1745
+ evErrors.push("event inclusion missing stamp_ref");
1746
+ } else {
1747
+ st = byRef.get(incl.stamp_ref);
1748
+ if (st === undefined) {
1749
+ // Hard reject, never undetermined: without a resolved stamp the
1750
+ // inclusion proof has no root to check against at all.
1751
+ evErrors.push(
1752
+ `event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`,
1753
+ );
1754
+ }
1755
+ }
1756
+ const stampBound = st !== undefined;
1757
+
1758
+ let inclusionOk = false;
1759
+ if (incl && stampBound && st) {
1760
+ // Rule 3: the tree walked must be the tree the stamp committed to. A
1761
+ // proof walked against a different leaf_count is a proof about a
1762
+ // different tree, and RFC 9162's path shape depends on the size.
1763
+ if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
1764
+ evErrors.push(
1765
+ `inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
1766
+ `leaf_count ${st.leafCount}`,
1767
+ );
1768
+ } else {
1769
+ try {
1770
+ inclusionOk = await verifyInclusion(
1771
+ hexToBytes(incl.leaf_hash),
1772
+ incl.leaf_index,
1773
+ incl.leaf_count,
1774
+ (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1775
+ hexToBytes(st.merkleRoot ?? ""),
1776
+ );
1777
+ if (!inclusionOk) {
1778
+ evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
1779
+ }
1780
+
1781
+ // Defense in depth, as §5.1: the claimed leaf MUST be this event's
1782
+ // own signed envelope bytes, or a valid proof for an UNRELATED leaf
1783
+ // could be smuggled in.
1784
+ const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
1785
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1786
+ inclusionOk = false;
1787
+ evErrors.push(
1788
+ "inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))",
1789
+ );
1790
+ }
1791
+ } catch (e) {
1792
+ evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1793
+ }
1794
+ }
1795
+ }
1796
+
1797
+ const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1798
+ const contentOk = contentRes.contentOk;
1799
+ evErrors.push(...contentRes.errors);
1800
+
1801
+ eventResults.push({
1802
+ eventId,
1803
+ envelopeOk,
1804
+ payloadBindingOk,
1805
+ inclusionOk,
1806
+ // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1807
+ checkpointBound: stampBound,
1808
+ contentOk,
1809
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1810
+ errors: evErrors,
1811
+ });
1812
+ }
1813
+
1814
+ // --- Rollup ---------------------------------------------------------------
1815
+ // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1816
+ // is the false green this body exists to prevent, so an event that did not
1817
+ // verify fails the bundle — it is never rolled up as verified.
1818
+ const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
1819
+ const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
1820
+
1821
+ let status: EvidenceStatus;
1822
+ if (anyHardFailure) {
1823
+ status = "failed";
1824
+ } else if (stamps.length === 0 && eventResults.length === 0) {
1825
+ status = "partial";
1826
+ errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1827
+ } else if (anyUnwitnessed) {
1828
+ // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1829
+ // the independent part, so the events resting on it inherit `partial`.
1830
+ status = "partial";
1831
+ errors.push(
1832
+ "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1833
+ );
1834
+ } else {
1835
+ status = "verified";
1836
+ }
1837
+
1838
+ return {
1839
+ status,
1840
+ specVersionOk,
1841
+ bodyHashOk: true,
1842
+ signatureOk: true,
1843
+ bodyType: STAMP_EVIDENCE_BODY_TYPE,
1844
+ onChainChecked: false,
1845
+ checkpoints: [],
1846
+ events: eventResults,
1847
+ assertedStatus,
1848
+ stamps,
1849
+ errors,
1850
+ };
1851
+ }
1852
+
1594
1853
  // ===========================================================================
1595
1854
  // Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
1596
1855
  //
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export {
66
66
  EXPORT_BODY_TYPE,
67
67
  STAMP_BODY_VERSION,
68
68
  STAMP_TRACE_BODY_TYPE,
69
+ STAMP_EVIDENCE_BODY_TYPE,
69
70
  disclosedContent,
70
71
  recomputeExportVerdict,
71
72
  verifyEvidenceBundle,
@@ -98,6 +99,9 @@ export type {
98
99
  StampSubtree,
99
100
  StampSubtreeResult,
100
101
  StampTraceBody,
102
+ StampEvidenceBody,
103
+ StampEvidenceEvent,
104
+ StampEvidenceInclusion,
101
105
  TraceCheckpoint,
102
106
  TraceCheckpointV1,
103
107
  TraceEvent,