attenu-guard 0.12.0 → 0.13.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +27 -0
  3. package/dist/cjs/audit.d.ts.map +1 -1
  4. package/dist/cjs/audit.js +45 -11
  5. package/dist/cjs/audit.js.map +1 -1
  6. package/dist/cjs/authority.d.ts.map +1 -1
  7. package/dist/cjs/authority.js +15 -9
  8. package/dist/cjs/authority.js.map +1 -1
  9. package/dist/cjs/canonical.d.ts +24 -0
  10. package/dist/cjs/canonical.d.ts.map +1 -1
  11. package/dist/cjs/canonical.js +49 -0
  12. package/dist/cjs/canonical.js.map +1 -1
  13. package/dist/cjs/ceilings.d.ts +17 -0
  14. package/dist/cjs/ceilings.d.ts.map +1 -1
  15. package/dist/cjs/ceilings.js +48 -0
  16. package/dist/cjs/ceilings.js.map +1 -1
  17. package/dist/cjs/cli.d.ts +12 -3
  18. package/dist/cjs/cli.d.ts.map +1 -1
  19. package/dist/cjs/cli.js +272 -20
  20. package/dist/cjs/cli.js.map +1 -1
  21. package/dist/cjs/display.d.ts +76 -0
  22. package/dist/cjs/display.d.ts.map +1 -0
  23. package/dist/cjs/display.js +238 -0
  24. package/dist/cjs/display.js.map +1 -0
  25. package/dist/cjs/evidence.d.ts +74 -5
  26. package/dist/cjs/evidence.d.ts.map +1 -1
  27. package/dist/cjs/evidence.js +655 -243
  28. package/dist/cjs/evidence.js.map +1 -1
  29. package/dist/cjs/version.d.ts +1 -1
  30. package/dist/cjs/version.js +1 -1
  31. package/dist/esm/audit.d.ts.map +1 -1
  32. package/dist/esm/audit.js +46 -12
  33. package/dist/esm/audit.js.map +1 -1
  34. package/dist/esm/authority.d.ts.map +1 -1
  35. package/dist/esm/authority.js +15 -9
  36. package/dist/esm/authority.js.map +1 -1
  37. package/dist/esm/canonical.d.ts +24 -0
  38. package/dist/esm/canonical.d.ts.map +1 -1
  39. package/dist/esm/canonical.js +46 -0
  40. package/dist/esm/canonical.js.map +1 -1
  41. package/dist/esm/ceilings.d.ts +17 -0
  42. package/dist/esm/ceilings.d.ts.map +1 -1
  43. package/dist/esm/ceilings.js +46 -0
  44. package/dist/esm/ceilings.js.map +1 -1
  45. package/dist/esm/cli.d.ts +12 -3
  46. package/dist/esm/cli.d.ts.map +1 -1
  47. package/dist/esm/cli.js +273 -21
  48. package/dist/esm/cli.js.map +1 -1
  49. package/dist/esm/display.d.ts +76 -0
  50. package/dist/esm/display.d.ts.map +1 -0
  51. package/dist/esm/display.js +226 -0
  52. package/dist/esm/display.js.map +1 -0
  53. package/dist/esm/evidence.d.ts +74 -5
  54. package/dist/esm/evidence.d.ts.map +1 -1
  55. package/dist/esm/evidence.js +638 -229
  56. package/dist/esm/evidence.js.map +1 -1
  57. package/dist/esm/version.d.ts +1 -1
  58. package/dist/esm/version.js +1 -1
  59. package/package.json +1 -1
@@ -30,11 +30,12 @@
30
30
  * point. This is byte-compatible with the Python library's
31
31
  * `attenu_guard.evidence`.
32
32
  */
33
- import { canonicalBytes, compareCodePoints, parseJson, pyNumber, RawNumber, sortedStrings, toPlain, } from "./canonical.js";
33
+ import { canonicalBytes, compareCodePoints, intOr, integral, parseJson, pyNumber, RawNumber, sortedStrings, toPlain, } from "./canonical.js";
34
34
  import { createHash } from "node:crypto";
35
35
  import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, GENESIS } from "./audit.js";
36
36
  import { Authority } from "./authority.js";
37
- import { describe as describeCeiling } from "./ceilings.js";
37
+ import { describeInFinding, wireKeyOf } from "./ceilings.js";
38
+ import { pyRepr, pyStr, pyStrRepr, shown } from "./display.js";
38
39
  import { CAPTURES, BODY_STATES, POLICIES, BodyState, Capture } from "./reasons.js";
39
40
  import { PARAMS_HASH_REASONS } from "./params.js";
40
41
  import { Ed25519Signer, Ed25519Verifier } from "./wire.js";
@@ -267,23 +268,41 @@ function orNull(value) {
267
268
  class FailureLog {
268
269
  messages = [];
269
270
  details = [];
271
+ /**
272
+ * A third list in step with those two: the ledger entry each failure is about, or `null` for a
273
+ * failure about no single entry. It stays out of `details`, whose member set is the published
274
+ * contract, and `entryIndices` turns it into positions in the bundle's `entries`, which
275
+ * `verifyBundle` reports as `failure_entries`. A position is exact where a seq is not: an
276
+ * entry's seq can be missing, null, a boolean or a duplicate, and its index is still its own.
277
+ */
278
+ about = [];
270
279
  add(reason, detail, position = {}) {
271
- const { seq = null, node = null, callId = null } = position;
280
+ const { seq = null, node = null, callId = null, entry = null } = position;
272
281
  this.messages.push(detail);
273
282
  this.details.push({ reason, seq, node, call_id: callId, detail });
283
+ this.about.push(entry);
274
284
  }
275
285
  extend(other) {
276
286
  this.messages.push(...other.messages);
277
287
  this.details.push(...other.details);
288
+ this.about.push(...other.about);
289
+ }
290
+ /**
291
+ * For each failure, the index in `entries` of the entry it is about, or `null`. Found by
292
+ * identity: every entry a check reports on is one of these objects.
293
+ */
294
+ entryIndices(entries) {
295
+ const at = new Map();
296
+ entries.forEach((e, i) => {
297
+ if (!at.has(e))
298
+ at.set(e, i);
299
+ });
300
+ return this.about.map((e) => (e === null ? null : (at.get(e) ?? null)));
278
301
  }
279
302
  get length() {
280
303
  return this.messages.length;
281
304
  }
282
305
  }
283
- /**
284
- * `node -> Authority` and `node -> parent`, reconstructed from `root` and
285
- * `spawn` events alone. No engine state.
286
- */
287
306
  /**
288
307
  * Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
289
308
  *
@@ -307,7 +326,7 @@ function monotonicityDetail(child, parent) {
307
326
  // does not cover is also absent from that set.
308
327
  if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
309
328
  const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
310
- return (`child scopes [${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] ` +
329
+ return (`child scopes ${reprList(extra.sort(compareCodePoints))} ` +
311
330
  `not held by parent`);
312
331
  }
313
332
  const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
@@ -316,11 +335,11 @@ function monotonicityDetail(child, parent) {
316
335
  const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
317
336
  const childCeiling = childByKey.get(key);
318
337
  if (childCeiling === undefined) {
319
- return `ceiling ${key} unbounded, parent holds ${describeCeiling(parentCeiling)}`;
338
+ return `ceiling ${shown(wireKeyOf(parentCeiling))} unbounded, parent holds ${describeInFinding(parentCeiling)}`;
320
339
  }
321
340
  if (!parentCeiling.subsumes(childCeiling)) {
322
- return (`ceiling ${describeCeiling(childCeiling)} looser than parent ` +
323
- `${describeCeiling(parentCeiling)}`);
341
+ return (`ceiling ${describeInFinding(childCeiling)} looser than parent ` +
342
+ `${describeInFinding(parentCeiling)}`);
324
343
  }
325
344
  }
326
345
  if (parent.ttl !== null) {
@@ -334,6 +353,17 @@ function monotonicityDetail(child, parent) {
334
353
  // it exists so that such a dimension cannot fail SILENTLY.
335
354
  return "child not narrower than parent";
336
355
  }
356
+ /**
357
+ * `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
358
+ * order. No engine state.
359
+ *
360
+ * A node id is a string, and a node is defined once, by the root or by one spawn. A root or a
361
+ * spawn whose `node` is not a string defines nothing and is reported unreadable here. A second
362
+ * definition of a node is left out of these maps, so every later check reads the first one, and
363
+ * `verifyBundle`'s monotonicity check reports it. `parent` maps a spawned node to its `parent`
364
+ * member as written, whatever its type; whether that names a node defined earlier is judged there
365
+ * too. The Python implementation's `_node_authorities`.
366
+ */
337
367
  function nodeAuthorities(entries) {
338
368
  const auth = new Map();
339
369
  const parent = new Map();
@@ -341,37 +371,58 @@ function nodeAuthorities(entries) {
341
371
  const definedBy = new Map();
342
372
  for (const e of entries) {
343
373
  const ev = toPlain(e["event"]);
374
+ if (ev !== "root" && ev !== "spawn")
375
+ continue;
344
376
  const node = toPlain(e["node"]);
377
+ const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
378
+ if (typeof node !== "string") {
379
+ // One of the two historical messages that name a node before their colon rather than a
380
+ // reason token, so the reason is stated here instead of parsed out of the string.
381
+ if (ev === "root") {
382
+ failures.add("unreadable_authority", `root ${shown(e["node"])}: unreadable authority (node is not a string)`, position);
383
+ }
384
+ else {
385
+ failures.add("unreadable_granted", `spawn ${shown(e["node"])}: unreadable granted (node is not a string)`, position);
386
+ }
387
+ continue;
388
+ }
389
+ if (definedBy.has(node))
390
+ continue; // defined twice: verifyBundle reports it
391
+ definedBy.set(node, e);
345
392
  if (ev === "root") {
346
- definedBy.set(node, e);
347
393
  try {
348
394
  auth.set(node, Authority.fromWire(e["authority"] ?? null));
349
395
  }
350
396
  catch (exc) {
351
- // One of the two historical messages that name a node before their colon rather than a
352
- // reason token, so the reason is stated here instead of parsed out of the string.
353
- failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
354
- seq: orNull(e["seq"]),
355
- node: orNull(e["node"]),
356
- });
397
+ failures.add("unreadable_authority", `root ${shown(e["node"])}: unreadable authority (${exc.message})`, position);
357
398
  }
358
399
  }
359
- else if (ev === "spawn") {
360
- definedBy.set(node, e);
361
- parent.set(node, toPlain(e["parent"]) ?? null);
400
+ else {
401
+ parent.set(node, orNull(e["parent"]));
362
402
  try {
363
403
  auth.set(node, Authority.fromWire(e["granted"] ?? null));
364
404
  }
365
405
  catch (exc) {
366
- failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
367
- seq: orNull(e["seq"]),
368
- node: orNull(e["node"]),
369
- });
406
+ failures.add("unreadable_granted", `spawn ${shown(e["node"])}: unreadable granted (${exc.message})`, position);
370
407
  }
371
408
  }
372
409
  }
