@sanning/proof 0.15.0 → 0.17.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/src/compose.ts CHANGED
@@ -105,6 +105,23 @@ function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
105
105
  return cloned;
106
106
  }
107
107
 
108
+ // True when `value` holds a number that is NaN or +-Infinity. Iterative, so a
109
+ // deeply nested input cannot overflow the stack before `jcs` bounds it.
110
+ function hasNonFiniteNumber(value: unknown): boolean {
111
+ const stack: unknown[] = [value];
112
+ while (stack.length > 0) {
113
+ const v = stack.pop();
114
+ if (typeof v === "number") {
115
+ if (!Number.isFinite(v)) return true;
116
+ } else if (Array.isArray(v)) {
117
+ for (const x of v) stack.push(x);
118
+ } else if (v !== null && typeof v === "object") {
119
+ for (const x of Object.values(v)) stack.push(x);
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
108
125
  // Compose an attested evidence export (evidence-export.md §5).
109
126
  //
110
127
  // 1. Normalize + transcode the attestation records (boundary hex, above).
@@ -133,6 +150,22 @@ export async function composeExport(
133
150
  throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
134
151
  }
135
152
 
153
+ // An over-long integer reads as Infinity (proof#217), which `jcs` refuses in
154
+ // the runtime's own words. Say it once, in the kernel's, before any
155
+ // verification: both kernels write this sentence, with no function-name prefix, so the CLI line is byte-identical.
156
+ if (hasNonFiniteNumber(sourceBundle)) {
157
+ throw new Error(
158
+ "the source bundle carries a number that is not a finite JSON number " +
159
+ "(an integer past the 1.8e308 range, or too long to read)",
160
+ );
161
+ }
162
+ if (hasNonFiniteNumber(attestations)) {
163
+ throw new Error(
164
+ "the attestations carry a number that is not a finite JSON number " +
165
+ "(an integer past the 1.8e308 range, or too long to read)",
166
+ );
167
+ }
168
+
136
169
  if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
137
170
  // The verifier reads this as `malformed generated_at` (evidence-bundle §2,
138
171
  // A31), so composing one would sign an export this kernel rejects.
@@ -131,10 +131,15 @@
131
131
  // The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
132
132
  // byte-order mark, no non-finite literal. `jcs_loads` in
133
133
  // `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
134
- import { hexToBytes } from "./crypto.js";
135
- import { jcsParse } from "./verifier.js";
134
+ import { hexToBytes, utf8 } from "./crypto.js";
135
+ import { jcs, jcsParse } from "./verifier.js";
136
136
  import { isConformantHandoffRecord, isHandoffNamespace, type HandoffRecord } from "./handoff.js";
137
- import { hasSideContent, inBodyContentOffered, type SideContentMap } from "./side-content.js";
137
+ import {
138
+ hasSideContent,
139
+ inBodyContentOffered,
140
+ sideContentEntry,
141
+ type SideContentMap,
142
+ } from "./side-content.js";
138
143
 
139
144
  /** The first declaration schema, which every earlier reader knows. */
140
145
  export const DECLARATION_VERSION = "sanning.declaration/v1";
@@ -278,6 +283,23 @@ export const DECLARATION_NOTE_KINDS = [
278
283
  * because its payload binding is undetermined.
279
284
  */
280
285
  "role_step_withheld",
286
+ /**
287
+ * Rule 10 (v1.29, A36). A record whose signed `event_type` is `declaration`
288
+ * and whose content HANDED IN to this reader is exactly `JCS(object)` of a
289
+ * declaration object of a version this reader knows, carrying exactly that
290
+ * version's keys, and not this pack's own. The note names the content handed
291
+ * in, never the record: whether those bytes bind to the record's
292
+ * `content_hash` is the bundle verifier's check, and an accuser can hand in
293
+ * declaration-shaped bytes for an honest record. What the pack holds is what
294
+ * the declaration in its body says, so this moves no verdict.
295
+ */
296
+ "other_declaration_held",
297
+ /**
298
+ * Rule 10. A record whose signed `event_type` is `declaration` and whose
299
+ * content did not reach this reader from either channel, so whether it is a
300
+ * declaration other than this pack's own is UNKNOWN.
301
+ */
302
+ "other_declaration_withheld",
281
303
  ] as const;
282
304
  export type DeclarationNoteKind = (typeof DECLARATION_NOTE_KINDS)[number];
283
305
 
@@ -698,6 +720,149 @@ function roleReading(
698
720
  return null;
699
721
  }
700
722
 
723
+ /**
724
+ * The `event_type` a declaration record is signed under: a reserved
725
+ * single-segment name, and the one `@sanning/anchor`'s `bundle` anchors a
726
+ * declaration as.
727
+ */
728
+ const DECLARATION_EVENT_TYPE = "declaration";
729
+
730
+ /**
731
+ * The keys a declaration object carries, under every version this reader
732
+ * knows: exactly these, no more and no fewer (5.4.1).
733
+ */
734
+ const DECLARATION_KEYS = ["declaration_version", "case_id", "agents", "period", "records", "handoffs"];
735
+
736
+ function hasExactKeys(value: unknown, keys: string[]): value is Record<string, unknown> {
737
+ if (!isObject(value)) return false;
738
+ const have = Object.keys(value);
739
+ return have.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(value, k));
740
+ }
741
+
742
+ /**
743
+ * Whether `obj` is shaped like a declaration this reader knows: one of
744
+ * `DECLARATION_VERSIONS` compared exactly, exactly the keys that version
745
+ * defines, and the value types this module reads.
746
+ *
747
+ * It is the shape test `@sanning/anchor`'s `isDeclarationShape` makes, and it
748
+ * checks shape only, not the 5.4.1 rules `readDeclaration` grades: a stale
749
+ * declaration is somebody else's claim, and this reader does not grade it, it
750
+ * names it.
751
+ */
752
+ function isDeclarationShape(obj: unknown): boolean {
753
+ if (!isObject(obj)) return false;
754
+ const version = obj["declaration_version"];
755
+ if (version !== DECLARATION_VERSION && version !== DECLARATION_VERSION_V2) return false;
756
+ if (!hasExactKeys(obj, DECLARATION_KEYS)) return false;
757
+ if (!isId(obj["case_id"])) return false;
758
+ const agents = obj["agents"];
759
+ if (!Array.isArray(agents) || !agents.every((a) => typeof a === "string")) return false;
760
+ const period = obj["period"];
761
+ if (!hasExactKeys(period, ["start", "end"])) return false;
762
+ if (typeof period["start"] !== "string" || typeof period["end"] !== "string") return false;
763
+ const recordKeys =
764
+ version === DECLARATION_VERSION_V2 ? ["event_id", "disclosure", "role"] : ["event_id", "disclosure"];
765
+ const records = obj["records"];
766
+ if (!Array.isArray(records)) return false;
767
+ for (const r of records) {
768
+ if (!hasExactKeys(r, recordKeys) || typeof r["event_id"] !== "string") return false;
769
+ }
770
+ const handoffs = obj["handoffs"];
771
+ if (!Array.isArray(handoffs)) return false;
772
+ return handoffs.every((h) => hasExactKeys(h, ["sent_event_id", "received_event_id"]));
773
+ }
774
+
775
+ /**
776
+ * The content bytes disclosed for one record, or `null` where none are usable:
777
+ * `events[].content` first, then the side-content map, chosen the way the
778
+ * bundle verifier chooses the bytes it binds.
779
+ */
780
+ function disclosedBytes(
781
+ ev: unknown,
782
+ p: PackRecord,
783
+ content: SideContentMap | null | undefined,
784
+ ): Uint8Array | null {
785
+ const inBody = isObject(ev) ? ev["content"] : undefined;
786
+ if (typeof inBody === "string") {
787
+ try {
788
+ return hexToBytes(inBody);
789
+ } catch {
790
+ // falls through to the side channel, as the bundle verifier does
791
+ }
792
+ }
793
+ if (!p.nameable) return null;
794
+ const side = sideContentEntry(content, p.eventId).value;
795
+ if (side instanceof Uint8Array) return side;
796
+ if (typeof side === "string") {
797
+ try {
798
+ return hexToBytes(side);
799
+ } catch {
800
+ return null;
801
+ }
802
+ }
803
+ return null;
804
+ }
805
+
806
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
807
+ if (a.length !== b.length) return false;
808
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
809
+ return true;
810
+ }
811
+
812
+ /**
813
+ * Whether `raw` is exactly `JCS(object)` of a declaration-shaped object.
814
+ * Canonical bytes, not merely parseable ones: a declaration is anchored as
815
+ * `JCS(declaration)`, and nothing an agent writes by accident.
816
+ */
817
+ function isDeclarationBytes(raw: Uint8Array): boolean {
818
+ try {
819
+ const obj = jcsParse(raw);
820
+ return isDeclarationShape(obj) && sameBytes(utf8(jcs(obj)), raw);
821
+ } catch {
822
+ return false;
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Rule 10: every record that is a declaration other than the pack's own, in
828
+ * bundle order.
829
+ *
830
+ * The record's `event_type` is read from its signed `event_bytes`, so a record
831
+ * whose bytes the pack withholds is not named: the reader cannot see what it
832
+ * was signed as. Its content is read from either channel; a record whose
833
+ * content is byte for byte `JCS` of the pack's own declaration is the pack's
834
+ * own and is never named.
835
+ */
836
+ function otherDeclarationNotes(
837
+ bundle: unknown,
838
+ pack: PackRecord[],
839
+ declaration: unknown,
840
+ content: SideContentMap | null | undefined,
841
+ ): DeclarationNote[] {
842
+ const events = pack.length > 0 ? (bodyOf(bundle)?.events ?? []) : [];
843
+ let own: Uint8Array | null = null;
844
+ try {
845
+ own = utf8(jcs(declaration));
846
+ } catch {
847
+ own = null;
848
+ }
849
+ const notes: DeclarationNote[] = [];
850
+ pack.forEach((p, i) => {
851
+ if (p.record === null || p.record.event_type !== DECLARATION_EVENT_TYPE) return;
852
+ const raw = disclosedBytes(events[i], p, content);
853
+ if (raw === null) {
854
+ // Offered and unusable is not withheld: something arrived, it is not a
855
+ // declaration, and the bundle verifier names what is wrong with it.
856
+ // Nothing arrived is the reading the reader cannot settle.
857
+ if (!p.disclosed) notes.push({ kind: "other_declaration_withheld", eventId: p.eventId });
858
+ return;
859
+ }
860
+ if (own !== null && sameBytes(raw, own)) return;
861
+ if (isDeclarationBytes(raw)) notes.push({ kind: "other_declaration_held", eventId: p.eventId });
862
+ });
863
+ return notes;
864
+ }
865
+
701
866
  /**
702
867
  * Check a pack against the declaration inside its body.
703
868
  *
@@ -915,6 +1080,12 @@ export function checkDeclaration(
915
1080
  }
916
1081
  }
917
1082
 
1083
+ // Rule 10 (5.4.2 v1.29, A36): content handed in for a record signed
1084
+ // `declaration` that is a declaration other than this pack's own. Notes only, in bundle order, after every other note, and
1085
+ // never a deviation: the pack holds what its own declaration says whatever
1086
+ // one of its records says.
1087
+ notes.push(...otherDeclarationNotes(bundle, pack, declaration, content));
1088
+
918
1089
  // A proven deviation wins: it is a mismatch between what the pack claimed
919
1090
  // and what its own signed body carries, and a failing check always wins.
920
1091
  // With no deviation and a content-dependent finding, the pack lines up with
@@ -1120,6 +1291,18 @@ export function renderNote(note: DeclarationNote): string {
1120
1291
  `${note.eventId} is signed as a step and this pack withholds its ` +
1121
1292
  "record bytes, so whether it is one is UNKNOWN"
1122
1293
  );
1294
+ case "other_declaration_held":
1295
+ return (
1296
+ `the content handed in for ${note.eventId} is a declaration that is ` +
1297
+ "not this pack's own: it says what some pack holds, and what this " +
1298
+ "pack holds is what the declaration in its body says"
1299
+ );
1300
+ case "other_declaration_withheld":
1301
+ return (
1302
+ `${note.eventId} is signed as a declaration and no content for it ` +
1303
+ "reached this verifier, so whether it is a declaration other than " +
1304
+ "this pack's own is UNKNOWN"
1305
+ );
1123
1306
  }
1124
1307
  }
1125
1308
 
package/src/evidence.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  utf8,
30
30
  verifyRsaPssSha256,
31
31
  } from "./crypto.js";
32
+ import { safeInteger, signedLeafCount } from "./grading.js";
32
33
  import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
33
34
  import { leafHash, verifyInclusion } from "./merkle.js";
34
35
  import {
@@ -1316,6 +1317,7 @@ async function verifyAnchorTrace(
1316
1317
  const cpErrors: string[] = [];
1317
1318
  let envelopeOk = false;
1318
1319
  let merkleRootOk = false;
1320
+ let leafCountOk = false;
1319
1321
 
1320
1322
  // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
1321
1323
  // is an input to identity, not just to the payload binding below.
@@ -1406,6 +1408,14 @@ async function verifyAnchorTrace(
1406
1408
  `committed=${jsonStr(committedRoot)}`,
1407
1409
  );
1408
1410
  }
1411
+ // §5.1, A38 (proof#197), decided by Phil 2026-10-06: fail closed. The
1412
+ // count the record signs is what every inclusion bound to this
1413
+ // checkpoint is held to, so a record that signs no integer count fails
1414
+ // the checkpoint, as a record that signs no merkle_root does. Read by
1415
+ // value through the one reader the inclusion compare and the
1416
+ // completeness notes share, so `3.0` and `3e0` are 3.
1417
+ leafCountOk = signedLeafCount(cp as { event_bytes?: unknown }) !== null;
1418
+ if (!leafCountOk) cpErrors.push("checkpoint record leaf_count is not an integer");
1409
1419
  } catch (e) {
1410
1420
  cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
1411
1421
  }
@@ -1424,7 +1434,7 @@ async function verifyAnchorTrace(
1424
1434
  // standing rule is that malformed is a failure while absent is
1425
1435
  // undetermined (the same rule disclosed `content` follows). Without
1426
1436
  // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
1427
- ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
1437
+ ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
1428
1438
  errors: cpErrors,
1429
1439
  });
1430
1440
  if (keyed.anchorRefMalformed) {
@@ -1554,36 +1564,66 @@ async function verifyAnchorTrace(
1554
1564
  const checkpointBound = cp !== undefined;
1555
1565
 
1556
1566
  let inclusionOk = false;
1567
+ // §5.1, A38 (proof#197): the tree walked must be the tree the checkpoint
1568
+ // record signed, the same rule and the same sentence as the stamp path's
1569
+ // rule 3. RFC 9162's walk reads one audit path as valid at more than one
1570
+ // (leaf_index, leaf_count), so without this a real proof at (2, 3) also
1571
+ // verified as (1, 2) or (4, 5). A signed count that cannot be read is not
1572
+ // compared here: it has already failed its checkpoint (above), so the
1573
+ // bundle cannot verify. One reader of the signed count, shared with the
1574
+ // completeness notes, so the verdict and the grade never read two
1575
+ // different numbers.
1576
+ //
1577
+ // Every number here is read BY VALUE (`Number.isSafeInteger`), the model
1578
+ // JCS signs, and the Python kernel reads the same way (`_safe_integer`):
1579
+ // JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
1580
+ // same wrapper signature. A value that is not a safe integer is malformed,
1581
+ // with one sentence that prints no value, because the two kernels cannot
1582
+ // print `2.5`, `1e400` or `9007199254740993` the same way.
1583
+ const signedCount = cp ? signedLeafCount(cp as { event_bytes?: unknown }) : null;
1557
1584
  if (incl && checkpointBound && cp) {
1558
- try {
1559
- inclusionOk = await verifyInclusion(
1560
- hexToBytes(incl.leaf_hash),
1561
- incl.leaf_index,
1562
- incl.leaf_count,
1563
- (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1564
- // No `?? ""` fallback. A checkpoint that claims NO root must not be
1565
- // handed an invented empty one: the Python kernel refuses the same
1566
- // value and reports the decode failure, so substituting "" here had
1567
- // the two kernels say different things about one bundle (TS: "the
1568
- // proof did not reconstruct the checkpoint root", which is not what
1569
- // happened: there was no claimed root to reconstruct).
1570
- hexToBytes(cp.merkle_root as string),
1585
+ const statedCount = safeInteger(incl.leaf_count);
1586
+ const statedIndex = safeInteger(incl.leaf_index);
1587
+ if (statedCount === null) {
1588
+ evErrors.push("inclusion leaf_count is not an integer");
1589
+ } else if (statedIndex === null) {
1590
+ evErrors.push("inclusion leaf_index is not an integer");
1591
+ } else if (signedCount !== null && statedCount !== signedCount) {
1592
+ evErrors.push(
1593
+ `inclusion leaf_count ${statedCount} does not equal ` +
1594
+ `the checkpoint's committed leaf_count ${signedCount}`,
1571
1595
  );
1572
- if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
1573
-
1574
- // Defense in depth: the claimed leaf_hash MUST be the hash of the
1575
- // event's own signed envelope bytes (profile §6) — otherwise a valid
1576
- // inclusion proof for an UNRELATED leaf could be smuggled in.
1577
- const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
1578
- const expectedLeafHex = toHex(expectedLeaf);
1579
- if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1580
- inclusionOk = false;
1581
- evErrors.push(
1582
- `inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`,
1596
+ } else {
1597
+ try {
1598
+ inclusionOk = await verifyInclusion(
1599
+ hexToBytes(incl.leaf_hash),
1600
+ statedIndex,
1601
+ statedCount,
1602
+ (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1603
+ // No `?? ""` fallback. A checkpoint that claims NO root must not be
1604
+ // handed an invented empty one: the Python kernel refuses the same
1605
+ // value and reports the decode failure, so substituting "" here had
1606
+ // the two kernels say different things about one bundle (TS: "the
1607
+ // proof did not reconstruct the checkpoint root", which is not what
1608
+ // happened: there was no claimed root to reconstruct).
1609
+ hexToBytes(cp.merkle_root as string),
1583
1610
  );
1611
+ if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
1612
+
1613
+ // Defense in depth: the claimed leaf_hash MUST be the hash of the
1614
+ // event's own signed envelope bytes (profile §6) — otherwise a valid
1615
+ // inclusion proof for an UNRELATED leaf could be smuggled in.
1616
+ const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
1617
+ const expectedLeafHex = toHex(expectedLeaf);
1618
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1619
+ inclusionOk = false;
1620
+ evErrors.push(
1621
+ `inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`,
1622
+ );
1623
+ }
1624
+ } catch (e) {
1625
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1584
1626
  }
1585
- } catch (e) {
1586
- evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1587
1627
  }
1588
1628
  }
1589
1629
 
@@ -1784,8 +1824,16 @@ function isLowerHex(s: unknown, nibbles?: number): s is string {
1784
1824
  return /^[0-9a-f]+$/.test(s);
1785
1825
  }
1786
1826
 
1787
- function isNonNegInt(n: unknown): n is number {
1788
- return typeof n === "number" && Number.isInteger(n) && n >= 0;
1827
+ /**
1828
+ * A JSON number read BY VALUE as a non-negative safe integer, or null.
1829
+ * `safeInteger`, the reader every leaf count and leaf index in this kernel
1830
+ * goes through (A38), mirrored by `_non_neg_integer` in
1831
+ * `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
1832
+ * JCS's own spelling of a count past 2**53 - 1, which Python refused.
1833
+ */
1834
+ function nonNegInteger(n: unknown): number | null {
1835
+ const v = safeInteger(n);
1836
+ return v !== null && v >= 0 ? v : null;
1789
1837
  }
1790
1838
 
1791
1839
  /**
@@ -2060,11 +2108,12 @@ async function verifyStampEntry(
2060
2108
  } else {
2061
2109
  base.merkleRoot = body.merkle_root;
2062
2110
  }
2063
- if (!isNonNegInt(body.leaf_count)) {
2111
+ const stampCount = nonNegInteger(body.leaf_count);
2112
+ if (stampCount === null) {
2064
2113
  errors.push("stamp body leaf_count is not a non-negative integer");
2065
2114
  base.bodyOk = false;
2066
2115
  } else {
2067
- base.leafCount = body.leaf_count;
2116
+ base.leafCount = stampCount;
2068
2117
  }
2069
2118
 
2070
2119
  // --- the sub-tree ranges --------------------------------------------------
@@ -2108,14 +2157,16 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2108
2157
  continue;
2109
2158
  }
2110
2159
  const s = v as Partial<StampSubtree>;
2111
- if (!isNonNegInt(s.first_leaf)) {
2160
+ const firstLeaf = nonNegInteger(s.first_leaf);
2161
+ const subCount = nonNegInteger(s.leaf_count);
2162
+ if (firstLeaf === null) {
2112
2163
  errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
2113
2164
  shapeOk = false;
2114
2165
  }
2115
2166
  // A zero-leaf sub-tree is not a slice of anything, and admitting one lets
2116
2167
  // an arbitrary number of empty ranges sit inside a tiling that still adds
2117
2168
  // up.
2118
- if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
2169
+ if (subCount === null || subCount === 0) {
2119
2170
  errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
2120
2171
  shapeOk = false;
2121
2172
  }
@@ -2126,8 +2177,8 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2126
2177
  if (!shapeOk) continue;
2127
2178
  subtrees.push({
2128
2179
  ref,
2129
- firstLeaf: s.first_leaf as number,
2130
- leafCount: s.leaf_count as number,
2180
+ firstLeaf: firstLeaf as number,
2181
+ leafCount: subCount as number,
2131
2182
  root: s.root as string,
2132
2183
  });
2133
2184
  }
@@ -2135,11 +2186,14 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2135
2186
  out.subtrees = subtrees;
2136
2187
  if (!shapeOk) return false;
2137
2188
 
2138
- const total = body.leaf_count;
2139
- if (!isNonNegInt(total)) return false; // already reported by the caller
2189
+ const total = out.leafCount;
2190
+ if (total === null) return false; // already reported by the caller
2140
2191
 
2141
- const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
2142
- if (sum !== total) {
2192
+ // Summed exactly. Each count is a safe integer but their sum need not be,
2193
+ // and a float sum past 2**53 prints a different number than Python's
2194
+ // unbounded one in the sentence below.
2195
+ const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
2196
+ if (sum !== BigInt(total)) {
2143
2197
  errors.push(
2144
2198
  `sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`,
2145
2199
  );
@@ -2428,17 +2482,29 @@ async function verifyStampEvidence(
2428
2482
  // Rule 3: the tree walked must be the tree the stamp committed to. A
2429
2483
  // proof walked against a different leaf_count is a proof about a
2430
2484
  // different tree, and RFC 9162's path shape depends on the size.
2431
- if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
2485
+ //
2486
+ // Read BY VALUE, as the anchor-trace path reads them (A38), and as the
2487
+ // Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
2488
+ // which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
2489
+ // signature. Anything that is not a safe integer is malformed, with a
2490
+ // sentence that prints no value.
2491
+ const statedCount = safeInteger(incl.leaf_count);
2492
+ const statedIndex = safeInteger(incl.leaf_index);
2493
+ if (statedCount === null) {
2494
+ evErrors.push("inclusion leaf_count is not an integer");
2495
+ } else if (statedIndex === null) {
2496
+ evErrors.push("inclusion leaf_index is not an integer");
2497
+ } else if (st.leafCount !== null && statedCount !== st.leafCount) {
2432
2498
  evErrors.push(
2433
- `inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
2499
+ `inclusion leaf_count ${statedCount} does not equal the stamp's committed ` +
2434
2500
  `leaf_count ${st.leafCount}`,
2435
2501
  );
2436
2502
  } else {
2437
2503
  try {
2438
2504
  inclusionOk = await verifyInclusion(
2439
2505
  hexToBytes(incl.leaf_hash),
2440
- incl.leaf_index,
2441
- incl.leaf_count,
2506
+ statedIndex,
2507
+ statedCount,
2442
2508
  (incl.audit_path ?? []).map((h) => hexToBytes(h)),
2443
2509
  hexToBytes(st.merkleRoot ?? ""),
2444
2510
  );