@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/dist/compose.js
CHANGED
|
@@ -51,6 +51,27 @@ function normalizeAttestation(rec) {
|
|
|
51
51
|
cloned.signature = attestationSignatureToHex(cloned.signature);
|
|
52
52
|
return cloned;
|
|
53
53
|
}
|
|
54
|
+
// True when `value` holds a number that is NaN or +-Infinity. Iterative, so a
|
|
55
|
+
// deeply nested input cannot overflow the stack before `jcs` bounds it.
|
|
56
|
+
function hasNonFiniteNumber(value) {
|
|
57
|
+
const stack = [value];
|
|
58
|
+
while (stack.length > 0) {
|
|
59
|
+
const v = stack.pop();
|
|
60
|
+
if (typeof v === "number") {
|
|
61
|
+
if (!Number.isFinite(v))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
else if (Array.isArray(v)) {
|
|
65
|
+
for (const x of v)
|
|
66
|
+
stack.push(x);
|
|
67
|
+
}
|
|
68
|
+
else if (v !== null && typeof v === "object") {
|
|
69
|
+
for (const x of Object.values(v))
|
|
70
|
+
stack.push(x);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
54
75
|
// Compose an attested evidence export (evidence-export.md §5).
|
|
55
76
|
//
|
|
56
77
|
// 1. Normalize + transcode the attestation records (boundary hex, above).
|
|
@@ -73,6 +94,17 @@ export async function composeExport(sourceBundle, attestations, exporterKey, opt
|
|
|
73
94
|
if (typeof exporterKey?.privateKey !== "string" || exporterKey.privateKey.length === 0) {
|
|
74
95
|
throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
|
|
75
96
|
}
|
|
97
|
+
// An over-long integer reads as Infinity (proof#217), which `jcs` refuses in
|
|
98
|
+
// the runtime's own words. Say it once, in the kernel's, before any
|
|
99
|
+
// verification: both kernels write this sentence, with no function-name prefix, so the CLI line is byte-identical.
|
|
100
|
+
if (hasNonFiniteNumber(sourceBundle)) {
|
|
101
|
+
throw new Error("the source bundle carries a number that is not a finite JSON number " +
|
|
102
|
+
"(an integer past the 1.8e308 range, or too long to read)");
|
|
103
|
+
}
|
|
104
|
+
if (hasNonFiniteNumber(attestations)) {
|
|
105
|
+
throw new Error("the attestations carry a number that is not a finite JSON number " +
|
|
106
|
+
"(an integer past the 1.8e308 range, or too long to read)");
|
|
107
|
+
}
|
|
76
108
|
if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
|
|
77
109
|
// The verifier reads this as `malformed generated_at` (evidence-bundle §2,
|
|
78
110
|
// A31), so composing one would sign an export this kernel rejects.
|
package/dist/declaration.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface DeclarationContentFinding {
|
|
|
60
60
|
eventId?: string;
|
|
61
61
|
}
|
|
62
62
|
/** Everything this check reports that is NOT a deviation, as a closed set. */
|
|
63
|
-
export declare const DECLARATION_NOTE_KINDS: readonly ["disclosure_did_not_travel", "pair_record_withheld", "role_step_withheld"];
|
|
63
|
+
export declare const DECLARATION_NOTE_KINDS: readonly ["disclosure_did_not_travel", "pair_record_withheld", "role_step_withheld", "other_declaration_held", "other_declaration_withheld"];
|
|
64
64
|
export type DeclarationNoteKind = (typeof DECLARATION_NOTE_KINDS)[number];
|
|
65
65
|
/**
|
|
66
66
|
* One named non-deviation finding, as data.
|
package/dist/declaration.js
CHANGED
|
@@ -130,10 +130,10 @@
|
|
|
130
130
|
// The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
|
|
131
131
|
// byte-order mark, no non-finite literal. `jcs_loads` in
|
|
132
132
|
// `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
|
|
133
|
-
import { hexToBytes } from "./crypto.js";
|
|
134
|
-
import { jcsParse } from "./verifier.js";
|
|
133
|
+
import { hexToBytes, utf8 } from "./crypto.js";
|
|
134
|
+
import { jcs, jcsParse } from "./verifier.js";
|
|
135
135
|
import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
|
|
136
|
-
import { hasSideContent, inBodyContentOffered } from "./side-content.js";
|
|
136
|
+
import { hasSideContent, inBodyContentOffered, sideContentEntry, } from "./side-content.js";
|
|
137
137
|
/** The first declaration schema, which every earlier reader knows. */
|
|
138
138
|
export const DECLARATION_VERSION = "sanning.declaration/v1";
|
|
139
139
|
/**
|
|
@@ -255,6 +255,23 @@ export const DECLARATION_NOTE_KINDS = [
|
|
|
255
255
|
* because its payload binding is undetermined.
|
|
256
256
|
*/
|
|
257
257
|
"role_step_withheld",
|
|
258
|
+
/**
|
|
259
|
+
* Rule 10 (v1.29, A36). A record whose signed `event_type` is `declaration`
|
|
260
|
+
* and whose content HANDED IN to this reader is exactly `JCS(object)` of a
|
|
261
|
+
* declaration object of a version this reader knows, carrying exactly that
|
|
262
|
+
* version's keys, and not this pack's own. The note names the content handed
|
|
263
|
+
* in, never the record: whether those bytes bind to the record's
|
|
264
|
+
* `content_hash` is the bundle verifier's check, and an accuser can hand in
|
|
265
|
+
* declaration-shaped bytes for an honest record. What the pack holds is what
|
|
266
|
+
* the declaration in its body says, so this moves no verdict.
|
|
267
|
+
*/
|
|
268
|
+
"other_declaration_held",
|
|
269
|
+
/**
|
|
270
|
+
* Rule 10. A record whose signed `event_type` is `declaration` and whose
|
|
271
|
+
* content did not reach this reader from either channel, so whether it is a
|
|
272
|
+
* declaration other than this pack's own is UNKNOWN.
|
|
273
|
+
*/
|
|
274
|
+
"other_declaration_withheld",
|
|
258
275
|
];
|
|
259
276
|
/**
|
|
260
277
|
* The record committed inside one `events[]` entry, or `null`.
|
|
@@ -500,6 +517,155 @@ function roleReading(p, role, claimed) {
|
|
|
500
517
|
}
|
|
501
518
|
return null;
|
|
502
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* The `event_type` a declaration record is signed under: a reserved
|
|
522
|
+
* single-segment name, and the one `@sanning/anchor`'s `bundle` anchors a
|
|
523
|
+
* declaration as.
|
|
524
|
+
*/
|
|
525
|
+
const DECLARATION_EVENT_TYPE = "declaration";
|
|
526
|
+
/**
|
|
527
|
+
* The keys a declaration object carries, under every version this reader
|
|
528
|
+
* knows: exactly these, no more and no fewer (5.4.1).
|
|
529
|
+
*/
|
|
530
|
+
const DECLARATION_KEYS = ["declaration_version", "case_id", "agents", "period", "records", "handoffs"];
|
|
531
|
+
function hasExactKeys(value, keys) {
|
|
532
|
+
if (!isObject(value))
|
|
533
|
+
return false;
|
|
534
|
+
const have = Object.keys(value);
|
|
535
|
+
return have.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(value, k));
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Whether `obj` is shaped like a declaration this reader knows: one of
|
|
539
|
+
* `DECLARATION_VERSIONS` compared exactly, exactly the keys that version
|
|
540
|
+
* defines, and the value types this module reads.
|
|
541
|
+
*
|
|
542
|
+
* It is the shape test `@sanning/anchor`'s `isDeclarationShape` makes, and it
|
|
543
|
+
* checks shape only, not the 5.4.1 rules `readDeclaration` grades: a stale
|
|
544
|
+
* declaration is somebody else's claim, and this reader does not grade it, it
|
|
545
|
+
* names it.
|
|
546
|
+
*/
|
|
547
|
+
function isDeclarationShape(obj) {
|
|
548
|
+
if (!isObject(obj))
|
|
549
|
+
return false;
|
|
550
|
+
const version = obj["declaration_version"];
|
|
551
|
+
if (version !== DECLARATION_VERSION && version !== DECLARATION_VERSION_V2)
|
|
552
|
+
return false;
|
|
553
|
+
if (!hasExactKeys(obj, DECLARATION_KEYS))
|
|
554
|
+
return false;
|
|
555
|
+
if (!isId(obj["case_id"]))
|
|
556
|
+
return false;
|
|
557
|
+
const agents = obj["agents"];
|
|
558
|
+
if (!Array.isArray(agents) || !agents.every((a) => typeof a === "string"))
|
|
559
|
+
return false;
|
|
560
|
+
const period = obj["period"];
|
|
561
|
+
if (!hasExactKeys(period, ["start", "end"]))
|
|
562
|
+
return false;
|
|
563
|
+
if (typeof period["start"] !== "string" || typeof period["end"] !== "string")
|
|
564
|
+
return false;
|
|
565
|
+
const recordKeys = version === DECLARATION_VERSION_V2 ? ["event_id", "disclosure", "role"] : ["event_id", "disclosure"];
|
|
566
|
+
const records = obj["records"];
|
|
567
|
+
if (!Array.isArray(records))
|
|
568
|
+
return false;
|
|
569
|
+
for (const r of records) {
|
|
570
|
+
if (!hasExactKeys(r, recordKeys) || typeof r["event_id"] !== "string")
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
const handoffs = obj["handoffs"];
|
|
574
|
+
if (!Array.isArray(handoffs))
|
|
575
|
+
return false;
|
|
576
|
+
return handoffs.every((h) => hasExactKeys(h, ["sent_event_id", "received_event_id"]));
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The content bytes disclosed for one record, or `null` where none are usable:
|
|
580
|
+
* `events[].content` first, then the side-content map, chosen the way the
|
|
581
|
+
* bundle verifier chooses the bytes it binds.
|
|
582
|
+
*/
|
|
583
|
+
function disclosedBytes(ev, p, content) {
|
|
584
|
+
const inBody = isObject(ev) ? ev["content"] : undefined;
|
|
585
|
+
if (typeof inBody === "string") {
|
|
586
|
+
try {
|
|
587
|
+
return hexToBytes(inBody);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// falls through to the side channel, as the bundle verifier does
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!p.nameable)
|
|
594
|
+
return null;
|
|
595
|
+
const side = sideContentEntry(content, p.eventId).value;
|
|
596
|
+
if (side instanceof Uint8Array)
|
|
597
|
+
return side;
|
|
598
|
+
if (typeof side === "string") {
|
|
599
|
+
try {
|
|
600
|
+
return hexToBytes(side);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
function sameBytes(a, b) {
|
|
609
|
+
if (a.length !== b.length)
|
|
610
|
+
return false;
|
|
611
|
+
for (let i = 0; i < a.length; i++)
|
|
612
|
+
if (a[i] !== b[i])
|
|
613
|
+
return false;
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Whether `raw` is exactly `JCS(object)` of a declaration-shaped object.
|
|
618
|
+
* Canonical bytes, not merely parseable ones: a declaration is anchored as
|
|
619
|
+
* `JCS(declaration)`, and nothing an agent writes by accident.
|
|
620
|
+
*/
|
|
621
|
+
function isDeclarationBytes(raw) {
|
|
622
|
+
try {
|
|
623
|
+
const obj = jcsParse(raw);
|
|
624
|
+
return isDeclarationShape(obj) && sameBytes(utf8(jcs(obj)), raw);
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Rule 10: every record that is a declaration other than the pack's own, in
|
|
632
|
+
* bundle order.
|
|
633
|
+
*
|
|
634
|
+
* The record's `event_type` is read from its signed `event_bytes`, so a record
|
|
635
|
+
* whose bytes the pack withholds is not named: the reader cannot see what it
|
|
636
|
+
* was signed as. Its content is read from either channel; a record whose
|
|
637
|
+
* content is byte for byte `JCS` of the pack's own declaration is the pack's
|
|
638
|
+
* own and is never named.
|
|
639
|
+
*/
|
|
640
|
+
function otherDeclarationNotes(bundle, pack, declaration, content) {
|
|
641
|
+
const events = pack.length > 0 ? (bodyOf(bundle)?.events ?? []) : [];
|
|
642
|
+
let own = null;
|
|
643
|
+
try {
|
|
644
|
+
own = utf8(jcs(declaration));
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
own = null;
|
|
648
|
+
}
|
|
649
|
+
const notes = [];
|
|
650
|
+
pack.forEach((p, i) => {
|
|
651
|
+
if (p.record === null || p.record.event_type !== DECLARATION_EVENT_TYPE)
|
|
652
|
+
return;
|
|
653
|
+
const raw = disclosedBytes(events[i], p, content);
|
|
654
|
+
if (raw === null) {
|
|
655
|
+
// Offered and unusable is not withheld: something arrived, it is not a
|
|
656
|
+
// declaration, and the bundle verifier names what is wrong with it.
|
|
657
|
+
// Nothing arrived is the reading the reader cannot settle.
|
|
658
|
+
if (!p.disclosed)
|
|
659
|
+
notes.push({ kind: "other_declaration_withheld", eventId: p.eventId });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (own !== null && sameBytes(raw, own))
|
|
663
|
+
return;
|
|
664
|
+
if (isDeclarationBytes(raw))
|
|
665
|
+
notes.push({ kind: "other_declaration_held", eventId: p.eventId });
|
|
666
|
+
});
|
|
667
|
+
return notes;
|
|
668
|
+
}
|
|
503
669
|
/**
|
|
504
670
|
* Check a pack against the declaration inside its body.
|
|
505
671
|
*
|
|
@@ -719,6 +885,11 @@ export function checkDeclaration(bundle, content) {
|
|
|
719
885
|
deviations.push({ kind, eventId });
|
|
720
886
|
}
|
|
721
887
|
}
|
|
888
|
+
// Rule 10 (5.4.2 v1.29, A36): content handed in for a record signed
|
|
889
|
+
// `declaration` that is a declaration other than this pack's own. Notes only, in bundle order, after every other note, and
|
|
890
|
+
// never a deviation: the pack holds what its own declaration says whatever
|
|
891
|
+
// one of its records says.
|
|
892
|
+
notes.push(...otherDeclarationNotes(bundle, pack, declaration, content));
|
|
722
893
|
// A proven deviation wins: it is a mismatch between what the pack claimed
|
|
723
894
|
// and what its own signed body carries, and a failing check always wins.
|
|
724
895
|
// With no deviation and a content-dependent finding, the pack lines up with
|
|
@@ -838,6 +1009,14 @@ export function renderNote(note) {
|
|
|
838
1009
|
case "role_step_withheld":
|
|
839
1010
|
return (`${note.eventId} is signed as a step and this pack withholds its ` +
|
|
840
1011
|
"record bytes, so whether it is one is UNKNOWN");
|
|
1012
|
+
case "other_declaration_held":
|
|
1013
|
+
return (`the content handed in for ${note.eventId} is a declaration that is ` +
|
|
1014
|
+
"not this pack's own: it says what some pack holds, and what this " +
|
|
1015
|
+
"pack holds is what the declaration in its body says");
|
|
1016
|
+
case "other_declaration_withheld":
|
|
1017
|
+
return (`${note.eventId} is signed as a declaration and no content for it ` +
|
|
1018
|
+
"reached this verifier, so whether it is a declaration other than " +
|
|
1019
|
+
"this pack's own is UNKNOWN");
|
|
841
1020
|
}
|
|
842
1021
|
}
|
|
843
1022
|
/**
|
package/dist/evidence.js
CHANGED
|
@@ -21,6 +21,7 @@ import { deriveOperatorAddress, ed25519Verify, hexToBytes,
|
|
|
21
21
|
// body: two copies of one rule is how two kernels drift apart one edit at a
|
|
22
22
|
// time, which is the whole defect class this fix is about.
|
|
23
23
|
isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
24
|
+
import { safeInteger, signedLeafCount } from "./grading.js";
|
|
24
25
|
import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
|
|
25
26
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
26
27
|
import { DuplicateKeyError, MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
|
|
@@ -683,6 +684,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
683
684
|
const cpErrors = [];
|
|
684
685
|
let envelopeOk = false;
|
|
685
686
|
let merkleRootOk = false;
|
|
687
|
+
let leafCountOk = false;
|
|
686
688
|
// Decode event_bytes first: in v2 the join key is DERIVED from it, so it
|
|
687
689
|
// is an input to identity, not just to the payload binding below.
|
|
688
690
|
let eventBytes;
|
|
@@ -770,6 +772,15 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
770
772
|
cpErrors.push(`checkpoint merkle_root mismatch: claimed=${jsonStr(cp.merkle_root)} ` +
|
|
771
773
|
`committed=${jsonStr(committedRoot)}`);
|
|
772
774
|
}
|
|
775
|
+
// §5.1, A38 (proof#197), decided by Phil 2026-10-06: fail closed. The
|
|
776
|
+
// count the record signs is what every inclusion bound to this
|
|
777
|
+
// checkpoint is held to, so a record that signs no integer count fails
|
|
778
|
+
// the checkpoint, as a record that signs no merkle_root does. Read by
|
|
779
|
+
// value through the one reader the inclusion compare and the
|
|
780
|
+
// completeness notes share, so `3.0` and `3e0` are 3.
|
|
781
|
+
leafCountOk = signedLeafCount(cp) !== null;
|
|
782
|
+
if (!leafCountOk)
|
|
783
|
+
cpErrors.push("checkpoint record leaf_count is not an integer");
|
|
773
784
|
}
|
|
774
785
|
catch (e) {
|
|
775
786
|
cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
|
|
@@ -788,7 +799,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
788
799
|
// standing rule is that malformed is a failure while absent is
|
|
789
800
|
// undetermined (the same rule disclosed `content` follows). Without
|
|
790
801
|
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
791
|
-
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
802
|
+
ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
|
|
792
803
|
errors: cpErrors,
|
|
793
804
|
});
|
|
794
805
|
if (keyed.anchorRefMalformed) {
|
|
@@ -919,30 +930,61 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
919
930
|
}
|
|
920
931
|
const checkpointBound = cp !== undefined;
|
|
921
932
|
let inclusionOk = false;
|
|
933
|
+
// §5.1, A38 (proof#197): the tree walked must be the tree the checkpoint
|
|
934
|
+
// record signed, the same rule and the same sentence as the stamp path's
|
|
935
|
+
// rule 3. RFC 9162's walk reads one audit path as valid at more than one
|
|
936
|
+
// (leaf_index, leaf_count), so without this a real proof at (2, 3) also
|
|
937
|
+
// verified as (1, 2) or (4, 5). A signed count that cannot be read is not
|
|
938
|
+
// compared here: it has already failed its checkpoint (above), so the
|
|
939
|
+
// bundle cannot verify. One reader of the signed count, shared with the
|
|
940
|
+
// completeness notes, so the verdict and the grade never read two
|
|
941
|
+
// different numbers.
|
|
942
|
+
//
|
|
943
|
+
// Every number here is read BY VALUE (`Number.isSafeInteger`), the model
|
|
944
|
+
// JCS signs, and the Python kernel reads the same way (`_safe_integer`):
|
|
945
|
+
// JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
|
|
946
|
+
// same wrapper signature. A value that is not a safe integer is malformed,
|
|
947
|
+
// with one sentence that prints no value, because the two kernels cannot
|
|
948
|
+
// print `2.5`, `1e400` or `9007199254740993` the same way.
|
|
949
|
+
const signedCount = cp ? signedLeafCount(cp) : null;
|
|
922
950
|
if (incl && checkpointBound && cp) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
// value and reports the decode failure, so substituting "" here had
|
|
928
|
-
// the two kernels say different things about one bundle (TS: "the
|
|
929
|
-
// proof did not reconstruct the checkpoint root", which is not what
|
|
930
|
-
// happened: there was no claimed root to reconstruct).
|
|
931
|
-
hexToBytes(cp.merkle_root));
|
|
932
|
-
if (!inclusionOk)
|
|
933
|
-
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
934
|
-
// Defense in depth: the claimed leaf_hash MUST be the hash of the
|
|
935
|
-
// event's own signed envelope bytes (profile §6) — otherwise a valid
|
|
936
|
-
// inclusion proof for an UNRELATED leaf could be smuggled in.
|
|
937
|
-
const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
|
|
938
|
-
const expectedLeafHex = toHex(expectedLeaf);
|
|
939
|
-
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
940
|
-
inclusionOk = false;
|
|
941
|
-
evErrors.push(`inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`);
|
|
942
|
-
}
|
|
951
|
+
const statedCount = safeInteger(incl.leaf_count);
|
|
952
|
+
const statedIndex = safeInteger(incl.leaf_index);
|
|
953
|
+
if (statedCount === null) {
|
|
954
|
+
evErrors.push("inclusion leaf_count is not an integer");
|
|
943
955
|
}
|
|
944
|
-
|
|
945
|
-
evErrors.push(
|
|
956
|
+
else if (statedIndex === null) {
|
|
957
|
+
evErrors.push("inclusion leaf_index is not an integer");
|
|
958
|
+
}
|
|
959
|
+
else if (signedCount !== null && statedCount !== signedCount) {
|
|
960
|
+
evErrors.push(`inclusion leaf_count ${statedCount} does not equal ` +
|
|
961
|
+
`the checkpoint's committed leaf_count ${signedCount}`);
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
try {
|
|
965
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), statedIndex, statedCount, (incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
966
|
+
// No `?? ""` fallback. A checkpoint that claims NO root must not be
|
|
967
|
+
// handed an invented empty one: the Python kernel refuses the same
|
|
968
|
+
// value and reports the decode failure, so substituting "" here had
|
|
969
|
+
// the two kernels say different things about one bundle (TS: "the
|
|
970
|
+
// proof did not reconstruct the checkpoint root", which is not what
|
|
971
|
+
// happened: there was no claimed root to reconstruct).
|
|
972
|
+
hexToBytes(cp.merkle_root));
|
|
973
|
+
if (!inclusionOk)
|
|
974
|
+
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
975
|
+
// Defense in depth: the claimed leaf_hash MUST be the hash of the
|
|
976
|
+
// event's own signed envelope bytes (profile §6) — otherwise a valid
|
|
977
|
+
// inclusion proof for an UNRELATED leaf could be smuggled in.
|
|
978
|
+
const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
|
|
979
|
+
const expectedLeafHex = toHex(expectedLeaf);
|
|
980
|
+
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
981
|
+
inclusionOk = false;
|
|
982
|
+
evErrors.push(`inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
catch (e) {
|
|
986
|
+
evErrors.push(`inclusion verify error${thrownDetail(e)}`);
|
|
987
|
+
}
|
|
946
988
|
}
|
|
947
989
|
}
|
|
948
990
|
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
@@ -1143,8 +1185,16 @@ function isLowerHex(s, nibbles) {
|
|
|
1143
1185
|
return false;
|
|
1144
1186
|
return /^[0-9a-f]+$/.test(s);
|
|
1145
1187
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1188
|
+
/**
|
|
1189
|
+
* A JSON number read BY VALUE as a non-negative safe integer, or null.
|
|
1190
|
+
* `safeInteger`, the reader every leaf count and leaf index in this kernel
|
|
1191
|
+
* goes through (A38), mirrored by `_non_neg_integer` in
|
|
1192
|
+
* `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
|
|
1193
|
+
* JCS's own spelling of a count past 2**53 - 1, which Python refused.
|
|
1194
|
+
*/
|
|
1195
|
+
function nonNegInteger(n) {
|
|
1196
|
+
const v = safeInteger(n);
|
|
1197
|
+
return v !== null && v >= 0 ? v : null;
|
|
1148
1198
|
}
|
|
1149
1199
|
/**
|
|
1150
1200
|
* Parse and check one stamp's witnessed bytes.
|
|
@@ -1382,12 +1432,13 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1382
1432
|
else {
|
|
1383
1433
|
base.merkleRoot = body.merkle_root;
|
|
1384
1434
|
}
|
|
1385
|
-
|
|
1435
|
+
const stampCount = nonNegInteger(body.leaf_count);
|
|
1436
|
+
if (stampCount === null) {
|
|
1386
1437
|
errors.push("stamp body leaf_count is not a non-negative integer");
|
|
1387
1438
|
base.bodyOk = false;
|
|
1388
1439
|
}
|
|
1389
1440
|
else {
|
|
1390
|
-
base.leafCount =
|
|
1441
|
+
base.leafCount = stampCount;
|
|
1391
1442
|
}
|
|
1392
1443
|
// --- the sub-tree ranges --------------------------------------------------
|
|
1393
1444
|
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
@@ -1426,14 +1477,16 @@ function checkSubtrees(body, out, errors) {
|
|
|
1426
1477
|
continue;
|
|
1427
1478
|
}
|
|
1428
1479
|
const s = v;
|
|
1429
|
-
|
|
1480
|
+
const firstLeaf = nonNegInteger(s.first_leaf);
|
|
1481
|
+
const subCount = nonNegInteger(s.leaf_count);
|
|
1482
|
+
if (firstLeaf === null) {
|
|
1430
1483
|
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
1431
1484
|
shapeOk = false;
|
|
1432
1485
|
}
|
|
1433
1486
|
// A zero-leaf sub-tree is not a slice of anything, and admitting one lets
|
|
1434
1487
|
// an arbitrary number of empty ranges sit inside a tiling that still adds
|
|
1435
1488
|
// up.
|
|
1436
|
-
if (
|
|
1489
|
+
if (subCount === null || subCount === 0) {
|
|
1437
1490
|
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
1438
1491
|
shapeOk = false;
|
|
1439
1492
|
}
|
|
@@ -1445,8 +1498,8 @@ function checkSubtrees(body, out, errors) {
|
|
|
1445
1498
|
continue;
|
|
1446
1499
|
subtrees.push({
|
|
1447
1500
|
ref,
|
|
1448
|
-
firstLeaf:
|
|
1449
|
-
leafCount:
|
|
1501
|
+
firstLeaf: firstLeaf,
|
|
1502
|
+
leafCount: subCount,
|
|
1450
1503
|
root: s.root,
|
|
1451
1504
|
});
|
|
1452
1505
|
}
|
|
@@ -1454,11 +1507,14 @@ function checkSubtrees(body, out, errors) {
|
|
|
1454
1507
|
out.subtrees = subtrees;
|
|
1455
1508
|
if (!shapeOk)
|
|
1456
1509
|
return false;
|
|
1457
|
-
const total =
|
|
1458
|
-
if (
|
|
1510
|
+
const total = out.leafCount;
|
|
1511
|
+
if (total === null)
|
|
1459
1512
|
return false; // already reported by the caller
|
|
1460
|
-
|
|
1461
|
-
|
|
1513
|
+
// Summed exactly. Each count is a safe integer but their sum need not be,
|
|
1514
|
+
// and a float sum past 2**53 prints a different number than Python's
|
|
1515
|
+
// unbounded one in the sentence below.
|
|
1516
|
+
const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
|
|
1517
|
+
if (sum !== BigInt(total)) {
|
|
1462
1518
|
errors.push(`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`);
|
|
1463
1519
|
return false;
|
|
1464
1520
|
}
|
|
@@ -1715,13 +1771,27 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1715
1771
|
// Rule 3: the tree walked must be the tree the stamp committed to. A
|
|
1716
1772
|
// proof walked against a different leaf_count is a proof about a
|
|
1717
1773
|
// different tree, and RFC 9162's path shape depends on the size.
|
|
1718
|
-
|
|
1719
|
-
|
|
1774
|
+
//
|
|
1775
|
+
// Read BY VALUE, as the anchor-trace path reads them (A38), and as the
|
|
1776
|
+
// Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
|
|
1777
|
+
// which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
|
|
1778
|
+
// signature. Anything that is not a safe integer is malformed, with a
|
|
1779
|
+
// sentence that prints no value.
|
|
1780
|
+
const statedCount = safeInteger(incl.leaf_count);
|
|
1781
|
+
const statedIndex = safeInteger(incl.leaf_index);
|
|
1782
|
+
if (statedCount === null) {
|
|
1783
|
+
evErrors.push("inclusion leaf_count is not an integer");
|
|
1784
|
+
}
|
|
1785
|
+
else if (statedIndex === null) {
|
|
1786
|
+
evErrors.push("inclusion leaf_index is not an integer");
|
|
1787
|
+
}
|
|
1788
|
+
else if (st.leafCount !== null && statedCount !== st.leafCount) {
|
|
1789
|
+
evErrors.push(`inclusion leaf_count ${statedCount} does not equal the stamp's committed ` +
|
|
1720
1790
|
`leaf_count ${st.leafCount}`);
|
|
1721
1791
|
}
|
|
1722
1792
|
else {
|
|
1723
1793
|
try {
|
|
1724
|
-
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash),
|
|
1794
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), statedIndex, statedCount, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(st.merkleRoot ?? ""));
|
|
1725
1795
|
if (!inclusionOk) {
|
|
1726
1796
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
1727
1797
|
}
|
package/dist/file-match.d.ts
CHANGED
|
@@ -47,6 +47,13 @@ export declare const FILE_MATCH_SCOPE: string;
|
|
|
47
47
|
/** Why a record's commitments could not be read, in the order they are tested. */
|
|
48
48
|
export declare const NOT_READ_REASONS: readonly ["no_records", "record_unreadable", "record_not_verified", "record_withheld", "no_content_hash", "handoff_record_malformed", "message_withheld"];
|
|
49
49
|
export type NotReadReason = (typeof NOT_READ_REASONS)[number];
|
|
50
|
+
/**
|
|
51
|
+
* Why a file was named in the report and never hashed (5.7.5). A skipped file
|
|
52
|
+
* is listed, never dropped: every file a command found is matched, unmatched or
|
|
53
|
+
* skipped, so "no match" is never read as "no file was there".
|
|
54
|
+
*/
|
|
55
|
+
export declare const SKIPPED_REASONS: readonly ["symbolic_link", "not_regular", "unreadable", "too_large", "refused"];
|
|
56
|
+
export type SkippedReason = (typeof SKIPPED_REASONS)[number];
|
|
50
57
|
/** One commitment a signed record makes to a file's bytes. */
|
|
51
58
|
export interface FileCommitment {
|
|
52
59
|
packRef: string | null;
|
|
@@ -56,6 +63,14 @@ export interface FileCommitment {
|
|
|
56
63
|
contentHash: string;
|
|
57
64
|
signer: string;
|
|
58
65
|
signedAt: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* The pack, by its position in the packs `matchCaseFiles` was handed (the
|
|
68
|
+
* same position the case reading's `packIndex` names), or `null` from
|
|
69
|
+
* `matchFiles`, which reads one pack (A43).
|
|
70
|
+
*/
|
|
71
|
+
packIndex: number | null;
|
|
72
|
+
/** The record, by its position in the events the pack presents (A43). */
|
|
73
|
+
recordIndex: number;
|
|
59
74
|
}
|
|
60
75
|
export interface MatchedFile {
|
|
61
76
|
name: string;
|
|
@@ -66,10 +81,24 @@ export interface UnmatchedFile {
|
|
|
66
81
|
name: string;
|
|
67
82
|
sha256: string;
|
|
68
83
|
}
|
|
84
|
+
/** A file a command found and did not hash, named with the reason. */
|
|
85
|
+
export interface SkippedFile {
|
|
86
|
+
name: string;
|
|
87
|
+
reason: SkippedReason;
|
|
88
|
+
/** The reader's own words, for a `.zip` entry it refused (`refused`). */
|
|
89
|
+
detail?: string;
|
|
90
|
+
}
|
|
69
91
|
export interface NotRead {
|
|
70
92
|
packRef: string | null;
|
|
71
93
|
eventId: string | null;
|
|
72
94
|
reason: NotReadReason;
|
|
95
|
+
/** As on `FileCommitment` (A43). */
|
|
96
|
+
packIndex: number | null;
|
|
97
|
+
/**
|
|
98
|
+
* As on `FileCommitment`, or `null` where the pack presents no events at all
|
|
99
|
+
* (`no_records`).
|
|
100
|
+
*/
|
|
101
|
+
recordIndex: number | null;
|
|
73
102
|
}
|
|
74
103
|
export interface FileMatchReading {
|
|
75
104
|
offered: number;
|
|
@@ -78,12 +107,27 @@ export interface FileMatchReading {
|
|
|
78
107
|
notOffered: FileCommitment[];
|
|
79
108
|
notRead: NotRead[];
|
|
80
109
|
scope: string;
|
|
110
|
+
/**
|
|
111
|
+
* Files found and not hashed. The matching functions leave it empty: it is
|
|
112
|
+
* the caller's, because only a caller that reads a folder knows a link.
|
|
113
|
+
*/
|
|
114
|
+
skipped?: SkippedFile[];
|
|
81
115
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
116
|
+
/**
|
|
117
|
+
* A file handed in beside a pack. `name` is a label and is never compared. The
|
|
118
|
+
* file is its `bytes`, or its `sha256` (64 lowercase hex characters) where the
|
|
119
|
+
* caller has already hashed it: a command streams a file of any size rather
|
|
120
|
+
* than hold it.
|
|
121
|
+
*/
|
|
122
|
+
export type OfferedFile = {
|
|
84
123
|
name: string;
|
|
85
124
|
bytes: Uint8Array;
|
|
86
|
-
|
|
125
|
+
sha256?: undefined;
|
|
126
|
+
} | {
|
|
127
|
+
name: string;
|
|
128
|
+
sha256: string;
|
|
129
|
+
bytes?: undefined;
|
|
130
|
+
};
|
|
87
131
|
/** One pack of a case, for `matchCaseFiles`. */
|
|
88
132
|
export interface FileMatchPack {
|
|
89
133
|
ref: string;
|
|
@@ -101,5 +145,20 @@ export declare function matchFiles(bundle: unknown, files: readonly OfferedFile[
|
|
|
101
145
|
export declare function matchCaseFiles(packs: readonly FileMatchPack[], files: readonly OfferedFile[]): Promise<FileMatchReading>;
|
|
102
146
|
/** The reading as the `files` member of `--json`, identical in both kernels. */
|
|
103
147
|
export declare function fileMatchToJson(reading: FileMatchReading): Record<string, unknown>;
|
|
104
|
-
/**
|
|
105
|
-
|
|
148
|
+
/**
|
|
149
|
+
* The commitments no offered file matched and no disclosed log answers.
|
|
150
|
+
*
|
|
151
|
+
* `contents` is each pack's side content by `pack_ref`. A record whose own pack
|
|
152
|
+
* discloses bytes that hash to its `content_hash` is accounted for by the pack,
|
|
153
|
+
* and is not returned. Only the text report's count reads this; the reading, and
|
|
154
|
+
* `--json`, still list every commitment no file matched.
|
|
155
|
+
*/
|
|
156
|
+
export declare function notAccountedFor(reading: FileMatchReading, contents: ReadonlyMap<string, SideContentMap | null | undefined>): Promise<FileCommitment[]>;
|
|
157
|
+
/**
|
|
158
|
+
* The text report's lines for the reading, identical in both kernels.
|
|
159
|
+
*
|
|
160
|
+
* By default every commitment no file matched is a line. `notAmong` is the case
|
|
161
|
+
* command's report when no `--files` was given: one count line for the recorded
|
|
162
|
+
* files no loose file matched, and no line for each (A41).
|
|
163
|
+
*/
|
|
164
|
+
export declare function renderFileMatch(reading: FileMatchReading, notAmong?: number): string[];
|