attenu-guard 0.12.0 → 0.13.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 +33 -0
- package/README.md +27 -0
- package/dist/cjs/audit.d.ts.map +1 -1
- package/dist/cjs/audit.js +45 -11
- package/dist/cjs/audit.js.map +1 -1
- package/dist/cjs/authority.d.ts.map +1 -1
- package/dist/cjs/authority.js +15 -9
- package/dist/cjs/authority.js.map +1 -1
- package/dist/cjs/canonical.d.ts +24 -0
- package/dist/cjs/canonical.d.ts.map +1 -1
- package/dist/cjs/canonical.js +49 -0
- package/dist/cjs/canonical.js.map +1 -1
- package/dist/cjs/ceilings.d.ts +17 -0
- package/dist/cjs/ceilings.d.ts.map +1 -1
- package/dist/cjs/ceilings.js +48 -0
- package/dist/cjs/ceilings.js.map +1 -1
- package/dist/cjs/cli.d.ts +12 -3
- package/dist/cjs/cli.d.ts.map +1 -1
- package/dist/cjs/cli.js +272 -20
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/display.d.ts +76 -0
- package/dist/cjs/display.d.ts.map +1 -0
- package/dist/cjs/display.js +238 -0
- package/dist/cjs/display.js.map +1 -0
- package/dist/cjs/evidence.d.ts +74 -5
- package/dist/cjs/evidence.d.ts.map +1 -1
- package/dist/cjs/evidence.js +655 -243
- package/dist/cjs/evidence.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.map +1 -1
- package/dist/esm/audit.js +46 -12
- package/dist/esm/audit.js.map +1 -1
- package/dist/esm/authority.d.ts.map +1 -1
- package/dist/esm/authority.js +15 -9
- package/dist/esm/authority.js.map +1 -1
- package/dist/esm/canonical.d.ts +24 -0
- package/dist/esm/canonical.d.ts.map +1 -1
- package/dist/esm/canonical.js +46 -0
- package/dist/esm/canonical.js.map +1 -1
- package/dist/esm/ceilings.d.ts +17 -0
- package/dist/esm/ceilings.d.ts.map +1 -1
- package/dist/esm/ceilings.js +46 -0
- package/dist/esm/ceilings.js.map +1 -1
- package/dist/esm/cli.d.ts +12 -3
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +273 -21
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/display.d.ts +76 -0
- package/dist/esm/display.d.ts.map +1 -0
- package/dist/esm/display.js +226 -0
- package/dist/esm/display.js.map +1 -0
- package/dist/esm/evidence.d.ts +74 -5
- package/dist/esm/evidence.d.ts.map +1 -1
- package/dist/esm/evidence.js +638 -229
- package/dist/esm/evidence.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
|
@@ -30,11 +30,12 @@
|
|
|
30
30
|
* point. This is byte-compatible with the Python library's
|
|
31
31
|
* `attenu_guard.evidence`.
|
|
32
32
|
*/
|
|
33
|
-
import { canonicalBytes, compareCodePoints, parseJson, pyNumber, RawNumber, sortedStrings, toPlain, } from "./canonical.js";
|
|
33
|
+
import { canonicalBytes, compareCodePoints, intOr, integral, parseJson, pyNumber, RawNumber, sortedStrings, toPlain, } from "./canonical.js";
|
|
34
34
|
import { createHash } from "node:crypto";
|
|
35
35
|
import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, GENESIS } from "./audit.js";
|
|
36
36
|
import { Authority } from "./authority.js";
|
|
37
|
-
import {
|
|
37
|
+
import { describeInFinding, wireKeyOf } from "./ceilings.js";
|
|
38
|
+
import { pyRepr, pyStr, pyStrRepr, shown } from "./display.js";
|
|
38
39
|
import { CAPTURES, BODY_STATES, POLICIES, BodyState, Capture } from "./reasons.js";
|
|
39
40
|
import { PARAMS_HASH_REASONS } from "./params.js";
|
|
40
41
|
import { Ed25519Signer, Ed25519Verifier } from "./wire.js";
|
|
@@ -267,23 +268,41 @@ function orNull(value) {
|
|
|
267
268
|
class FailureLog {
|
|
268
269
|
messages = [];
|
|
269
270
|
details = [];
|
|
271
|
+
/**
|
|
272
|
+
* A third list in step with those two: the ledger entry each failure is about, or `null` for a
|
|
273
|
+
* failure about no single entry. It stays out of `details`, whose member set is the published
|
|
274
|
+
* contract, and `entryIndices` turns it into positions in the bundle's `entries`, which
|
|
275
|
+
* `verifyBundle` reports as `failure_entries`. A position is exact where a seq is not: an
|
|
276
|
+
* entry's seq can be missing, null, a boolean or a duplicate, and its index is still its own.
|
|
277
|
+
*/
|
|
278
|
+
about = [];
|
|
270
279
|
add(reason, detail, position = {}) {
|
|
271
|
-
const { seq = null, node = null, callId = null } = position;
|
|
280
|
+
const { seq = null, node = null, callId = null, entry = null } = position;
|
|
272
281
|
this.messages.push(detail);
|
|
273
282
|
this.details.push({ reason, seq, node, call_id: callId, detail });
|
|
283
|
+
this.about.push(entry);
|
|
274
284
|
}
|
|
275
285
|
extend(other) {
|
|
276
286
|
this.messages.push(...other.messages);
|
|
277
287
|
this.details.push(...other.details);
|
|
288
|
+
this.about.push(...other.about);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* For each failure, the index in `entries` of the entry it is about, or `null`. Found by
|
|
292
|
+
* identity: every entry a check reports on is one of these objects.
|
|
293
|
+
*/
|
|
294
|
+
entryIndices(entries) {
|
|
295
|
+
const at = new Map();
|
|
296
|
+
entries.forEach((e, i) => {
|
|
297
|
+
if (!at.has(e))
|
|
298
|
+
at.set(e, i);
|
|
299
|
+
});
|
|
300
|
+
return this.about.map((e) => (e === null ? null : (at.get(e) ?? null)));
|
|
278
301
|
}
|
|
279
302
|
get length() {
|
|
280
303
|
return this.messages.length;
|
|
281
304
|
}
|
|
282
305
|
}
|
|
283
|
-
/**
|
|
284
|
-
* `node -> Authority` and `node -> parent`, reconstructed from `root` and
|
|
285
|
-
* `spawn` events alone. No engine state.
|
|
286
|
-
*/
|
|
287
306
|
/**
|
|
288
307
|
* Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
|
|
289
308
|
*
|
|
@@ -307,7 +326,7 @@ function monotonicityDetail(child, parent) {
|
|
|
307
326
|
// does not cover is also absent from that set.
|
|
308
327
|
if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
|
|
309
328
|
const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
|
|
310
|
-
return (`child scopes
|
|
329
|
+
return (`child scopes ${reprList(extra.sort(compareCodePoints))} ` +
|
|
311
330
|
`not held by parent`);
|
|
312
331
|
}
|
|
313
332
|
const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
|
|
@@ -316,11 +335,11 @@ function monotonicityDetail(child, parent) {
|
|
|
316
335
|
const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
|
|
317
336
|
const childCeiling = childByKey.get(key);
|
|
318
337
|
if (childCeiling === undefined) {
|
|
319
|
-
return `ceiling ${
|
|
338
|
+
return `ceiling ${shown(wireKeyOf(parentCeiling))} unbounded, parent holds ${describeInFinding(parentCeiling)}`;
|
|
320
339
|
}
|
|
321
340
|
if (!parentCeiling.subsumes(childCeiling)) {
|
|
322
|
-
return (`ceiling ${
|
|
323
|
-
`${
|
|
341
|
+
return (`ceiling ${describeInFinding(childCeiling)} looser than parent ` +
|
|
342
|
+
`${describeInFinding(parentCeiling)}`);
|
|
324
343
|
}
|
|
325
344
|
}
|
|
326
345
|
if (parent.ttl !== null) {
|
|
@@ -334,6 +353,17 @@ function monotonicityDetail(child, parent) {
|
|
|
334
353
|
// it exists so that such a dimension cannot fail SILENTLY.
|
|
335
354
|
return "child not narrower than parent";
|
|
336
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
|
|
358
|
+
* order. No engine state.
|
|
359
|
+
*
|
|
360
|
+
* A node id is a string, and a node is defined once, by the root or by one spawn. A root or a
|
|
361
|
+
* spawn whose `node` is not a string defines nothing and is reported unreadable here. A second
|
|
362
|
+
* definition of a node is left out of these maps, so every later check reads the first one, and
|
|
363
|
+
* `verifyBundle`'s monotonicity check reports it. `parent` maps a spawned node to its `parent`
|
|
364
|
+
* member as written, whatever its type; whether that names a node defined earlier is judged there
|
|
365
|
+
* too. The Python implementation's `_node_authorities`.
|
|
366
|
+
*/
|
|
337
367
|
function nodeAuthorities(entries) {
|
|
338
368
|
const auth = new Map();
|
|
339
369
|
const parent = new Map();
|
|
@@ -341,37 +371,58 @@ function nodeAuthorities(entries) {
|
|
|
341
371
|
const definedBy = new Map();
|
|
342
372
|
for (const e of entries) {
|
|
343
373
|
const ev = toPlain(e["event"]);
|
|
374
|
+
if (ev !== "root" && ev !== "spawn")
|
|
375
|
+
continue;
|
|
344
376
|
const node = toPlain(e["node"]);
|
|
377
|
+
const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
|
|
378
|
+
if (typeof node !== "string") {
|
|
379
|
+
// One of the two historical messages that name a node before their colon rather than a
|
|
380
|
+
// reason token, so the reason is stated here instead of parsed out of the string.
|
|
381
|
+
if (ev === "root") {
|
|
382
|
+
failures.add("unreadable_authority", `root ${shown(e["node"])}: unreadable authority (node is not a string)`, position);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
failures.add("unreadable_granted", `spawn ${shown(e["node"])}: unreadable granted (node is not a string)`, position);
|
|
386
|
+
}
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (definedBy.has(node))
|
|
390
|
+
continue; // defined twice: verifyBundle reports it
|
|
391
|
+
definedBy.set(node, e);
|
|
345
392
|
if (ev === "root") {
|
|
346
|
-
definedBy.set(node, e);
|
|
347
393
|
try {
|
|
348
394
|
auth.set(node, Authority.fromWire(e["authority"] ?? null));
|
|
349
395
|
}
|
|
350
396
|
catch (exc) {
|
|
351
|
-
|
|
352
|
-
// reason token, so the reason is stated here instead of parsed out of the string.
|
|
353
|
-
failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
|
|
354
|
-
seq: orNull(e["seq"]),
|
|
355
|
-
node: orNull(e["node"]),
|
|
356
|
-
});
|
|
397
|
+
failures.add("unreadable_authority", `root ${shown(e["node"])}: unreadable authority (${exc.message})`, position);
|
|
357
398
|
}
|
|
358
399
|
}
|
|
359
|
-
else
|
|
360
|
-
|
|
361
|
-
parent.set(node, toPlain(e["parent"]) ?? null);
|
|
400
|
+
else {
|
|
401
|
+
parent.set(node, orNull(e["parent"]));
|
|
362
402
|
try {
|
|
363
403
|
auth.set(node, Authority.fromWire(e["granted"] ?? null));
|
|
364
404
|
}
|
|
365
405
|
catch (exc) {
|
|
366
|
-
failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`,
|
|
367
|
-
seq: orNull(e["seq"]),
|
|
368
|
-
node: orNull(e["node"]),
|
|
369
|
-
});
|
|
406
|
+
failures.add("unreadable_granted", `spawn ${shown(e["node"])}: unreadable granted (${exc.message})`, position);
|
|
370
407
|
}
|
|
371
408
|
}
|
|
372
409
|
}
|
|
373
410
|
return { auth, parent, failures, definedBy };
|
|
374
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Record the nodes a `kill` entry revokes, node -> that kill's own `seq`; the first kill stands.
|
|
414
|
+
* Only string ids in a list count: anything else names no node. The Python implementation's
|
|
415
|
+
* `_note_revoked`.
|
|
416
|
+
*/
|
|
417
|
+
function noteRevoked(entry, revokedAt) {
|
|
418
|
+
const revoked = entry["revoked"];
|
|
419
|
+
if (!Array.isArray(revoked))
|
|
420
|
+
return;
|
|
421
|
+
for (const node of revoked) {
|
|
422
|
+
if (typeof node === "string" && !revokedAt.has(node))
|
|
423
|
+
revokedAt.set(node, entry["seq"]);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
375
426
|
/**
|
|
376
427
|
* A view of the chain from the bundle: each node with its agent, task,
|
|
377
428
|
* authority, parent and per-node action counts — what a reviewer or a UI
|
|
@@ -383,10 +434,15 @@ export function delegationGraph(bundle) {
|
|
|
383
434
|
const meta = {};
|
|
384
435
|
for (const e of entries) {
|
|
385
436
|
const ev = toPlain(e["event"]);
|
|
386
|
-
const
|
|
437
|
+
const raw = toPlain(e["node"]);
|
|
438
|
+
if (typeof raw !== "string" && ev !== "kill")
|
|
439
|
+
continue; // a node id is a string; anything else names no node
|
|
440
|
+
const n = raw;
|
|
387
441
|
if (ev === "root" || ev === "spawn") {
|
|
442
|
+
if (Object.hasOwn(meta, n))
|
|
443
|
+
continue; // defined once: the first definition stands
|
|
388
444
|
const a = auth.get(n);
|
|
389
|
-
meta
|
|
445
|
+
setOwn(meta, n, {
|
|
390
446
|
agent: orNull(e["agent"]),
|
|
391
447
|
task: orNull(e["task"]),
|
|
392
448
|
parent: orNull(e["parent"]),
|
|
@@ -396,30 +452,32 @@ export function delegationGraph(bundle) {
|
|
|
396
452
|
revoked: false,
|
|
397
453
|
complete: false,
|
|
398
454
|
denials_by_disposition: {},
|
|
399
|
-
};
|
|
455
|
+
});
|
|
400
456
|
}
|
|
401
|
-
else if (ev === "allow" && meta
|
|
457
|
+
else if (ev === "allow" && Object.hasOwn(meta, n)) {
|
|
402
458
|
meta[n].allows += 1;
|
|
403
459
|
}
|
|
404
|
-
else if (ev === "deny" && meta
|
|
460
|
+
else if (ev === "deny" && Object.hasOwn(meta, n)) {
|
|
405
461
|
meta[n].denies += 1;
|
|
406
462
|
// A deny without a disposition is named by its reason.
|
|
407
463
|
const d = (toPlain(e["disposition"]) ?? toPlain(e["reason"]) ?? "unstated");
|
|
408
464
|
meta[n].denials_by_disposition[d] = (meta[n].denials_by_disposition[d] ?? 0) + 1;
|
|
409
465
|
}
|
|
410
|
-
else if (ev === "done" && meta
|
|
466
|
+
else if (ev === "done" && Object.hasOwn(meta, n)) {
|
|
411
467
|
meta[n].complete = true;
|
|
412
468
|
}
|
|
413
469
|
else if (ev === "kill") {
|
|
414
|
-
|
|
415
|
-
|
|
470
|
+
const revoked = new Map();
|
|
471
|
+
noteRevoked(e, revoked);
|
|
472
|
+
for (const r of revoked.keys()) {
|
|
473
|
+
if (Object.hasOwn(meta, r))
|
|
416
474
|
meta[r].revoked = true;
|
|
417
475
|
}
|
|
418
476
|
}
|
|
419
477
|
}
|
|
420
478
|
const edges = [];
|
|
421
479
|
for (const [child, p] of parent) {
|
|
422
|
-
if (p)
|
|
480
|
+
if (typeof p === "string" && p !== "")
|
|
423
481
|
edges.push({ parent: p, child });
|
|
424
482
|
}
|
|
425
483
|
return { chain_id: orNull(bundle.chain_id), nodes: meta, edges };
|
|
@@ -487,37 +545,9 @@ export function denials(bundle) {
|
|
|
487
545
|
}
|
|
488
546
|
return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
|
|
489
547
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
return "None";
|
|
494
|
-
if (typeof value === "boolean")
|
|
495
|
-
return value ? "True" : "False";
|
|
496
|
-
if (typeof value === "number")
|
|
497
|
-
return String(value);
|
|
498
|
-
if (typeof value === "string")
|
|
499
|
-
return `'${value}'`;
|
|
500
|
-
// Containers reach here only on hostile input — a subject member that is a list or an object
|
|
501
|
-
// where the contract wants a scalar. Rendered the way Python's repr renders them, because the
|
|
502
|
-
// two implementations report the same failure strings and a JSON spelling would not match.
|
|
503
|
-
if (Array.isArray(value))
|
|
504
|
-
return `[${value.map((v) => pyRepr(v)).join(", ")}]`;
|
|
505
|
-
if (typeof value === "object") {
|
|
506
|
-
const body = Object.entries(value)
|
|
507
|
-
.map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`)
|
|
508
|
-
.join(", ");
|
|
509
|
-
return `{${body}}`;
|
|
510
|
-
}
|
|
511
|
-
return JSON.stringify(value);
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* Python's `str()` where a failure string interpolates a value bare (`{x}`) rather than through
|
|
515
|
-
* `repr` (`{x!r}`) — the same rendering for every type except a string, which `str` leaves
|
|
516
|
-
* unquoted. Both implementations report the same failure bytes, so the distinction is load-bearing.
|
|
517
|
-
*/
|
|
518
|
-
function pyStr(value) {
|
|
519
|
-
return typeof value === "string" ? value : pyRepr(value);
|
|
520
|
-
}
|
|
548
|
+
// `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
|
|
549
|
+
// messages carry. Both implementations report the same failure bytes, so the distinction between
|
|
550
|
+
// `{x}` and `{x!r}` is load-bearing, and `pyRepr` escapes a string exactly as `repr` does.
|
|
521
551
|
// =============================================================================================
|
|
522
552
|
// Observer envelopes (envelope v1) — the TypeScript half of `attenu_guard.evidence`'s.
|
|
523
553
|
//
|
|
@@ -572,7 +602,9 @@ export const ENVELOPE_ALG = "EdDSA";
|
|
|
572
602
|
/**
|
|
573
603
|
* A verifying envelope's state. It says where the signature came from and NOTHING about
|
|
574
604
|
* authority — the witness is whoever holds the key `witness.kid` names, which nothing in the
|
|
575
|
-
* envelope makes the delegation parent.
|
|
605
|
+
* envelope makes the delegation parent. The signature covers the entry's hash and chain position
|
|
606
|
+
* plus what the witness-key holder observed; it does not attest that the action was permitted.
|
|
607
|
+
* Monotonicity and containment answer that, from the ledger.
|
|
576
608
|
*/
|
|
577
609
|
export const WITNESS_SIGNED = "witness-signed";
|
|
578
610
|
/**
|
|
@@ -605,15 +637,25 @@ export function envelopeSigningInput(envelope) {
|
|
|
605
637
|
return canonicalBytes(body);
|
|
606
638
|
}
|
|
607
639
|
/**
|
|
608
|
-
*
|
|
640
|
+
* Where `states` and `results` file an entry: its seq when it has one, as the integer when it is
|
|
641
|
+
* integral (`intOr`: `1.0` is filed as 1), and its index when it has none. The Python
|
|
642
|
+
* implementation's `_state_key`, rendered as a record key. It is a report key, never a lookup: an
|
|
643
|
+
* envelope finds the entry it covers through `subjectIndex`.
|
|
644
|
+
*/
|
|
645
|
+
export function stateKey(e, index) {
|
|
646
|
+
return String("seq" in e ? orNull(intOr(e["seq"])) : index);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
|
|
609
650
|
*
|
|
610
651
|
* `entry_hash` in a subject is checked against this. The walk mirrors `AuditLog.verify`, so an
|
|
611
652
|
* entry whose stored `hash` was replaced does not get to supply the value it is compared against.
|
|
653
|
+
* By index, not by seq: an entry's seq can be missing, another entry's, or not an integer.
|
|
612
654
|
*/
|
|
613
655
|
function recomputedHashes(entries) {
|
|
614
|
-
const out =
|
|
656
|
+
const out = [];
|
|
615
657
|
let prev = GENESIS;
|
|
616
|
-
|
|
658
|
+
for (const e of entries) {
|
|
617
659
|
const payload = {};
|
|
618
660
|
for (const [k, v] of Object.entries(e))
|
|
619
661
|
if (k !== "hash")
|
|
@@ -626,20 +668,134 @@ function recomputedHashes(entries) {
|
|
|
626
668
|
// An unhashable payload has no recomputable hash; that IS the break, at this entry.
|
|
627
669
|
computed = null;
|
|
628
670
|
}
|
|
629
|
-
out.
|
|
671
|
+
out.push(computed);
|
|
630
672
|
prev = computed ?? GENESIS;
|
|
673
|
+
}
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* subject.seq -> the index of the entry an envelope naming that seq covers.
|
|
678
|
+
*
|
|
679
|
+
* An entry is keyed by its own seq when that is an integral number and not a boolean, as the
|
|
680
|
+
* schema's integer type defines (`integral`: `1.0` is 1), and by its index when it has no seq
|
|
681
|
+
* member at all. An entry whose seq is a boolean, a string, null, or a fractional number is keyed
|
|
682
|
+
* by nothing, so no envelope covers it: in Python, keyed by the raw value, `"seq": true` took the
|
|
683
|
+
* envelope written for seq 1, and here a null seq was keyed by its index. Where two entries share
|
|
684
|
+
* a key the later one is covered, as it always was. The Python implementation's `_subject_index`.
|
|
685
|
+
*/
|
|
686
|
+
function subjectIndex(entries) {
|
|
687
|
+
const keyed = new Map();
|
|
688
|
+
entries.forEach((e, i) => {
|
|
689
|
+
if (!("seq" in e)) {
|
|
690
|
+
keyed.set(i, i);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const seq = integral(e["seq"]);
|
|
694
|
+
if (seq !== null)
|
|
695
|
+
keyed.set(seq, i);
|
|
631
696
|
});
|
|
697
|
+
return keyed;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Two `seq` or `v` values are the same: equal integers when either is integral (`integral`), so
|
|
701
|
+
* `1.0` is 1; otherwise the same value of the same type, so `true` is not 1 and `"1"` is not 1. An
|
|
702
|
+
* absent value is null, as Python's `dict.get` reads it. The Python implementation's
|
|
703
|
+
* `_same_number`.
|
|
704
|
+
*/
|
|
705
|
+
function sameNumber(a, b) {
|
|
706
|
+
const ia = integral(a);
|
|
707
|
+
const ib = integral(b);
|
|
708
|
+
if (ia !== null || ib !== null)
|
|
709
|
+
return ia === ib;
|
|
710
|
+
const pa = orNull(a);
|
|
711
|
+
const pb = orNull(b);
|
|
712
|
+
return pyTypeOf(pa) === pyTypeOf(pb) && pyEquals(pa, pb);
|
|
713
|
+
}
|
|
714
|
+
/** The Python type of a parsed JSON value, which tells a boolean from a number as `type()` does. */
|
|
715
|
+
function pyTypeOf(value) {
|
|
716
|
+
if (value === null)
|
|
717
|
+
return "NoneType";
|
|
718
|
+
if (Array.isArray(value))
|
|
719
|
+
return "list";
|
|
720
|
+
if (typeof value === "number")
|
|
721
|
+
return Number.isInteger(value) ? "int" : "float";
|
|
722
|
+
return typeof value === "object" ? "dict" : typeof value;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Python's `==` on two parsed JSON values: numbers and booleans by value (`True == 1`), strings by
|
|
726
|
+
* text, lists and objects member by member.
|
|
727
|
+
*/
|
|
728
|
+
function pyEquals(a, b) {
|
|
729
|
+
const x = typeof a === "boolean" ? Number(a) : a;
|
|
730
|
+
const y = typeof b === "boolean" ? Number(b) : b;
|
|
731
|
+
if (typeof x !== "object" || typeof y !== "object" || x === null || y === null)
|
|
732
|
+
return x === y;
|
|
733
|
+
if (Array.isArray(x) || Array.isArray(y)) {
|
|
734
|
+
return Array.isArray(x) && Array.isArray(y) && x.length === y.length && x.every((v, i) => pyEquals(v, y[i]));
|
|
735
|
+
}
|
|
736
|
+
const keys = Object.keys(x);
|
|
737
|
+
return (keys.length === Object.keys(y).length && keys.every((k) => Object.hasOwn(y, k) && pyEquals(x[k], y[k])));
|
|
738
|
+
}
|
|
739
|
+
/** Python's `{value!r}` of an integral-or-not `seq` or `v`: the integer when it is integral (`intOr`). */
|
|
740
|
+
function reprIntOr(value) {
|
|
741
|
+
return pyRepr((intOr(value) ?? null));
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* The distinct versions of `values`, as a Python set holds them: two that Python's `==` calls equal
|
|
745
|
+
* are one (`True` and 1 included), and the first of them stays. A list or an object has no place
|
|
746
|
+
* in a Python set (Python raises there), and each is kept on its own.
|
|
747
|
+
*/
|
|
748
|
+
function pySetOf(values) {
|
|
749
|
+
const seen = new Set();
|
|
750
|
+
const out = [];
|
|
751
|
+
for (const value of values) {
|
|
752
|
+
const plain = toPlain(value);
|
|
753
|
+
const key = plain === null
|
|
754
|
+
? "None"
|
|
755
|
+
: typeof plain === "boolean" || typeof plain === "number"
|
|
756
|
+
? `n:${Number(plain) === 0 ? 0 : Number(plain)}`
|
|
757
|
+
: typeof plain === "string"
|
|
758
|
+
? `s:${plain}`
|
|
759
|
+
: null;
|
|
760
|
+
if (key !== null) {
|
|
761
|
+
if (seen.has(key))
|
|
762
|
+
continue;
|
|
763
|
+
seen.add(key);
|
|
764
|
+
}
|
|
765
|
+
out.push(value);
|
|
766
|
+
}
|
|
632
767
|
return out;
|
|
633
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* The order `mixed_entry_versions` lists versions in: numbers in numeric order, and anything else
|
|
771
|
+
* after them by its Python `repr`, so a boolean beside a string still sorts. The Python
|
|
772
|
+
* implementation's `_version_order`.
|
|
773
|
+
*/
|
|
774
|
+
function compareVersions(a, b) {
|
|
775
|
+
const numberOf = (v) => v instanceof RawNumber ? v.value : typeof v === "number" ? v : null;
|
|
776
|
+
const na = numberOf(a);
|
|
777
|
+
const nb = numberOf(b);
|
|
778
|
+
if (na !== null && nb !== null)
|
|
779
|
+
return na - nb;
|
|
780
|
+
if (na !== null)
|
|
781
|
+
return -1;
|
|
782
|
+
if (nb !== null)
|
|
783
|
+
return 1;
|
|
784
|
+
return compareCodePoints(pyRepr(a), pyRepr(b));
|
|
785
|
+
}
|
|
634
786
|
/**
|
|
635
787
|
* The v1 subject for the entry at `seq`, recomputed from the ledger.
|
|
636
788
|
*
|
|
637
|
-
*
|
|
789
|
+
* `seq` finds its entry the way a verifier finds it (`subjectIndex`), so the subject is the one the
|
|
790
|
+
* verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
|
|
791
|
+
* defines no subject for.
|
|
638
792
|
*/
|
|
639
793
|
export function envelopeSubject(entries, seq) {
|
|
640
|
-
const
|
|
641
|
-
|
|
794
|
+
const n = integral(seq);
|
|
795
|
+
const at = n === null ? undefined : subjectIndex(entries).get(n);
|
|
796
|
+
if (n === null || at === undefined)
|
|
642
797
|
throw new Error(`no entry at seq ${seq}`);
|
|
798
|
+
const entry = entries[at];
|
|
643
799
|
const event = toPlain(entry["event"]);
|
|
644
800
|
if (!ENVELOPE_SUBJECT_MEMBERS.has(event)) {
|
|
645
801
|
throw new Error(`envelope v${ENVELOPE_VERSION} defines no subject for event '${event}'`);
|
|
@@ -647,8 +803,8 @@ export function envelopeSubject(entries, seq) {
|
|
|
647
803
|
const subject = {
|
|
648
804
|
chain_id: orNull(entry["chain_id"]),
|
|
649
805
|
node: orNull(entry["node"]),
|
|
650
|
-
seq,
|
|
651
|
-
entry_hash: recomputedHashes(entries)
|
|
806
|
+
seq: n,
|
|
807
|
+
entry_hash: recomputedHashes(entries)[at] ?? null,
|
|
652
808
|
event,
|
|
653
809
|
};
|
|
654
810
|
if (event === "allow")
|
|
@@ -691,21 +847,21 @@ export function signEnvelope(entries, seq, seed, kid, observed) {
|
|
|
691
847
|
function witnessPublicKey(kid, value) {
|
|
692
848
|
if (typeof value === "string") {
|
|
693
849
|
if (value.length !== 64) {
|
|
694
|
-
throw new Error(`witness key
|
|
850
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
|
|
695
851
|
`got ${value.length}`);
|
|
696
852
|
}
|
|
697
853
|
if (!/^[0-9a-fA-F]{64}$/.test(value)) {
|
|
698
|
-
throw new Error(`witness key
|
|
854
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: public_key_hex is not hexadecimal`);
|
|
699
855
|
}
|
|
700
856
|
return Buffer.from(value, "hex");
|
|
701
857
|
}
|
|
702
858
|
if (value instanceof Uint8Array) {
|
|
703
859
|
if (value.length !== 32) {
|
|
704
|
-
throw new Error(`witness key
|
|
860
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: an Ed25519 public key is 32 bytes, got ${value.length}`);
|
|
705
861
|
}
|
|
706
862
|
return Buffer.from(value);
|
|
707
863
|
}
|
|
708
|
-
throw new Error(`witness key
|
|
864
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: expected 64 hex characters or 32 bytes`);
|
|
709
865
|
}
|
|
710
866
|
/**
|
|
711
867
|
* kid -> `[alg, raw public key]`, from the vector file's own `witness_keys` shape or from a plain
|
|
@@ -720,27 +876,154 @@ function witnessPublicKey(kid, value) {
|
|
|
720
876
|
* reported to the caller rather than folded into a finding about the bundle. v1 defines Ed25519
|
|
721
877
|
* and no other algorithm, so a row declaring anything else is refused too.
|
|
722
878
|
*/
|
|
723
|
-
function trustedWitnesses(witnessKeys) {
|
|
879
|
+
function trustedWitnesses(witnessKeys, now) {
|
|
724
880
|
const trusted = new Map();
|
|
881
|
+
const expired = new Map();
|
|
882
|
+
const seen = new Set();
|
|
725
883
|
if (witnessKeys === null || witnessKeys === undefined)
|
|
726
|
-
return trusted;
|
|
884
|
+
return { trusted, expired };
|
|
727
885
|
const rows = Array.isArray(witnessKeys)
|
|
728
886
|
? witnessKeys.map((k) => [isRecordLike(k) ? k["kid"] : undefined, k])
|
|
729
887
|
: Object.entries(witnessKeys);
|
|
730
888
|
for (const [kid, value] of rows) {
|
|
731
889
|
if (typeof kid !== "string")
|
|
732
890
|
throw new Error("witness key kid must be a string");
|
|
891
|
+
// One row per kid. The later row used to win, so a row added to expire a key could leave it
|
|
892
|
+
// trusted, and an expired row beside a live one did.
|
|
893
|
+
if (seen.has(kid))
|
|
894
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: more than one row names this kid`);
|
|
895
|
+
seen.add(kid);
|
|
733
896
|
let key = value;
|
|
897
|
+
let notAfter = null;
|
|
898
|
+
let until = null;
|
|
734
899
|
if (isRecordLike(value)) {
|
|
900
|
+
// Read whole: a row read by projection let a misspelled `notAfter` leave a key that never
|
|
901
|
+
// expired. In the `{kid: row}` form a row may repeat its kid, and must agree with it.
|
|
902
|
+
const unknown = Object.keys(value)
|
|
903
|
+
.filter((m) => !TRUST_ROW_MEMBERS.has(m))
|
|
904
|
+
.sort(compareCodePoints);
|
|
905
|
+
if (unknown.length > 0) {
|
|
906
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: the row carries members this build does not evaluate ` +
|
|
907
|
+
`and will not ignore: ${unknown.map(pyStrRepr).join(", ")}`);
|
|
908
|
+
}
|
|
909
|
+
if ("kid" in value && value["kid"] !== kid) {
|
|
910
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: its row names a different kid, ${pyRepr(value["kid"])}`);
|
|
911
|
+
}
|
|
735
912
|
const alg = value["alg"];
|
|
736
913
|
if (alg !== ENVELOPE_ALG) {
|
|
737
|
-
throw new Error(`witness key
|
|
914
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: alg must be '${ENVELOPE_ALG}', got ${pyRepr(alg)}`);
|
|
915
|
+
}
|
|
916
|
+
// Present means checked: `null` is not "no expiry", it is a row that says nothing usable.
|
|
917
|
+
if (value["not_after"] !== undefined) {
|
|
918
|
+
until = rfc3339Utc(value["not_after"]);
|
|
919
|
+
if (until === null) {
|
|
920
|
+
throw new Error(`witness key ${pyStrRepr(kid)}: not_after must be an RFC 3339 UTC date-time such as ` +
|
|
921
|
+
`'2026-10-05T00:00:00Z', got ${pyRepr(value["not_after"])}`);
|
|
922
|
+
}
|
|
923
|
+
notAfter = value["not_after"];
|
|
738
924
|
}
|
|
739
925
|
key = value["public_key_hex"];
|
|
740
926
|
}
|
|
741
|
-
|
|
927
|
+
const publicKey = witnessPublicKey(kid, key);
|
|
928
|
+
if (until !== null && atOrBefore(until, now)) {
|
|
929
|
+
// Validated in full, then left out. An expired row neither adds its kid nor removes one
|
|
930
|
+
// that another row trusts; it is remembered so the failure can say the key expired, and
|
|
931
|
+
// when, instead of reading as a key nobody ever configured.
|
|
932
|
+
expired.set(kid, notAfter);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
trusted.set(kid, [ENVELOPE_ALG, publicKey]);
|
|
742
936
|
}
|
|
743
|
-
return trusted;
|
|
937
|
+
return { trusted, expired };
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Every row of a trust set checked as the verifier checks it — kid, alg, public key, `not_after` —
|
|
941
|
+
* and an `Error` naming the kid for the first bad one. The CLI runs this when it loads a
|
|
942
|
+
* `--witness-keys` file, so a bad row is reported against that file before any bundle is read.
|
|
943
|
+
* Expiry is not an error: an expired row is a row the verifier leaves out, not a malformed one.
|
|
944
|
+
*/
|
|
945
|
+
export function validateWitnessKeys(witnessKeys) {
|
|
946
|
+
trustedWitnesses(witnessKeys, verificationTime(null));
|
|
947
|
+
}
|
|
948
|
+
/** A trust-set row's members. A row is read whole: anything else in it is refused. */
|
|
949
|
+
const TRUST_ROW_MEMBERS = new Set(["kid", "alg", "public_key_hex", "not_after"]);
|
|
950
|
+
/**
|
|
951
|
+
* RFC 3339 `date-time` in UTC: `YYYY-MM-DDTHH:MM:SS`, an optional fraction, and `Z`. RFC 3339
|
|
952
|
+
* allows `t` and `z` in lower case, so they are accepted too. A numeric offset, even `+00:00`, is
|
|
953
|
+
* not: a trust-set row says UTC in the one spelling that cannot be misread as local time. ASCII
|
|
954
|
+
* digits only. The Python implementation accepts exactly this grammar, so a row one of them
|
|
955
|
+
* refuses, both refuse.
|
|
956
|
+
*/
|
|
957
|
+
const RFC3339_UTC = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?[Zz]$/;
|
|
958
|
+
/**
|
|
959
|
+
* `value` as an instant, or `null` when it is not an RFC 3339 UTC date-time. An out-of-range field
|
|
960
|
+
* (month 13, February 30, hour 24, second 60) is `null`: there is no leap second here, as there is
|
|
961
|
+
* none in Python's `datetime`.
|
|
962
|
+
*/
|
|
963
|
+
function rfc3339Utc(value) {
|
|
964
|
+
const m = typeof value === "string" ? RFC3339_UTC.exec(value) : null;
|
|
965
|
+
if (m !== null) {
|
|
966
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
967
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
968
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
969
|
+
if (year >= 1 && days !== undefined && day >= 1 && day <= days && hour <= 23 && minute <= 59 && second <= 59) {
|
|
970
|
+
// `Date.UTC` reads a year below 100 as 19xx; `setUTCFullYear` does not.
|
|
971
|
+
const at = new Date(0);
|
|
972
|
+
at.setUTCFullYear(year, month - 1, day);
|
|
973
|
+
at.setUTCHours(hour, minute, second, 0);
|
|
974
|
+
return { seconds: at.getTime() / 1000, fraction: (m[7] ?? "").slice(0, 6) };
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* The verification time: `now` when given — a `Date`, or a string in the `not_after` grammar — and
|
|
981
|
+
* the current time otherwise.
|
|
982
|
+
*
|
|
983
|
+
* A number is refused, never read. The Python implementation takes epoch seconds there; a
|
|
984
|
+
* JavaScript epoch counts milliseconds, and a number read in the wrong unit moves the verification
|
|
985
|
+
* time a thousandfold, so this side takes no number at all rather than guess which one it got.
|
|
986
|
+
*/
|
|
987
|
+
function verificationTime(now) {
|
|
988
|
+
if (now === null || now === undefined)
|
|
989
|
+
return dateInstant(new Date());
|
|
990
|
+
if (now instanceof Date && !Number.isNaN(now.getTime()))
|
|
991
|
+
return dateInstant(now);
|
|
992
|
+
if (typeof now === "number") {
|
|
993
|
+
throw new Error("now must be a Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z', not a " +
|
|
994
|
+
"number: a JavaScript epoch counts milliseconds and a Python one seconds");
|
|
995
|
+
}
|
|
996
|
+
const parsed = typeof now === "string" ? rfc3339Utc(now) : null;
|
|
997
|
+
if (parsed === null) {
|
|
998
|
+
throw new Error("now must be a valid Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z'; got " +
|
|
999
|
+
(now instanceof Date ? "an invalid Date" : pyRepr(now)));
|
|
1000
|
+
}
|
|
1001
|
+
return parsed;
|
|
1002
|
+
}
|
|
1003
|
+
function dateInstant(at) {
|
|
1004
|
+
const ms = at.getTime();
|
|
1005
|
+
const seconds = Math.floor(ms / 1000);
|
|
1006
|
+
return { seconds, fraction: String(ms - seconds * 1000).padStart(3, "0") };
|
|
1007
|
+
}
|
|
1008
|
+
/** `a` is at or before `b`: whole seconds first, then the fraction digits, padded to one length. */
|
|
1009
|
+
function atOrBefore(a, b) {
|
|
1010
|
+
if (a.seconds !== b.seconds)
|
|
1011
|
+
return a.seconds < b.seconds;
|
|
1012
|
+
const width = Math.max(a.fraction.length, b.fraction.length);
|
|
1013
|
+
return a.fraction.padEnd(width, "0") <= b.fraction.padEnd(width, "0");
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Python's truthiness for a parsed JSON value, read where the Python implementation writes
|
|
1017
|
+
* `value or default`: absent, null, false, a zero, "", [] and {} are false.
|
|
1018
|
+
*/
|
|
1019
|
+
function pyFalsy(value) {
|
|
1020
|
+
if (value === undefined || value === null || value === false || value === 0 || value === "")
|
|
1021
|
+
return true;
|
|
1022
|
+
if (Array.isArray(value))
|
|
1023
|
+
return value.length === 0;
|
|
1024
|
+
if (typeof value === "object")
|
|
1025
|
+
return Object.keys(value).length === 0;
|
|
1026
|
+
return false;
|
|
744
1027
|
}
|
|
745
1028
|
/** A plain object (not an array, not null, not a Buffer). */
|
|
746
1029
|
function isRecordLike(v) {
|
|
@@ -763,16 +1046,14 @@ function receivedBytes(raw) {
|
|
|
763
1046
|
return Buffer.from(raw);
|
|
764
1047
|
return null;
|
|
765
1048
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
return typeof value === "number" && Number.isInteger(value);
|
|
775
|
-
}
|
|
1049
|
+
// A subject `seq` this build looks an entry up by, and an envelope's `v`, must be an integral
|
|
1050
|
+
// number and never a boolean (`integral`, canonical.ts), as the schema's integer type defines:
|
|
1051
|
+
// Python's `_is_seq`. `1.0` is 1, and RFC 8785 writes it as 1, so an envelope signed over one
|
|
1052
|
+
// verifies as if signed over the other. The type check comes first and every use is behind it — in
|
|
1053
|
+
// Python an unguarded lookup raises on a list or an object and finds the entry at seq 1 for
|
|
1054
|
+
// `true`, and the two implementations report the same failure for the same bundle. The entries are
|
|
1055
|
+
// keyed by the same rule (`subjectIndex`), so an entry whose own seq is `true` or `1.5` is covered
|
|
1056
|
+
// by no envelope.
|
|
776
1057
|
/**
|
|
777
1058
|
* The report line: the state and the result together, in the same form for all three results. A
|
|
778
1059
|
* process-asserted entry gets no result.
|
|
@@ -794,43 +1075,45 @@ function envelopeLine(state, result) {
|
|
|
794
1075
|
* whoever appends the second decides what the first said, and an entry whose coverage is
|
|
795
1076
|
* disputed must not read as clean.
|
|
796
1077
|
*/
|
|
797
|
-
function scoreEnvelopes(entries, envelopes,
|
|
1078
|
+
function scoreEnvelopes(entries, envelopes, trust, rawBytes) {
|
|
798
1079
|
const fail = new FailureLog();
|
|
799
1080
|
const states = {};
|
|
800
1081
|
const results = {};
|
|
1082
|
+
const witnesses = {};
|
|
801
1083
|
entries.forEach((e, i) => {
|
|
802
|
-
states
|
|
1084
|
+
setOwn(states, stateKey(e, i), PROCESS_ASSERTED);
|
|
803
1085
|
});
|
|
804
1086
|
// The hash walk is what an envelope's binding member is checked against; a bundle carrying
|
|
805
1087
|
// none does not pay for it. Every entry is process-asserted in that case, which is the status
|
|
806
1088
|
// quo and exactly what this reports.
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
recomputed = recomputedHashes(entries);
|
|
812
|
-
}
|
|
813
|
-
// seq -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts an
|
|
814
|
-
// envelope in as soon as its subject names an entry this bundle has.
|
|
1089
|
+
const subjectAt = envelopes.length > 0 ? subjectIndex(entries) : new Map();
|
|
1090
|
+
const recomputed = envelopes.length > 0 ? recomputedHashes(entries) : [];
|
|
1091
|
+
// entry index -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts
|
|
1092
|
+
// an envelope in as soon as its subject names an entry this bundle has.
|
|
815
1093
|
const claims = new Map();
|
|
816
1094
|
envelopes.forEach((envelope, index) => {
|
|
817
1095
|
const raw = rawBytes !== null && index < rawBytes.length ? rawBytes[index] ?? null : null;
|
|
818
|
-
const covered = scoreEnvelope(envelope, index,
|
|
1096
|
+
const covered = scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims);
|
|
819
1097
|
if (covered === null)
|
|
820
1098
|
return;
|
|
821
|
-
states
|
|
822
|
-
|
|
1099
|
+
// Where `states` files that entry; `witnesses` files it by the index the subject resolved to.
|
|
1100
|
+
const key = stateKey(entries[covered.at], covered.at);
|
|
1101
|
+
setOwn(states, key, WITNESS_SIGNED);
|
|
1102
|
+
setOwn(results, key, covered.result);
|
|
1103
|
+
setOwn(witnesses, String(covered.at), covered.kid);
|
|
823
1104
|
});
|
|
824
1105
|
// The first envelope's result stands in `results` — it is what that witness said, and the
|
|
825
1106
|
// duplicate does not erase it — but the STATE falls back, so a contradicted entry never
|
|
826
|
-
// reports witness-signed and the bundle rejects
|
|
827
|
-
for (const [
|
|
828
|
-
if (count > 1)
|
|
829
|
-
states[
|
|
1107
|
+
// reports witness-signed and the bundle rejects; and it leaves `witnesses`.
|
|
1108
|
+
for (const [at, count] of claims) {
|
|
1109
|
+
if (count > 1) {
|
|
1110
|
+
setOwn(states, stateKey(entries[at], at), PROCESS_ASSERTED);
|
|
1111
|
+
delete witnesses[String(at)];
|
|
1112
|
+
}
|
|
830
1113
|
}
|
|
831
1114
|
const lines = {};
|
|
832
1115
|
for (const [seq, state] of Object.entries(states)) {
|
|
833
|
-
lines
|
|
1116
|
+
setOwn(lines, seq, envelopeLine(state, Object.hasOwn(results, seq) ? results[seq] : null));
|
|
834
1117
|
}
|
|
835
1118
|
const summary = {
|
|
836
1119
|
status: fail.length === 0 ? "verified" : "FAILED",
|
|
@@ -841,47 +1124,58 @@ function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
|
|
|
841
1124
|
.sort((a, b) => a - b),
|
|
842
1125
|
states,
|
|
843
1126
|
results,
|
|
1127
|
+
witnesses,
|
|
844
1128
|
lines,
|
|
845
1129
|
failures: [...fail.messages],
|
|
846
1130
|
};
|
|
847
1131
|
return [summary, fail];
|
|
848
1132
|
}
|
|
1133
|
+
/**
|
|
1134
|
+
* `record[key] = value` as an own property, whatever the key. A key is an entry's seq rendered as
|
|
1135
|
+
* text, and a bundle chooses its seqs: a plain assignment of `"__proto__"` would reach the
|
|
1136
|
+
* prototype setter and record nothing.
|
|
1137
|
+
*/
|
|
1138
|
+
function setOwn(record, key, value) {
|
|
1139
|
+
Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
|
|
1140
|
+
}
|
|
849
1141
|
/** Python `repr` for a member set, so both implementations print the same failure strings. */
|
|
850
1142
|
function reprList(values) {
|
|
851
|
-
return `[${values.map(
|
|
1143
|
+
return `[${values.map(pyStrRepr).join(", ")}]`;
|
|
852
1144
|
}
|
|
853
1145
|
/**
|
|
854
1146
|
* One envelope, checked in the order the seven named failures are defined in.
|
|
855
1147
|
*
|
|
856
|
-
* Returns the
|
|
857
|
-
* Every failure is positioned on the entry the envelope COVERS, found by
|
|
858
|
-
* locators are checked against that entry, not used to
|
|
1148
|
+
* Returns the index in `entries` of the entry an envelope that verified covers, and `null` for one
|
|
1149
|
+
* that did not. Every failure is positioned on the entry the envelope COVERS, found by
|
|
1150
|
+
* `subject.seq` through `subjectIndex` — the locators are checked against that entry, not used to
|
|
1151
|
+
* find it.
|
|
859
1152
|
*
|
|
860
|
-
* `claims` is the caller's
|
|
1153
|
+
* `claims` is the caller's entry index -> count of the envelopes that have named it so far, and
|
|
861
1154
|
* this function updates it. An envelope claims its entry as soon as `subject.seq` finds one,
|
|
862
1155
|
* BEFORE the rest of the subject is checked, so a second envelope over an entry an earlier one
|
|
863
1156
|
* already named is `envelope_duplicate_subject` whether either of them is otherwise sound: the
|
|
864
1157
|
* point of the check is that no one can decide what an earlier witness said by appending after
|
|
865
1158
|
* it.
|
|
866
1159
|
*/
|
|
867
|
-
function scoreEnvelope(envelope, index,
|
|
1160
|
+
function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims) {
|
|
868
1161
|
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof RawNumber);
|
|
869
1162
|
const subject = isRecord(envelope) ? envelope["subject"] : undefined;
|
|
870
1163
|
function position() {
|
|
871
1164
|
// Every failure is positioned by `subject.seq`, and `subject` is attacker-supplied, so the
|
|
872
1165
|
// lookup is guarded: a seq that is not an integer positions nothing, which is honest — it
|
|
873
1166
|
// names no entry — and it is never used as a key.
|
|
874
|
-
const s = isRecord(subject) ?
|
|
875
|
-
if (
|
|
876
|
-
return [null, null];
|
|
877
|
-
const
|
|
878
|
-
if (
|
|
879
|
-
return [s, null];
|
|
880
|
-
|
|
1167
|
+
const s = isRecord(subject) ? integral(subject["seq"]) : null;
|
|
1168
|
+
if (s === null)
|
|
1169
|
+
return [null, null, null];
|
|
1170
|
+
const at = subjectAt.get(s);
|
|
1171
|
+
if (at === undefined)
|
|
1172
|
+
return [s, null, null];
|
|
1173
|
+
const entry = entries[at];
|
|
1174
|
+
return [orNull(entry["seq"]), orNull(entry["node"]), entry];
|
|
881
1175
|
}
|
|
882
1176
|
function report(reason, detail) {
|
|
883
|
-
const [seq, node] = position();
|
|
884
|
-
fail.add(reason, `${reason}: ${detail}`, { seq, node });
|
|
1177
|
+
const [seq, node, entry] = position();
|
|
1178
|
+
fail.add(reason, `${reason}: ${detail}`, { seq, node, entry });
|
|
885
1179
|
return null;
|
|
886
1180
|
}
|
|
887
1181
|
if (!isRecord(envelope)) {
|
|
@@ -890,11 +1184,11 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
890
1184
|
}
|
|
891
1185
|
// (1) version — a `v` or `typ` this build does not know is a DIFFERENT CONTRACT, and nothing
|
|
892
1186
|
// further about it can be read safely.
|
|
893
|
-
|
|
1187
|
+
// The version is read by the seq rule (`integral`): `1.0` is 1, and `true` is not.
|
|
894
1188
|
const typ = toPlain(envelope["typ"]);
|
|
895
|
-
if (v !== ENVELOPE_VERSION || typ !== ENVELOPE_TYP) {
|
|
896
|
-
return report("envelope_unknown_version", `envelope v=${
|
|
897
|
-
`typ='${ENVELOPE_TYP}'`);
|
|
1189
|
+
if (integral(envelope["v"]) !== ENVELOPE_VERSION || typ !== ENVELOPE_TYP) {
|
|
1190
|
+
return report("envelope_unknown_version", `envelope v=${reprIntOr(envelope["v"])} typ=${pyRepr((envelope["typ"] ?? null))}, ` +
|
|
1191
|
+
`this build knows v=${ENVELOPE_VERSION} typ='${ENVELOPE_TYP}'`);
|
|
898
1192
|
}
|
|
899
1193
|
// (2) member sets — the version commits the exact signed member set of the whole envelope, so
|
|
900
1194
|
// a member added ANYWHERE is a new version that did not declare itself.
|
|
@@ -943,30 +1237,31 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
943
1237
|
// (3a) the binding member. `seq` is the lookup key, so there is nothing to compare it against;
|
|
944
1238
|
// the entry it finds supplies the hash the subject is checked against. It is also the one
|
|
945
1239
|
// subject member used as a KEY, so its type is checked before it is used as one.
|
|
946
|
-
const subjectSeq =
|
|
947
|
-
if (
|
|
1240
|
+
const subjectSeq = integral(subject["seq"]);
|
|
1241
|
+
if (subjectSeq === null) {
|
|
948
1242
|
return report("envelope_subject_mismatch", "subject seq is not an integer");
|
|
949
1243
|
}
|
|
950
|
-
const
|
|
951
|
-
if (
|
|
952
|
-
return report("envelope_subject_mismatch", `no entry at seq ${
|
|
1244
|
+
const at = subjectAt.get(subjectSeq);
|
|
1245
|
+
if (at === undefined) {
|
|
1246
|
+
return report("envelope_subject_mismatch", `no entry at seq ${reprIntOr(subject["seq"])} in this bundle`);
|
|
953
1247
|
}
|
|
954
|
-
const
|
|
1248
|
+
const entry = entries[at];
|
|
1249
|
+
// The messages below print an integral seq as its integer, and an entry without one as None.
|
|
1250
|
+
const seq = (intOr(entry["seq"]) ?? null);
|
|
955
1251
|
// (3a') one entry, at most one envelope. Counted here, before anything else about this
|
|
956
1252
|
// envelope is judged, so the rule cannot be sidestepped by making the second envelope
|
|
957
1253
|
// defective in some other way as well.
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
claims.set(claimKey, already + 1);
|
|
1254
|
+
const already = claims.get(at) ?? 0;
|
|
1255
|
+
claims.set(at, already + 1);
|
|
961
1256
|
if (already > 0) {
|
|
962
|
-
return report("envelope_duplicate_subject", `seq ${
|
|
1257
|
+
return report("envelope_duplicate_subject", `seq ${pyStr(seq)} is already covered by an earlier envelope in this bundle; two ` +
|
|
963
1258
|
"observations of one event contradict each other by construction, so this entry is not " +
|
|
964
1259
|
"witness-signed");
|
|
965
1260
|
}
|
|
966
|
-
const computed = recomputed
|
|
1261
|
+
const computed = recomputed[at] ?? null;
|
|
967
1262
|
const claimed = toPlain(subject["entry_hash"]);
|
|
968
1263
|
if (claimed !== computed) {
|
|
969
|
-
return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${
|
|
1264
|
+
return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${pyStr(seq)} from ` +
|
|
970
1265
|
`this bundle (${pyRepr(computed)})`);
|
|
971
1266
|
}
|
|
972
1267
|
// (3b) the locators, checked against the SAME entry `seq` found. A matching locator attests
|
|
@@ -981,7 +1276,7 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
981
1276
|
for (const [member, actual] of locators) {
|
|
982
1277
|
const stated = toPlain(subject[member]);
|
|
983
1278
|
if (stated !== actual) {
|
|
984
|
-
return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${
|
|
1279
|
+
return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${pyStr(seq)}`);
|
|
985
1280
|
}
|
|
986
1281
|
}
|
|
987
1282
|
// (4) canonicality — an invariant SEPARATE from the signature: the received bytes must equal
|
|
@@ -1024,10 +1319,15 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
1024
1319
|
return report("envelope_unknown_witness", `witness alg=${pyRepr(alg)} is not '${ENVELOPE_ALG}'; envelope v${ENVELOPE_VERSION} ` +
|
|
1025
1320
|
"defines Ed25519 and no other algorithm");
|
|
1026
1321
|
}
|
|
1027
|
-
const known = trusted.get(kid);
|
|
1322
|
+
const known = trust.trusted.get(kid);
|
|
1028
1323
|
if (known === undefined) {
|
|
1324
|
+
// A kid whose rows have all expired is not in the trust set, so it is the same failure as any
|
|
1325
|
+
// other untrusted kid. The message keeps that wording first and then says why, because "not
|
|
1326
|
+
// trusted" and "trusted until a date that has passed" call for different fixes.
|
|
1327
|
+
const lapsed = trust.expired.get(kid);
|
|
1029
1328
|
return report("envelope_unknown_witness", `witness kid=${pyRepr(kid)} alg=${pyRepr(alg)} is not in the trusted witness keys ` +
|
|
1030
|
-
`(${reprList(sortedStrings(trusted.keys()))})`
|
|
1329
|
+
`(${reprList(sortedStrings(trust.trusted.keys()))})` +
|
|
1330
|
+
(lapsed === undefined ? "" : `: the key expired at not_after=${pyRepr(lapsed)}`));
|
|
1031
1331
|
}
|
|
1032
1332
|
// (6) the signature, over JCS(envelope minus "sig").
|
|
1033
1333
|
const sigHex = toPlain(envelope["sig"]);
|
|
@@ -1060,17 +1360,22 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
1060
1360
|
}
|
|
1061
1361
|
if (nonCanonical)
|
|
1062
1362
|
return null;
|
|
1063
|
-
return {
|
|
1363
|
+
return { at, node: orNull(entry["node"]), result: toPlain(envelope["observed"]["result"]), kid };
|
|
1064
1364
|
}
|
|
1065
1365
|
/**
|
|
1066
1366
|
* Score a bundle's observer envelopes on their own, without the ledger checks.
|
|
1067
1367
|
*
|
|
1068
|
-
* Returns `{ok, ...summary, failure_details}`. `states` maps every entry's seq to
|
|
1069
|
-
* `witness-signed` or `process-asserted`; `lines` is the report line for each.
|
|
1368
|
+
* Returns `{ok, ...summary, failure_details, failure_entries}`. `states` maps every entry's seq to
|
|
1369
|
+
* `witness-signed` or `process-asserted`; `lines` is the report line for each. `results` maps a
|
|
1370
|
+
* covered seq to the verifying envelope's `observed.result`. `witnesses` maps the index in
|
|
1371
|
+
* `bundle.entries` of every witness-signed entry to that envelope's `witness.kid`: the entry the
|
|
1372
|
+
* subject resolved to, the one `failure_entries` names, so of two entries sharing a seq only the
|
|
1373
|
+
* one the witness signed is in it.
|
|
1070
1374
|
*/
|
|
1071
1375
|
export function verifyEnvelopes(bundle, options = {}) {
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1376
|
+
const entries = bundle.entries ?? [];
|
|
1377
|
+
const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
|
|
1378
|
+
return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
|
|
1074
1379
|
}
|
|
1075
1380
|
// =============================================================================================
|
|
1076
1381
|
// Execution binding (0.9.0): offline checks over callId/allow/outcome, from the ledger alone —
|
|
@@ -1159,7 +1464,7 @@ function validateAllow(e) {
|
|
|
1159
1464
|
for (const k of ["module", "version", "hook_path"]) {
|
|
1160
1465
|
const v = adapter[k];
|
|
1161
1466
|
if (typeof v !== "string" || !v) {
|
|
1162
|
-
return `adapter[${
|
|
1467
|
+
return `adapter[${pyStrRepr(k)}] must be a non-empty string`;
|
|
1163
1468
|
}
|
|
1164
1469
|
}
|
|
1165
1470
|
if (presentButNull(e, "policy")) {
|
|
@@ -1187,7 +1492,7 @@ function validateDeny(e) {
|
|
|
1187
1492
|
return err;
|
|
1188
1493
|
const leaked = ALLOW_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
1189
1494
|
if (leaked.length > 0) {
|
|
1190
|
-
return `deny carries allow-only field(s) ${
|
|
1495
|
+
return `deny carries allow-only field(s) ${reprList(leaked)}`;
|
|
1191
1496
|
}
|
|
1192
1497
|
return null;
|
|
1193
1498
|
}
|
|
@@ -1235,7 +1540,7 @@ function validateOutcome(e) {
|
|
|
1235
1540
|
for (const k of ["type", "ref"]) {
|
|
1236
1541
|
const v = receipt[k];
|
|
1237
1542
|
if (typeof v !== "string" || !v) {
|
|
1238
|
-
return `receipt[${
|
|
1543
|
+
return `receipt[${pyStrRepr(k)}] must be a non-empty string`;
|
|
1239
1544
|
}
|
|
1240
1545
|
}
|
|
1241
1546
|
const digest = receipt["digest"];
|
|
@@ -1352,19 +1657,19 @@ function policyFailures(entries, bundleV) {
|
|
|
1352
1657
|
if (!("policy" in e))
|
|
1353
1658
|
continue;
|
|
1354
1659
|
const ev = toPlain(e["event"]);
|
|
1355
|
-
const position = { seq: orNull(e["seq"]), node: orNull(e["node"]) };
|
|
1660
|
+
const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
|
|
1356
1661
|
if (ev === "allow") {
|
|
1357
1662
|
if (bundleV === 2)
|
|
1358
1663
|
continue; // validateAllow owns this entry's message
|
|
1359
1664
|
if (!isKnownPolicy(toPlain(e["policy"]))) {
|
|
1360
|
-
failures.add("invalid_policy", `invalid_policy: seq=${
|
|
1361
|
-
`${pyRepr(
|
|
1665
|
+
failures.add("invalid_policy", `invalid_policy: seq=${shown(intOr(e["seq"]))} allow carries policy ` +
|
|
1666
|
+
`${pyRepr(e["policy"] ?? null)}, not a value this format defines`, position);
|
|
1362
1667
|
}
|
|
1363
1668
|
}
|
|
1364
1669
|
else {
|
|
1365
1670
|
if (ev === "deny" && bundleV === 2)
|
|
1366
1671
|
continue; // validateDeny owns this entry's message
|
|
1367
|
-
failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${
|
|
1672
|
+
failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${shown(intOr(e["seq"]))} event=${pyRepr(e["event"] ?? null)} ` +
|
|
1368
1673
|
"carries `policy`, which is an allow-only field", position);
|
|
1369
1674
|
}
|
|
1370
1675
|
}
|
|
@@ -1375,8 +1680,8 @@ function v2FieldLeaksOnV1(entries) {
|
|
|
1375
1680
|
for (const e of entries) {
|
|
1376
1681
|
const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
1377
1682
|
if (leaked.length > 0) {
|
|
1378
|
-
failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${
|
|
1379
|
-
`carries v2-only field(s) ${
|
|
1683
|
+
failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${shown(intOr(e["seq"]))} event=${pyRepr(e["event"] ?? null)} ` +
|
|
1684
|
+
`carries v2-only field(s) ${reprList(leaked)} on a schema_version=1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
1380
1685
|
}
|
|
1381
1686
|
}
|
|
1382
1687
|
return failures;
|
|
@@ -1396,7 +1701,8 @@ function executionBinding(entries, bundleV) {
|
|
|
1396
1701
|
if (bundleV !== 2)
|
|
1397
1702
|
return [{ status: "not applicable" }, new FailureLog()];
|
|
1398
1703
|
const failures = new FailureLog();
|
|
1399
|
-
|
|
1704
|
+
// callId -> [event, node, the entry's own seq value as the bundle wrote it]
|
|
1705
|
+
const seenCallIds = new Map();
|
|
1400
1706
|
const allows = new Map();
|
|
1401
1707
|
const outcomes = new Map();
|
|
1402
1708
|
const invalidAllowIds = new Set();
|
|
@@ -1405,16 +1711,18 @@ function executionBinding(entries, bundleV) {
|
|
|
1405
1711
|
const revokedNodes = new Set();
|
|
1406
1712
|
for (const e of entries) {
|
|
1407
1713
|
const ev = toPlain(e["event"]);
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1714
|
+
const raw = toPlain(e["node"]);
|
|
1715
|
+
// A node id is a string; anything else names no node, here as in the Python implementation.
|
|
1716
|
+
const node = typeof raw === "string" ? raw : null;
|
|
1410
1717
|
if (ev === "root") {
|
|
1411
1718
|
if (node !== null)
|
|
1412
1719
|
nodes.add(node);
|
|
1413
1720
|
const err = validateRoot(e);
|
|
1414
1721
|
if (err) {
|
|
1415
|
-
failures.add("invalid_root", `invalid_root: ${err} (seq ${
|
|
1722
|
+
failures.add("invalid_root", `invalid_root: ${err} (seq ${shown(intOr(e["seq"]))})`, {
|
|
1416
1723
|
seq: orNull(e["seq"]),
|
|
1417
1724
|
node: orNull(e["node"]),
|
|
1725
|
+
entry: e,
|
|
1418
1726
|
});
|
|
1419
1727
|
}
|
|
1420
1728
|
}
|
|
@@ -1427,37 +1735,40 @@ function executionBinding(entries, bundleV) {
|
|
|
1427
1735
|
finalizedNodes.add(node);
|
|
1428
1736
|
}
|
|
1429
1737
|
else if (ev === "kill") {
|
|
1430
|
-
|
|
1738
|
+
const killed = new Map();
|
|
1739
|
+
noteRevoked(e, killed);
|
|
1740
|
+
for (const r of killed.keys())
|
|
1431
1741
|
revokedNodes.add(r);
|
|
1432
1742
|
const err = validateKill(e);
|
|
1433
1743
|
if (err) {
|
|
1434
|
-
failures.add("invalid_kill", `invalid_kill: ${err} (seq ${
|
|
1744
|
+
failures.add("invalid_kill", `invalid_kill: ${err} (seq ${shown(intOr(e["seq"]))})`, {
|
|
1435
1745
|
seq: orNull(e["seq"]),
|
|
1436
1746
|
node: orNull(e["node"]),
|
|
1747
|
+
entry: e,
|
|
1437
1748
|
});
|
|
1438
1749
|
}
|
|
1439
1750
|
}
|
|
1440
1751
|
if (ev === "allow" || ev === "deny") {
|
|
1441
1752
|
const cid = toPlain(e["call_id"]);
|
|
1442
|
-
const seq = toPlain(e["seq"]);
|
|
1443
1753
|
if (cid !== null && cid !== undefined) {
|
|
1444
1754
|
const prior = seenCallIds.get(cid);
|
|
1445
1755
|
if (prior !== undefined) {
|
|
1446
1756
|
// Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
|
|
1447
1757
|
// record, the first one having been legitimate when it was written.
|
|
1448
|
-
failures.add("duplicate_call_id", `duplicate_call_id: call_id ${
|
|
1449
|
-
`${
|
|
1758
|
+
failures.add("duplicate_call_id", `duplicate_call_id: call_id ${shown(e["call_id"])} on seq ${shown(intOr(e["seq"]))} (${ev}) already used at seq ` +
|
|
1759
|
+
`${shown(intOr(prior[2]))} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
|
|
1450
1760
|
}
|
|
1451
1761
|
else {
|
|
1452
|
-
seenCallIds.set(cid, [ev, node, seq]);
|
|
1762
|
+
seenCallIds.set(cid, [ev, node, e["seq"] ?? null]);
|
|
1453
1763
|
}
|
|
1454
1764
|
}
|
|
1455
1765
|
const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
|
|
1456
1766
|
if (err) {
|
|
1457
|
-
failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${
|
|
1767
|
+
failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${shown(intOr(e["seq"]))})`, {
|
|
1458
1768
|
seq: orNull(e["seq"]),
|
|
1459
1769
|
node: orNull(e["node"]),
|
|
1460
1770
|
callId: cid ?? null,
|
|
1771
|
+
entry: e,
|
|
1461
1772
|
});
|
|
1462
1773
|
if (ev === "allow" && cid !== null && cid !== undefined)
|
|
1463
1774
|
invalidAllowIds.add(cid);
|
|
@@ -1468,19 +1779,19 @@ function executionBinding(entries, bundleV) {
|
|
|
1468
1779
|
}
|
|
1469
1780
|
else if (ev === "outcome") {
|
|
1470
1781
|
const cid = toPlain(e["call_id"]);
|
|
1471
|
-
const seq = toPlain(e["seq"]);
|
|
1472
1782
|
const err = validateOutcome(e);
|
|
1473
1783
|
if (err) {
|
|
1474
|
-
failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${
|
|
1784
|
+
failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${shown(intOr(e["seq"]))})`, {
|
|
1475
1785
|
seq: orNull(e["seq"]),
|
|
1476
1786
|
node: orNull(e["node"]),
|
|
1477
1787
|
callId: cid ?? null,
|
|
1788
|
+
entry: e,
|
|
1478
1789
|
});
|
|
1479
1790
|
continue;
|
|
1480
1791
|
}
|
|
1481
1792
|
if (cid !== null && outcomes.has(cid)) {
|
|
1482
|
-
failures.add("duplicate_outcome", `duplicate_outcome: call_id ${
|
|
1483
|
-
`${
|
|
1793
|
+
failures.add("duplicate_outcome", `duplicate_outcome: call_id ${shown(e["call_id"])} at seq ${shown(intOr(e["seq"]))} (first at seq ` +
|
|
1794
|
+
`${shown(intOr(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
|
|
1484
1795
|
continue;
|
|
1485
1796
|
}
|
|
1486
1797
|
if (cid !== null)
|
|
@@ -1501,25 +1812,26 @@ function executionBinding(entries, bundleV) {
|
|
|
1501
1812
|
for (const [cid, oc] of outcomes) {
|
|
1502
1813
|
const allowE = allows.get(cid);
|
|
1503
1814
|
if (allowE === undefined) {
|
|
1504
|
-
failures.add("outcome_without_allow", `outcome_without_allow: call_id ${
|
|
1815
|
+
failures.add("outcome_without_allow", `outcome_without_allow: call_id ${shown(oc["call_id"])} at seq ${shown(intOr(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1505
1816
|
continue;
|
|
1506
1817
|
}
|
|
1507
1818
|
const nodeOk = toPlain(allowE["node"]) === toPlain(oc["node"]);
|
|
1508
1819
|
if (!nodeOk) {
|
|
1509
|
-
failures.add("cross_ref", `cross_ref: call_id ${
|
|
1510
|
-
`outcome on node ${pyRepr(
|
|
1820
|
+
failures.add("cross_ref", `cross_ref: call_id ${shown(oc["call_id"])} allow on node ${pyRepr(allowE["node"] ?? null)} but ` +
|
|
1821
|
+
`outcome on node ${pyRepr(oc["node"] ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1511
1822
|
}
|
|
1512
1823
|
const ocSeq = toPlain(oc["seq"]);
|
|
1513
1824
|
const allowSeq = toPlain(allowE["seq"]);
|
|
1514
1825
|
const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
|
|
1515
1826
|
if (!orderOk) {
|
|
1516
|
-
failures.add("outcome_before_allow", `outcome_before_allow: call_id ${
|
|
1517
|
-
`after allow seq ${
|
|
1827
|
+
failures.add("outcome_before_allow", `outcome_before_allow: call_id ${shown(oc["call_id"])} outcome seq ${shown(intOr(oc["seq"]))} not ` +
|
|
1828
|
+
`after allow seq ${shown(intOr(allowE["seq"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1518
1829
|
}
|
|
1519
1830
|
const ah = toPlain(allowE["authorized_params_hash"]);
|
|
1520
1831
|
const ih = toPlain(oc["invoked_params_hash"]);
|
|
1521
1832
|
if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
|
|
1522
|
-
failures.add("params_mismatch", `params_mismatch: call_id ${
|
|
1833
|
+
failures.add("params_mismatch", `params_mismatch: call_id ${shown(oc["call_id"])} authorized_params_hash ` +
|
|
1834
|
+
`${shown(allowE["authorized_params_hash"])} != invoked_params_hash ${shown(oc["invoked_params_hash"])}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1523
1835
|
}
|
|
1524
1836
|
if (nodeOk && orderOk)
|
|
1525
1837
|
boundOk.add(cid);
|
|
@@ -1545,7 +1857,8 @@ function executionBinding(entries, bundleV) {
|
|
|
1545
1857
|
}
|
|
1546
1858
|
else {
|
|
1547
1859
|
perCall[cid] = "unaccounted";
|
|
1548
|
-
const
|
|
1860
|
+
const raw = toPlain(allowE["node"]);
|
|
1861
|
+
const node = typeof raw === "string" ? raw : null;
|
|
1549
1862
|
const list = nodePending.get(node) ?? [];
|
|
1550
1863
|
list.push(cid);
|
|
1551
1864
|
nodePending.set(node, list);
|
|
@@ -1601,16 +1914,16 @@ function executionBinding(entries, bundleV) {
|
|
|
1601
1914
|
];
|
|
1602
1915
|
}
|
|
1603
1916
|
/**
|
|
1604
|
-
*
|
|
1917
|
+
* The index of the FIRST entry the hash chain does not reproduce at — position only.
|
|
1605
1918
|
*
|
|
1606
1919
|
* `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
|
|
1607
1920
|
* module reports; this walk exists so the structured twin of that message can say WHERE, which
|
|
1608
1921
|
* the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
|
|
1609
|
-
* exactly (same seq/prev_hash/hash order
|
|
1610
|
-
*
|
|
1611
|
-
* chain-level.
|
|
1922
|
+
* exactly (same seq/prev_hash/hash order, and the same rule that a seq is an integral number and
|
|
1923
|
+
* never a boolean, `integral`). `null` when nothing entry-local is wrong — a consistently
|
|
1924
|
+
* re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
|
|
1612
1925
|
*/
|
|
1613
|
-
function
|
|
1926
|
+
export function integrityBreak(entries) {
|
|
1614
1927
|
let prev = GENESIS;
|
|
1615
1928
|
for (let i = 0; i < entries.length; i++) {
|
|
1616
1929
|
const e = entries[i];
|
|
@@ -1622,19 +1935,19 @@ function integrityPosition(entries) {
|
|
|
1622
1935
|
let broken;
|
|
1623
1936
|
try {
|
|
1624
1937
|
broken =
|
|
1625
|
-
|
|
1938
|
+
integral(e["seq"]) !== i ||
|
|
1626
1939
|
orNull(payload["prev_hash"]) !== prev ||
|
|
1627
1940
|
hashEntry(prev, payload) !== orNull(e["hash"]);
|
|
1628
1941
|
}
|
|
1629
1942
|
catch {
|
|
1630
1943
|
// An unhashable payload is itself the break, at this entry.
|
|
1631
|
-
return
|
|
1944
|
+
return i;
|
|
1632
1945
|
}
|
|
1633
1946
|
if (broken)
|
|
1634
|
-
return
|
|
1947
|
+
return i;
|
|
1635
1948
|
prev = orNull(e["hash"]);
|
|
1636
1949
|
}
|
|
1637
|
-
return
|
|
1950
|
+
return null;
|
|
1638
1951
|
}
|
|
1639
1952
|
export function verifyBundle(bundle, signer = null, options = {}) {
|
|
1640
1953
|
const entries = bundle.entries ?? [];
|
|
@@ -1654,17 +1967,18 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1654
1967
|
};
|
|
1655
1968
|
const log = new FailureLog();
|
|
1656
1969
|
// (0) version: the bundle must declare a schema version this build understands, and — when
|
|
1657
|
-
// an anchor is present — the anchor must be anchoring THAT version, not a different one.
|
|
1658
|
-
|
|
1659
|
-
|
|
1970
|
+
// an anchor is present — the anchor must be anchoring THAT version, not a different one. A
|
|
1971
|
+
// version is read by the seq rule (`integral`): `2.0` is 2, and `true` is not.
|
|
1972
|
+
const rawV = bundle.v;
|
|
1973
|
+
const bundleV = integral(rawV);
|
|
1974
|
+
let versionOk = bundleV !== null && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
|
|
1660
1975
|
if (!versionOk) {
|
|
1661
1976
|
const supported = Array.from(SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
|
|
1662
|
-
log.add("unsupported_version", `unsupported_version: bundle v=${
|
|
1977
|
+
log.add("unsupported_version", `unsupported_version: bundle v=${reprIntOr(rawV)} not in [${supported.join(", ")}]`);
|
|
1663
1978
|
}
|
|
1664
|
-
|
|
1665
|
-
if (anchorPresent && anchorV !== bundleV) {
|
|
1979
|
+
if (anchorPresent && !sameNumber(anchor["v"], rawV)) {
|
|
1666
1980
|
versionOk = false;
|
|
1667
|
-
log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${
|
|
1981
|
+
log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${reprIntOr(anchor["v"])} != bundle v=${reprIntOr(rawV)}`);
|
|
1668
1982
|
}
|
|
1669
1983
|
// (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
|
|
1670
1984
|
// sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
|
|
@@ -1676,17 +1990,19 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1676
1990
|
const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
|
|
1677
1991
|
// 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
|
|
1678
1992
|
// entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
|
|
1679
|
-
if (rootEntry !== undefined &&
|
|
1993
|
+
if (rootEntry !== undefined && !sameNumber(rootEntry["v"], rawV)) {
|
|
1680
1994
|
versionOk = false;
|
|
1681
|
-
log.add("root_version_mismatch", `root_version_mismatch: root v=${
|
|
1995
|
+
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 });
|
|
1682
1996
|
}
|
|
1683
|
-
const mixedEntries = entries.filter((e) =>
|
|
1684
|
-
|
|
1997
|
+
const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
|
|
1998
|
+
// An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
|
|
1999
|
+
// everything else by its repr, compared by code point as Python compares strings, never by locale.
|
|
2000
|
+
const mixed = pySetOf(mixedEntries.map((e) => (intOr(e["v"]) ?? null))).sort(compareVersions);
|
|
1685
2001
|
if (mixed.length > 0) {
|
|
1686
2002
|
versionOk = false;
|
|
1687
2003
|
// One aggregate message over every offending entry (unchanged); the twin is positioned on
|
|
1688
2004
|
// the first of them, which is where a reader looks.
|
|
1689
|
-
log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${
|
|
2005
|
+
log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${reprIntOr(rawV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]), entry: mixedEntries[0] });
|
|
1690
2006
|
}
|
|
1691
2007
|
checks.version = versionOk;
|
|
1692
2008
|
// (0b2) every entry must be read WHOLE. `LEDGER_FIELDS` was enforced only on the EXPORT path
|
|
@@ -1712,10 +2028,24 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1712
2028
|
if (extra.length > 0) {
|
|
1713
2029
|
unknownOk = false;
|
|
1714
2030
|
log.add("unknown_ledger_fields", `unknown_ledger_fields: entry carries fields this verifier does not evaluate and will ` +
|
|
1715
|
-
`not ignore: ${extra.join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
|
|
2031
|
+
`not ignore: ${extra.map((f) => shown(f)).join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
1716
2032
|
}
|
|
1717
2033
|
}
|
|
1718
2034
|
checks.ledger_fields = unknownOk;
|
|
2035
|
+
// (0b3) a node id is a string (the schema's `node`). A root, a spawn and an allow are judged by
|
|
2036
|
+
// the checks that read their node, below; any other entry that carries a node which is a list,
|
|
2037
|
+
// an object, a number or a boolean is reported here, never read by projection and never used as
|
|
2038
|
+
// a key. A null node counts as absent.
|
|
2039
|
+
for (const e of entries) {
|
|
2040
|
+
const ev = toPlain(e["event"]);
|
|
2041
|
+
if (ev === "root" || ev === "spawn" || ev === "allow")
|
|
2042
|
+
continue;
|
|
2043
|
+
const node = toPlain(e["node"]);
|
|
2044
|
+
if (node === undefined || node === null || typeof node === "string")
|
|
2045
|
+
continue;
|
|
2046
|
+
log.add("invalid_node", `invalid_node: seq=${shown(intOr(e["seq"]))} event=${pyRepr((e["event"] ?? null))} ` +
|
|
2047
|
+
`carries node ${shown(e["node"])}, which is not a string`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
2048
|
+
}
|
|
1719
2049
|
// (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
|
|
1720
2050
|
// computed head, never against its own (possibly forged) enclosed anchor.
|
|
1721
2051
|
const { expectedAnchor = null, expectedHead = null } = options;
|
|
@@ -1727,7 +2057,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1727
2057
|
const [expSeq, expHash] = expectedHead;
|
|
1728
2058
|
if (actualSeq !== expSeq || actualHead !== expHash) {
|
|
1729
2059
|
expectedOk = false;
|
|
1730
|
-
log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
|
|
2060
|
+
log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${shown(actualHead)}) but the ` +
|
|
1731
2061
|
`independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
|
|
1732
2062
|
}
|
|
1733
2063
|
}
|
|
@@ -1736,7 +2066,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1736
2066
|
if (toPlain(ea["seq"]) !== actualSeq ||
|
|
1737
2067
|
toPlain(ea["head"]) !== actualHead ||
|
|
1738
2068
|
toPlain(ea["chain_id"]) !== toPlain(bundle.chain_id) ||
|
|
1739
|
-
|
|
2069
|
+
!sameNumber(ea["v"], rawV)) {
|
|
1740
2070
|
expectedOk = false;
|
|
1741
2071
|
log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
|
|
1742
2072
|
"the independently retained expected anchor");
|
|
@@ -1754,6 +2084,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1754
2084
|
log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
|
|
1755
2085
|
seq: orNull(foreign["seq"]),
|
|
1756
2086
|
node: orNull(foreign["node"]),
|
|
2087
|
+
entry: foreign,
|
|
1757
2088
|
});
|
|
1758
2089
|
}
|
|
1759
2090
|
const anchorChainId = orNull(anchor["chain_id"]);
|
|
@@ -1765,8 +2096,13 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1765
2096
|
// (1) integrity: the hash chain, plus the signed anchor when a key is given.
|
|
1766
2097
|
const [okChain, err] = AuditLog.verify(entries);
|
|
1767
2098
|
if (!okChain) {
|
|
1768
|
-
const
|
|
1769
|
-
|
|
2099
|
+
const bad = integrityBreak(entries);
|
|
2100
|
+
const badEntry = bad === null ? null : entries[bad];
|
|
2101
|
+
log.add("integrity", `integrity: ${err}`, {
|
|
2102
|
+
seq: badEntry === null ? null : orNull(badEntry["seq"]),
|
|
2103
|
+
node: badEntry === null ? null : orNull(badEntry["node"]),
|
|
2104
|
+
entry: badEntry,
|
|
2105
|
+
});
|
|
1770
2106
|
}
|
|
1771
2107
|
if (signer !== null) {
|
|
1772
2108
|
const [okAnchor, aerr] = AuditLog.verifyAnchor(entries, anchor, signer);
|
|
@@ -1780,24 +2116,62 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1780
2116
|
else {
|
|
1781
2117
|
checks.integrity = okChain;
|
|
1782
2118
|
}
|
|
1783
|
-
const { auth,
|
|
2119
|
+
const { auth, failures: afail } = nodeAuthorities(entries);
|
|
1784
2120
|
log.extend(afail);
|
|
1785
|
-
// (2) monotonicity: every child ⊆ its parent.
|
|
2121
|
+
// (2) monotonicity: every child ⊆ its parent, read in ledger order. Every spawn is checked, and
|
|
2122
|
+
// the node it names as `parent` has to be one the root or an EARLIER spawn defined, not revoked
|
|
2123
|
+
// by an earlier kill, and not the spawn's own node. Through 0.12.0 a spawn whose parent was
|
|
2124
|
+
// absent, null, or named no node in the bundle was skipped, so a child widened past the
|
|
2125
|
+
// authority it was really given verified OK; the process being watched writes that field. A
|
|
2126
|
+
// node is defined once: a second definition is a failure too, since only one of the two could
|
|
2127
|
+
// be read. The Python implementation's same pass, finding for finding.
|
|
1786
2128
|
let mono = true;
|
|
1787
|
-
|
|
1788
|
-
|
|
2129
|
+
const definedAt = new Map(); // node -> the entry that defined it, as of this point
|
|
2130
|
+
const revokedAt = new Map(); // node -> the seq of the kill that revoked it
|
|
2131
|
+
for (const e of entries) {
|
|
2132
|
+
const ev = toPlain(e["event"]);
|
|
2133
|
+
if (ev === "kill") {
|
|
2134
|
+
noteRevoked(e, revokedAt);
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
if (ev !== "root" && ev !== "spawn")
|
|
1789
2138
|
continue;
|
|
1790
|
-
const
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
if (!child.isNarrowerThan(p)) {
|
|
2139
|
+
const node = toPlain(e["node"]);
|
|
2140
|
+
if (typeof node !== "string")
|
|
2141
|
+
continue; // unreadable, reported by nodeAuthorities
|
|
2142
|
+
const position = { seq: orNull(e["seq"]), node, entry: e };
|
|
2143
|
+
const first = definedAt.get(node);
|
|
2144
|
+
if (first !== undefined) {
|
|
1797
2145
|
mono = false;
|
|
1798
|
-
|
|
1799
|
-
|
|
2146
|
+
log.add("monotonicity", `monotonicity: ${shown(node)} is defined a second time in this bundle (first at seq ${shown(intOr(first["seq"]))})`, position);
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
if (ev === "spawn") {
|
|
2150
|
+
const pid = toPlain(e["parent"]);
|
|
2151
|
+
if (typeof pid !== "string" || !definedAt.has(pid)) {
|
|
2152
|
+
mono = false;
|
|
2153
|
+
log.add("monotonicity", `monotonicity: ${shown(node)} names no parent defined earlier in this bundle (parent ${shown(e["parent"])})`, position);
|
|
2154
|
+
}
|
|
2155
|
+
else if (revokedAt.has(pid)) {
|
|
2156
|
+
mono = false;
|
|
2157
|
+
log.add("monotonicity", `monotonicity: ${shown(node)} is spawned from ${shown(pid)} after ${shown(pid)} was revoked at seq ` +
|
|
2158
|
+
shown(intOr(revokedAt.get(pid))), position);
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
// 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
|
|
2162
|
+
// non-wildcard-aware scope difference, which silently accepted a delegation that widened
|
|
2163
|
+
// only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
|
|
2164
|
+
// the parent's. An unreadable authority on either side is reported by nodeAuthorities
|
|
2165
|
+
// and fails this check on its own.
|
|
2166
|
+
const child = auth.get(node);
|
|
2167
|
+
const p = auth.get(pid);
|
|
2168
|
+
if (child !== undefined && p !== undefined && !child.isNarrowerThan(p)) {
|
|
2169
|
+
mono = false;
|
|
2170
|
+
log.add("monotonicity", `monotonicity: ${shown(node)} not ⊆ parent ${shown(pid)} (${monotonicityDetail(child, p)})`, position);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
1800
2173
|
}
|
|
2174
|
+
definedAt.set(node, e);
|
|
1801
2175
|
}
|
|
1802
2176
|
checks.monotonicity = mono && afail.length === 0;
|
|
1803
2177
|
// (3) containment: every allowed action's scope within the acting node's authority.
|
|
@@ -1807,11 +2181,27 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1807
2181
|
// a violation the entry never asserted. Such entries are counted as UNGATED and reported as
|
|
1808
2182
|
// their own number instead: a reader sees how much of the run was actually measured, which is
|
|
1809
2183
|
// the honest answer and never a silent one.
|
|
2184
|
+
// Read in ledger order, like monotonicity: an allow is judged against a node defined EARLIER (one
|
|
2185
|
+
// defined only later is unknown at that point), and an allow on a node an earlier kill revoked
|
|
2186
|
+
// is outside its authority, since a revoked node holds none.
|
|
1810
2187
|
let contained = true;
|
|
1811
2188
|
let actions = 0;
|
|
1812
2189
|
let ungated = 0;
|
|
2190
|
+
const known = new Set();
|
|
2191
|
+
const killedAt = new Map();
|
|
1813
2192
|
for (const e of entries) {
|
|
1814
|
-
|
|
2193
|
+
const ev = toPlain(e["event"]);
|
|
2194
|
+
if (ev === "root" || ev === "spawn") {
|
|
2195
|
+
const defined = toPlain(e["node"]);
|
|
2196
|
+
if (typeof defined === "string")
|
|
2197
|
+
known.add(defined);
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
if (ev === "kill") {
|
|
2201
|
+
noteRevoked(e, killedAt);
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
if (ev !== "allow")
|
|
1815
2205
|
continue;
|
|
1816
2206
|
if (isKnownPolicy(toPlain(e["policy"]))) {
|
|
1817
2207
|
// Only a policy value the format DEFINES buys the exemption. An entry carrying anything
|
|
@@ -1823,21 +2213,39 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1823
2213
|
actions += 1;
|
|
1824
2214
|
const node = toPlain(e["node"]);
|
|
1825
2215
|
const scope = toPlain(e["scope"]);
|
|
1826
|
-
|
|
1827
|
-
|
|
2216
|
+
// The Python implementation reads `e.get("context") or {}`, so an absent context and any value
|
|
2217
|
+
// Python counts as false (null, false, 0, "", [] or {}) are no context. Any other value that
|
|
2218
|
+
// is not an object is not a context the allow could have been checked against, and it does not
|
|
2219
|
+
// verify: it is the containment finding below. (Python 0.19.0 raises on it.)
|
|
2220
|
+
const rawContext = toPlain(e["context"]);
|
|
2221
|
+
const noContext = pyFalsy(rawContext);
|
|
2222
|
+
const contextIsObject = isRecordLike(rawContext);
|
|
2223
|
+
const ctx = (noContext ? {} : rawContext);
|
|
2224
|
+
const a = typeof node === "string" && known.has(node) ? auth.get(node) : undefined;
|
|
1828
2225
|
if (a === undefined) {
|
|
1829
2226
|
contained = false;
|
|
1830
|
-
log.add("containment", `containment: allow on unknown node ${node}`, {
|
|
2227
|
+
log.add("containment", `containment: allow on unknown node ${shown(e["node"])}`, {
|
|
1831
2228
|
seq: orNull(e["seq"]),
|
|
1832
2229
|
node: orNull(e["node"]),
|
|
1833
2230
|
callId: orNull(e["call_id"]),
|
|
2231
|
+
entry: e,
|
|
1834
2232
|
});
|
|
1835
2233
|
continue;
|
|
1836
2234
|
}
|
|
1837
|
-
if (
|
|
2235
|
+
if (killedAt.has(node)) {
|
|
2236
|
+
contained = false;
|
|
2237
|
+
log.add("containment", `containment: allow of ${pyRepr(e["scope"] ?? null)} on ${shown(e["node"])} after ${shown(e["node"])} was ` +
|
|
2238
|
+
`revoked at seq ${shown(intOr(killedAt.get(node)))}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
// A scope that is not a string is no scope the node can hold. Against a wildcard it threw
|
|
2242
|
+
// (`startsWith` on a number or null) out of the verifier; it is this finding either way, as
|
|
2243
|
+
// in the Python implementation. A context that is neither absent, nor false to Python, nor an
|
|
2244
|
+
// object is this finding too.
|
|
2245
|
+
if (typeof scope !== "string" || !(noContext || contextIsObject) || !a.permits(scope, ctx).allowed) {
|
|
1838
2246
|
contained = false;
|
|
1839
|
-
log.add("containment", `containment: allow of
|
|
1840
|
-
|
|
2247
|
+
log.add("containment", `containment: allow of ${pyRepr(e["scope"] ?? null)} on ${shown(e["node"])} outside its authority ` +
|
|
2248
|
+
reprList(Array.from(a.scopes).sort(compareCodePoints)), { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
|
|
1841
2249
|
}
|
|
1842
2250
|
}
|
|
1843
2251
|
checks.containment = contained;
|
|
@@ -1851,7 +2259,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1851
2259
|
// nothing — but a PRESENT one has to verify, and a broken one lands in this same list. The
|
|
1852
2260
|
// per-entry state is reported either way, so a reader sees which hops were covered before
|
|
1853
2261
|
// reading which one failed.
|
|
1854
|
-
const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
|
|
2262
|
+
const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
|
|
1855
2263
|
if (bundle.envelopes === undefined) {
|
|
1856
2264
|
envelopeSummary.status = "not present";
|
|
1857
2265
|
}
|
|
@@ -1875,6 +2283,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1875
2283
|
checks,
|
|
1876
2284
|
failures: log.messages,
|
|
1877
2285
|
failure_details: log.details,
|
|
2286
|
+
failure_entries: log.entryIndices(entries),
|
|
1878
2287
|
nodes: auth.size,
|
|
1879
2288
|
actions_checked: actions,
|
|
1880
2289
|
ungated,
|