attenu-guard 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -359
- package/README.md +11 -0
- package/dist/cjs/evidence.d.ts +34 -0
- package/dist/cjs/evidence.d.ts.map +1 -1
- package/dist/cjs/evidence.js +247 -62
- package/dist/cjs/evidence.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/evidence.d.ts +34 -0
- package/dist/esm/evidence.d.ts.map +1 -1
- package/dist/esm/evidence.js +248 -63
- package/dist/esm/evidence.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/evidence.js
CHANGED
|
@@ -18,14 +18,23 @@
|
|
|
18
18
|
* const bundle = exportBundle(guard.auditLog(), signer);
|
|
19
19
|
* const report = verifyBundle(bundle, signer);
|
|
20
20
|
*
|
|
21
|
+
* `report.failures` is the human-readable list — its strings are a published
|
|
22
|
+
* contract, other implementations parse them — and `report.failure_details` is
|
|
23
|
+
* its machine-readable twin: one entry per string, same order, same count,
|
|
24
|
+
* `{reason, seq, node, call_id, detail}`. It exists so a conformance suite can
|
|
25
|
+
* assert WHICH check failed and WHERE, not merely that something did. The
|
|
26
|
+
* bundle-level interop vectors under `test/fixtures/vectors/bundles/` are scored
|
|
27
|
+
* against exactly that shape.
|
|
28
|
+
*
|
|
21
29
|
* No engine state is consulted — the bundle is the whole input, which is the
|
|
22
30
|
* point. This is byte-compatible with the Python library's
|
|
23
31
|
* `attenu_guard.evidence`.
|
|
24
32
|
*/
|
|
25
|
-
import { canonicalBytes, compareCodePoints, parseJson, toPlain, } from "./canonical.js";
|
|
33
|
+
import { canonicalBytes, compareCodePoints, parseJson, pyNumber, toPlain, } from "./canonical.js";
|
|
26
34
|
import { createHash } from "node:crypto";
|
|
27
35
|
import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, GENESIS } from "./audit.js";
|
|
28
36
|
import { Authority } from "./authority.js";
|
|
37
|
+
import { describe as describeCeiling } from "./ceilings.js";
|
|
29
38
|
import { CAPTURES, BODY_STATES, BodyState, Capture } from "./reasons.js";
|
|
30
39
|
import { PARAMS_HASH_REASONS } from "./params.js";
|
|
31
40
|
/**
|
|
@@ -217,36 +226,124 @@ function orNull(value) {
|
|
|
217
226
|
const plain = toPlain(value);
|
|
218
227
|
return plain === undefined ? null : plain;
|
|
219
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* The verifier's failure list, kept in two shapes that cannot drift apart.
|
|
231
|
+
*
|
|
232
|
+
* `messages` is the string list `verifyBundle` has always returned as
|
|
233
|
+
* `failures`; those exact strings are a published contract, so they are never
|
|
234
|
+
* reworded here. `details` is the structured twin of each one, appended in the
|
|
235
|
+
* same call. Every failure in this module goes through `add`, so a new check
|
|
236
|
+
* cannot add a message without its twin — `test/bundle-vectors.test.ts` greps
|
|
237
|
+
* this file for a direct append to a failure list and fails on one, and asserts
|
|
238
|
+
* the two lists stay in step at every site.
|
|
239
|
+
*/
|
|
240
|
+
class FailureLog {
|
|
241
|
+
messages = [];
|
|
242
|
+
details = [];
|
|
243
|
+
add(reason, detail, position = {}) {
|
|
244
|
+
const { seq = null, node = null, callId = null } = position;
|
|
245
|
+
this.messages.push(detail);
|
|
246
|
+
this.details.push({ reason, seq, node, call_id: callId, detail });
|
|
247
|
+
}
|
|
248
|
+
extend(other) {
|
|
249
|
+
this.messages.push(...other.messages);
|
|
250
|
+
this.details.push(...other.details);
|
|
251
|
+
}
|
|
252
|
+
get length() {
|
|
253
|
+
return this.messages.length;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
220
256
|
/**
|
|
221
257
|
* `node -> Authority` and `node -> parent`, reconstructed from `root` and
|
|
222
258
|
* `spawn` events alone. No engine state.
|
|
223
259
|
*/
|
|
260
|
+
/**
|
|
261
|
+
* Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
|
|
262
|
+
*
|
|
263
|
+
* Called only once `Authority.isNarrowerThan` has already returned false, and it walks the
|
|
264
|
+
* dimensions in the ORDER that relation compares them — scopes, then ceilings by key, then ttl
|
|
265
|
+
* — so the message names the dimension that actually failed. Every dimension the relation can
|
|
266
|
+
* fail on has a branch here:
|
|
267
|
+
*
|
|
268
|
+
* scopes a scope the parent does not cover (wildcard-aware);
|
|
269
|
+
* ceilings a key the parent bounds and the child does not (child unbounded there, so MORE
|
|
270
|
+
* powerful), or one the child bounds more loosely than the parent;
|
|
271
|
+
* ttl a child that never expires under a parent that does, or one that outlives it.
|
|
272
|
+
*
|
|
273
|
+
* Reports the FIRST failing dimension: one message per unsound delegation. Byte-identical to
|
|
274
|
+
* the Python `evidence._monotonicity_detail`, since these strings are a published contract that
|
|
275
|
+
* both implementations are scored against.
|
|
276
|
+
*/
|
|
277
|
+
function monotonicityDetail(child, parent) {
|
|
278
|
+
// Unchanged since 0.1.0, byte for byte. A scope failure always leaves this list non-empty:
|
|
279
|
+
// a scope literally present in the parent's set is covered by it, so anything the parent
|
|
280
|
+
// does not cover is also absent from that set.
|
|
281
|
+
if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
|
|
282
|
+
const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
|
|
283
|
+
return (`child scopes [${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] ` +
|
|
284
|
+
`not held by parent`);
|
|
285
|
+
}
|
|
286
|
+
const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
|
|
287
|
+
const parentKeys = parent.ceilings.map((c) => String(c.key)).sort(compareCodePoints);
|
|
288
|
+
for (const key of parentKeys) {
|
|
289
|
+
const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
|
|
290
|
+
const childCeiling = childByKey.get(key);
|
|
291
|
+
if (childCeiling === undefined) {
|
|
292
|
+
return `ceiling ${key} unbounded, parent holds ${describeCeiling(parentCeiling)}`;
|
|
293
|
+
}
|
|
294
|
+
if (!parentCeiling.subsumes(childCeiling)) {
|
|
295
|
+
return (`ceiling ${describeCeiling(childCeiling)} looser than parent ` +
|
|
296
|
+
`${describeCeiling(parentCeiling)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (parent.ttl !== null) {
|
|
300
|
+
if (child.ttl === null)
|
|
301
|
+
return `ttl unbounded, parent ${pyNumber(parent.ttl)}`;
|
|
302
|
+
if (child.ttl > parent.ttl) {
|
|
303
|
+
return `ttl ${pyNumber(child.ttl)} > parent ${pyNumber(parent.ttl)}`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Only reachable if a future dimension is added to `isNarrowerThan` without a branch here;
|
|
307
|
+
// it exists so that such a dimension cannot fail SILENTLY.
|
|
308
|
+
return "child not narrower than parent";
|
|
309
|
+
}
|
|
224
310
|
function nodeAuthorities(entries) {
|
|
225
311
|
const auth = new Map();
|
|
226
312
|
const parent = new Map();
|
|
227
|
-
const failures =
|
|
313
|
+
const failures = new FailureLog();
|
|
314
|
+
const definedBy = new Map();
|
|
228
315
|
for (const e of entries) {
|
|
229
316
|
const ev = toPlain(e["event"]);
|
|
230
317
|
const node = toPlain(e["node"]);
|
|
231
318
|
if (ev === "root") {
|
|
319
|
+
definedBy.set(node, e);
|
|
232
320
|
try {
|
|
233
321
|
auth.set(node, Authority.fromWire(e["authority"] ?? null));
|
|
234
322
|
}
|
|
235
323
|
catch (exc) {
|
|
236
|
-
|
|
324
|
+
// One of the two historical messages that name a node before their colon rather than a
|
|
325
|
+
// reason token, so the reason is stated here instead of parsed out of the string.
|
|
326
|
+
failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
|
|
327
|
+
seq: orNull(e["seq"]),
|
|
328
|
+
node: orNull(e["node"]),
|
|
329
|
+
});
|
|
237
330
|
}
|
|
238
331
|
}
|
|
239
332
|
else if (ev === "spawn") {
|
|
333
|
+
definedBy.set(node, e);
|
|
240
334
|
parent.set(node, toPlain(e["parent"]) ?? null);
|
|
241
335
|
try {
|
|
242
336
|
auth.set(node, Authority.fromWire(e["granted"] ?? null));
|
|
243
337
|
}
|
|
244
338
|
catch (exc) {
|
|
245
|
-
failures.
|
|
339
|
+
failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
|
|
340
|
+
seq: orNull(e["seq"]),
|
|
341
|
+
node: orNull(e["node"]),
|
|
342
|
+
});
|
|
246
343
|
}
|
|
247
344
|
}
|
|
248
345
|
}
|
|
249
|
-
return { auth, parent, failures };
|
|
346
|
+
return { auth, parent, failures, definedBy };
|
|
250
347
|
}
|
|
251
348
|
/**
|
|
252
349
|
* A view of the chain from the bundle: each node with its agent, task,
|
|
@@ -585,24 +682,31 @@ const V2_ONLY_FIELDS = [
|
|
|
585
682
|
* invalid regardless of which field it is (merge-gate item 4/(c)).
|
|
586
683
|
*/
|
|
587
684
|
function v2FieldLeaksOnV1(entries) {
|
|
588
|
-
const failures =
|
|
685
|
+
const failures = new FailureLog();
|
|
589
686
|
for (const e of entries) {
|
|
590
687
|
const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
591
688
|
if (leaked.length > 0) {
|
|
592
|
-
failures.
|
|
593
|
-
`carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry
|
|
689
|
+
failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${pyRepr(toPlain(e["seq"]))} event=${pyRepr(toPlain(e["event"]))} ` +
|
|
690
|
+
`carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
|
|
594
691
|
}
|
|
595
692
|
}
|
|
596
693
|
return failures;
|
|
597
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* `[the execution_binding report, its failures]`. The report's own `failures` key keeps its
|
|
697
|
+
* historical list-of-strings shape — the structured twins ride alongside it rather than inside
|
|
698
|
+
* it, so this sub-report's published shape is unchanged.
|
|
699
|
+
*/
|
|
598
700
|
function executionBinding(entries, bundleV) {
|
|
599
701
|
if (bundleV === 1) {
|
|
600
702
|
const leaked = v2FieldLeaksOnV1(entries);
|
|
601
|
-
return leaked.length > 0
|
|
703
|
+
return leaked.length > 0
|
|
704
|
+
? [{ status: "not applicable", failures: leaked.messages }, leaked]
|
|
705
|
+
: [{ status: "not applicable" }, new FailureLog()];
|
|
602
706
|
}
|
|
603
707
|
if (bundleV !== 2)
|
|
604
|
-
return { status: "not applicable" };
|
|
605
|
-
const failures =
|
|
708
|
+
return [{ status: "not applicable" }, new FailureLog()];
|
|
709
|
+
const failures = new FailureLog();
|
|
606
710
|
const seenCallIds = new Map(); // callId -> [event, node, seq]
|
|
607
711
|
const allows = new Map();
|
|
608
712
|
const outcomes = new Map();
|
|
@@ -618,8 +722,12 @@ function executionBinding(entries, bundleV) {
|
|
|
618
722
|
if (node !== null)
|
|
619
723
|
nodes.add(node);
|
|
620
724
|
const err = validateRoot(e);
|
|
621
|
-
if (err)
|
|
622
|
-
failures.
|
|
725
|
+
if (err) {
|
|
726
|
+
failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
|
|
727
|
+
seq: orNull(e["seq"]),
|
|
728
|
+
node: orNull(e["node"]),
|
|
729
|
+
});
|
|
730
|
+
}
|
|
623
731
|
}
|
|
624
732
|
else if (ev === "spawn") {
|
|
625
733
|
if (node !== null)
|
|
@@ -633,8 +741,12 @@ function executionBinding(entries, bundleV) {
|
|
|
633
741
|
for (const r of toPlain(e["revoked"]) ?? [])
|
|
634
742
|
revokedNodes.add(r);
|
|
635
743
|
const err = validateKill(e);
|
|
636
|
-
if (err)
|
|
637
|
-
failures.
|
|
744
|
+
if (err) {
|
|
745
|
+
failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
|
|
746
|
+
seq: orNull(e["seq"]),
|
|
747
|
+
node: orNull(e["node"]),
|
|
748
|
+
});
|
|
749
|
+
}
|
|
638
750
|
}
|
|
639
751
|
if (ev === "allow" || ev === "deny") {
|
|
640
752
|
const cid = toPlain(e["call_id"]);
|
|
@@ -642,8 +754,10 @@ function executionBinding(entries, bundleV) {
|
|
|
642
754
|
if (cid !== null && cid !== undefined) {
|
|
643
755
|
const prior = seenCallIds.get(cid);
|
|
644
756
|
if (prior !== undefined) {
|
|
645
|
-
|
|
646
|
-
|
|
757
|
+
// Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
|
|
758
|
+
// record, the first one having been legitimate when it was written.
|
|
759
|
+
failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
|
|
760
|
+
`${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
|
|
647
761
|
}
|
|
648
762
|
else {
|
|
649
763
|
seenCallIds.set(cid, [ev, node, seq]);
|
|
@@ -651,7 +765,11 @@ function executionBinding(entries, bundleV) {
|
|
|
651
765
|
}
|
|
652
766
|
const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
|
|
653
767
|
if (err) {
|
|
654
|
-
failures.
|
|
768
|
+
failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
|
|
769
|
+
seq: orNull(e["seq"]),
|
|
770
|
+
node: orNull(e["node"]),
|
|
771
|
+
callId: cid ?? null,
|
|
772
|
+
});
|
|
655
773
|
if (ev === "allow" && cid !== null && cid !== undefined)
|
|
656
774
|
invalidAllowIds.add(cid);
|
|
657
775
|
continue;
|
|
@@ -664,12 +782,16 @@ function executionBinding(entries, bundleV) {
|
|
|
664
782
|
const seq = toPlain(e["seq"]);
|
|
665
783
|
const err = validateOutcome(e);
|
|
666
784
|
if (err) {
|
|
667
|
-
failures.
|
|
785
|
+
failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
|
|
786
|
+
seq: orNull(e["seq"]),
|
|
787
|
+
node: orNull(e["node"]),
|
|
788
|
+
callId: cid ?? null,
|
|
789
|
+
});
|
|
668
790
|
continue;
|
|
669
791
|
}
|
|
670
792
|
if (cid !== null && outcomes.has(cid)) {
|
|
671
|
-
failures.
|
|
672
|
-
`${pyRepr(toPlain(outcomes.get(cid)["seq"]))})
|
|
793
|
+
failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
|
|
794
|
+
`${pyRepr(toPlain(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
|
|
673
795
|
continue;
|
|
674
796
|
}
|
|
675
797
|
if (cid !== null)
|
|
@@ -683,29 +805,32 @@ function executionBinding(entries, bundleV) {
|
|
|
683
805
|
// its recorded content disagrees with what was authorized (spec: "parameter equality is
|
|
684
806
|
// established only for calls where both hashes are present; elsewhere only identity and order
|
|
685
807
|
// binding was checked" — params_mismatch is that separate concern).
|
|
808
|
+
// Every failure in this loop is about a PAIR, and is positioned on the `outcome` entry: the
|
|
809
|
+
// allow was a complete, valid record when it was written, and it is the outcome that fails to
|
|
810
|
+
// bind to it (or reports different arguments than were authorized).
|
|
686
811
|
const boundOk = new Set();
|
|
687
812
|
for (const [cid, oc] of outcomes) {
|
|
688
813
|
const allowE = allows.get(cid);
|
|
689
814
|
if (allowE === undefined) {
|
|
690
|
-
failures.
|
|
815
|
+
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 });
|
|
691
816
|
continue;
|
|
692
817
|
}
|
|
693
818
|
const nodeOk = toPlain(allowE["node"]) === toPlain(oc["node"]);
|
|
694
819
|
if (!nodeOk) {
|
|
695
|
-
failures.
|
|
696
|
-
`outcome on node ${pyRepr(toPlain(oc["node"]))}
|
|
820
|
+
failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr(toPlain(allowE["node"]))} but ` +
|
|
821
|
+
`outcome on node ${pyRepr(toPlain(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
|
|
697
822
|
}
|
|
698
823
|
const ocSeq = toPlain(oc["seq"]);
|
|
699
824
|
const allowSeq = toPlain(allowE["seq"]);
|
|
700
825
|
const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
|
|
701
826
|
if (!orderOk) {
|
|
702
|
-
failures.
|
|
703
|
-
`after allow seq ${pyRepr(allowSeq ?? null)}
|
|
827
|
+
failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
|
|
828
|
+
`after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
|
|
704
829
|
}
|
|
705
830
|
const ah = toPlain(allowE["authorized_params_hash"]);
|
|
706
831
|
const ih = toPlain(oc["invoked_params_hash"]);
|
|
707
832
|
if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
|
|
708
|
-
failures.
|
|
833
|
+
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 });
|
|
709
834
|
}
|
|
710
835
|
if (nodeOk && orderOk)
|
|
711
836
|
boundOk.add(cid);
|
|
@@ -775,13 +900,52 @@ function executionBinding(entries, bundleV) {
|
|
|
775
900
|
}
|
|
776
901
|
if (Object.values(perCall).some((s) => s === "unobserved"))
|
|
777
902
|
escalate("incomplete");
|
|
778
|
-
return
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
903
|
+
return [
|
|
904
|
+
{
|
|
905
|
+
aggregate,
|
|
906
|
+
params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
|
|
907
|
+
per_call: perCall,
|
|
908
|
+
per_node_lifecycle: lifecycle,
|
|
909
|
+
failures: failures.messages,
|
|
910
|
+
},
|
|
783
911
|
failures,
|
|
784
|
-
|
|
912
|
+
];
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
|
|
916
|
+
*
|
|
917
|
+
* `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
|
|
918
|
+
* module reports; this walk exists so the structured twin of that message can say WHERE, which
|
|
919
|
+
* the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
|
|
920
|
+
* exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
|
|
921
|
+
* consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
|
|
922
|
+
* chain-level.
|
|
923
|
+
*/
|
|
924
|
+
function integrityPosition(entries) {
|
|
925
|
+
let prev = GENESIS;
|
|
926
|
+
for (let i = 0; i < entries.length; i++) {
|
|
927
|
+
const e = entries[i];
|
|
928
|
+
const payload = {};
|
|
929
|
+
for (const [k, v] of Object.entries(e)) {
|
|
930
|
+
if (k !== "hash")
|
|
931
|
+
payload[k] = v;
|
|
932
|
+
}
|
|
933
|
+
let broken;
|
|
934
|
+
try {
|
|
935
|
+
broken =
|
|
936
|
+
orNull(e["seq"]) !== i ||
|
|
937
|
+
orNull(payload["prev_hash"]) !== prev ||
|
|
938
|
+
hashEntry(prev, payload) !== orNull(e["hash"]);
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
// An unhashable payload is itself the break, at this entry.
|
|
942
|
+
return [orNull(e["seq"]), orNull(e["node"])];
|
|
943
|
+
}
|
|
944
|
+
if (broken)
|
|
945
|
+
return [orNull(e["seq"]), orNull(e["node"])];
|
|
946
|
+
prev = orNull(e["hash"]);
|
|
947
|
+
}
|
|
948
|
+
return [null, null];
|
|
785
949
|
}
|
|
786
950
|
export function verifyBundle(bundle, signer = null, options = {}) {
|
|
787
951
|
const entries = bundle.entries ?? [];
|
|
@@ -797,38 +961,41 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
797
961
|
root: false,
|
|
798
962
|
expected_anchor: "not checked",
|
|
799
963
|
};
|
|
800
|
-
const
|
|
964
|
+
const log = new FailureLog();
|
|
801
965
|
// (0) version: the bundle must declare a schema version this build understands, and — when
|
|
802
966
|
// an anchor is present — the anchor must be anchoring THAT version, not a different one.
|
|
803
967
|
const bundleV = toPlain(bundle.v);
|
|
804
968
|
let versionOk = typeof bundleV === "number" && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
|
|
805
969
|
if (!versionOk) {
|
|
806
970
|
const supported = Array.from(SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
|
|
807
|
-
|
|
971
|
+
log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
|
|
808
972
|
}
|
|
809
973
|
const anchorV = toPlain(anchor["v"]);
|
|
810
974
|
if (anchorPresent && anchorV !== bundleV) {
|
|
811
975
|
versionOk = false;
|
|
812
|
-
|
|
976
|
+
log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
|
|
813
977
|
}
|
|
814
978
|
// (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
|
|
815
979
|
// sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
|
|
816
980
|
const rootEvents = entries.filter((e) => toPlain(e["event"]) === "root");
|
|
817
981
|
checks.root = rootEvents.length === 1;
|
|
818
982
|
if (!checks.root) {
|
|
819
|
-
|
|
983
|
+
log.add("missing_root", `missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
|
|
820
984
|
}
|
|
821
985
|
const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
|
|
822
986
|
// 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
|
|
823
987
|
// entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
|
|
824
988
|
if (rootEntry !== undefined && toPlain(rootEntry["v"]) !== bundleV) {
|
|
825
989
|
versionOk = false;
|
|
826
|
-
|
|
990
|
+
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"]) });
|
|
827
991
|
}
|
|
828
|
-
const
|
|
992
|
+
const mixedEntries = entries.filter((e) => toPlain(e["v"]) !== bundleV);
|
|
993
|
+
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))));
|
|
829
994
|
if (mixed.length > 0) {
|
|
830
995
|
versionOk = false;
|
|
831
|
-
|
|
996
|
+
// One aggregate message over every offending entry (unchanged); the twin is positioned on
|
|
997
|
+
// the first of them, which is where a reader looks.
|
|
998
|
+
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"]) });
|
|
832
999
|
}
|
|
833
1000
|
checks.version = versionOk;
|
|
834
1001
|
// (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
|
|
@@ -842,7 +1009,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
842
1009
|
const [expSeq, expHash] = expectedHead;
|
|
843
1010
|
if (actualSeq !== expSeq || actualHead !== expHash) {
|
|
844
1011
|
expectedOk = false;
|
|
845
|
-
|
|
1012
|
+
log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
|
|
846
1013
|
`independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
|
|
847
1014
|
}
|
|
848
1015
|
}
|
|
@@ -853,7 +1020,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
853
1020
|
toPlain(ea["chain_id"]) !== toPlain(bundle.chain_id) ||
|
|
854
1021
|
toPlain(ea["v"]) !== bundleV) {
|
|
855
1022
|
expectedOk = false;
|
|
856
|
-
|
|
1023
|
+
log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
|
|
857
1024
|
"the independently retained expected anchor");
|
|
858
1025
|
}
|
|
859
1026
|
}
|
|
@@ -863,32 +1030,40 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
863
1030
|
// must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
|
|
864
1031
|
// for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
|
|
865
1032
|
const bundleChainId = orNull(bundle.chain_id);
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
1033
|
+
const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
|
|
1034
|
+
const entriesOk = foreign === undefined;
|
|
1035
|
+
if (foreign !== undefined) {
|
|
1036
|
+
log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
|
|
1037
|
+
seq: orNull(foreign["seq"]),
|
|
1038
|
+
node: orNull(foreign["node"]),
|
|
1039
|
+
});
|
|
869
1040
|
}
|
|
870
1041
|
const anchorChainId = orNull(anchor["chain_id"]);
|
|
871
1042
|
const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
|
|
872
1043
|
if (!anchorChainOk) {
|
|
873
|
-
|
|
1044
|
+
log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
|
|
874
1045
|
}
|
|
875
1046
|
checks.chain_id = entriesOk && anchorChainOk;
|
|
876
1047
|
// (1) integrity: the hash chain, plus the signed anchor when a key is given.
|
|
877
1048
|
const [okChain, err] = AuditLog.verify(entries);
|
|
878
|
-
if (!okChain)
|
|
879
|
-
|
|
1049
|
+
if (!okChain) {
|
|
1050
|
+
const [badSeq, badNode] = integrityPosition(entries);
|
|
1051
|
+
log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
|
|
1052
|
+
}
|
|
880
1053
|
if (signer !== null) {
|
|
881
1054
|
const [okAnchor, aerr] = AuditLog.verifyAnchor(entries, anchor, signer);
|
|
882
1055
|
checks.anchor = okAnchor ? "verified" : "FAILED";
|
|
1056
|
+
// Chain-level by construction: the anchor commits to the head of the WHOLE ledger, so a
|
|
1057
|
+
// consistently re-hashed chain has no single offending entry to point at.
|
|
883
1058
|
if (!okAnchor)
|
|
884
|
-
|
|
1059
|
+
log.add("integrity(anchor)", `integrity(anchor): ${aerr}`);
|
|
885
1060
|
checks.integrity = okChain && okAnchor;
|
|
886
1061
|
}
|
|
887
1062
|
else {
|
|
888
1063
|
checks.integrity = okChain;
|
|
889
1064
|
}
|
|
890
|
-
const { auth, parent, failures: afail } = nodeAuthorities(entries);
|
|
891
|
-
|
|
1065
|
+
const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
|
|
1066
|
+
log.extend(afail);
|
|
892
1067
|
// (2) monotonicity: every child ⊆ its parent.
|
|
893
1068
|
let mono = true;
|
|
894
1069
|
for (const [node, pid] of parent) {
|
|
@@ -896,11 +1071,14 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
896
1071
|
continue;
|
|
897
1072
|
const child = auth.get(node);
|
|
898
1073
|
const p = auth.get(pid);
|
|
899
|
-
|
|
900
|
-
|
|
1074
|
+
// 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
|
|
1075
|
+
// non-wildcard-aware scope difference, which silently accepted a delegation that widened
|
|
1076
|
+
// only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
|
|
1077
|
+
// the parent's — the child was more powerful and the bundle verified clean.
|
|
1078
|
+
if (!child.isNarrowerThan(p)) {
|
|
901
1079
|
mono = false;
|
|
902
|
-
|
|
903
|
-
|
|
1080
|
+
const spawnE = definedBy.get(node);
|
|
1081
|
+
log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (${monotonicityDetail(child, p)})`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
|
|
904
1082
|
}
|
|
905
1083
|
}
|
|
906
1084
|
checks.monotonicity = mono && afail.length === 0;
|
|
@@ -917,19 +1095,25 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
917
1095
|
const a = auth.get(node);
|
|
918
1096
|
if (a === undefined) {
|
|
919
1097
|
contained = false;
|
|
920
|
-
|
|
1098
|
+
log.add("containment", `containment: allow on unknown node ${node}`, {
|
|
1099
|
+
seq: orNull(e["seq"]),
|
|
1100
|
+
node: orNull(e["node"]),
|
|
1101
|
+
callId: orNull(e["call_id"]),
|
|
1102
|
+
});
|
|
921
1103
|
continue;
|
|
922
1104
|
}
|
|
923
1105
|
if (!a.permits(scope, ctx).allowed) {
|
|
924
1106
|
contained = false;
|
|
925
|
-
|
|
926
|
-
`[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]
|
|
1107
|
+
log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
|
|
1108
|
+
`[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]) });
|
|
927
1109
|
}
|
|
928
1110
|
}
|
|
929
1111
|
checks.containment = contained;
|
|
930
|
-
const eb = versionOk
|
|
931
|
-
|
|
932
|
-
|
|
1112
|
+
const [eb, ebFailures] = versionOk
|
|
1113
|
+
? executionBinding(entries, bundleV)
|
|
1114
|
+
: [{ status: "not applicable" }, new FailureLog()];
|
|
1115
|
+
if (eb.failures !== undefined && eb.failures.length > 0)
|
|
1116
|
+
log.extend(ebFailures);
|
|
933
1117
|
// "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
|
|
934
1118
|
// ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
|
|
935
1119
|
// either already lands its own entry in `failures`, which the `ok` computation still gates on.
|
|
@@ -939,11 +1123,12 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
939
1123
|
checks.version &&
|
|
940
1124
|
checks.chain_id &&
|
|
941
1125
|
checks.root &&
|
|
942
|
-
|
|
1126
|
+
log.length === 0;
|
|
943
1127
|
return {
|
|
944
1128
|
ok,
|
|
945
1129
|
checks,
|
|
946
|
-
failures,
|
|
1130
|
+
failures: log.messages,
|
|
1131
|
+
failure_details: log.details,
|
|
947
1132
|
nodes: auth.size,
|
|
948
1133
|
actions_checked: actions,
|
|
949
1134
|
chain_id: orNull(bundle.chain_id),
|