attenu-guard 0.13.1 → 0.13.2
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 +10 -0
- package/dist/cjs/audit.d.ts +15 -1
- package/dist/cjs/audit.d.ts.map +1 -1
- package/dist/cjs/audit.js +26 -3
- package/dist/cjs/audit.js.map +1 -1
- package/dist/cjs/ceilings.d.ts +7 -0
- package/dist/cjs/ceilings.d.ts.map +1 -1
- package/dist/cjs/ceilings.js +1 -0
- package/dist/cjs/ceilings.js.map +1 -1
- package/dist/cjs/cli.d.ts.map +1 -1
- package/dist/cjs/cli.js +11 -4
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/evidence.d.ts +35 -9
- package/dist/cjs/evidence.d.ts.map +1 -1
- package/dist/cjs/evidence.js +332 -38
- 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 +3 -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/audit.d.ts +15 -1
- package/dist/esm/audit.d.ts.map +1 -1
- package/dist/esm/audit.js +26 -4
- package/dist/esm/audit.js.map +1 -1
- package/dist/esm/ceilings.d.ts +7 -0
- package/dist/esm/ceilings.d.ts.map +1 -1
- package/dist/esm/ceilings.js +1 -1
- package/dist/esm/ceilings.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +12 -5
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/evidence.d.ts +35 -9
- package/dist/esm/evidence.d.ts.map +1 -1
- package/dist/esm/evidence.js +334 -40
- 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 +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
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { canonicalBytes, compareCodePoints, intOr, integral, parseJson, pyNumber, RawNumber, sortedStrings, toPlain, } from "./canonical.js";
|
|
34
34
|
import { createHash } from "node:crypto";
|
|
35
|
-
import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, GENESIS } from "./audit.js";
|
|
35
|
+
import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, isLedgerObject, GENESIS, } from "./audit.js";
|
|
36
36
|
import { Authority } from "./authority.js";
|
|
37
|
-
import { describeInFinding, sameType, wireKeyOf } from "./ceilings.js";
|
|
38
|
-
import { pyRepr, pyStr, pyStrRepr, shown } from "./display.js";
|
|
37
|
+
import { describeInFinding, jsonKind, sameType, wireKeyOf } from "./ceilings.js";
|
|
38
|
+
import { escaped, pyRepr, pyStr, pyStrRepr, shown } from "./display.js";
|
|
39
39
|
import { CAPTURES, BODY_STATES, POLICIES, BodyState, Capture } from "./reasons.js";
|
|
40
40
|
import { PARAMS_HASH_REASONS } from "./params.js";
|
|
41
41
|
import { Ed25519Signer, Ed25519Verifier } from "./wire.js";
|
|
@@ -100,6 +100,26 @@ export const LEDGER_FIELDS = new Set([
|
|
|
100
100
|
"receipt",
|
|
101
101
|
"pending_at_kill",
|
|
102
102
|
]);
|
|
103
|
+
/**
|
|
104
|
+
* The COMPLETE set of ledger event names, closed: the `event` enum of
|
|
105
|
+
* `schema/agent-audit.schema.json` (in the Python distribution). Every check in `verifyBundle`
|
|
106
|
+
* reads an entry by its event, so an entry whose event none of them reads was skipped by all of
|
|
107
|
+
* them: a `done` renamed `frobnicate` verified, and an `allow` renamed left containment without a
|
|
108
|
+
* word. `verifyBundle` reports such an entry as `unknown_ledger_event`. The Python implementation's
|
|
109
|
+
* `evidence.LEDGER_EVENTS`, name for name.
|
|
110
|
+
*/
|
|
111
|
+
export const LEDGER_EVENTS = new Set([
|
|
112
|
+
"root",
|
|
113
|
+
"spawn",
|
|
114
|
+
"spawn_denied",
|
|
115
|
+
"allow",
|
|
116
|
+
"deny",
|
|
117
|
+
"kill",
|
|
118
|
+
"done",
|
|
119
|
+
"outcome",
|
|
120
|
+
]);
|
|
121
|
+
/** `outcome` is v2-only (0.9.0, execution binding): a schemaVersion 1 chain has the other seven. */
|
|
122
|
+
export const LEDGER_EVENTS_V1 = new Set([...LEDGER_EVENTS].filter((event) => event !== "outcome"));
|
|
103
123
|
/**
|
|
104
124
|
* Thrown by `exportBundle({strict: true})` when a bundle would carry a field or
|
|
105
125
|
* context key outside the allow-list — potential customer data the custody
|
|
@@ -146,27 +166,60 @@ function redactTask(t) {
|
|
|
146
166
|
export function redactionReport(entries, contextAllowlist) {
|
|
147
167
|
const allow = contextAllowlist === undefined || contextAllowlist === null ? null : new Set(contextAllowlist);
|
|
148
168
|
const violations = [];
|
|
149
|
-
|
|
169
|
+
// What it cannot read is a violation, never a throw, and never read by projection (a string's
|
|
170
|
+
// characters as fields). The Python implementation's same rule and shapes.
|
|
171
|
+
if (!Array.isArray(entries)) {
|
|
172
|
+
return { ok: false, violations: [{ event_index: null, event: null, entries: jsonKind(entries) }] };
|
|
173
|
+
}
|
|
174
|
+
for (let i = 0; i < entries.length; i++) {
|
|
175
|
+
const e = entries[i];
|
|
176
|
+
if (!isLedgerObject(e)) {
|
|
177
|
+
violations.push({ event_index: i, event: null, entry: jsonKind(e) });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
150
180
|
for (const f of Object.keys(e)) {
|
|
151
181
|
if (!LEDGER_FIELDS.has(f)) {
|
|
152
|
-
violations.push({ event_index: i, event:
|
|
182
|
+
violations.push({ event_index: i, event: orNull(e["event"]), field: f });
|
|
153
183
|
}
|
|
154
184
|
}
|
|
155
185
|
if (allow !== null) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
186
|
+
// A context Python counts as false (null, "", 0, false, [] or {}) is no context, as
|
|
187
|
+
// `verifyBundle` reads it; any other context that is not an object is a violation.
|
|
188
|
+
const raw = e["context"];
|
|
189
|
+
if (pyFalsy(toPlain(raw)))
|
|
190
|
+
continue;
|
|
191
|
+
if (!isLedgerObject(raw)) {
|
|
192
|
+
violations.push({ event_index: i, event: orNull(e["event"]), context: jsonKind(raw) });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (const k of Object.keys(raw)) {
|
|
196
|
+
if (!allow.has(k)) {
|
|
197
|
+
violations.push({ event_index: i, event: orNull(e["event"]), context_key: k });
|
|
162
198
|
}
|
|
163
199
|
}
|
|
164
200
|
}
|
|
165
|
-
}
|
|
201
|
+
}
|
|
166
202
|
return { ok: violations.length === 0, violations };
|
|
167
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* `entries` as the functions that SIGN them take them: an array of JSON objects. These produce
|
|
206
|
+
* evidence rather than read it, so anything else is refused by name, with a TypeError, before
|
|
207
|
+
* anything is signed over it. Before, a TypeError in the runtime's own words, or a signature over
|
|
208
|
+
* whatever was read. The Python implementation's `export_bundle` takes an `AuditLog`, so it has no
|
|
209
|
+
* such input to refuse.
|
|
210
|
+
*/
|
|
211
|
+
function signable(entries) {
|
|
212
|
+
if (!Array.isArray(entries))
|
|
213
|
+
throw new TypeError(`entries is ${jsonKind(entries)}, not an array`);
|
|
214
|
+
for (let i = 0; i < entries.length; i++) {
|
|
215
|
+
if (!isLedgerObject(entries[i]))
|
|
216
|
+
throw new TypeError(`entries[${i}] is ${jsonKind(entries[i])}, not an object`);
|
|
217
|
+
}
|
|
218
|
+
return entries;
|
|
219
|
+
}
|
|
168
220
|
/** A signed commitment to the head of `entries` — mirrors `AuditLog.anchor`. */
|
|
169
|
-
export function anchorFor(
|
|
221
|
+
export function anchorFor(given, signer, ts = 0) {
|
|
222
|
+
const entries = signable(given);
|
|
170
223
|
let seq;
|
|
171
224
|
let head;
|
|
172
225
|
if (entries.length === 0) {
|
|
@@ -204,7 +257,7 @@ export function exportBundle(auditLog, signer, options = {}) {
|
|
|
204
257
|
throw new Error("sign envelopes over the redacted ledger: export with redact_task=True first, then " +
|
|
205
258
|
"sign_envelope over the exported entries");
|
|
206
259
|
}
|
|
207
|
-
const source = auditLog instanceof AuditLog ? auditLog.entries : auditLog;
|
|
260
|
+
const source = signable(auditLog instanceof AuditLog ? auditLog.entries : auditLog);
|
|
208
261
|
const entries = source.map((e) => ({ ...e }));
|
|
209
262
|
if (options.redactTask) {
|
|
210
263
|
for (const e of entries) {
|
|
@@ -353,6 +406,45 @@ function monotonicityDetail(child, parent) {
|
|
|
353
406
|
// it exists so that such a dimension cannot fail SILENTLY.
|
|
354
407
|
return "child not narrower than parent";
|
|
355
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Why `value`, a root's `authority` or a spawn's `granted`, cannot be read as an authority by the
|
|
411
|
+
* type of one of its members, or null. The object itself is an object; `scopes` and `constraints`,
|
|
412
|
+
* when present, arrays, and a scope a string; `ttl`, when present and not null, a finite number.
|
|
413
|
+
* `Authority.fromWire` reports the rest, as before: a member it does not read, a scope outside the
|
|
414
|
+
* grammar, a constraint of the wrong shape.
|
|
415
|
+
*
|
|
416
|
+
* `fromWire` read a `ttl` that was not a number as unbounded, so a root `ttl: "x"` verified, and a
|
|
417
|
+
* null `scopes` or `constraints` as empty; the Python implementation raised on the first and on
|
|
418
|
+
* the null ones. The Python implementation's `_authority_wire_error`, message for message.
|
|
419
|
+
*/
|
|
420
|
+
function authorityWireError(value, member) {
|
|
421
|
+
if (!isLedgerObject(value))
|
|
422
|
+
return `${member} is ${jsonKind(value)}, not an object`;
|
|
423
|
+
if (Object.keys(value).some((k) => k !== "scopes" && k !== "constraints" && k !== "ttl")) {
|
|
424
|
+
return null; // fromWire names the members it does not read
|
|
425
|
+
}
|
|
426
|
+
if (Object.hasOwn(value, "scopes")) {
|
|
427
|
+
const scopes = value["scopes"];
|
|
428
|
+
if (!Array.isArray(scopes))
|
|
429
|
+
return `scopes is ${jsonKind(scopes)}, not an array`;
|
|
430
|
+
for (const scope of scopes) {
|
|
431
|
+
if (typeof scope !== "string")
|
|
432
|
+
return `a scope is ${jsonKind(scope)}, not a string`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (Object.hasOwn(value, "constraints") && !Array.isArray(value["constraints"])) {
|
|
436
|
+
return `constraints is ${jsonKind(value["constraints"])}, not an array`;
|
|
437
|
+
}
|
|
438
|
+
const ttl = value["ttl"];
|
|
439
|
+
if (ttl !== undefined && ttl !== null) {
|
|
440
|
+
const plain = toPlain(ttl);
|
|
441
|
+
if (typeof plain !== "number")
|
|
442
|
+
return `ttl is ${jsonKind(ttl)}, not a number`;
|
|
443
|
+
if (!Number.isFinite(plain))
|
|
444
|
+
return "ttl is not a finite number";
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
356
448
|
/**
|
|
357
449
|
* `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
|
|
358
450
|
* order. No engine state.
|
|
@@ -391,6 +483,9 @@ function nodeAuthorities(entries) {
|
|
|
391
483
|
definedBy.set(node, e);
|
|
392
484
|
if (ev === "root") {
|
|
393
485
|
try {
|
|
486
|
+
const wrong = authorityWireError(e["authority"] ?? null, "authority");
|
|
487
|
+
if (wrong !== null)
|
|
488
|
+
throw new Error(wrong);
|
|
394
489
|
auth.set(node, Authority.fromWire(e["authority"] ?? null));
|
|
395
490
|
}
|
|
396
491
|
catch (exc) {
|
|
@@ -400,6 +495,9 @@ function nodeAuthorities(entries) {
|
|
|
400
495
|
else {
|
|
401
496
|
parent.set(node, orNull(e["parent"]));
|
|
402
497
|
try {
|
|
498
|
+
const wrong = authorityWireError(e["granted"] ?? null, "granted");
|
|
499
|
+
if (wrong !== null)
|
|
500
|
+
throw new Error(wrong);
|
|
403
501
|
auth.set(node, Authority.fromWire(e["granted"] ?? null));
|
|
404
502
|
}
|
|
405
503
|
catch (exc) {
|
|
@@ -429,7 +527,10 @@ function noteRevoked(entry, revokedAt) {
|
|
|
429
527
|
* renders. Derived from the ledger alone.
|
|
430
528
|
*/
|
|
431
529
|
export function delegationGraph(bundle) {
|
|
432
|
-
|
|
530
|
+
// A view, not a verdict: it shows what it can read and throws on nothing. A bundle, or entries,
|
|
531
|
+
// it cannot read is an empty graph, and an entry that is not a JSON object names no node;
|
|
532
|
+
// `verifyBundle` is what reports either.
|
|
533
|
+
const entries = readableEntries(bundle);
|
|
433
534
|
const { auth, parent } = nodeAuthorities(entries);
|
|
434
535
|
const meta = {};
|
|
435
536
|
for (const e of entries) {
|
|
@@ -459,9 +560,14 @@ export function delegationGraph(bundle) {
|
|
|
459
560
|
}
|
|
460
561
|
else if (ev === "deny" && Object.hasOwn(meta, n)) {
|
|
461
562
|
meta[n].denies += 1;
|
|
462
|
-
// A deny without a disposition is named by its reason
|
|
463
|
-
|
|
464
|
-
|
|
563
|
+
// A deny without a disposition is named by its reason, read as Python reads
|
|
564
|
+
// `disposition or reason`: an empty disposition (null, "", 0, false, [] or {}) names none.
|
|
565
|
+
// A key is a string; a value of another kind is filed by its printed form.
|
|
566
|
+
const disposition = toPlain(e["disposition"]);
|
|
567
|
+
const reason = toPlain(e["reason"]);
|
|
568
|
+
const picked = !pyFalsy(disposition) ? e["disposition"] : !pyFalsy(reason) ? e["reason"] : "unstated";
|
|
569
|
+
const d = typeof toPlain(picked) === "string" ? toPlain(picked) : shown(picked);
|
|
570
|
+
setOwn(meta[n].denials_by_disposition, d, (Object.hasOwn(meta[n].denials_by_disposition, d) ? meta[n].denials_by_disposition[d] : 0) + 1);
|
|
465
571
|
}
|
|
466
572
|
else if (ev === "done" && Object.hasOwn(meta, n)) {
|
|
467
573
|
meta[n].complete = true;
|
|
@@ -480,7 +586,7 @@ export function delegationGraph(bundle) {
|
|
|
480
586
|
if (typeof p === "string" && p !== "")
|
|
481
587
|
edges.push({ parent: p, child });
|
|
482
588
|
}
|
|
483
|
-
return { chain_id: orNull(bundle
|
|
589
|
+
return { chain_id: isLedgerObject(bundle) ? orNull(bundle["chain_id"]) : null, nodes: meta, edges };
|
|
484
590
|
}
|
|
485
591
|
/**
|
|
486
592
|
* Every refusal on the ledger, grouped by (node, tool, scope, disposition, requested) — the rows
|
|
@@ -497,12 +603,16 @@ export function delegationGraph(bundle) {
|
|
|
497
603
|
* larger event of the two.
|
|
498
604
|
*/
|
|
499
605
|
export function denials(bundle) {
|
|
500
|
-
|
|
606
|
+
// A view, not a verdict, like `delegationGraph`: a bundle, or entries, it cannot read has no
|
|
607
|
+
// rows, an entry that is not a JSON object is no refusal, and it throws on nothing.
|
|
608
|
+
const entries = readableEntries(bundle);
|
|
501
609
|
const agentOf = new Map();
|
|
502
610
|
for (const e of entries) {
|
|
503
611
|
const ev = toPlain(e["event"]);
|
|
504
|
-
|
|
505
|
-
|
|
612
|
+
const node = toPlain(e["node"]);
|
|
613
|
+
// A node id is a string; anything else names no node.
|
|
614
|
+
if ((ev === "root" || ev === "spawn") && typeof node === "string") {
|
|
615
|
+
agentOf.set(node, orNull(e["agent"]));
|
|
506
616
|
}
|
|
507
617
|
}
|
|
508
618
|
const rows = new Map();
|
|
@@ -514,19 +624,22 @@ export function denials(bundle) {
|
|
|
514
624
|
// what was refused), so it is folded onto the node that asked.
|
|
515
625
|
const node = ev === "spawn_denied" ? orNull(e["parent"]) : orNull(e["node"]);
|
|
516
626
|
const requested = ev === "spawn_denied" ? orNull(e["agent"]) : null;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
627
|
+
// Grouped by the escaped JSON text of the five values, as the Python implementation groups
|
|
628
|
+
// them, which tells `true` from 1 and 1.0 from 1 as written.
|
|
629
|
+
const asWritten = (v) => (v === undefined ? null : v);
|
|
630
|
+
const key = escaped([
|
|
631
|
+
asWritten(ev === "spawn_denied" ? e["parent"] : e["node"]),
|
|
632
|
+
asWritten(e["tool"]),
|
|
633
|
+
asWritten(e["scope"]),
|
|
634
|
+
asWritten(e["disposition"]),
|
|
635
|
+
ev === "spawn_denied" ? asWritten(e["agent"]) : null,
|
|
523
636
|
]);
|
|
524
|
-
const seq =
|
|
637
|
+
const seq = orNull(e["seq"]);
|
|
525
638
|
const existing = rows.get(key);
|
|
526
639
|
if (existing === undefined) {
|
|
527
640
|
rows.set(key, {
|
|
528
641
|
node,
|
|
529
|
-
agent: agentOf.get(node) ?? null,
|
|
642
|
+
agent: typeof node === "string" ? (agentOf.get(node) ?? null) : null,
|
|
530
643
|
tool: orNull(e["tool"]),
|
|
531
644
|
scope: orNull(e["scope"]),
|
|
532
645
|
disposition: orNull(e["disposition"]),
|
|
@@ -543,7 +656,9 @@ export function denials(bundle) {
|
|
|
543
656
|
existing.last_seq = seq;
|
|
544
657
|
}
|
|
545
658
|
}
|
|
546
|
-
|
|
659
|
+
// In the order each row first occurs. Sorting by `first_seq` said the same on a well-formed
|
|
660
|
+
// ledger, and ordered a seq that is not a number by NaN.
|
|
661
|
+
return Array.from(rows.values());
|
|
547
662
|
}
|
|
548
663
|
// `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
|
|
549
664
|
// messages carry. Both implementations report the same failure bytes, so the distinction between
|
|
@@ -630,6 +745,10 @@ export const ENVELOPE_FAILURES = [
|
|
|
630
745
|
* implementation, not a second one for envelopes.
|
|
631
746
|
*/
|
|
632
747
|
export function envelopeSigningInput(envelope) {
|
|
748
|
+
// An envelope that is not a JSON object has no members to sign. The Python implementation raises
|
|
749
|
+
// ValueError with the same words.
|
|
750
|
+
if (!isLedgerObject(envelope))
|
|
751
|
+
throw new Error(`an envelope is ${jsonKind(envelope)}, not an object`);
|
|
633
752
|
const body = {};
|
|
634
753
|
for (const [k, v] of Object.entries(envelope))
|
|
635
754
|
if (k !== "sig")
|
|
@@ -643,7 +762,12 @@ export function envelopeSigningInput(envelope) {
|
|
|
643
762
|
* envelope finds the entry it covers through `subjectIndex`.
|
|
644
763
|
*/
|
|
645
764
|
export function stateKey(e, index) {
|
|
646
|
-
|
|
765
|
+
// An entry that is not a JSON object has no seq member, so it is filed by its index; a seq that
|
|
766
|
+
// is a list or an object by its escaped JSON text, as the Python implementation files it.
|
|
767
|
+
if (!isLedgerObject(e) || !("seq" in e))
|
|
768
|
+
return String(index);
|
|
769
|
+
const seq = orNull(intOr(e["seq"]));
|
|
770
|
+
return seq !== null && typeof seq === "object" ? escaped(seq) : String(seq);
|
|
647
771
|
}
|
|
648
772
|
/**
|
|
649
773
|
* Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
|
|
@@ -790,14 +914,20 @@ function compareVersions(a, b) {
|
|
|
790
914
|
* verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
|
|
791
915
|
* defines no subject for.
|
|
792
916
|
*/
|
|
793
|
-
export function envelopeSubject(
|
|
917
|
+
export function envelopeSubject(given, seq) {
|
|
918
|
+
// `entries` that are not an array are refused, and an entry that is not a JSON object is read as
|
|
919
|
+
// one with no members, as the verifier reads it. The Python implementation's ValueError, worded
|
|
920
|
+
// the same.
|
|
921
|
+
if (!Array.isArray(given))
|
|
922
|
+
throw new Error(`entries is ${jsonKind(given)}, not an array`);
|
|
923
|
+
const entries = Array.from(given, (e) => (isLedgerObject(e) ? e : {}));
|
|
794
924
|
const n = integral(seq);
|
|
795
925
|
const at = n === null ? undefined : subjectIndex(entries).get(n);
|
|
796
926
|
if (n === null || at === undefined)
|
|
797
927
|
throw new Error(`no entry at seq ${seq}`);
|
|
798
928
|
const entry = entries[at];
|
|
799
929
|
const event = toPlain(entry["event"]);
|
|
800
|
-
if (!ENVELOPE_SUBJECT_MEMBERS.has(event)) {
|
|
930
|
+
if (typeof event !== "string" || !ENVELOPE_SUBJECT_MEMBERS.has(event)) {
|
|
801
931
|
throw new Error(`envelope v${ENVELOPE_VERSION} defines no subject for event '${event}'`);
|
|
802
932
|
}
|
|
803
933
|
const subject = {
|
|
@@ -1373,7 +1503,30 @@ function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, r
|
|
|
1373
1503
|
* one the witness signed is in it.
|
|
1374
1504
|
*/
|
|
1375
1505
|
export function verifyEnvelopes(bundle, options = {}) {
|
|
1376
|
-
|
|
1506
|
+
// A bundle it cannot read, or whose `entries` or `envelopes` it cannot (the anchor is not read
|
|
1507
|
+
// here), is reported, `ok` false and status "not checked", one `invalid_bundle` failure per
|
|
1508
|
+
// member, as `verifyBundle` reports it. An entry that is not a JSON object is read as one with
|
|
1509
|
+
// no members, filed by its index. The Python implementation's same report.
|
|
1510
|
+
const unreadable = unreadableBundle(bundle, ["entries", "envelopes"]);
|
|
1511
|
+
if (unreadable.length > 0) {
|
|
1512
|
+
const log = new FailureLog();
|
|
1513
|
+
for (const detail of unreadable)
|
|
1514
|
+
log.add("invalid_bundle", detail);
|
|
1515
|
+
return {
|
|
1516
|
+
ok: false,
|
|
1517
|
+
status: "not checked",
|
|
1518
|
+
count: 0,
|
|
1519
|
+
witness_signed: [],
|
|
1520
|
+
states: {},
|
|
1521
|
+
results: {},
|
|
1522
|
+
witnesses: {},
|
|
1523
|
+
lines: {},
|
|
1524
|
+
failures: log.messages,
|
|
1525
|
+
failure_details: log.details,
|
|
1526
|
+
failure_entries: unreadable.map(() => null),
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
const entries = readableEntries(bundle);
|
|
1377
1530
|
const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
|
|
1378
1531
|
return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
|
|
1379
1532
|
}
|
|
@@ -1924,9 +2077,16 @@ function executionBinding(entries, bundleV) {
|
|
|
1924
2077
|
* re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
|
|
1925
2078
|
*/
|
|
1926
2079
|
export function integrityBreak(entries) {
|
|
2080
|
+
// `entries` that are not an array are no chain, and no entry is where it breaks.
|
|
2081
|
+
if (!Array.isArray(entries))
|
|
2082
|
+
return null;
|
|
1927
2083
|
let prev = GENESIS;
|
|
1928
2084
|
for (let i = 0; i < entries.length; i++) {
|
|
1929
2085
|
const e = entries[i];
|
|
2086
|
+
// An entry that is not a JSON object carries no member, so the chain breaks at it, as
|
|
2087
|
+
// `AuditLog.verify` reads it.
|
|
2088
|
+
if (!isLedgerObject(e))
|
|
2089
|
+
return i;
|
|
1930
2090
|
const payload = {};
|
|
1931
2091
|
for (const [k, v] of Object.entries(e)) {
|
|
1932
2092
|
if (k !== "hash")
|
|
@@ -1949,8 +2109,118 @@ export function integrityBreak(entries) {
|
|
|
1949
2109
|
}
|
|
1950
2110
|
return null;
|
|
1951
2111
|
}
|
|
2112
|
+
/** What each bundle member this module reads has to be, when it is present and not null. */
|
|
2113
|
+
const BUNDLE_MEMBERS = {
|
|
2114
|
+
entries: ["an array", (v) => Array.isArray(v)],
|
|
2115
|
+
anchor: ["an object", (v) => isLedgerObject(v)],
|
|
2116
|
+
envelopes: ["an array", (v) => Array.isArray(v)],
|
|
2117
|
+
};
|
|
2118
|
+
/**
|
|
2119
|
+
* The `invalid_bundle` messages for a bundle this module cannot read: one when the bundle is not a
|
|
2120
|
+
* JSON object, or one for each member in `members` that is present, not null, and not the type the
|
|
2121
|
+
* format gives it. Empty when there is nothing of the kind. Reading such a member anyway reads it
|
|
2122
|
+
* by projection (a string's characters as entries) or throws. The Python implementation's
|
|
2123
|
+
* `_unreadable_bundle`.
|
|
2124
|
+
*/
|
|
2125
|
+
function unreadableBundle(bundle, members = ["entries", "anchor", "envelopes"]) {
|
|
2126
|
+
if (!isLedgerObject(bundle))
|
|
2127
|
+
return [`invalid_bundle: the bundle is ${jsonKind(bundle)}, not an object`];
|
|
2128
|
+
const out = [];
|
|
2129
|
+
for (const member of members) {
|
|
2130
|
+
const [want, ok] = BUNDLE_MEMBERS[member];
|
|
2131
|
+
const value = bundle[member];
|
|
2132
|
+
if (value !== undefined && value !== null && !ok(value)) {
|
|
2133
|
+
out.push(`invalid_bundle: ${member} is ${jsonKind(value)}, not ${want}`);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
return out;
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* A bundle's entries as the readers that never fail read them: none when the bundle or its
|
|
2140
|
+
* `entries` cannot be read (`unreadableBundle`), and an entry that is not a JSON object read as one
|
|
2141
|
+
* with no members, which names no node, no event and no seq. The Python implementation's
|
|
2142
|
+
* `_readable_entries`.
|
|
2143
|
+
*/
|
|
2144
|
+
function readableEntries(bundle) {
|
|
2145
|
+
if (!isLedgerObject(bundle))
|
|
2146
|
+
return [];
|
|
2147
|
+
const entries = bundle["entries"];
|
|
2148
|
+
if (!Array.isArray(entries))
|
|
2149
|
+
return [];
|
|
2150
|
+
return Array.from(entries, (e) => (isLedgerObject(e) ? e : {}));
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* The report on a bundle this verifier cannot read at all (`unreadableBundle`): one
|
|
2154
|
+
* `invalid_bundle` failure per member it cannot read, each about no single entry; every check
|
|
2155
|
+
* false, and every status `"not checked"`, since none ran. The report has every member a full
|
|
2156
|
+
* verification's has, so a reader of it needs no second shape. The Python implementation's
|
|
2157
|
+
* `_invalid_bundle_report`, member for member.
|
|
2158
|
+
*/
|
|
2159
|
+
function invalidBundleReport(bundle, details, verifiedAgainst) {
|
|
2160
|
+
const log = new FailureLog();
|
|
2161
|
+
for (const detail of details)
|
|
2162
|
+
log.add("invalid_bundle", detail);
|
|
2163
|
+
return {
|
|
2164
|
+
ok: false,
|
|
2165
|
+
checks: {
|
|
2166
|
+
integrity: false,
|
|
2167
|
+
monotonicity: false,
|
|
2168
|
+
containment: false,
|
|
2169
|
+
anchor: "not checked",
|
|
2170
|
+
version: false,
|
|
2171
|
+
ledger_fields: false,
|
|
2172
|
+
chain_id: false,
|
|
2173
|
+
root: false,
|
|
2174
|
+
expected_anchor: "not checked",
|
|
2175
|
+
envelopes: "not checked",
|
|
2176
|
+
},
|
|
2177
|
+
failures: log.messages,
|
|
2178
|
+
failure_details: log.details,
|
|
2179
|
+
failure_entries: details.map(() => null),
|
|
2180
|
+
nodes: 0,
|
|
2181
|
+
actions_checked: 0,
|
|
2182
|
+
ungated: 0,
|
|
2183
|
+
chain_id: isLedgerObject(bundle) ? orNull(bundle["chain_id"]) : null,
|
|
2184
|
+
execution_binding: { status: "not applicable" },
|
|
2185
|
+
envelopes: {
|
|
2186
|
+
status: "not checked",
|
|
2187
|
+
count: 0,
|
|
2188
|
+
witness_signed: [],
|
|
2189
|
+
states: {},
|
|
2190
|
+
results: {},
|
|
2191
|
+
witnesses: {},
|
|
2192
|
+
lines: {},
|
|
2193
|
+
failures: [],
|
|
2194
|
+
},
|
|
2195
|
+
verified_against: verifiedAgainst,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
1952
2198
|
export function verifyBundle(bundle, signer = null, options = {}) {
|
|
1953
|
-
const
|
|
2199
|
+
const verifiedAgainst = (options.expectedAnchor ?? null) !== null || (options.expectedHead ?? null) !== null
|
|
2200
|
+
? "expected_anchor"
|
|
2201
|
+
: "bundle_anchor";
|
|
2202
|
+
// A bundle is a JSON object, its `entries` and `envelopes` arrays and its `anchor` an object.
|
|
2203
|
+
// Anything else threw out of this function (a TypeError), whose 0.8.0 note says it never throws,
|
|
2204
|
+
// or was read by projection (a string anchor's characters as its members). Such a bundle is
|
|
2205
|
+
// reported alone, as `invalid_bundle`, once per member it cannot read. An absent or null member
|
|
2206
|
+
// is absent. The Python implementation's same rule, message for message.
|
|
2207
|
+
const given = bundle;
|
|
2208
|
+
const unreadable = unreadableBundle(given);
|
|
2209
|
+
if (unreadable.length > 0 || !isLedgerObject(given))
|
|
2210
|
+
return invalidBundleReport(given, unreadable, verifiedAgainst);
|
|
2211
|
+
const rawEntries = (given["entries"] ?? []);
|
|
2212
|
+
// An entry that is not a JSON object is reported once, as `invalid_ledger_entry` in (0b2), and
|
|
2213
|
+
// read everywhere else as an entry with no members: the checks that read an entry's fields skip
|
|
2214
|
+
// it, and the hash chain breaks at it. Each stand-in is a new object, so `failure_entries` finds
|
|
2215
|
+
// its index by identity, as it finds every other entry's. `Array.from` visits a hole as well.
|
|
2216
|
+
const unread = new Set();
|
|
2217
|
+
const entries = Array.from(rawEntries, (e) => {
|
|
2218
|
+
if (isLedgerObject(e))
|
|
2219
|
+
return e;
|
|
2220
|
+
const standIn = {};
|
|
2221
|
+
unread.add(standIn);
|
|
2222
|
+
return standIn;
|
|
2223
|
+
});
|
|
1954
2224
|
const anchor = (bundle.anchor ?? {});
|
|
1955
2225
|
const anchorPresent = Object.keys(anchor).length > 0;
|
|
1956
2226
|
const checks = {
|
|
@@ -1994,7 +2264,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1994
2264
|
versionOk = false;
|
|
1995
2265
|
log.add("root_version_mismatch", `root_version_mismatch: root v=${reprIntOr(rootEntry["v"])} != bundle v=${reprIntOr(rawV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]), entry: rootEntry });
|
|
1996
2266
|
}
|
|
1997
|
-
const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
|
|
2267
|
+
const mixedEntries = entries.filter((e) => !unread.has(e) && !sameNumber(e["v"], rawV));
|
|
1998
2268
|
// An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
|
|
1999
2269
|
// everything else by its repr, compared by code point as Python compares strings, never by locale.
|
|
2000
2270
|
const mixed = pySetOf(mixedEntries.map((e) => (intOr(e["v"]) ?? null))).sort(compareVersions);
|
|
@@ -2020,8 +2290,32 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
2020
2290
|
// is a property of the bundle, not an error in the call.
|
|
2021
2291
|
//
|
|
2022
2292
|
// Kept in step with the Python port (`evidence._verify_bundle`, same check, same reason string).
|
|
2293
|
+
//
|
|
2294
|
+
// The same pass reads every entry's shape and event, and reports each defect an entry has. An
|
|
2295
|
+
// entry that is not a JSON object is `invalid_ledger_entry`, chain-level since it has no seq or
|
|
2296
|
+
// node, its index in the message. An event that is absent, not a string, or not one its chain's
|
|
2297
|
+
// version defines (`outcome` is v2-only) is `unknown_ledger_event`: every check below reads an
|
|
2298
|
+
// entry by its event, so such an entry was read by none of them.
|
|
2023
2299
|
let unknownOk = true;
|
|
2024
|
-
|
|
2300
|
+
const events = bundleV === 1 ? LEDGER_EVENTS_V1 : LEDGER_EVENTS;
|
|
2301
|
+
for (let index = 0; index < entries.length; index++) {
|
|
2302
|
+
const e = entries[index];
|
|
2303
|
+
if (unread.has(e)) {
|
|
2304
|
+
unknownOk = false;
|
|
2305
|
+
log.add("invalid_ledger_entry", `invalid_ledger_entry: entries[${index}] is ${jsonKind(rawEntries[index])}, not an object`, { entry: e });
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
const ev = toPlain(e["event"]);
|
|
2309
|
+
if (typeof ev !== "string" || !events.has(ev)) {
|
|
2310
|
+
unknownOk = false;
|
|
2311
|
+
const why = typeof ev !== "string"
|
|
2312
|
+
? ", which is not a string"
|
|
2313
|
+
: LEDGER_EVENTS.has(ev)
|
|
2314
|
+
? ", a v2-only event on a schema_version=1 chain"
|
|
2315
|
+
: "";
|
|
2316
|
+
log.add("unknown_ledger_event", `unknown_ledger_event: entry carries an event this verifier does not evaluate and will not ` +
|
|
2317
|
+
`ignore: ${shown(e["event"])}${why}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
2318
|
+
}
|
|
2025
2319
|
const extra = Object.keys(e)
|
|
2026
2320
|
.filter((f) => !LEDGER_FIELDS.has(f))
|
|
2027
2321
|
.sort();
|
|
@@ -2078,7 +2372,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
2078
2372
|
// must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
|
|
2079
2373
|
// for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
|
|
2080
2374
|
const bundleChainId = orNull(bundle.chain_id);
|
|
2081
|
-
const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
|
|
2375
|
+
const foreign = entries.find((e) => !unread.has(e) && orNull(e["chain_id"]) !== bundleChainId);
|
|
2082
2376
|
const entriesOk = foreign === undefined;
|
|
2083
2377
|
if (foreign !== undefined) {
|
|
2084
2378
|
log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
|
|
@@ -2290,7 +2584,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
2290
2584
|
chain_id: orNull(bundle.chain_id),
|
|
2291
2585
|
execution_binding: eb,
|
|
2292
2586
|
envelopes: envelopeSummary,
|
|
2293
|
-
verified_against:
|
|
2587
|
+
verified_against: verifiedAgainst,
|
|
2294
2588
|
};
|
|
2295
2589
|
}
|
|
2296
2590
|
/** Read a bundle from JSON text, keeping every number's original literal. */
|