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