attenu-guard 0.7.0 → 0.8.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.
@@ -30,13 +30,14 @@
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, toPlain, } from "./canonical.js";
33
+ import { canonicalBytes, compareCodePoints, 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
37
  import { describe as describeCeiling } from "./ceilings.js";
38
38
  import { CAPTURES, BODY_STATES, BodyState, Capture } from "./reasons.js";
39
39
  import { PARAMS_HASH_REASONS } from "./params.js";
40
+ import { Ed25519Signer, Ed25519Verifier } from "./wire.js";
40
41
  /**
41
42
  * The COMPLETE set of top-level ledger field names the library emits. Custody
42
43
  * guarantee: an exported bundle may carry ONLY these — an unknown field is
@@ -178,8 +179,18 @@ export function anchorFor(entries, signer, ts = 0) {
178
179
  * only ever removes. With `strict`, the bundle is checked against
179
180
  * `LEDGER_FIELDS` (and `contextAllowlist` if given) and an `EvidenceLeakError`
180
181
  * is thrown on any field outside it.
182
+ *
183
+ * ORDER MATTERS with `redactTask`: redaction rewrites every entry hash, so envelopes signed
184
+ * over the unredacted ledger no longer bind to the entries that ship and would all fail
185
+ * `envelope_subject_mismatch`. Giving both throws `Error` rather than exporting a bundle that
186
+ * cannot verify. Export the redacted bundle first, then `signEnvelope` over ITS entries, then
187
+ * export again with those envelopes.
181
188
  */
182
189
  export function exportBundle(auditLog, signer, options = {}) {
190
+ if (options.redactTask && (options.envelopes?.length ?? 0) > 0) {
191
+ throw new Error("sign envelopes over the redacted ledger: export with redact_task=True first, then " +
192
+ "sign_envelope over the exported entries");
193
+ }
183
194
  const source = auditLog instanceof AuditLog ? auditLog.entries : auditLog;
184
195
  const entries = source.map((e) => ({ ...e }));
185
196
  if (options.redactTask) {
@@ -206,7 +217,7 @@ export function exportBundle(auditLog, signer, options = {}) {
206
217
  }
207
218
  const anchor = anchorFor(entries, signer, options.ts ?? 0);
208
219
  anchor.verified = AuditLog.verifyAnchor(entries, anchor, signer)[0];
209
- return {
220
+ const bundle = {
210
221
  v: bundleVersion(entries),
211
222
  c14n: "JCS",
212
223
  chain_id: chainIdOf(entries),
@@ -215,6 +226,10 @@ export function exportBundle(auditLog, signer, options = {}) {
215
226
  redaction: report,
216
227
  note: "offline-verifiable: attenu_guard.evidence.verify_bundle(bundle, signer)",
217
228
  };
229
+ const envelopes = options.envelopes ?? null;
230
+ if (envelopes !== null && envelopes.length > 0)
231
+ bundle.envelopes = [...envelopes];
232
+ return bundle;
218
233
  }
219
234
  /**
220
235
  * A ledger field, or `null` when it is absent. Python's `dict.get` yields `None`
@@ -444,13 +459,580 @@ export function denials(bundle) {
444
459
  function pyRepr(value) {
445
460
  if (value === null)
446
461
  return "None";
462
+ if (typeof value === "boolean")
463
+ return value ? "True" : "False";
447
464
  if (typeof value === "number")
448
465
  return String(value);
449
466
  if (typeof value === "string")
450
467
  return `'${value}'`;
468
+ // Containers reach here only on hostile input — a subject member that is a list or an object
469
+ // where the contract wants a scalar. Rendered the way Python's repr renders them, because the
470
+ // two implementations report the same failure strings and a JSON spelling would not match.
471
+ if (Array.isArray(value))
472
+ return `[${value.map((v) => pyRepr(v)).join(", ")}]`;
473
+ if (typeof value === "object") {
474
+ const body = Object.entries(value)
475
+ .map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`)
476
+ .join(", ");
477
+ return `{${body}}`;
478
+ }
451
479
  return JSON.stringify(value);
452
480
  }
453
481
  // =============================================================================================
482
+ // Observer envelopes (envelope v1) — the TypeScript half of `attenu_guard.evidence`'s.
483
+ //
484
+ // One question a reader of a bundle cannot answer today: was this delegation event signed by
485
+ // something OUTSIDE the process that wrote it? An envelope is a witness's signature over the
486
+ // IDENTITY of one committed ledger entry — never over its contents, which the entry's own hash
487
+ // already covers. Envelopes travel beside the ledger in a top-level `envelopes` array; no entry
488
+ // changes, so a bundle without them stays valid exactly as it is today.
489
+ //
490
+ // An envelope is never REQUIRED. An absent one is the status quo and changes nothing. A present
491
+ // one has to verify: a broken envelope lands in the same failure list as the chain-level checks
492
+ // and the bundle rejects. Byte-compatible with the Python implementation, and scored against the
493
+ // same `envelope_vectors_v1.json`.
494
+ // =============================================================================================
495
+ /**
496
+ * The only envelope version this build knows. The version commits the exact signed member set of
497
+ * the WHOLE envelope, the subject included, so a member added anywhere is a new version and the
498
+ * digest cannot widen silently.
499
+ */
500
+ export const ENVELOPE_VERSION = 1;
501
+ /** The only `typ` at v1. A different one is a different contract, not a different envelope. */
502
+ export const ENVELOPE_TYP = "delegation-event-observation";
503
+ /** The envelope's own member set at v1. */
504
+ export const ENVELOPE_MEMBERS = new Set([
505
+ "v",
506
+ "typ",
507
+ "subject",
508
+ "observed",
509
+ "witness",
510
+ "sig",
511
+ ]);
512
+ /**
513
+ * The subject member set, keyed by `event`. v1 defines a subject for `spawn` and `allow` and for
514
+ * no other event. `entry_hash` is the BINDING member — the only evidence of WHICH entry the
515
+ * witness signed — and the rest are locators, whose job is to find the entry without hashing
516
+ * every entry.
517
+ */
518
+ export const ENVELOPE_SUBJECT_MEMBERS = new Map([
519
+ ["spawn", new Set(["chain_id", "node", "seq", "entry_hash", "event"])],
520
+ ["allow", new Set(["chain_id", "node", "seq", "entry_hash", "event", "call_id"])],
521
+ ]);
522
+ const ENVELOPE_OBSERVED_MEMBERS = new Set(["result", "at", "method"]);
523
+ const ENVELOPE_WITNESS_MEMBERS = new Set(["kid", "alg"]);
524
+ /**
525
+ * `observed.result`'s closed vocabulary. `not_matched` requires evidence that CONTRADICTS the
526
+ * event; `indeterminate` is the residual state, and covers thin or absent evidence. No verifier
527
+ * decision turns on the result: it is reported next to the state, never instead of it.
528
+ */
529
+ export const ENVELOPE_RESULTS = ["matched", "not_matched", "indeterminate"];
530
+ /** The JOSE identifier for Ed25519, and the only `witness.alg` v1 defines. */
531
+ export const ENVELOPE_ALG = "EdDSA";
532
+ /**
533
+ * A verifying envelope's state. It says where the signature came from and NOTHING about
534
+ * authority — the witness is whoever holds the key `witness.kid` names, which nothing in the
535
+ * envelope makes the delegation parent.
536
+ */
537
+ export const WITNESS_SIGNED = "witness-signed";
538
+ /**
539
+ * No envelope, or one that does not verify. It covers two facts a bundle does not separate — a
540
+ * hop nobody undertook to cover, and a hop a witness undertook to cover and never did — and v1
541
+ * takes the weaker reading of the two.
542
+ */
543
+ export const PROCESS_ASSERTED = "process-asserted";
544
+ /** The seven named envelope failures, in the order this build checks them. */
545
+ export const ENVELOPE_FAILURES = [
546
+ "envelope_unknown_version",
547
+ "envelope_unknown_member",
548
+ "envelope_subject_mismatch",
549
+ "envelope_duplicate_subject",
550
+ "envelope_non_canonical",
551
+ "envelope_unknown_witness",
552
+ "envelope_bad_signature",
553
+ ];
554
+ /**
555
+ * The bytes a witness signs: `JCS(envelope minus its "sig" member)`.
556
+ *
557
+ * The same RFC 8785 canonicalization the ledger has signed with since 0.7.0 — one
558
+ * implementation, not a second one for envelopes.
559
+ */
560
+ export function envelopeSigningInput(envelope) {
561
+ const body = {};
562
+ for (const [k, v] of Object.entries(envelope))
563
+ if (k !== "sig")
564
+ body[k] = v;
565
+ return canonicalBytes(body);
566
+ }
567
+ /**
568
+ * seq -> the entry's hash RECOMPUTED from the bundle, never read off the entry.
569
+ *
570
+ * `entry_hash` in a subject is checked against this. The walk mirrors `AuditLog.verify`, so an
571
+ * entry whose stored `hash` was replaced does not get to supply the value it is compared against.
572
+ */
573
+ function recomputedHashes(entries) {
574
+ const out = new Map();
575
+ let prev = GENESIS;
576
+ entries.forEach((e, i) => {
577
+ const payload = {};
578
+ for (const [k, v] of Object.entries(e))
579
+ if (k !== "hash")
580
+ payload[k] = v;
581
+ let computed;
582
+ try {
583
+ computed = hashEntry(prev, payload);
584
+ }
585
+ catch {
586
+ // An unhashable payload has no recomputable hash; that IS the break, at this entry.
587
+ computed = null;
588
+ }
589
+ out.set(orNull(e["seq"]) ?? i, computed);
590
+ prev = computed ?? GENESIS;
591
+ });
592
+ return out;
593
+ }
594
+ /**
595
+ * The v1 subject for the entry at `seq`, recomputed from the ledger.
596
+ *
597
+ * Throws when `seq` names no entry, or names one whose `event` v1 defines no subject for.
598
+ */
599
+ export function envelopeSubject(entries, seq) {
600
+ const entry = entries.find((e) => toPlain(e["seq"]) === seq);
601
+ if (entry === undefined)
602
+ throw new Error(`no entry at seq ${seq}`);
603
+ const event = toPlain(entry["event"]);
604
+ if (!ENVELOPE_SUBJECT_MEMBERS.has(event)) {
605
+ throw new Error(`envelope v${ENVELOPE_VERSION} defines no subject for event '${event}'`);
606
+ }
607
+ const subject = {
608
+ chain_id: orNull(entry["chain_id"]),
609
+ node: orNull(entry["node"]),
610
+ seq,
611
+ entry_hash: recomputedHashes(entries).get(seq) ?? null,
612
+ event,
613
+ };
614
+ if (event === "allow")
615
+ subject["call_id"] = orNull(entry["call_id"]);
616
+ return subject;
617
+ }
618
+ /**
619
+ * An observer envelope over the entry at `seq`, signed with the 32-byte Ed25519 `seed`.
620
+ *
621
+ * `entries` is the ledger the subject is recomputed from — a witness signs the identity of an
622
+ * entry that already exists, never a claim it composes itself. That makes the ledger it is
623
+ * signed over part of the signature: sign over the entries AS THEY WILL SHIP. With
624
+ * `exportBundle({redactTask: true})` those are the redacted entries, so export first and sign
625
+ * over the exported bundle's `entries` — `exportBundle` refuses to redact and carry envelopes in
626
+ * one call for exactly this reason.
627
+ */
628
+ export function signEnvelope(entries, seq, seed, kid, observed) {
629
+ const result = observed.result ?? "matched";
630
+ if (!ENVELOPE_RESULTS.includes(result)) {
631
+ throw new Error(`observed.result must be one of [${ENVELOPE_RESULTS.join(", ")}], got '${result}'`);
632
+ }
633
+ const body = {
634
+ v: ENVELOPE_VERSION,
635
+ typ: ENVELOPE_TYP,
636
+ subject: envelopeSubject(entries, seq),
637
+ observed: { result, at: observed.at, method: observed.method },
638
+ witness: { kid, alg: ENVELOPE_ALG },
639
+ };
640
+ const sig = Ed25519Signer.fromPrivateBytes(seed, kid).sign(envelopeSigningInput(body));
641
+ return { ...body, sig: sig.toString("hex") };
642
+ }
643
+ /**
644
+ * The 32-byte Ed25519 public key for `kid`, or an `Error` naming it.
645
+ *
646
+ * A trust set is CALLER CONFIGURATION, not bundle content, so a malformed row is a mistake in the
647
+ * deployment and failing loudly is the only way it does not become a silent downgrade: coercing a
648
+ * number would fabricate zero bytes, and every envelope from that witness would then fail on its
649
+ * SIGNATURE, reading as a witness who signed badly rather than as a trust set never configured.
650
+ */
651
+ function witnessPublicKey(kid, value) {
652
+ if (typeof value === "string") {
653
+ if (value.length !== 64) {
654
+ throw new Error(`witness key '${kid}': public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
655
+ `got ${value.length}`);
656
+ }
657
+ if (!/^[0-9a-fA-F]{64}$/.test(value)) {
658
+ throw new Error(`witness key '${kid}': public_key_hex is not hexadecimal`);
659
+ }
660
+ return Buffer.from(value, "hex");
661
+ }
662
+ if (value instanceof Uint8Array) {
663
+ if (value.length !== 32) {
664
+ throw new Error(`witness key '${kid}': an Ed25519 public key is 32 bytes, got ${value.length}`);
665
+ }
666
+ return Buffer.from(value);
667
+ }
668
+ throw new Error(`witness key '${kid}': expected 64 hex characters or 32 bytes`);
669
+ }
670
+ /**
671
+ * kid -> `[alg, raw public key]`, from the vector file's own `witness_keys` shape or from a plain
672
+ * `{kid: publicKeyBytes}` record.
673
+ *
674
+ * `null`/absent means no trust anchor is configured, which is an EMPTY set, not an absent check:
675
+ * an envelope naming a kid nobody trusts is `envelope_unknown_witness`, and that is the honest
676
+ * answer whether the trust set is empty or merely does not contain it.
677
+ *
678
+ * Every row is validated here and a bad one throws, naming its kid. This is the one envelope
679
+ * input that is NOT attacker-supplied — the deployment chose these keys — so a mistake in them is
680
+ * reported to the caller rather than folded into a finding about the bundle. v1 defines Ed25519
681
+ * and no other algorithm, so a row declaring anything else is refused too.
682
+ */
683
+ function trustedWitnesses(witnessKeys) {
684
+ const trusted = new Map();
685
+ if (witnessKeys === null || witnessKeys === undefined)
686
+ return trusted;
687
+ const rows = Array.isArray(witnessKeys)
688
+ ? witnessKeys.map((k) => [isRecordLike(k) ? k["kid"] : undefined, k])
689
+ : Object.entries(witnessKeys);
690
+ for (const [kid, value] of rows) {
691
+ if (typeof kid !== "string")
692
+ throw new Error("witness key kid must be a string");
693
+ let key = value;
694
+ if (isRecordLike(value)) {
695
+ const alg = value["alg"];
696
+ if (alg !== ENVELOPE_ALG) {
697
+ throw new Error(`witness key '${kid}': alg must be '${ENVELOPE_ALG}', got ${pyRepr(alg)}`);
698
+ }
699
+ key = value["public_key_hex"];
700
+ }
701
+ trusted.set(kid, [ENVELOPE_ALG, witnessPublicKey(kid, key)]);
702
+ }
703
+ return trusted;
704
+ }
705
+ /** A plain object (not an array, not null, not a Buffer). */
706
+ function isRecordLike(v) {
707
+ return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Uint8Array);
708
+ }
709
+ /**
710
+ * One `envelopeBytes` element as bytes, or null when it is not bytes at all.
711
+ *
712
+ * Coercing a number would fabricate that many ZERO bytes, turning a caller's mistake into a
713
+ * canonicality finding about the bundle; hex that does not parse is the same mistake in a
714
+ * different shape. Neither is coerced.
715
+ */
716
+ function receivedBytes(raw) {
717
+ if (typeof raw === "string") {
718
+ if (raw.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(raw))
719
+ return null;
720
+ return Buffer.from(raw, "hex");
721
+ }
722
+ if (raw instanceof Uint8Array)
723
+ return Buffer.from(raw);
724
+ return null;
725
+ }
726
+ /**
727
+ * A subject `seq` this build will look an entry up by: a JSON integer, and never a boolean.
728
+ *
729
+ * The type check comes first and every use of `seq` is behind it — in Python an unguarded lookup
730
+ * raises on a list or an object and finds the entry at seq 1 for `true`, and the two
731
+ * implementations report the same failure for the same bundle.
732
+ */
733
+ function isSeq(value) {
734
+ return typeof value === "number" && Number.isInteger(value);
735
+ }
736
+ /**
737
+ * The report line: the state and the result together, in the same form for all three results. A
738
+ * process-asserted entry gets no result.
739
+ */
740
+ function envelopeLine(state, result) {
741
+ return state === WITNESS_SIGNED ? `${state} (${String(result)})` : state;
742
+ }
743
+ /**
744
+ * Score every envelope in the bundle and derive the per-entry state.
745
+ *
746
+ * Two rules bind where a failure may land: an envelope failure lands only on the hop that
747
+ * envelope covers, never on a hop coverage skipped; and no chain-level integrity failure is ever
748
+ * raised because an envelope failed — that one comes from a real anchor mismatch and from
749
+ * nothing else.
750
+ *
751
+ * One entry, at most one envelope. A second envelope naming a `subject.seq` an earlier one in
752
+ * this array already named is `envelope_duplicate_subject`, and the entry falls back to
753
+ * `process-asserted`: two observations of one event contradict each other by construction —
754
+ * whoever appends the second decides what the first said, and an entry whose coverage is
755
+ * disputed must not read as clean.
756
+ */
757
+ function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
758
+ const fail = new FailureLog();
759
+ const states = {};
760
+ const results = {};
761
+ entries.forEach((e, i) => {
762
+ states[String(orNull(e["seq"]) ?? i)] = PROCESS_ASSERTED;
763
+ });
764
+ // The hash walk is what an envelope's binding member is checked against; a bundle carrying
765
+ // none does not pay for it. Every entry is process-asserted in that case, which is the status
766
+ // quo and exactly what this reports.
767
+ const bySeq = new Map();
768
+ let recomputed = new Map();
769
+ if (envelopes.length > 0) {
770
+ entries.forEach((e, i) => bySeq.set(orNull(e["seq"]) ?? i, e));
771
+ recomputed = recomputedHashes(entries);
772
+ }
773
+ // seq -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts an
774
+ // envelope in as soon as its subject names an entry this bundle has.
775
+ const claims = new Map();
776
+ envelopes.forEach((envelope, index) => {
777
+ const raw = rawBytes !== null && index < rawBytes.length ? rawBytes[index] ?? null : null;
778
+ const covered = scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, claims);
779
+ if (covered === null)
780
+ return;
781
+ states[String(covered.seq)] = WITNESS_SIGNED;
782
+ results[String(covered.seq)] = covered.result;
783
+ });
784
+ // The first envelope's result stands in `results` — it is what that witness said, and the
785
+ // duplicate does not erase it — but the STATE falls back, so a contradicted entry never
786
+ // reports witness-signed and the bundle rejects.
787
+ for (const [seq, count] of claims) {
788
+ if (count > 1)
789
+ states[seq] = PROCESS_ASSERTED;
790
+ }
791
+ const lines = {};
792
+ for (const [seq, state] of Object.entries(states)) {
793
+ lines[seq] = envelopeLine(state, results[seq] ?? null);
794
+ }
795
+ const summary = {
796
+ status: fail.length === 0 ? "verified" : "FAILED",
797
+ count: envelopes.length,
798
+ witness_signed: Object.entries(states)
799
+ .filter(([, state]) => state === WITNESS_SIGNED)
800
+ .map(([seq]) => Number(seq))
801
+ .sort((a, b) => a - b),
802
+ states,
803
+ results,
804
+ lines,
805
+ failures: [...fail.messages],
806
+ };
807
+ return [summary, fail];
808
+ }
809
+ /** Python `repr` for a member set, so both implementations print the same failure strings. */
810
+ function reprList(values) {
811
+ return `[${values.map((v) => `'${v}'`).join(", ")}]`;
812
+ }
813
+ /**
814
+ * One envelope, checked in the order the seven named failures are defined in.
815
+ *
816
+ * Returns the covered entry for an envelope that verified, and `null` for one that did not.
817
+ * Every failure is positioned on the entry the envelope COVERS, found by `subject.seq` — the
818
+ * locators are checked against that entry, not used to find it.
819
+ *
820
+ * `claims` is the caller's seq -> count of the envelopes that have named each entry so far, and
821
+ * this function updates it. An envelope claims its entry as soon as `subject.seq` finds one,
822
+ * BEFORE the rest of the subject is checked, so a second envelope over an entry an earlier one
823
+ * already named is `envelope_duplicate_subject` whether either of them is otherwise sound: the
824
+ * point of the check is that no one can decide what an earlier witness said by appending after
825
+ * it.
826
+ */
827
+ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, claims) {
828
+ const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof RawNumber);
829
+ const subject = isRecord(envelope) ? envelope["subject"] : undefined;
830
+ function position() {
831
+ // Every failure is positioned by `subject.seq`, and `subject` is attacker-supplied, so the
832
+ // lookup is guarded: a seq that is not an integer positions nothing, which is honest — it
833
+ // names no entry — and it is never used as a key.
834
+ const s = isRecord(subject) ? toPlain(subject["seq"]) : null;
835
+ if (!isSeq(s))
836
+ return [null, null];
837
+ const entry = bySeq.get(s);
838
+ if (entry === undefined)
839
+ return [s, null];
840
+ return [orNull(entry["seq"]), orNull(entry["node"])];
841
+ }
842
+ function report(reason, detail) {
843
+ const [seq, node] = position();
844
+ fail.add(reason, `${reason}: ${detail}`, { seq, node });
845
+ return null;
846
+ }
847
+ if (!isRecord(envelope)) {
848
+ fail.add("envelope_unknown_version", `envelope_unknown_version: envelope #${index} is not a JSON object`);
849
+ return null;
850
+ }
851
+ // (1) version — a `v` or `typ` this build does not know is a DIFFERENT CONTRACT, and nothing
852
+ // further about it can be read safely.
853
+ const v = toPlain(envelope["v"]);
854
+ const typ = toPlain(envelope["typ"]);
855
+ if (v !== ENVELOPE_VERSION || typ !== ENVELOPE_TYP) {
856
+ return report("envelope_unknown_version", `envelope v=${pyRepr(v)} typ=${pyRepr(typ)}, this build knows v=${ENVELOPE_VERSION} ` +
857
+ `typ='${ENVELOPE_TYP}'`);
858
+ }
859
+ // (2) member sets — the version commits the exact signed member set of the whole envelope, so
860
+ // a member added ANYWHERE is a new version that did not declare itself.
861
+ const levels = [
862
+ ["envelope", envelope, ENVELOPE_MEMBERS],
863
+ ["observed", envelope["observed"], ENVELOPE_OBSERVED_MEMBERS],
864
+ ["witness", envelope["witness"], ENVELOPE_WITNESS_MEMBERS],
865
+ ];
866
+ for (const [label, value, expected] of levels) {
867
+ const members = isRecord(value) ? sortedStrings(Object.keys(value)) : null;
868
+ if (members === null || members.length !== expected.size || members.some((m) => !expected.has(m))) {
869
+ // "not an object" rather than the type's name: the two languages spell their type names
870
+ // differently and both implementations report the same failure strings for the same bundle.
871
+ const got = members === null ? "not an object" : reprList(members);
872
+ return report("envelope_unknown_member", `${label} member set is ${got}, expected ${reprList(sortedStrings(expected))}`);
873
+ }
874
+ }
875
+ // (3) subject — the event decides the member set; a member ADDED to it is unknown_member, one
876
+ // MISSING is subject_mismatch (a subject that does not say what it covers).
877
+ if (!isRecord(subject)) {
878
+ // No type name, for the same reason as the member-set message above: the two languages spell
879
+ // their type names differently and report the same strings for the same bundle.
880
+ return report("envelope_subject_mismatch", "subject is not a JSON object");
881
+ }
882
+ const event = toPlain(subject["event"]);
883
+ if (typeof event !== "string") {
884
+ // `event` selects the subject member set, so it is a lookup key as well; in Python an
885
+ // unhashable one raises. Found by the hostile-value suite, not by review.
886
+ return report("envelope_subject_mismatch", "subject event is not a string");
887
+ }
888
+ const expectedMembers = ENVELOPE_SUBJECT_MEMBERS.get(event);
889
+ if (expectedMembers === undefined) {
890
+ return report("envelope_subject_mismatch", `subject event=${pyRepr(event)}; envelope v${ENVELOPE_VERSION} defines a subject for ` +
891
+ `${reprList(sortedStrings(ENVELOPE_SUBJECT_MEMBERS.keys()))} and no other event`);
892
+ }
893
+ const present = sortedStrings(Object.keys(subject));
894
+ const added = present.filter((m) => !expectedMembers.has(m));
895
+ if (added.length > 0) {
896
+ return report("envelope_unknown_member", `subject member set is ${reprList(present)}, expected ` +
897
+ `${reprList(sortedStrings(expectedMembers))} for a ${event} subject`);
898
+ }
899
+ const missing = sortedStrings(expectedMembers).filter((m) => !(m in subject));
900
+ if (missing.length > 0) {
901
+ return report("envelope_subject_mismatch", `subject is missing ${reprList(missing)}, which a ${event} subject requires`);
902
+ }
903
+ // (3a) the binding member. `seq` is the lookup key, so there is nothing to compare it against;
904
+ // the entry it finds supplies the hash the subject is checked against. It is also the one
905
+ // subject member used as a KEY, so its type is checked before it is used as one.
906
+ const subjectSeq = toPlain(subject["seq"]);
907
+ if (!isSeq(subjectSeq)) {
908
+ return report("envelope_subject_mismatch", "subject seq is not an integer");
909
+ }
910
+ const entry = bySeq.get(subjectSeq);
911
+ if (entry === undefined) {
912
+ return report("envelope_subject_mismatch", `no entry at seq ${pyRepr(subjectSeq)} in this bundle`);
913
+ }
914
+ const seq = orNull(entry["seq"]);
915
+ // (3a') one entry, at most one envelope. Counted here, before anything else about this
916
+ // envelope is judged, so the rule cannot be sidestepped by making the second envelope
917
+ // defective in some other way as well.
918
+ const claimKey = String(seq);
919
+ const already = claims.get(claimKey) ?? 0;
920
+ claims.set(claimKey, already + 1);
921
+ if (already > 0) {
922
+ return report("envelope_duplicate_subject", `seq ${claimKey} is already covered by an earlier envelope in this bundle; two ` +
923
+ "observations of one event contradict each other by construction, so this entry is not " +
924
+ "witness-signed");
925
+ }
926
+ const computed = recomputed.get(seq) ?? null;
927
+ const claimed = toPlain(subject["entry_hash"]);
928
+ if (claimed !== computed) {
929
+ return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${String(seq)} from ` +
930
+ `this bundle (${pyRepr(computed)})`);
931
+ }
932
+ // (3b) the locators, checked against the SAME entry `seq` found. A matching locator attests
933
+ // nothing on its own; a disagreeing one is the same failure at the same position.
934
+ const locators = [
935
+ ["chain_id", orNull(entry["chain_id"])],
936
+ ["node", orNull(entry["node"])],
937
+ ["event", orNull(entry["event"])],
938
+ ];
939
+ if (event === "allow")
940
+ locators.push(["call_id", orNull(entry["call_id"])]);
941
+ for (const [member, actual] of locators) {
942
+ const stated = toPlain(subject[member]);
943
+ if (stated !== actual) {
944
+ return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${String(seq)}`);
945
+ }
946
+ }
947
+ // (4) canonicality — an invariant SEPARATE from the signature: the received bytes must equal
948
+ // JCS of what they parse to. It can only be raised where the bytes as received are supplied,
949
+ // because formatting and escaping do not survive a parse.
950
+ let nonCanonical = false;
951
+ if (raw !== null) {
952
+ const received = receivedBytes(raw);
953
+ if (received === null) {
954
+ return report("envelope_non_canonical", "envelope_bytes entry is not hex or bytes");
955
+ }
956
+ let recanonicalized;
957
+ try {
958
+ recanonicalized = canonicalBytes(envelope);
959
+ }
960
+ catch (err) {
961
+ // A value JCS cannot represent at all — a non-finite number, an integer outside the
962
+ // binary64 safe range, a lone surrogate. There is no canonical form to compare the
963
+ // received bytes with and none to verify a signature over, so this is the end of it.
964
+ return report("envelope_non_canonical", `the envelope cannot be canonicalized: ${String(err)}`);
965
+ }
966
+ if (!recanonicalized.equals(received)) {
967
+ nonCanonical = true;
968
+ report("envelope_non_canonical", "the bytes as received are not JCS of what they parse to " +
969
+ `(${received.length} received, ${recanonicalized.length} canonical)`);
970
+ }
971
+ }
972
+ // (5) the witness key. A signature that verifies under some OTHER trusted key is not
973
+ // witness-signed: the kid names the key, and that is the key it has to verify under.
974
+ const witness = envelope["witness"];
975
+ const kid = toPlain(witness["kid"]);
976
+ const alg = toPlain(witness["alg"]);
977
+ if (typeof kid !== "string") {
978
+ // `kid` names a key, so it is a lookup key here too, and in Python an unhashable one raises.
979
+ return report("envelope_unknown_witness", "witness kid is not a string");
980
+ }
981
+ if (alg !== ENVELOPE_ALG) {
982
+ // v1 defines Ed25519 and nothing else. Without this, `"alg": "none"` on both sides — in the
983
+ // envelope and in a trust-set row — agreed with each other and read as witness-signed.
984
+ return report("envelope_unknown_witness", `witness alg=${pyRepr(alg)} is not '${ENVELOPE_ALG}'; envelope v${ENVELOPE_VERSION} ` +
985
+ "defines Ed25519 and no other algorithm");
986
+ }
987
+ const known = trusted.get(kid);
988
+ if (known === undefined) {
989
+ return report("envelope_unknown_witness", `witness kid=${pyRepr(kid)} alg=${pyRepr(alg)} is not in the trusted witness keys ` +
990
+ `(${reprList(sortedStrings(trusted.keys()))})`);
991
+ }
992
+ // (6) the signature, over JCS(envelope minus "sig").
993
+ const sigHex = toPlain(envelope["sig"]);
994
+ if (typeof sigHex !== "string") {
995
+ // A `sig` that is not a string is not a signature. In Python it reached `bytes.fromhex` and
996
+ // raised a TypeError the surrounding `except ValueError` does not catch.
997
+ return report("envelope_bad_signature", "sig is not a hex string");
998
+ }
999
+ const signature = /^[0-9a-fA-F]*$/.test(sigHex) && sigHex.length % 2 === 0
1000
+ ? Buffer.from(sigHex, "hex")
1001
+ : Buffer.alloc(0);
1002
+ let signingInput;
1003
+ try {
1004
+ signingInput = envelopeSigningInput(envelope);
1005
+ }
1006
+ catch (err) {
1007
+ // Reached only when no `envelopeBytes` were supplied, so step (4) did not run: the envelope
1008
+ // holds a value JCS cannot represent and there is nothing to verify OVER.
1009
+ return report("envelope_non_canonical", `the envelope cannot be canonicalized: ${String(err)}`);
1010
+ }
1011
+ let verified = false;
1012
+ try {
1013
+ verified = new Ed25519Verifier(known[1], kid).verify(signingInput, signature);
1014
+ }
1015
+ catch {
1016
+ verified = false;
1017
+ }
1018
+ if (!verified) {
1019
+ return report("envelope_bad_signature", `the signature does not verify under the key kid=${pyRepr(kid)} names`);
1020
+ }
1021
+ if (nonCanonical)
1022
+ return null;
1023
+ return { seq, node: orNull(entry["node"]), result: toPlain(envelope["observed"]["result"]) };
1024
+ }
1025
+ /**
1026
+ * Score a bundle's observer envelopes on their own, without the ledger checks.
1027
+ *
1028
+ * Returns `{ok, ...summary, failure_details}`. `states` maps every entry's seq to
1029
+ * `witness-signed` or `process-asserted`; `lines` is the report line for each.
1030
+ */
1031
+ export function verifyEnvelopes(bundle, options = {}) {
1032
+ const [summary, fail] = scoreEnvelopes(bundle.entries ?? [], bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
1033
+ return { ok: fail.length === 0, ...summary, failure_details: fail.details };
1034
+ }
1035
+ // =============================================================================================
454
1036
  // Execution binding (0.9.0): offline checks over callId/allow/outcome, from the ledger alone —
455
1037
  // docs/execution-binding spec section 5. schemaVersion=2 chains only; a v1 bundle's
456
1038
  // executionBinding is `{status: "not applicable"}`.
@@ -960,6 +1542,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
960
1542
  chain_id: false,
961
1543
  root: false,
962
1544
  expected_anchor: "not checked",
1545
+ envelopes: "not present",
963
1546
  };
964
1547
  const log = new FailureLog();
965
1548
  // (0) version: the bundle must declare a schema version this build understands, and — when
@@ -1114,9 +1697,21 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1114
1697
  : [{ status: "not applicable" }, new FailureLog()];
1115
1698
  if (eb.failures !== undefined && eb.failures.length > 0)
1116
1699
  log.extend(ebFailures);
1117
- // "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
1118
- // ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
1119
- // either already lands its own entry in `failures`, which the `ok` computation still gates on.
1700
+ // (4) observer envelopes. Never required — an absent envelope is the status quo and changes
1701
+ // nothing — but a PRESENT one has to verify, and a broken one lands in this same list. The
1702
+ // per-entry state is reported either way, so a reader sees which hops were covered before
1703
+ // reading which one failed.
1704
+ const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
1705
+ if (bundle.envelopes === undefined) {
1706
+ envelopeSummary.status = "not present";
1707
+ }
1708
+ else {
1709
+ checks.envelopes = envelopeSummary.status;
1710
+ log.extend(envelopeFailures);
1711
+ }
1712
+ // "anchor", "expected_anchor" and "envelopes" are excluded here — each carries a status
1713
+ // string, not a plain pass/fail boolean, and a failed check on any of them already lands its
1714
+ // own entry in `failures`, which the `ok` computation still gates on.
1120
1715
  const ok = checks.integrity &&
1121
1716
  checks.monotonicity &&
1122
1717
  checks.containment &&
@@ -1133,6 +1728,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1133
1728
  actions_checked: actions,
1134
1729
  chain_id: orNull(bundle.chain_id),
1135
1730
  execution_binding: eb,
1731
+ envelopes: envelopeSummary,
1136
1732
  verified_against: expectedAnchor !== null || expectedHead !== null ? "expected_anchor" : "bundle_anchor",
1137
1733
  };
1138
1734
  }