@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/README.md +49 -4
- package/dist/agent-proof.js +50 -23
- package/dist/case-file.d.ts +24 -4
- package/dist/case-file.js +62 -5
- package/dist/case.d.ts +104 -8
- package/dist/case.js +195 -53
- package/dist/cli.d.ts +105 -2
- package/dist/cli.js +664 -123
- package/dist/compose.js +32 -0
- package/dist/crypto.d.ts +17 -0
- package/dist/crypto.js +116 -16
- package/dist/evidence.js +108 -38
- package/dist/file-match.d.ts +64 -5
- package/dist/file-match.js +113 -22
- package/dist/grading.d.ts +7 -0
- package/dist/grading.js +14 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/merkle.js +13 -7
- package/dist/verifier.js +32 -23
- package/dist/zip-reader.d.ts +7 -0
- package/dist/zip-reader.js +7 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +53 -27
- package/src/case-file.ts +61 -8
- package/src/case.ts +293 -64
- package/src/cli.ts +703 -132
- package/src/compose.ts +33 -0
- package/src/crypto.ts +125 -16
- package/src/evidence.ts +109 -43
- package/src/file-match.ts +160 -26
- package/src/grading.ts +15 -2
- package/src/index.ts +7 -0
- package/src/merkle.ts +13 -7
- package/src/verifier.ts +33 -25
- package/src/zip-reader.ts +7 -0
- package/vectors/README.md +5 -1
- package/vectors/case-file-index.json +88 -2
- package/vectors/file-match.json +135 -1
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
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
2130
|
-
leafCount:
|
|
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 =
|
|
2139
|
-
if (
|
|
2189
|
+
const total = out.leafCount;
|
|
2190
|
+
if (total === null) return false; // already reported by the caller
|
|
2140
2191
|
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
2441
|
-
|
|
2506
|
+
statedIndex,
|
|
2507
|
+
statedCount,
|
|
2442
2508
|
(incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
2443
2509
|
hexToBytes(st.merkleRoot ?? ""),
|
|
2444
2510
|
);
|