@sanning/proof 0.16.0 → 0.18.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.
package/src/crypto.ts CHANGED
@@ -8,8 +8,10 @@ import * as ed from "@noble/ed25519";
8
8
  // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
9
9
  // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
10
10
  // browser and in Node >= 19, so the same code path runs in the app and in tests.
11
- ed.etc.sha512Async = async (...msgs: Uint8Array[]): Promise<Uint8Array> =>
12
- new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
11
+ // noble 3 ships this provider as its default; it is set here so the kernel
12
+ // says what it hashes with rather than inheriting it.
13
+ ed.hashes.sha512Async = async (message) =>
14
+ new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(message)));
13
15
 
14
16
  // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
15
17
  // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
@@ -76,10 +78,125 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
76
78
  return bytesToHex(await sha256Bytes(bytes));
77
79
  }
78
80
 
79
- // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
80
- // appear in the envelope; message is raw bytes. Never throws — a malformed
81
- // signature or key is "not verified," not an exception, so a hostile envelope
82
- // can't crash the checker.
81
+ // ---------------------------------------------------------------------------
82
+ // The Ed25519 rule (envelope-spec §7, A44, proof#230)
83
+ //
84
+ // Strict RFC 8032 (`zip215: false`, a frozen invariant), and more: the public
85
+ // key A and the signature's R must each be the canonical encoding of a point
86
+ // in the prime-order subgroup other than the identity, and S below the group
87
+ // order L. Only then is the equation checked.
88
+ //
89
+ // Why more than `zip215: false`: noble checks the equation with the cofactor,
90
+ // 8·S·B = 8·R + 8·k·A, and Go's crypto/ed25519 and libsodium without it, so on
91
+ // their own they disagree when A or R has a small-order component. noble 2.3
92
+ // also could not encode an identity R and hashed nothing for k, so it accepted
93
+ // one signature from a key holder over every message. With A and R in the
94
+ // prime-order subgroup the two equations are the same equation, and every
95
+ // kernel accepts exactly the same signatures.
96
+ //
97
+ // Each clause is checked here, in this order, and named in these words: the
98
+ // Python twin is `ed25519_rule_reason` in `src/sanning_proof/ed25519_rule.py`
99
+ // and the Go kernel's `verifyEd25519Strict` in sanning-io/agent `pkg/proof`.
100
+ // Several clauses are ones noble 3 refuses on its own; they are checked here
101
+ // anyway so the refusal names the clause, which a library's `false` cannot.
102
+
103
+ /** The sentence a refused signature reads in an envelope result. */
104
+ export const SIGNATURE_FAILED = "Ed25519 signature verification failed";
105
+
106
+ export const ED25519_RULE_REASONS = [
107
+ "the public key is not the canonical encoding of a point",
108
+ "the public key is a point of small order",
109
+ "the public key is not in the prime-order subgroup",
110
+ "R is not the canonical encoding of a point",
111
+ "R is the identity",
112
+ "R is a point of small order",
113
+ "R is not in the prime-order subgroup",
114
+ "S is not below the group order",
115
+ ] as const;
116
+
117
+ const [
118
+ KEY_NOT_CANONICAL,
119
+ KEY_SMALL_ORDER,
120
+ KEY_NOT_IN_SUBGROUP,
121
+ R_NOT_CANONICAL,
122
+ R_IDENTITY,
123
+ R_SMALL_ORDER,
124
+ R_NOT_IN_SUBGROUP,
125
+ S_NOT_BELOW_L,
126
+ ] = ED25519_RULE_REASONS;
127
+
128
+ const ED25519_L = 2n ** 252n + 27742317777372353535851937790883648493n;
129
+
130
+ function bytesToNumberLE(bytes: Uint8Array): bigint {
131
+ let v = 0n;
132
+ for (let i = bytes.length - 1; i >= 0; i--) v = (v << 8n) | BigInt(bytes[i]);
133
+ return v;
134
+ }
135
+
136
+ // `Point.fromBytes(..., false)` is RFC 8032 decoding with its two strict
137
+ // branches: it throws on y at or above p and on x zero with the sign bit set,
138
+ // the two non-canonical encodings of a point, and on bytes that are no point.
139
+ function decodeStrict(bytes: Uint8Array): ed.Point | null {
140
+ try {
141
+ return ed.Point.fromBytes(bytes, false);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
149
+ * when it breaks none and the equation is what is left to check. Lengths are
150
+ * the caller's (32 and 64 bytes). Never throws.
151
+ */
152
+ export function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array): string | null {
153
+ const A = decodeStrict(publicKey);
154
+ if (A === null) return KEY_NOT_CANONICAL;
155
+ if (A.isSmallOrder()) return KEY_SMALL_ORDER;
156
+ if (!A.isTorsionFree()) return KEY_NOT_IN_SUBGROUP;
157
+ const R = decodeStrict(signature.subarray(0, 32));
158
+ if (R === null) return R_NOT_CANONICAL;
159
+ if (R.is0()) return R_IDENTITY;
160
+ if (R.isSmallOrder()) return R_SMALL_ORDER;
161
+ if (!R.isTorsionFree()) return R_NOT_IN_SUBGROUP;
162
+ if (bytesToNumberLE(signature.subarray(32, 64)) >= ED25519_L) return S_NOT_BELOW_L;
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
168
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
169
+ * with `": "` and the clause of the rule it broke when it broke one. A
170
+ * malformed field, a wrong length and an equation that does not hold read
171
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
172
+ */
173
+ export async function ed25519Refusal(
174
+ message: Uint8Array,
175
+ signatureHex: string,
176
+ publicKeyHex: string,
177
+ ): Promise<string | null> {
178
+ let signature: Uint8Array;
179
+ let publicKey: Uint8Array;
180
+ try {
181
+ signature = hexToBytes(signatureHex);
182
+ publicKey = hexToBytes(publicKeyHex);
183
+ } catch {
184
+ return SIGNATURE_FAILED;
185
+ }
186
+ if (signature.length !== 64 || publicKey.length !== 32) return SIGNATURE_FAILED;
187
+ const reason = ed25519RuleReason(signature, publicKey);
188
+ if (reason !== null) return `${SIGNATURE_FAILED}: ${reason}`;
189
+ try {
190
+ return (await ed.verifyAsync(signature, message, publicKey, { zip215: false })) ? null : SIGNATURE_FAILED;
191
+ } catch {
192
+ return SIGNATURE_FAILED;
193
+ }
194
+ }
195
+
196
+ // Verify an Ed25519 signature by the rule above. Inputs are hex (signature,
197
+ // public key) as they appear in the envelope; message is raw bytes. Never
198
+ // throws — a malformed signature or key is "not verified," not an exception,
199
+ // so a hostile envelope can't crash the checker.
83
200
  // Arguments in the Python twin's order, `ed25519_verify(message,
84
201
  // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
85
202
  // until 0.13.0; a caller that still passes the signature first gets a compile
@@ -89,22 +206,14 @@ export async function ed25519Verify(
89
206
  signatureHex: string,
90
207
  publicKeyHex: string,
91
208
  ): Promise<boolean> {
92
- try {
93
- // zip215:false → strict RFC-8032 verification, matching the Go agent's
94
- // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
95
- return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
96
- zip215: false,
97
- });
98
- } catch {
99
- return false;
100
- }
209
+ return (await ed25519Refusal(message, signatureHex, publicKeyHex)) === null;
101
210
  }
102
211
 
103
212
  // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
104
213
  // hex). Returns the signature as lowercase hex. The signing counterpart to
105
214
  // ed25519Verify — the ONE signing primitive the composer (`proof export`,
106
215
  // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
107
- // exporter's key ("anyone can export", P-4). The @noble sha512Async hook wired at
216
+ // exporter's key ("anyone can export", P-4). The @noble sha512Async provider wired at
108
217
  // module load serves signing and verification alike, so this stays WebCrypto-only.
109
218
  export async function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string> {
110
219
  return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));
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
  );