373
410
  return { auth, parent, failures, definedBy };
374
411
  }
412
+ /**
413
+ * Record the nodes a `kill` entry revokes, node -> that kill's own `seq`; the first kill stands.
414
+ * Only string ids in a list count: anything else names no node. The Python implementation's
415
+ * `_note_revoked`.
416
+ */
417
+ function noteRevoked(entry, revokedAt) {
418
+ const revoked = entry["revoked"];
419
+ if (!Array.isArray(revoked))
420
+ return;
421
+ for (const node of revoked) {
422
+ if (typeof node === "string" && !revokedAt.has(node))
423
+ revokedAt.set(node, entry["seq"]);
424
+ }
425
+ }
375
426
  /**
376
427
  * A view of the chain from the bundle: each node with its agent, task,
377
428
  * authority, parent and per-node action counts — what a reviewer or a UI
@@ -383,10 +434,15 @@ export function delegationGraph(bundle) {
383
434
  const meta = {};
384
435
  for (const e of entries) {
385
436
  const ev = toPlain(e["event"]);
386
- const n = toPlain(e["node"]);
437
+ const raw = toPlain(e["node"]);
438
+ if (typeof raw !== "string" && ev !== "kill")
439
+ continue; // a node id is a string; anything else names no node
440
+ const n = raw;
387
441
  if (ev === "root" || ev === "spawn") {
442
+ if (Object.hasOwn(meta, n))
443
+ continue; // defined once: the first definition stands
388
444
  const a = auth.get(n);
389
- meta[n] = {
445
+ setOwn(meta, n, {
390
446
  agent: orNull(e["agent"]),
391
447
  task: orNull(e["task"]),
392
448
  parent: orNull(e["parent"]),
@@ -396,30 +452,32 @@ export function delegationGraph(bundle) {
396
452
  revoked: false,
397
453
  complete: false,
398
454
  denials_by_disposition: {},
399
- };
455
+ });
400
456
  }
401
- else if (ev === "allow" && meta[n]) {
457
+ else if (ev === "allow" && Object.hasOwn(meta, n)) {
402
458
  meta[n].allows += 1;
403
459
  }
404
- else if (ev === "deny" && meta[n]) {
460
+ else if (ev === "deny" && Object.hasOwn(meta, n)) {
405
461
  meta[n].denies += 1;
406
462
  // A deny without a disposition is named by its reason.
407
463
  const d = (toPlain(e["disposition"]) ?? toPlain(e["reason"]) ?? "unstated");
408
464
  meta[n].denials_by_disposition[d] = (meta[n].denials_by_disposition[d] ?? 0) + 1;
409
465
  }
410
- else if (ev === "done" && meta[n]) {
466
+ else if (ev === "done" && Object.hasOwn(meta, n)) {
411
467
  meta[n].complete = true;
412
468
  }
413
469
  else if (ev === "kill") {
414
- for (const r of toPlain(e["revoked"]) ?? []) {
415
- if (meta[r])
470
+ const revoked = new Map();
471
+ noteRevoked(e, revoked);
472
+ for (const r of revoked.keys()) {
473
+ if (Object.hasOwn(meta, r))
416
474
  meta[r].revoked = true;
417
475
  }
418
476
  }
419
477
  }
420
478
  const edges = [];
421
479
  for (const [child, p] of parent) {
422
- if (p)
480
+ if (typeof p === "string" && p !== "")
423
481
  edges.push({ parent: p, child });
424
482
  }
425
483
  return { chain_id: orNull(bundle.chain_id), nodes: meta, edges };
@@ -487,37 +545,9 @@ export function denials(bundle) {
487
545
  }
488
546
  return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
489
547
  }
490
- /** Python-style repr for the small set of value types these failure messages carry. */
491
- function pyRepr(value) {
492
- if (value === null)
493
- return "None";
494
- if (typeof value === "boolean")
495
- return value ? "True" : "False";
496
- if (typeof value === "number")
497
- return String(value);
498
- if (typeof value === "string")
499
- return `'${value}'`;
500
- // Containers reach here only on hostile input — a subject member that is a list or an object
501
- // where the contract wants a scalar. Rendered the way Python's repr renders them, because the
502
- // two implementations report the same failure strings and a JSON spelling would not match.
503
- if (Array.isArray(value))
504
- return `[${value.map((v) => pyRepr(v)).join(", ")}]`;
505
- if (typeof value === "object") {
506
- const body = Object.entries(value)
507
- .map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`)
508
- .join(", ");
509
- return `{${body}}`;
510
- }
511
- return JSON.stringify(value);
512
- }
513
- /**
514
- * Python's `str()` where a failure string interpolates a value bare (`{x}`) rather than through
515
- * `repr` (`{x!r}`) — the same rendering for every type except a string, which `str` leaves
516
- * unquoted. Both implementations report the same failure bytes, so the distinction is load-bearing.
517
- */
518
- function pyStr(value) {
519
- return typeof value === "string" ? value : pyRepr(value);
520
- }
548
+ // `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
549
+ // messages carry. Both implementations report the same failure bytes, so the distinction between
550
+ // `{x}` and `{x!r}` is load-bearing, and `pyRepr` escapes a string exactly as `repr` does.
521
551
  // =============================================================================================
522
552
  // Observer envelopes (envelope v1) — the TypeScript half of `attenu_guard.evidence`'s.
523
553
  //
@@ -572,7 +602,9 @@ export const ENVELOPE_ALG = "EdDSA";
572
602
  /**
573
603
  * A verifying envelope's state. It says where the signature came from and NOTHING about
574
604
  * authority — the witness is whoever holds the key `witness.kid` names, which nothing in the
575
- * envelope makes the delegation parent.
605
+ * envelope makes the delegation parent. The signature covers the entry's hash and chain position
606
+ * plus what the witness-key holder observed; it does not attest that the action was permitted.
607
+ * Monotonicity and containment answer that, from the ledger.
576
608
  */
577
609
  export const WITNESS_SIGNED = "witness-signed";
578
610
  /**
@@ -605,15 +637,25 @@ export function envelopeSigningInput(envelope) {
605
637
  return canonicalBytes(body);
606
638
  }
607
639
  /**
608
- * seq -> the entry's hash RECOMPUTED from the bundle, never read off the entry.
640
+ * Where `states` and `results` file an entry: its seq when it has one, as the integer when it is
641
+ * integral (`intOr`: `1.0` is filed as 1), and its index when it has none. The Python
642
+ * implementation's `_state_key`, rendered as a record key. It is a report key, never a lookup: an
643
+ * envelope finds the entry it covers through `subjectIndex`.
644
+ */
645
+ export function stateKey(e, index) {
646
+ return String("seq" in e ? orNull(intOr(e["seq"])) : index);
647
+ }
648
+ /**
649
+ * Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
609
650
  *
610
651
  * `entry_hash` in a subject is checked against this. The walk mirrors `AuditLog.verify`, so an
611
652
  * entry whose stored `hash` was replaced does not get to supply the value it is compared against.
653
+ * By index, not by seq: an entry's seq can be missing, another entry's, or not an integer.
612
654
  */
613
655
  function recomputedHashes(entries) {
614
- const out = new Map();
656
+ const out = [];
615
657
  let prev = GENESIS;
616
- entries.forEach((e, i) => {
658
+ for (const e of entries) {
617
659
  const payload = {};
618
660
  for (const [k, v] of Object.entries(e))
619
661
  if (k !== "hash")
@@ -626,20 +668,134 @@ function recomputedHashes(entries) {
626
668
  // An unhashable payload has no recomputable hash; that IS the break, at this entry.
627
669
  computed = null;
628
670
  }
629
- out.set(orNull(e["seq"]) ?? i, computed);
671
+ out.push(computed);
630
672
  prev = computed ?? GENESIS;
673
+ }
674
+ return out;
675
+ }
676
+ /**
677
+ * subject.seq -> the index of the entry an envelope naming that seq covers.
678
+ *
679
+ * An entry is keyed by its own seq when that is an integral number and not a boolean, as the
680
+ * schema's integer type defines (`integral`: `1.0` is 1), and by its index when it has no seq
681
+ * member at all. An entry whose seq is a boolean, a string, null, or a fractional number is keyed
682
+ * by nothing, so no envelope covers it: in Python, keyed by the raw value, `"seq": true` took the
683
+ * envelope written for seq 1, and here a null seq was keyed by its index. Where two entries share
684
+ * a key the later one is covered, as it always was. The Python implementation's `_subject_index`.
685
+ */
686
+ function subjectIndex(entries) {
687
+ const keyed = new Map();
688
+ entries.forEach((e, i) => {
689
+ if (!("seq" in e)) {
690
+ keyed.set(i, i);
691
+ return;
692
+ }
693
+ const seq = integral(e["seq"]);
694
+ if (seq !== null)
695
+ keyed.set(seq, i);
631
696
  });
697
+ return keyed;
698
+ }
699
+ /**
700
+ * Two `seq` or `v` values are the same: equal integers when either is integral (`integral`), so
701
+ * `1.0` is 1; otherwise the same value of the same type, so `true` is not 1 and `"1"` is not 1. An
702
+ * absent value is null, as Python's `dict.get` reads it. The Python implementation's
703
+ * `_same_number`.
704
+ */
705
+ function sameNumber(a, b) {
706
+ const ia = integral(a);
707
+ const ib = integral(b);
708
+ if (ia !== null || ib !== null)
709
+ return ia === ib;
710
+ const pa = orNull(a);
711
+ const pb = orNull(b);
712
+ return pyTypeOf(pa) === pyTypeOf(pb) && pyEquals(pa, pb);
713
+ }
714
+ /** The Python type of a parsed JSON value, which tells a boolean from a number as `type()` does. */
715
+ function pyTypeOf(value) {
716
+ if (value === null)
717
+ return "NoneType";
718
+ if (Array.isArray(value))
719
+ return "list";
720
+ if (typeof value === "number")
721
+ return Number.isInteger(value) ? "int" : "float";
722
+ return typeof value === "object" ? "dict" : typeof value;
723
+ }
724
+ /**
725
+ * Python's `==` on two parsed JSON values: numbers and booleans by value (`True == 1`), strings by
726
+ * text, lists and objects member by member.
727
+ */
728
+ function pyEquals(a, b) {
729
+ const x = typeof a === "boolean" ? Number(a) : a;
730
+ const y = typeof b === "boolean" ? Number(b) : b;
731
+ if (typeof x !== "object" || typeof y !== "object" || x === null || y === null)
732
+ return x === y;
733
+ if (Array.isArray(x) || Array.isArray(y)) {
734
+ return Array.isArray(x) && Array.isArray(y) && x.length === y.length && x.every((v, i) => pyEquals(v, y[i]));
735
+ }
736
+ const keys = Object.keys(x);
737
+ return (keys.length === Object.keys(y).length && keys.every((k) => Object.hasOwn(y, k) && pyEquals(x[k], y[k])));
738
+ }
739
+ /** Python's `{value!r}` of an integral-or-not `seq` or `v`: the integer when it is integral (`intOr`). */
740
+ function reprIntOr(value) {
741
+ return pyRepr((intOr(value) ?? null));
742
+ }
743
+ /**
744
+ * The distinct versions of `values`, as a Python set holds them: two that Python's `==` calls equal
745
+ * are one (`True` and 1 included), and the first of them stays. A list or an object has no place
746
+ * in a Python set (Python raises there), and each is kept on its own.
747
+ */
748
+ function pySetOf(values) {
749
+ const seen = new Set();
750
+ const out = [];
751
+ for (const value of values) {
752
+ const plain = toPlain(value);
753
+ const key = plain === null
754
+ ? "None"
755
+ : typeof plain === "boolean" || typeof plain === "number"
756
+ ? `n:${Number(plain) === 0 ? 0 : Number(plain)}`
757
+ : typeof plain === "string"
758
+ ? `s:${plain}`
759
+ : null;
760
+ if (key !== null) {
761
+ if (seen.has(key))
762
+ continue;
763
+ seen.add(key);
764
+ }
765
+ out.push(value);
766
+ }
632
767
  return out;
633
768
  }
769
+ /**
770
+ * The order `mixed_entry_versions` lists versions in: numbers in numeric order, and anything else
771
+ * after them by its Python `repr`, so a boolean beside a string still sorts. The Python
772
+ * implementation's `_version_order`.
773
+ */
774
+ function compareVersions(a, b) {
775
+ const numberOf = (v) => v instanceof RawNumber ? v.value : typeof v === "number" ? v : null;
776
+ const na = numberOf(a);
777
+ const nb = numberOf(b);
778
+ if (na !== null && nb !== null)
779
+ return na - nb;
780
+ if (na !== null)
781
+ return -1;
782
+ if (nb !== null)
783
+ return 1;
784
+ return compareCodePoints(pyRepr(a), pyRepr(b));
785
+ }
634
786
  /**
635
787
  * The v1 subject for the entry at `seq`, recomputed from the ledger.
636
788
  *
637
- * Throws when `seq` names no entry, or names one whose `event` v1 defines no subject for.
789
+ * `seq` finds its entry the way a verifier finds it (`subjectIndex`), so the subject is the one the
790
+ * verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
791
+ * defines no subject for.
638
792
  */
639
793
  export function envelopeSubject(entries, seq) {
640
- const entry = entries.find((e) => toPlain(e["seq"]) === seq);
641
- if (entry === undefined)
794
+ const n = integral(seq);
795
+ const at = n === null ? undefined : subjectIndex(entries).get(n);
796
+ if (n === null || at === undefined)
642
797
  throw new Error(`no entry at seq ${seq}`);
798
+ const entry = entries[at];
643
799
  const event = toPlain(entry["event"]);
644
800
  if (!ENVELOPE_SUBJECT_MEMBERS.has(event)) {
645
801
  throw new Error(`envelope v${ENVELOPE_VERSION} defines no subject for event '${event}'`);
@@ -647,8 +803,8 @@ export function envelopeSubject(entries, seq) {
647
803
  const subject = {
648
804
  chain_id: orNull(entry["chain_id"]),
649
805
  node: orNull(entry["node"]),
650
- seq,
651
- entry_hash: recomputedHashes(entries).get(seq) ?? null,
806
+ seq: n,
807
+ entry_hash: recomputedHashes(entries)[at] ?? null,
652
808
  event,
653
809
  };
654
810
  if (event === "allow")
@@ -691,21 +847,21 @@ export function signEnvelope(entries, seq, seed, kid, observed) {
691
847
  function witnessPublicKey(kid, value) {
692
848
  if (typeof value === "string") {
693
849
  if (value.length !== 64) {
694
- throw new Error(`witness key '${kid}': public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
850
+ throw new Error(`witness key ${pyStrRepr(kid)}: public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
695
851
  `got ${value.length}`);
696
852
  }
697
853
  if (!/^[0-9a-fA-F]{64}$/.test(value)) {
698
- throw new Error(`witness key '${kid}': public_key_hex is not hexadecimal`);
854
+ throw new Error(`witness key ${pyStrRepr(kid)}: public_key_hex is not hexadecimal`);
699
855
  }
700
856
  return Buffer.from(value, "hex");
701
857
  }
702
858
  if (value instanceof Uint8Array) {
703
859
  if (value.length !== 32) {
704
- throw new Error(`witness key '${kid}': an Ed25519 public key is 32 bytes, got ${value.length}`);
860
+ throw new Error(`witness key ${pyStrRepr(kid)}: an Ed25519 public key is 32 bytes, got ${value.length}`);
705
861
  }
706
862
  return Buffer.from(value);
707
863
  }
708
- throw new Error(`witness key '${kid}': expected 64 hex characters or 32 bytes`);
864
+ throw new Error(`witness key ${pyStrRepr(kid)}: expected 64 hex characters or 32 bytes`);
709
865
  }
710
866
  /**
711
867
  * kid -> `[alg, raw public key]`, from the vector file's own `witness_keys` shape or from a plain
@@ -720,27 +876,154 @@ function witnessPublicKey(kid, value) {
720
876
  * reported to the caller rather than folded into a finding about the bundle. v1 defines Ed25519
721
877
  * and no other algorithm, so a row declaring anything else is refused too.
722
878
  */
723
- function trustedWitnesses(witnessKeys) {
879
+ function trustedWitnesses(witnessKeys, now) {
724
880
  const trusted = new Map();
881
+ const expired = new Map();
882
+ const seen = new Set();
725
883
  if (witnessKeys === null || witnessKeys === undefined)
726
- return trusted;
884
+ return { trusted, expired };
727
885
  const rows = Array.isArray(witnessKeys)
728
886
  ? witnessKeys.map((k) => [isRecordLike(k) ? k["kid"] : undefined, k])
729
887
  : Object.entries(witnessKeys);
730
888
  for (const [kid, value] of rows) {
731
889
  if (typeof kid !== "string")
732
890
  throw new Error("witness key kid must be a string");
891
+ // One row per kid. The later row used to win, so a row added to expire a key could leave it
892
+ // trusted, and an expired row beside a live one did.
893
+ if (seen.has(kid))
894
+ throw new Error(`witness key ${pyStrRepr(kid)}: more than one row names this kid`);
895
+ seen.add(kid);
733
896
  let key = value;
897
+ let notAfter = null;
898
+ let until = null;
734
899
  if (isRecordLike(value)) {
900
+ // Read whole: a row read by projection let a misspelled `notAfter` leave a key that never
901
+ // expired. In the `{kid: row}` form a row may repeat its kid, and must agree with it.
902
+ const unknown = Object.keys(value)
903
+ .filter((m) => !TRUST_ROW_MEMBERS.has(m))
904
+ .sort(compareCodePoints);
905
+ if (unknown.length > 0) {
906
+ throw new Error(`witness key ${pyStrRepr(kid)}: the row carries members this build does not evaluate ` +
907
+ `and will not ignore: ${unknown.map(pyStrRepr).join(", ")}`);
908
+ }
909
+ if ("kid" in value && value["kid"] !== kid) {
910
+ throw new Error(`witness key ${pyStrRepr(kid)}: its row names a different kid, ${pyRepr(value["kid"])}`);
911
+ }
735
912
  const alg = value["alg"];
736
913
  if (alg !== ENVELOPE_ALG) {
737
- throw new Error(`witness key '${kid}': alg must be '${ENVELOPE_ALG}', got ${pyRepr(alg)}`);
914
+ throw new Error(`witness key ${pyStrRepr(kid)}: alg must be '${ENVELOPE_ALG}', got ${pyRepr(alg)}`);
915
+ }
916
+ // Present means checked: `null` is not "no expiry", it is a row that says nothing usable.
917
+ if (value["not_after"] !== undefined) {
918
+ until = rfc3339Utc(value["not_after"]);
919
+ if (until === null) {
920
+ throw new Error(`witness key ${pyStrRepr(kid)}: not_after must be an RFC 3339 UTC date-time such as ` +
921
+ `'2026-10-05T00:00:00Z', got ${pyRepr(value["not_after"])}`);
922
+ }
923
+ notAfter = value["not_after"];
738
924
  }
739
925
  key = value["public_key_hex"];
740
926
  }
741
- trusted.set(kid, [ENVELOPE_ALG, witnessPublicKey(kid, key)]);
927
+ const publicKey = witnessPublicKey(kid, key);
928
+ if (until !== null && atOrBefore(until, now)) {
929
+ // Validated in full, then left out. An expired row neither adds its kid nor removes one
930
+ // that another row trusts; it is remembered so the failure can say the key expired, and
931
+ // when, instead of reading as a key nobody ever configured.
932
+ expired.set(kid, notAfter);
933
+ continue;
934
+ }
935
+ trusted.set(kid, [ENVELOPE_ALG, publicKey]);
742
936
  }
743
- return trusted;
937
+ return { trusted, expired };
938
+ }
939
+ /**
940
+ * Every row of a trust set checked as the verifier checks it — kid, alg, public key, `not_after` —
941
+ * and an `Error` naming the kid for the first bad one. The CLI runs this when it loads a
942
+ * `--witness-keys` file, so a bad row is reported against that file before any bundle is read.
943
+ * Expiry is not an error: an expired row is a row the verifier leaves out, not a malformed one.
944
+ */
945
+ export function validateWitnessKeys(witnessKeys) {
946
+ trustedWitnesses(witnessKeys, verificationTime(null));
947
+ }
948
+ /** A trust-set row's members. A row is read whole: anything else in it is refused. */
949
+ const TRUST_ROW_MEMBERS = new Set(["kid", "alg", "public_key_hex", "not_after"]);
950
+ /**
951
+ * RFC 3339 `date-time` in UTC: `YYYY-MM-DDTHH:MM:SS`, an optional fraction, and `Z`. RFC 3339
952
+ * allows `t` and `z` in lower case, so they are accepted too. A numeric offset, even `+00:00`, is
953
+ * not: a trust-set row says UTC in the one spelling that cannot be misread as local time. ASCII
954
+ * digits only. The Python implementation accepts exactly this grammar, so a row one of them
955
+ * refuses, both refuse.
956
+ */
957
+ const RFC3339_UTC = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?[Zz]$/;
958
+ /**
959
+ * `value` as an instant, or `null` when it is not an RFC 3339 UTC date-time. An out-of-range field
960
+ * (month 13, February 30, hour 24, second 60) is `null`: there is no leap second here, as there is
961
+ * none in Python's `datetime`.
962
+ */
963
+ function rfc3339Utc(value) {
964
+ const m = typeof value === "string" ? RFC3339_UTC.exec(value) : null;
965
+ if (m !== null) {
966
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
967
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
968
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
969
+ if (year >= 1 && days !== undefined && day >= 1 && day <= days && hour <= 23 && minute <= 59 && second <= 59) {
970
+ // `Date.UTC` reads a year below 100 as 19xx; `setUTCFullYear` does not.
971
+ const at = new Date(0);
972
+ at.setUTCFullYear(year, month - 1, day);
973
+ at.setUTCHours(hour, minute, second, 0);
974
+ return { seconds: at.getTime() / 1000, fraction: (m[7] ?? "").slice(0, 6) };
975
+ }
976
+ }
977
+ return null;
978
+ }
979
+ /**
980
+ * The verification time: `now` when given — a `Date`, or a string in the `not_after` grammar — and
981
+ * the current time otherwise.
982
+ *
983
+ * A number is refused, never read. The Python implementation takes epoch seconds there; a
984
+ * JavaScript epoch counts milliseconds, and a number read in the wrong unit moves the verification
985
+ * time a thousandfold, so this side takes no number at all rather than guess which one it got.
986
+ */
987
+ function verificationTime(now) {
988
+ if (now === null || now === undefined)
989
+ return dateInstant(new Date());
990
+ if (now instanceof Date && !Number.isNaN(now.getTime()))
991
+ return dateInstant(now);
992
+ if (typeof now === "number") {
993
+ throw new Error("now must be a Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z', not a " +
994
+ "number: a JavaScript epoch counts milliseconds and a Python one seconds");
995
+ }
996
+ const parsed = typeof now === "string" ? rfc3339Utc(now) : null;
997
+ if (parsed === null) {
998
+ throw new Error("now must be a valid Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z'; got " +
999
+ (now instanceof Date ? "an invalid Date" : pyRepr(now)));
1000
+ }
1001
+ return parsed;
1002
+ }
1003
+ function dateInstant(at) {
1004
+ const ms = at.getTime();
1005
+ const seconds = Math.floor(ms / 1000);
1006
+ return { seconds, fraction: String(ms - seconds * 1000).padStart(3, "0") };
1007
+ }
1008
+ /** `a` is at or before `b`: whole seconds first, then the fraction digits, padded to one length. */
1009
+ function atOrBefore(a, b) {
1010
+ if (a.seconds !== b.seconds)
1011
+ return a.seconds < b.seconds;
1012
+ const width = Math.max(a.fraction.length, b.fraction.length);
1013
+ return a.fraction.padEnd(width, "0") <= b.fraction.padEnd(width, "0");
1014
+ }
1015
+ /**
1016
+ * Python's truthiness for a parsed JSON value, read where the Python implementation writes
1017
+ * `value or default`: absent, null, false, a zero, "", [] and {} are false.
1018
+ */
1019
+ function pyFalsy(value) {
1020
+ if (value === undefined || value === null || value === false || value === 0 || value === "")
1021
+ return true;
1022
+ if (Array.isArray(value))
1023
+ return value.length === 0;
1024
+ if (typeof value === "object")
1025
+ return Object.keys(value).length === 0;
1026
+ return false;
744
1027
  }
745
1028
  /** A plain object (not an array, not null, not a Buffer). */
746
1029
  function isRecordLike(v) {
@@ -763,16 +1046,14 @@ function receivedBytes(raw) {
763
1046
  return Buffer.from(raw);
764
1047
  return null;
765
1048
  }
766
- /**
767
- * A subject `seq` this build will look an entry up by: a JSON integer, and never a boolean.
768
- *
769
- * The type check comes first and every use of `seq` is behind it — in Python an unguarded lookup
770
- * raises on a list or an object and finds the entry at seq 1 for `true`, and the two
771
- * implementations report the same failure for the same bundle.
772
- */
773
- function isSeq(value) {
774
- return typeof value === "number" && Number.isInteger(value);
775
- }
1049
+ // A subject `seq` this build looks an entry up by, and an envelope's `v`, must be an integral
1050
+ // number and never a boolean (`integral`, canonical.ts), as the schema's integer type defines:
1051
+ // Python's `_is_seq`. `1.0` is 1, and RFC 8785 writes it as 1, so an envelope signed over one
1052
+ // verifies as if signed over the other. The type check comes first and every use is behind it — in
1053
+ // Python an unguarded lookup raises on a list or an object and finds the entry at seq 1 for
1054
+ // `true`, and the two implementations report the same failure for the same bundle. The entries are
1055
+ // keyed by the same rule (`subjectIndex`), so an entry whose own seq is `true` or `1.5` is covered
1056
+ // by no envelope.
776
1057
  /**
777
1058
  * The report line: the state and the result together, in the same form for all three results. A
778
1059
  * process-asserted entry gets no result.
@@ -794,43 +1075,45 @@ function envelopeLine(state, result) {
794
1075
  * whoever appends the second decides what the first said, and an entry whose coverage is
795
1076
  * disputed must not read as clean.
796
1077
  */
797
- function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
1078
+ function scoreEnvelopes(entries, envelopes, trust, rawBytes) {
798
1079
  const fail = new FailureLog();
799
1080
  const states = {};
800
1081
  const results = {};
1082
+ const witnesses = {};
801
1083
  entries.forEach((e, i) => {
802
- states[String(orNull(e["seq"]) ?? i)] = PROCESS_ASSERTED;
1084
+ setOwn(states, stateKey(e, i), PROCESS_ASSERTED);
803
1085
  });
804
1086
  // The hash walk is what an envelope's binding member is checked against; a bundle carrying
805
1087
  // none does not pay for it. Every entry is process-asserted in that case, which is the status
806
1088
  // quo and exactly what this reports.
807
- const bySeq = new Map();
808
- let recomputed = new Map();
809
- if (envelopes.length > 0) {
810
- entries.forEach((e, i) => bySeq.set(orNull(e["seq"]) ?? i, e));
811
- recomputed = recomputedHashes(entries);
812
- }
813
- // seq -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts an
814
- // envelope in as soon as its subject names an entry this bundle has.
1089
+ const subjectAt = envelopes.length > 0 ? subjectIndex(entries) : new Map();
1090
+ const recomputed = envelopes.length > 0 ? recomputedHashes(entries) : [];
1091
+ // entry index -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts
1092
+ // an envelope in as soon as its subject names an entry this bundle has.
815
1093
  const claims = new Map();
816
1094
  envelopes.forEach((envelope, index) => {
817
1095
  const raw = rawBytes !== null && index < rawBytes.length ? rawBytes[index] ?? null : null;
818
- const covered = scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, claims);
1096
+ const covered = scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims);
819
1097
  if (covered === null)
820
1098
  return;
821
- states[String(covered.seq)] = WITNESS_SIGNED;
822
- results[String(covered.seq)] = covered.result;
1099
+ // Where `states` files that entry; `witnesses` files it by the index the subject resolved to.
1100
+ const key = stateKey(entries[covered.at], covered.at);
1101
+ setOwn(states, key, WITNESS_SIGNED);
1102
+ setOwn(results, key, covered.result);
1103
+ setOwn(witnesses, String(covered.at), covered.kid);
823
1104
  });
824
1105
  // The first envelope's result stands in `results` — it is what that witness said, and the
825
1106
  // duplicate does not erase it — but the STATE falls back, so a contradicted entry never
826
- // reports witness-signed and the bundle rejects.
827
- for (const [seq, count] of claims) {
828
- if (count > 1)
829
- states[seq] = PROCESS_ASSERTED;
1107
+ // reports witness-signed and the bundle rejects; and it leaves `witnesses`.
1108
+ for (const [at, count] of claims) {
1109
+ if (count > 1) {
1110
+ setOwn(states, stateKey(entries[at], at), PROCESS_ASSERTED);
1111
+ delete witnesses[String(at)];
1112
+ }
830
1113
  }
831
1114
  const lines = {};
832
1115
  for (const [seq, state] of Object.entries(states)) {
833
- lines[seq] = envelopeLine(state, results[seq] ?? null);
1116
+ setOwn(lines, seq, envelopeLine(state, Object.hasOwn(results, seq) ? results[seq] : null));
834
1117
  }
835
1118
  const summary = {
836
1119
  status: fail.length === 0 ? "verified" : "FAILED",
@@ -841,47 +1124,58 @@ function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
841
1124
  .sort((a, b) => a - b),
842
1125
  states,
843
1126
  results,
1127
+ witnesses,
844
1128
  lines,
845
1129
  failures: [...fail.messages],
846
1130
  };
847
1131
  return [summary, fail];
848
1132
  }
1133
+ /**
1134
+ * `record[key] = value` as an own property, whatever the key. A key is an entry's seq rendered as
1135
+ * text, and a bundle chooses its seqs: a plain assignment of `"__proto__"` would reach the
1136
+ * prototype setter and record nothing.
1137
+ */
1138
+ function setOwn(record, key, value) {
1139
+ Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
1140
+ }
849
1141
  /** Python `repr` for a member set, so both implementations print the same failure strings. */
850
1142
  function reprList(values) {
851
- return `[${values.map((v) => `'${v}'`).join(", ")}]`;
1143
+ return `[${values.map(pyStrRepr).join(", ")}]`;
852
1144
  }
853
1145
  /**
854
1146
  * One envelope, checked in the order the seven named failures are defined in.
855
1147
  *
856
- * Returns the covered entry for an envelope that verified, and `null` for one that did not.
857
- * Every failure is positioned on the entry the envelope COVERS, found by `subject.seq` — the
858
- * locators are checked against that entry, not used to find it.
1148
+ * Returns the index in `entries` of the entry an envelope that verified covers, and `null` for one
1149
+ * that did not. Every failure is positioned on the entry the envelope COVERS, found by
1150
+ * `subject.seq` through `subjectIndex` — the locators are checked against that entry, not used to
1151
+ * find it.
859
1152
  *
860
- * `claims` is the caller's seq -> count of the envelopes that have named each entry so far, and
1153
+ * `claims` is the caller's entry index -> count of the envelopes that have named it so far, and
861
1154
  * this function updates it. An envelope claims its entry as soon as `subject.seq` finds one,
862
1155
  * BEFORE the rest of the subject is checked, so a second envelope over an entry an earlier one
863
1156
  * already named is `envelope_duplicate_subject` whether either of them is otherwise sound: the
864
1157
  * point of the check is that no one can decide what an earlier witness said by appending after
865
1158
  * it.
866
1159
  */
867
- function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, claims) {
1160
+ function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims) {
868
1161
  const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof RawNumber);
869
1162
  const subject = isRecord(envelope) ? envelope["subject"] : undefined;
870
1163
  function position() {
871
1164
  // Every failure is positioned by `subject.seq`, and `subject` is attacker-supplied, so the
872
1165
  // lookup is guarded: a seq that is not an integer positions nothing, which is honest — it
873
1166
  // names no entry — and it is never used as a key.
874
- const s = isRecord(subject) ? toPlain(subject["seq"]) : null;
875
- if (!isSeq(s))
876
- return [null, null];
877
- const entry = bySeq.get(s);
878
- if (entry === undefined)
879
- return [s, null];
880
- return [orNull(entry["seq"]), orNull(entry["node"])];
1167
+ const s = isRecord(subject) ? integral(subject["seq"]) : null;
1168
+ if (s === null)
1169
+ return [null, null, null];
1170
+ const at = subjectAt.get(s);
1171
+ if (at === undefined)
1172
+ return [s, null, null];
1173
+ const entry = entries[at];
1174
+ return [orNull(entry["seq"]), orNull(entry["node"]), entry];
881
1175
  }
882
1176
  function report(reason, detail) {
883
- const [seq, node] = position();
884
- fail.add(reason, `${reason}: ${detail}`, { seq, node });
1177
+ const [seq, node, entry] = position();
1178
+ fail.add(reason, `${reason}: ${detail}`, { seq, node, entry });
885
1179
  return null;
886
1180
  }
887
1181
  if (!isRecord(envelope)) {
@@ -890,11 +1184,11 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
890
1184
  }
891
1185
  // (1) version — a `v` or `typ` this build does not know is a DIFFERENT CONTRACT, and nothing
892
1186
  // further about it can be read safely.
893
- const v = toPlain(envelope["v"]);
1187
+ // The version is read by the seq rule (`integral`): `1.0` is 1, and `true` is not.
894
1188
  const typ = toPlain(envelope["typ"]);
895
- if (v !== ENVELOPE_VERSION || typ !== ENVELOPE_TYP) {
896
- return report("envelope_unknown_version", `envelope v=${pyRepr(v)} typ=${pyRepr(typ)}, this build knows v=${ENVELOPE_VERSION} ` +
897
- `typ='${ENVELOPE_TYP}'`);
1189
+ if (integral(envelope["v"]) !== ENVELOPE_VERSION || typ !== ENVELOPE_TYP) {
1190
+ return report("envelope_unknown_version", `envelope v=${reprIntOr(envelope["v"])} typ=${pyRepr((envelope["typ"] ?? null))}, ` +
1191
+ `this build knows v=${ENVELOPE_VERSION} typ='${ENVELOPE_TYP}'`);
898
1192
  }
899
1193
  // (2) member sets — the version commits the exact signed member set of the whole envelope, so
900
1194
  // a member added ANYWHERE is a new version that did not declare itself.
@@ -943,30 +1237,31 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
943
1237
  // (3a) the binding member. `seq` is the lookup key, so there is nothing to compare it against;
944
1238
  // the entry it finds supplies the hash the subject is checked against. It is also the one
945
1239
  // subject member used as a KEY, so its type is checked before it is used as one.
946
- const subjectSeq = toPlain(subject["seq"]);
947
- if (!isSeq(subjectSeq)) {
1240
+ const subjectSeq = integral(subject["seq"]);
1241
+ if (subjectSeq === null) {
948
1242
  return report("envelope_subject_mismatch", "subject seq is not an integer");
949
1243
  }
950
- const entry = bySeq.get(subjectSeq);
951
- if (entry === undefined) {
952
- return report("envelope_subject_mismatch", `no entry at seq ${pyRepr(subjectSeq)} in this bundle`);
1244
+ const at = subjectAt.get(subjectSeq);
1245
+ if (at === undefined) {
1246
+ return report("envelope_subject_mismatch", `no entry at seq ${reprIntOr(subject["seq"])} in this bundle`);
953
1247
  }
954
- const seq = orNull(entry["seq"]);
1248
+ const entry = entries[at];
1249
+ // The messages below print an integral seq as its integer, and an entry without one as None.
1250
+ const seq = (intOr(entry["seq"]) ?? null);
955
1251
  // (3a') one entry, at most one envelope. Counted here, before anything else about this
956
1252
  // envelope is judged, so the rule cannot be sidestepped by making the second envelope
957
1253
  // defective in some other way as well.
958
- const claimKey = String(seq);
959
- const already = claims.get(claimKey) ?? 0;
960
- claims.set(claimKey, already + 1);
1254
+ const already = claims.get(at) ?? 0;
1255
+ claims.set(at, already + 1);
961
1256
  if (already > 0) {
962
- return report("envelope_duplicate_subject", `seq ${claimKey} is already covered by an earlier envelope in this bundle; two ` +
1257
+ return report("envelope_duplicate_subject", `seq ${pyStr(seq)} is already covered by an earlier envelope in this bundle; two ` +
963
1258
  "observations of one event contradict each other by construction, so this entry is not " +
964
1259
  "witness-signed");
965
1260
  }
966
- const computed = recomputed.get(seq) ?? null;
1261
+ const computed = recomputed[at] ?? null;
967
1262
  const claimed = toPlain(subject["entry_hash"]);
968
1263
  if (claimed !== computed) {
969
- return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${String(seq)} from ` +
1264
+ return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${pyStr(seq)} from ` +
970
1265
  `this bundle (${pyRepr(computed)})`);
971
1266
  }
972
1267
  // (3b) the locators, checked against the SAME entry `seq` found. A matching locator attests
@@ -981,7 +1276,7 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
981
1276
  for (const [member, actual] of locators) {
982
1277
  const stated = toPlain(subject[member]);
983
1278
  if (stated !== actual) {
984
- return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${String(seq)}`);
1279
+ return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${pyStr(seq)}`);
985
1280
  }
986
1281
  }
987
1282
  // (4) canonicality — an invariant SEPARATE from the signature: the received bytes must equal
@@ -1024,10 +1319,15 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
1024
1319
  return report("envelope_unknown_witness", `witness alg=${pyRepr(alg)} is not '${ENVELOPE_ALG}'; envelope v${ENVELOPE_VERSION} ` +
1025
1320
  "defines Ed25519 and no other algorithm");
1026
1321
  }
1027
- const known = trusted.get(kid);
1322
+ const known = trust.trusted.get(kid);
1028
1323
  if (known === undefined) {
1324
+ // A kid whose rows have all expired is not in the trust set, so it is the same failure as any
1325
+ // other untrusted kid. The message keeps that wording first and then says why, because "not
1326
+ // trusted" and "trusted until a date that has passed" call for different fixes.
1327
+ const lapsed = trust.expired.get(kid);
1029
1328
  return report("envelope_unknown_witness", `witness kid=${pyRepr(kid)} alg=${pyRepr(alg)} is not in the trusted witness keys ` +
1030
- `(${reprList(sortedStrings(trusted.keys()))})`);
1329
+ `(${reprList(sortedStrings(trust.trusted.keys()))})` +
1330
+ (lapsed === undefined ? "" : `: the key expired at not_after=${pyRepr(lapsed)}`));
1031
1331
  }
1032
1332
  // (6) the signature, over JCS(envelope minus "sig").
1033
1333
  const sigHex = toPlain(envelope["sig"]);
@@ -1060,17 +1360,22 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
1060
1360
  }
1061
1361
  if (nonCanonical)
1062
1362
  return null;
1063
- return { seq, node: orNull(entry["node"]), result: toPlain(envelope["observed"]["result"]) };
1363
+ return { at, node: orNull(entry["node"]), result: toPlain(envelope["observed"]["result"]), kid };
1064
1364
  }
1065
1365
  /**
1066
1366
  * Score a bundle's observer envelopes on their own, without the ledger checks.
1067
1367
  *
1068
- * Returns `{ok, ...summary, failure_details}`. `states` maps every entry's seq to
1069
- * `witness-signed` or `process-asserted`; `lines` is the report line for each.
1368
+ * Returns `{ok, ...summary, failure_details, failure_entries}`. `states` maps every entry's seq to
1369
+ * `witness-signed` or `process-asserted`; `lines` is the report line for each. `results` maps a
1370
+ * covered seq to the verifying envelope's `observed.result`. `witnesses` maps the index in
1371
+ * `bundle.entries` of every witness-signed entry to that envelope's `witness.kid`: the entry the
1372
+ * subject resolved to, the one `failure_entries` names, so of two entries sharing a seq only the
1373
+ * one the witness signed is in it.
1070
1374
  */
1071
1375
  export function verifyEnvelopes(bundle, options = {}) {
1072
- const [summary, fail] = scoreEnvelopes(bundle.entries ?? [], bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
1073
- return { ok: fail.length === 0, ...summary, failure_details: fail.details };
1376
+ const entries = bundle.entries ?? [];
1377
+ const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
1378
+ return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
1074
1379
  }
1075
1380
  // =============================================================================================
1076
1381
  // Execution binding (0.9.0): offline checks over callId/allow/outcome, from the ledger alone —
@@ -1159,7 +1464,7 @@ function validateAllow(e) {
1159
1464
  for (const k of ["module", "version", "hook_path"]) {
1160
1465
  const v = adapter[k];
1161
1466
  if (typeof v !== "string" || !v) {
1162
- return `adapter[${JSON.stringify(k)}] must be a non-empty string`;
1467
+ return `adapter[${pyStrRepr(k)}] must be a non-empty string`;
1163
1468
  }
1164
1469
  }
1165
1470
  if (presentButNull(e, "policy")) {
@@ -1187,7 +1492,7 @@ function validateDeny(e) {
1187
1492
  return err;
1188
1493
  const leaked = ALLOW_ONLY_FIELDS.filter((f) => f in e).sort();
1189
1494
  if (leaked.length > 0) {
1190
- return `deny carries allow-only field(s) ${JSON.stringify(leaked)}`;
1495
+ return `deny carries allow-only field(s) ${reprList(leaked)}`;
1191
1496
  }
1192
1497
  return null;
1193
1498
  }
@@ -1235,7 +1540,7 @@ function validateOutcome(e) {
1235
1540
  for (const k of ["type", "ref"]) {
1236
1541
  const v = receipt[k];
1237
1542
  if (typeof v !== "string" || !v) {
1238
- return `receipt[${JSON.stringify(k)}] must be a non-empty string`;
1543
+ return `receipt[${pyStrRepr(k)}] must be a non-empty string`;
1239
1544
  }
1240
1545
  }
1241
1546
  const digest = receipt["digest"];
@@ -1352,19 +1657,19 @@ function policyFailures(entries, bundleV) {
1352
1657
  if (!("policy" in e))
1353
1658
  continue;
1354
1659
  const ev = toPlain(e["event"]);
1355
- const position = { seq: orNull(e["seq"]), node: orNull(e["node"]) };
1660
+ const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
1356
1661
  if (ev === "allow") {
1357
1662
  if (bundleV === 2)
1358
1663
  continue; // validateAllow owns this entry's message
1359
1664
  if (!isKnownPolicy(toPlain(e["policy"]))) {
1360
- failures.add("invalid_policy", `invalid_policy: seq=${pyStr(orNull(e["seq"]))} allow carries policy ` +
1361
- `${pyRepr(orNull(e["policy"]))}, not a value this format defines`, position);
1665
+ failures.add("invalid_policy", `invalid_policy: seq=${shown(intOr(e["seq"]))} allow carries policy ` +
1666
+ `${pyRepr(e["policy"] ?? null)}, not a value this format defines`, position);
1362
1667
  }
1363
1668
  }
1364
1669
  else {
1365
1670
  if (ev === "deny" && bundleV === 2)
1366
1671
  continue; // validateDeny owns this entry's message
1367
- failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${pyStr(orNull(e["seq"]))} event=${pyRepr(orNull(e["event"]))} ` +
1672
+ failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${shown(intOr(e["seq"]))} event=${pyRepr(e["event"] ?? null)} ` +
1368
1673
  "carries `policy`, which is an allow-only field", position);
1369
1674
  }
1370
1675
  }
@@ -1375,8 +1680,8 @@ function v2FieldLeaksOnV1(entries) {
1375
1680
  for (const e of entries) {
1376
1681
  const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
1377
1682
  if (leaked.length > 0) {
1378
- failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${pyRepr(toPlain(e["seq"]))} event=${pyRepr(toPlain(e["event"]))} ` +
1379
- `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
1683
+ failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${shown(intOr(e["seq"]))} event=${pyRepr(e["event"] ?? null)} ` +
1684
+ `carries v2-only field(s) ${reprList(leaked)} on a schema_version=1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
1380
1685
  }
1381
1686
  }
1382
1687
  return failures;
@@ -1396,7 +1701,8 @@ function executionBinding(entries, bundleV) {
1396
1701
  if (bundleV !== 2)
1397
1702
  return [{ status: "not applicable" }, new FailureLog()];
1398
1703
  const failures = new FailureLog();
1399
- const seenCallIds = new Map(); // callId -> [event, node, seq]
1704
+ // callId -> [event, node, the entry's own seq value as the bundle wrote it]
1705
+ const seenCallIds = new Map();
1400
1706
  const allows = new Map();
1401
1707
  const outcomes = new Map();
1402
1708
  const invalidAllowIds = new Set();
@@ -1405,16 +1711,18 @@ function executionBinding(entries, bundleV) {
1405
1711
  const revokedNodes = new Set();
1406
1712
  for (const e of entries) {
1407
1713
  const ev = toPlain(e["event"]);
1408
- const node = toPlain(e["node"]);
1409
- const seqForEvent = toPlain(e["seq"]);
1714
+ const raw = toPlain(e["node"]);
1715
+ // A node id is a string; anything else names no node, here as in the Python implementation.
1716
+ const node = typeof raw === "string" ? raw : null;
1410
1717
  if (ev === "root") {
1411
1718
  if (node !== null)
1412
1719
  nodes.add(node);
1413
1720
  const err = validateRoot(e);
1414
1721
  if (err) {
1415
- failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
1722
+ failures.add("invalid_root", `invalid_root: ${err} (seq ${shown(intOr(e["seq"]))})`, {
1416
1723
  seq: orNull(e["seq"]),
1417
1724
  node: orNull(e["node"]),
1725
+ entry: e,
1418
1726
  });
1419
1727
  }
1420
1728
  }
@@ -1427,37 +1735,40 @@ function executionBinding(entries, bundleV) {
1427
1735
  finalizedNodes.add(node);
1428
1736
  }
1429
1737
  else if (ev === "kill") {
1430
- for (const r of toPlain(e["revoked"]) ?? [])
1738
+ const killed = new Map();
1739
+ noteRevoked(e, killed);
1740
+ for (const r of killed.keys())
1431
1741
  revokedNodes.add(r);
1432
1742
  const err = validateKill(e);
1433
1743
  if (err) {
1434
- failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
1744
+ failures.add("invalid_kill", `invalid_kill: ${err} (seq ${shown(intOr(e["seq"]))})`, {
1435
1745
  seq: orNull(e["seq"]),
1436
1746
  node: orNull(e["node"]),
1747
+ entry: e,
1437
1748
  });
1438
1749
  }
1439
1750
  }
1440
1751
  if (ev === "allow" || ev === "deny") {
1441
1752
  const cid = toPlain(e["call_id"]);
1442
- const seq = toPlain(e["seq"]);
1443
1753
  if (cid !== null && cid !== undefined) {
1444
1754
  const prior = seenCallIds.get(cid);
1445
1755
  if (prior !== undefined) {
1446
1756
  // Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
1447
1757
  // record, the first one having been legitimate when it was written.
1448
- failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
1449
- `${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
1758
+ failures.add("duplicate_call_id", `duplicate_call_id: call_id ${shown(e["call_id"])} on seq ${shown(intOr(e["seq"]))} (${ev}) already used at seq ` +
1759
+ `${shown(intOr(prior[2]))} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
1450
1760
  }
1451
1761
  else {
1452
- seenCallIds.set(cid, [ev, node, seq]);
1762
+ seenCallIds.set(cid, [ev, node, e["seq"] ?? null]);
1453
1763
  }
1454
1764
  }
1455
1765
  const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
1456
1766
  if (err) {
1457
- failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
1767
+ failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${shown(intOr(e["seq"]))})`, {
1458
1768
  seq: orNull(e["seq"]),
1459
1769
  node: orNull(e["node"]),
1460
1770
  callId: cid ?? null,
1771
+ entry: e,
1461
1772
  });
1462
1773
  if (ev === "allow" && cid !== null && cid !== undefined)
1463
1774
  invalidAllowIds.add(cid);
@@ -1468,19 +1779,19 @@ function executionBinding(entries, bundleV) {
1468
1779
  }
1469
1780
  else if (ev === "outcome") {
1470
1781
  const cid = toPlain(e["call_id"]);
1471
- const seq = toPlain(e["seq"]);
1472
1782
  const err = validateOutcome(e);
1473
1783
  if (err) {
1474
- failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
1784
+ failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${shown(intOr(e["seq"]))})`, {
1475
1785
  seq: orNull(e["seq"]),
1476
1786
  node: orNull(e["node"]),
1477
1787
  callId: cid ?? null,
1788
+ entry: e,
1478
1789
  });
1479
1790
  continue;
1480
1791
  }
1481
1792
  if (cid !== null && outcomes.has(cid)) {
1482
- failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
1483
- `${pyRepr(toPlain(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
1793
+ failures.add("duplicate_outcome", `duplicate_outcome: call_id ${shown(e["call_id"])} at seq ${shown(intOr(e["seq"]))} (first at seq ` +
1794
+ `${shown(intOr(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
1484
1795
  continue;
1485
1796
  }
1486
1797
  if (cid !== null)
@@ -1501,25 +1812,26 @@ function executionBinding(entries, bundleV) {
1501
1812
  for (const [cid, oc] of outcomes) {
1502
1813
  const allowE = allows.get(cid);
1503
1814
  if (allowE === undefined) {
1504
- failures.add("outcome_without_allow", `outcome_without_allow: call_id ${cid} at seq ${pyRepr(toPlain(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
1815
+ failures.add("outcome_without_allow", `outcome_without_allow: call_id ${shown(oc["call_id"])} at seq ${shown(intOr(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1505
1816
  continue;
1506
1817
  }
1507
1818
  const nodeOk = toPlain(allowE["node"]) === toPlain(oc["node"]);
1508
1819
  if (!nodeOk) {
1509
- failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr(toPlain(allowE["node"]))} but ` +
1510
- `outcome on node ${pyRepr(toPlain(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
1820
+ failures.add("cross_ref", `cross_ref: call_id ${shown(oc["call_id"])} allow on node ${pyRepr(allowE["node"] ?? null)} but ` +
1821
+ `outcome on node ${pyRepr(oc["node"] ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1511
1822
  }
1512
1823
  const ocSeq = toPlain(oc["seq"]);
1513
1824
  const allowSeq = toPlain(allowE["seq"]);
1514
1825
  const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
1515
1826
  if (!orderOk) {
1516
- failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
1517
- `after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
1827
+ failures.add("outcome_before_allow", `outcome_before_allow: call_id ${shown(oc["call_id"])} outcome seq ${shown(intOr(oc["seq"]))} not ` +
1828
+ `after allow seq ${shown(intOr(allowE["seq"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1518
1829
  }
1519
1830
  const ah = toPlain(allowE["authorized_params_hash"]);
1520
1831
  const ih = toPlain(oc["invoked_params_hash"]);
1521
1832
  if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
1522
- failures.add("params_mismatch", `params_mismatch: call_id ${cid} authorized_params_hash ${ah} != invoked_params_hash ${ih}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
1833
+ failures.add("params_mismatch", `params_mismatch: call_id ${shown(oc["call_id"])} authorized_params_hash ` +
1834
+ `${shown(allowE["authorized_params_hash"])} != invoked_params_hash ${shown(oc["invoked_params_hash"])}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1523
1835
  }
1524
1836
  if (nodeOk && orderOk)
1525
1837
  boundOk.add(cid);
@@ -1545,7 +1857,8 @@ function executionBinding(entries, bundleV) {
1545
1857
  }
1546
1858
  else {
1547
1859
  perCall[cid] = "unaccounted";
1548
- const node = toPlain(allowE["node"]);
1860
+ const raw = toPlain(allowE["node"]);
1861
+ const node = typeof raw === "string" ? raw : null;
1549
1862
  const list = nodePending.get(node) ?? [];
1550
1863
  list.push(cid);
1551
1864
  nodePending.set(node, list);
@@ -1601,16 +1914,16 @@ function executionBinding(entries, bundleV) {
1601
1914
  ];
1602
1915
  }
1603
1916
  /**
1604
- * `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
1917
+ * The index of the FIRST entry the hash chain does not reproduce at — position only.
1605
1918
  *
1606
1919
  * `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
1607
1920
  * module reports; this walk exists so the structured twin of that message can say WHERE, which
1608
1921
  * the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
1609
- * exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
1610
- * consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
1611
- * chain-level.
1922
+ * exactly (same seq/prev_hash/hash order, and the same rule that a seq is an integral number and
1923
+ * never a boolean, `integral`). `null` when nothing entry-local is wrong — a consistently
1924
+ * re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
1612
1925
  */
1613
- function integrityPosition(entries) {
1926
+ export function integrityBreak(entries) {
1614
1927
  let prev = GENESIS;
1615
1928
  for (let i = 0; i < entries.length; i++) {
1616
1929
  const e = entries[i];
@@ -1622,19 +1935,19 @@ function integrityPosition(entries) {
1622
1935
  let broken;
1623
1936
  try {
1624
1937
  broken =
1625
- orNull(e["seq"]) !== i ||
1938
+ integral(e["seq"]) !== i ||
1626
1939
  orNull(payload["prev_hash"]) !== prev ||
1627
1940
  hashEntry(prev, payload) !== orNull(e["hash"]);
1628
1941
  }
1629
1942
  catch {
1630
1943
  // An unhashable payload is itself the break, at this entry.
1631
- return [orNull(e["seq"]), orNull(e["node"])];
1944
+ return i;
1632
1945
  }
1633
1946
  if (broken)
1634
- return [orNull(e["seq"]), orNull(e["node"])];
1947
+ return i;
1635
1948
  prev = orNull(e["hash"]);
1636
1949
  }
1637
- return [null, null];
1950
+ return null;
1638
1951
  }
1639
1952
  export function verifyBundle(bundle, signer = null, options = {}) {
1640
1953
  const entries = bundle.entries ?? [];
@@ -1654,17 +1967,18 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1654
1967
  };
1655
1968
  const log = new FailureLog();
1656
1969
  // (0) version: the bundle must declare a schema version this build understands, and — when
1657
- // an anchor is present — the anchor must be anchoring THAT version, not a different one.
1658
- const bundleV = toPlain(bundle.v);
1659
- let versionOk = typeof bundleV === "number" && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
1970
+ // an anchor is present — the anchor must be anchoring THAT version, not a different one. A
1971
+ // version is read by the seq rule (`integral`): `2.0` is 2, and `true` is not.
1972
+ const rawV = bundle.v;
1973
+ const bundleV = integral(rawV);
1974
+ let versionOk = bundleV !== null && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
1660
1975
  if (!versionOk) {
1661
1976
  const supported = Array.from(SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
1662
- log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
1977
+ log.add("unsupported_version", `unsupported_version: bundle v=${reprIntOr(rawV)} not in [${supported.join(", ")}]`);
1663
1978
  }
1664
- const anchorV = toPlain(anchor["v"]);
1665
- if (anchorPresent && anchorV !== bundleV) {
1979
+ if (anchorPresent && !sameNumber(anchor["v"], rawV)) {
1666
1980
  versionOk = false;
1667
- log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
1981
+ log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${reprIntOr(anchor["v"])} != bundle v=${reprIntOr(rawV)}`);
1668
1982
  }
1669
1983
  // (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
1670
1984
  // sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
@@ -1676,17 +1990,19 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1676
1990
  const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
1677
1991
  // 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
1678
1992
  // entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
1679
- if (rootEntry !== undefined && toPlain(rootEntry["v"]) !== bundleV) {
1993
+ if (rootEntry !== undefined && !sameNumber(rootEntry["v"], rawV)) {
1680
1994
  versionOk = false;
1681
- log.add("root_version_mismatch", `root_version_mismatch: root v=${pyRepr(toPlain(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]) });
1995
+ log.add("root_version_mismatch", `root_version_mismatch: root v=${reprIntOr(rootEntry["v"])} != bundle v=${reprIntOr(rawV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]), entry: rootEntry });
1682
1996
  }
1683
- const mixedEntries = entries.filter((e) => toPlain(e["v"]) !== bundleV);
1684
- const mixed = Array.from(new Set(mixedEntries.map((e) => toPlain(e["v"])))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
1997
+ const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
1998
+ // An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
1999
+ // everything else by its repr, compared by code point as Python compares strings, never by locale.
2000
+ const mixed = pySetOf(mixedEntries.map((e) => (intOr(e["v"]) ?? null))).sort(compareVersions);
1685
2001
  if (mixed.length > 0) {
1686
2002
  versionOk = false;
1687
2003
  // One aggregate message over every offending entry (unchanged); the twin is positioned on
1688
2004
  // the first of them, which is where a reader looks.
1689
- log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]) });
2005
+ log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${reprIntOr(rawV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]), entry: mixedEntries[0] });
1690
2006
  }
1691
2007
  checks.version = versionOk;
1692
2008
  // (0b2) every entry must be read WHOLE. `LEDGER_FIELDS` was enforced only on the EXPORT path
@@ -1712,10 +2028,24 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1712
2028
  if (extra.length > 0) {
1713
2029
  unknownOk = false;
1714
2030
  log.add("unknown_ledger_fields", `unknown_ledger_fields: entry carries fields this verifier does not evaluate and will ` +
1715
- `not ignore: ${extra.join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
2031
+ `not ignore: ${extra.map((f) => shown(f)).join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
1716
2032
  }
1717
2033
  }
1718
2034
  checks.ledger_fields = unknownOk;
2035
+ // (0b3) a node id is a string (the schema's `node`). A root, a spawn and an allow are judged by
2036
+ // the checks that read their node, below; any other entry that carries a node which is a list,
2037
+ // an object, a number or a boolean is reported here, never read by projection and never used as
2038
+ // a key. A null node counts as absent.
2039
+ for (const e of entries) {
2040
+ const ev = toPlain(e["event"]);
2041
+ if (ev === "root" || ev === "spawn" || ev === "allow")
2042
+ continue;
2043
+ const node = toPlain(e["node"]);
2044
+ if (node === undefined || node === null || typeof node === "string")
2045
+ continue;
2046
+ log.add("invalid_node", `invalid_node: seq=${shown(intOr(e["seq"]))} event=${pyRepr((e["event"] ?? null))} ` +
2047
+ `carries node ${shown(e["node"])}, which is not a string`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
2048
+ }
1719
2049
  // (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
1720
2050
  // computed head, never against its own (possibly forged) enclosed anchor.
1721
2051
  const { expectedAnchor = null, expectedHead = null } = options;
@@ -1727,7 +2057,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1727
2057
  const [expSeq, expHash] = expectedHead;
1728
2058
  if (actualSeq !== expSeq || actualHead !== expHash) {
1729
2059
  expectedOk = false;
1730
- log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
2060
+ log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${shown(actualHead)}) but the ` +
1731
2061
  `independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
1732
2062
  }
1733
2063
  }
@@ -1736,7 +2066,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1736
2066
  if (toPlain(ea["seq"]) !== actualSeq ||
1737
2067
  toPlain(ea["head"]) !== actualHead ||
1738
2068
  toPlain(ea["chain_id"]) !== toPlain(bundle.chain_id) ||
1739
- toPlain(ea["v"]) !== bundleV) {
2069
+ !sameNumber(ea["v"], rawV)) {
1740
2070
  expectedOk = false;
1741
2071
  log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
1742
2072
  "the independently retained expected anchor");
@@ -1754,6 +2084,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1754
2084
  log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
1755
2085
  seq: orNull(foreign["seq"]),
1756
2086
  node: orNull(foreign["node"]),
2087
+ entry: foreign,
1757
2088
  });
1758
2089
  }
1759
2090
  const anchorChainId = orNull(anchor["chain_id"]);
@@ -1765,8 +2096,13 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1765
2096
  // (1) integrity: the hash chain, plus the signed anchor when a key is given.
1766
2097
  const [okChain, err] = AuditLog.verify(entries);
1767
2098
  if (!okChain) {
1768
- const [badSeq, badNode] = integrityPosition(entries);
1769
- log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
2099
+ const bad = integrityBreak(entries);
2100
+ const badEntry = bad === null ? null : entries[bad];
2101
+ log.add("integrity", `integrity: ${err}`, {
2102
+ seq: badEntry === null ? null : orNull(badEntry["seq"]),
2103
+ node: badEntry === null ? null : orNull(badEntry["node"]),
2104
+ entry: badEntry,
2105
+ });
1770
2106
  }
1771
2107
  if (signer !== null) {
1772
2108
  const [okAnchor, aerr] = AuditLog.verifyAnchor(entries, anchor, signer);
@@ -1780,24 +2116,62 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1780
2116
  else {
1781
2117
  checks.integrity = okChain;
1782
2118
  }
1783
- const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
2119
+ const { auth, failures: afail } = nodeAuthorities(entries);
1784
2120
  log.extend(afail);
1785
- // (2) monotonicity: every child ⊆ its parent.
2121
+ // (2) monotonicity: every child ⊆ its parent, read in ledger order. Every spawn is checked, and
2122
+ // the node it names as `parent` has to be one the root or an EARLIER spawn defined, not revoked
2123
+ // by an earlier kill, and not the spawn's own node. Through 0.12.0 a spawn whose parent was
2124
+ // absent, null, or named no node in the bundle was skipped, so a child widened past the
2125
+ // authority it was really given verified OK; the process being watched writes that field. A
2126
+ // node is defined once: a second definition is a failure too, since only one of the two could
2127
+ // be read. The Python implementation's same pass, finding for finding.
1786
2128
  let mono = true;
1787
- for (const [node, pid] of parent) {
1788
- if (pid === null || !auth.has(pid) || !auth.has(node))
2129
+ const definedAt = new Map(); // node -> the entry that defined it, as of this point
2130
+ const revokedAt = new Map(); // node -> the seq of the kill that revoked it
2131
+ for (const e of entries) {
2132
+ const ev = toPlain(e["event"]);
2133
+ if (ev === "kill") {
2134
+ noteRevoked(e, revokedAt);
2135
+ continue;
2136
+ }
2137
+ if (ev !== "root" && ev !== "spawn")
1789
2138
  continue;
1790
- const child = auth.get(node);
1791
- const p = auth.get(pid);
1792
- // 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
1793
- // non-wildcard-aware scope difference, which silently accepted a delegation that widened
1794
- // only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
1795
- // the parent's — the child was more powerful and the bundle verified clean.
1796
- if (!child.isNarrowerThan(p)) {
2139
+ const node = toPlain(e["node"]);
2140
+ if (typeof node !== "string")
2141
+ continue; // unreadable, reported by nodeAuthorities
2142
+ const position = { seq: orNull(e["seq"]), node, entry: e };
2143
+ const first = definedAt.get(node);
2144
+ if (first !== undefined) {
1797
2145
  mono = false;
1798
- const spawnE = definedBy.get(node);
1799
- log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (${monotonicityDetail(child, p)})`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
2146
+ log.add("monotonicity", `monotonicity: ${shown(node)} is defined a second time in this bundle (first at seq ${shown(intOr(first["seq"]))})`, position);
2147
+ continue;
2148
+ }
2149
+ if (ev === "spawn") {
2150
+ const pid = toPlain(e["parent"]);
2151
+ if (typeof pid !== "string" || !definedAt.has(pid)) {
2152
+ mono = false;
2153
+ log.add("monotonicity", `monotonicity: ${shown(node)} names no parent defined earlier in this bundle (parent ${shown(e["parent"])})`, position);
2154
+ }
2155
+ else if (revokedAt.has(pid)) {
2156
+ mono = false;
2157
+ log.add("monotonicity", `monotonicity: ${shown(node)} is spawned from ${shown(pid)} after ${shown(pid)} was revoked at seq ` +
2158
+ shown(intOr(revokedAt.get(pid))), position);
2159
+ }
2160
+ else {
2161
+ // 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
2162
+ // non-wildcard-aware scope difference, which silently accepted a delegation that widened
2163
+ // only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
2164
+ // the parent's. An unreadable authority on either side is reported by nodeAuthorities
2165
+ // and fails this check on its own.
2166
+ const child = auth.get(node);
2167
+ const p = auth.get(pid);
2168
+ if (child !== undefined && p !== undefined && !child.isNarrowerThan(p)) {
2169
+ mono = false;
2170
+ log.add("monotonicity", `monotonicity: ${shown(node)} not ⊆ parent ${shown(pid)} (${monotonicityDetail(child, p)})`, position);
2171
+ }
2172
+ }
1800
2173
  }
2174
+ definedAt.set(node, e);
1801
2175
  }
1802
2176
  checks.monotonicity = mono && afail.length === 0;
1803
2177
  // (3) containment: every allowed action's scope within the acting node's authority.
@@ -1807,11 +2181,27 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1807
2181
  // a violation the entry never asserted. Such entries are counted as UNGATED and reported as
1808
2182
  // their own number instead: a reader sees how much of the run was actually measured, which is
1809
2183
  // the honest answer and never a silent one.
2184
+ // Read in ledger order, like monotonicity: an allow is judged against a node defined EARLIER (one
2185
+ // defined only later is unknown at that point), and an allow on a node an earlier kill revoked
2186
+ // is outside its authority, since a revoked node holds none.
1810
2187
  let contained = true;
1811
2188
  let actions = 0;
1812
2189
  let ungated = 0;
2190
+ const known = new Set();
2191
+ const killedAt = new Map();
1813
2192
  for (const e of entries) {
1814
- if (toPlain(e["event"]) !== "allow")
2193
+ const ev = toPlain(e["event"]);
2194
+ if (ev === "root" || ev === "spawn") {
2195
+ const defined = toPlain(e["node"]);
2196
+ if (typeof defined === "string")
2197
+ known.add(defined);
2198
+ continue;
2199
+ }
2200
+ if (ev === "kill") {
2201
+ noteRevoked(e, killedAt);
2202
+ continue;
2203
+ }
2204
+ if (ev !== "allow")
1815
2205
  continue;
1816
2206
  if (isKnownPolicy(toPlain(e["policy"]))) {
1817
2207
  // Only a policy value the format DEFINES buys the exemption. An entry carrying anything
@@ -1823,21 +2213,39 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1823
2213
  actions += 1;
1824
2214
  const node = toPlain(e["node"]);
1825
2215
  const scope = toPlain(e["scope"]);
1826
- const ctx = toPlain(e["context"]) ?? {};
1827
- const a = auth.get(node);
2216
+ // The Python implementation reads `e.get("context") or {}`, so an absent context and any value
2217
+ // Python counts as false (null, false, 0, "", [] or {}) are no context. Any other value that
2218
+ // is not an object is not a context the allow could have been checked against, and it does not
2219
+ // verify: it is the containment finding below. (Python 0.19.0 raises on it.)
2220
+ const rawContext = toPlain(e["context"]);
2221
+ const noContext = pyFalsy(rawContext);
2222
+ const contextIsObject = isRecordLike(rawContext);
2223
+ const ctx = (noContext ? {} : rawContext);
2224
+ const a = typeof node === "string" && known.has(node) ? auth.get(node) : undefined;
1828
2225
  if (a === undefined) {
1829
2226
  contained = false;
1830
- log.add("containment", `containment: allow on unknown node ${node}`, {
2227
+ log.add("containment", `containment: allow on unknown node ${shown(e["node"])}`, {
1831
2228
  seq: orNull(e["seq"]),
1832
2229
  node: orNull(e["node"]),
1833
2230
  callId: orNull(e["call_id"]),
2231
+ entry: e,
1834
2232
  });
1835
2233
  continue;
1836
2234
  }
1837
- if (!a.permits(scope, ctx).allowed) {
2235
+ if (killedAt.has(node)) {
2236
+ contained = false;
2237
+ log.add("containment", `containment: allow of ${pyRepr(e["scope"] ?? null)} on ${shown(e["node"])} after ${shown(e["node"])} was ` +
2238
+ `revoked at seq ${shown(intOr(killedAt.get(node)))}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
2239
+ continue;
2240
+ }
2241
+ // A scope that is not a string is no scope the node can hold. Against a wildcard it threw
2242
+ // (`startsWith` on a number or null) out of the verifier; it is this finding either way, as
2243
+ // in the Python implementation. A context that is neither absent, nor false to Python, nor an
2244
+ // object is this finding too.
2245
+ if (typeof scope !== "string" || !(noContext || contextIsObject) || !a.permits(scope, ctx).allowed) {
1838
2246
  contained = false;
1839
- log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
1840
- `[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]) });
2247
+ log.add("containment", `containment: allow of ${pyRepr(e["scope"] ?? null)} on ${shown(e["node"])} outside its authority ` +
2248
+ reprList(Array.from(a.scopes).sort(compareCodePoints)), { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
1841
2249
  }
1842
2250
  }
1843
2251
  checks.containment = contained;
@@ -1851,7 +2259,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1851
2259
  // nothing — but a PRESENT one has to verify, and a broken one lands in this same list. The
1852
2260
  // per-entry state is reported either way, so a reader sees which hops were covered before
1853
2261
  // reading which one failed.
1854
- const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
2262
+ const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
1855
2263
  if (bundle.envelopes === undefined) {
1856
2264
  envelopeSummary.status = "not present";
1857
2265
  }
@@ -1875,6 +2283,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1875
2283
  checks,
1876
2284
  failures: log.messages,
1877
2285
  failure_details: log.details,
2286
+ failure_entries: log.entryIndices(entries),
1878
2287
  nodes: auth.size,
1879
2288
  actions_checked: actions,
1880
2289
  ungated,