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