@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/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.
@@ -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.
@@ -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
- try {
924
- inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)),
925
- // No `?? ""` fallback. A checkpoint that claims NO root must not be
926
- // handed an invented empty one: the Python kernel refuses the same
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
- catch (e) {
945
- evErrors.push(`inclusion verify error${thrownDetail(e)}`);
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
- function isNonNegInt(n) {
1147
- return typeof n === "number" && Number.isInteger(n) && n >= 0;
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
- if (!isNonNegInt(body.leaf_count)) {
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 = body.leaf_count;
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
- if (!isNonNegInt(s.first_leaf)) {
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 (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
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: s.first_leaf,
1449
- leafCount: s.leaf_count,
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 = body.leaf_count;
1458
- if (!isNonNegInt(total))
1510
+ const total = out.leafCount;
1511
+ if (total === null)
1459
1512
  return false; // already reported by the caller
1460
- const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
1461
- if (sum !== total) {
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
- if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
1719
- evErrors.push(`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
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), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(st.merkleRoot ?? ""));
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
  }
@@ -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
- /** A file handed in beside a pack. `name` is a label and is never compared. */
83
- export interface OfferedFile {
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
- /** The text report's lines for the reading, identical in both kernels. */
105
- export declare function renderFileMatch(reading: FileMatchReading): string[];
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[];