@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/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/declaration.d.ts +1 -1
- package/dist/declaration.js +182 -3
- 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 +4 -4
- package/dist/index.js +2 -2
- package/dist/merkle.js +13 -7
- package/dist/verifier.js +24 -19
- 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/declaration.ts +186 -3
- package/src/evidence.ts +109 -43
- package/src/file-match.ts +160 -26
- package/src/grading.ts +15 -2
- package/src/index.ts +6 -0
- package/src/merkle.ts +13 -7
- package/src/verifier.ts +26 -22
- 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/declaration.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
);
|