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/cjs/evidence.js
CHANGED
|
@@ -39,9 +39,12 @@ exports.exportBundle = exportBundle;
|
|
|
39
39
|
exports.delegationGraph = delegationGraph;
|
|
40
40
|
exports.denials = denials;
|
|
41
41
|
exports.envelopeSigningInput = envelopeSigningInput;
|
|
42
|
+
exports.stateKey = stateKey;
|
|
42
43
|
exports.envelopeSubject = envelopeSubject;
|
|
43
44
|
exports.signEnvelope = signEnvelope;
|
|
45
|
+
exports.validateWitnessKeys = validateWitnessKeys;
|
|
44
46
|
exports.verifyEnvelopes = verifyEnvelopes;
|
|
47
|
+
exports.integrityBreak = integrityBreak;
|
|
45
48
|
exports.verifyBundle = verifyBundle;
|
|
46
49
|
exports.parseBundle = parseBundle;
|
|
47
50
|
const canonical_js_1 = require("./canonical.js");
|
|
@@ -49,6 +52,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
49
52
|
const audit_js_1 = require("./audit.js");
|
|
50
53
|
const authority_js_1 = require("./authority.js");
|
|
51
54
|
const ceilings_js_1 = require("./ceilings.js");
|
|
55
|
+
const display_js_1 = require("./display.js");
|
|
52
56
|
const reasons_js_1 = require("./reasons.js");
|
|
53
57
|
const params_js_1 = require("./params.js");
|
|
54
58
|
const wire_js_1 = require("./wire.js");
|
|
@@ -282,23 +286,41 @@ function orNull(value) {
|
|
|
282
286
|
class FailureLog {
|
|
283
287
|
messages = [];
|
|
284
288
|
details = [];
|
|
289
|
+
/**
|
|
290
|
+
* A third list in step with those two: the ledger entry each failure is about, or `null` for a
|
|
291
|
+
* failure about no single entry. It stays out of `details`, whose member set is the published
|
|
292
|
+
* contract, and `entryIndices` turns it into positions in the bundle's `entries`, which
|
|
293
|
+
* `verifyBundle` reports as `failure_entries`. A position is exact where a seq is not: an
|
|
294
|
+
* entry's seq can be missing, null, a boolean or a duplicate, and its index is still its own.
|
|
295
|
+
*/
|
|
296
|
+
about = [];
|
|
285
297
|
add(reason, detail, position = {}) {
|
|
286
|
-
const { seq = null, node = null, callId = null } = position;
|
|
298
|
+
const { seq = null, node = null, callId = null, entry = null } = position;
|
|
287
299
|
this.messages.push(detail);
|
|
288
300
|
this.details.push({ reason, seq, node, call_id: callId, detail });
|
|
301
|
+
this.about.push(entry);
|
|
289
302
|
}
|
|
290
303
|
extend(other) {
|
|
291
304
|
this.messages.push(...other.messages);
|
|
292
305
|
this.details.push(...other.details);
|
|
306
|
+
this.about.push(...other.about);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* For each failure, the index in `entries` of the entry it is about, or `null`. Found by
|
|
310
|
+
* identity: every entry a check reports on is one of these objects.
|
|
311
|
+
*/
|
|
312
|
+
entryIndices(entries) {
|
|
313
|
+
const at = new Map();
|
|
314
|
+
entries.forEach((e, i) => {
|
|
315
|
+
if (!at.has(e))
|
|
316
|
+
at.set(e, i);
|
|
317
|
+
});
|
|
318
|
+
return this.about.map((e) => (e === null ? null : (at.get(e) ?? null)));
|
|
293
319
|
}
|
|
294
320
|
get length() {
|
|
295
321
|
return this.messages.length;
|
|
296
322
|
}
|
|
297
323
|
}
|
|
298
|
-
/**
|
|
299
|
-
* `node -> Authority` and `node -> parent`, reconstructed from `root` and
|
|
300
|
-
* `spawn` events alone. No engine state.
|
|
301
|
-
*/
|
|
302
324
|
/**
|
|
303
325
|
* Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
|
|
304
326
|
*
|
|
@@ -322,7 +344,7 @@ function monotonicityDetail(child, parent) {
|
|
|
322
344
|
// does not cover is also absent from that set.
|
|
323
345
|
if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
|
|
324
346
|
const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
|
|
325
|
-
return (`child scopes
|
|
347
|
+
return (`child scopes ${reprList(extra.sort(canonical_js_1.compareCodePoints))} ` +
|
|
326
348
|
`not held by parent`);
|
|
327
349
|
}
|
|
328
350
|
const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
|
|
@@ -331,11 +353,11 @@ function monotonicityDetail(child, parent) {
|
|
|
331
353
|
const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
|
|
332
354
|
const childCeiling = childByKey.get(key);
|
|
333
355
|
if (childCeiling === undefined) {
|
|
334
|
-
return `ceiling ${
|
|
356
|
+
return `ceiling ${(0, display_js_1.shown)((0, ceilings_js_1.wireKeyOf)(parentCeiling))} unbounded, parent holds ${(0, ceilings_js_1.describeInFinding)(parentCeiling)}`;
|
|
335
357
|
}
|
|
336
358
|
if (!parentCeiling.subsumes(childCeiling)) {
|
|
337
|
-
return (`ceiling ${(0, ceilings_js_1.
|
|
338
|
-
`${(0, ceilings_js_1.
|
|
359
|
+
return (`ceiling ${(0, ceilings_js_1.describeInFinding)(childCeiling)} looser than parent ` +
|
|
360
|
+
`${(0, ceilings_js_1.describeInFinding)(parentCeiling)}`);
|
|
339
361
|
}
|
|
340
362
|
}
|
|
341
363
|
if (parent.ttl !== null) {
|
|
@@ -349,6 +371,17 @@ function monotonicityDetail(child, parent) {
|
|
|
349
371
|
// it exists so that such a dimension cannot fail SILENTLY.
|
|
350
372
|
return "child not narrower than parent";
|
|
351
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
|
|
376
|
+
* order. No engine state.
|
|
377
|
+
*
|
|
378
|
+
* A node id is a string, and a node is defined once, by the root or by one spawn. A root or a
|
|
379
|
+
* spawn whose `node` is not a string defines nothing and is reported unreadable here. A second
|
|
380
|
+
* definition of a node is left out of these maps, so every later check reads the first one, and
|
|
381
|
+
* `verifyBundle`'s monotonicity check reports it. `parent` maps a spawned node to its `parent`
|
|
382
|
+
* member as written, whatever its type; whether that names a node defined earlier is judged there
|
|
383
|
+
* too. The Python implementation's `_node_authorities`.
|
|
384
|
+
*/
|
|
352
385
|
function nodeAuthorities(entries) {
|
|
353
386
|
const auth = new Map();
|
|
354
387
|
const parent = new Map();
|
|
@@ -356,37 +389,58 @@ function nodeAuthorities(entries) {
|
|
|
356
389
|
const definedBy = new Map();
|
|
357
390
|
for (const e of entries) {
|
|
358
391
|
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
392
|
+
if (ev !== "root" && ev !== "spawn")
|
|
393
|
+
continue;
|
|
359
394
|
const node = (0, canonical_js_1.toPlain)(e["node"]);
|
|
395
|
+
const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
|
|
396
|
+
if (typeof node !== "string") {
|
|
397
|
+
// One of the two historical messages that name a node before their colon rather than a
|
|
398
|
+
// reason token, so the reason is stated here instead of parsed out of the string.
|
|
399
|
+
if (ev === "root") {
|
|
400
|
+
failures.add("unreadable_authority", `root ${(0, display_js_1.shown)(e["node"])}: unreadable authority (node is not a string)`, position);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
failures.add("unreadable_granted", `spawn ${(0, display_js_1.shown)(e["node"])}: unreadable granted (node is not a string)`, position);
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (definedBy.has(node))
|
|
408
|
+
continue; // defined twice: verifyBundle reports it
|
|
409
|
+
definedBy.set(node, e);
|
|
360
410
|
if (ev === "root") {
|
|
361
|
-
definedBy.set(node, e);
|
|
362
411
|
try {
|
|
363
412
|
auth.set(node, authority_js_1.Authority.fromWire(e["authority"] ?? null));
|
|
364
413
|
}
|
|
365
414
|
catch (exc) {
|
|
366
|
-
|
|
367
|
-
// reason token, so the reason is stated here instead of parsed out of the string.
|
|
368
|
-
failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
|
|
369
|
-
seq: orNull(e["seq"]),
|
|
370
|
-
node: orNull(e["node"]),
|
|
371
|
-
});
|
|
415
|
+
failures.add("unreadable_authority", `root ${(0, display_js_1.shown)(e["node"])}: unreadable authority (${exc.message})`, position);
|
|
372
416
|
}
|
|
373
417
|
}
|
|
374
|
-
else
|
|
375
|
-
|
|
376
|
-
parent.set(node, (0, canonical_js_1.toPlain)(e["parent"]) ?? null);
|
|
418
|
+
else {
|
|
419
|
+
parent.set(node, orNull(e["parent"]));
|
|
377
420
|
try {
|
|
378
421
|
auth.set(node, authority_js_1.Authority.fromWire(e["granted"] ?? null));
|
|
379
422
|
}
|
|
380
423
|
catch (exc) {
|
|
381
|
-
failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`,
|
|
382
|
-
seq: orNull(e["seq"]),
|
|
383
|
-
node: orNull(e["node"]),
|
|
384
|
-
});
|
|
424
|
+
failures.add("unreadable_granted", `spawn ${(0, display_js_1.shown)(e["node"])}: unreadable granted (${exc.message})`, position);
|
|
385
425
|
}
|
|
386
426
|
}
|
|
387
427
|
}
|
|
388
428
|
return { auth, parent, failures, definedBy };
|
|
389
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Record the nodes a `kill` entry revokes, node -> that kill's own `seq`; the first kill stands.
|
|
432
|
+
* Only string ids in a list count: anything else names no node. The Python implementation's
|
|
433
|
+
* `_note_revoked`.
|
|
434
|
+
*/
|
|
435
|
+
function noteRevoked(entry, revokedAt) {
|
|
436
|
+
const revoked = entry["revoked"];
|
|
437
|
+
if (!Array.isArray(revoked))
|
|
438
|
+
return;
|
|
439
|
+
for (const node of revoked) {
|
|
440
|
+
if (typeof node === "string" && !revokedAt.has(node))
|
|
441
|
+
revokedAt.set(node, entry["seq"]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
390
444
|
/**
|
|
391
445
|
* A view of the chain from the bundle: each node with its agent, task,
|
|
392
446
|
* authority, parent and per-node action counts — what a reviewer or a UI
|
|
@@ -398,10 +452,15 @@ function delegationGraph(bundle) {
|
|
|
398
452
|
const meta = {};
|
|
399
453
|
for (const e of entries) {
|
|
400
454
|
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
401
|
-
const
|
|
455
|
+
const raw = (0, canonical_js_1.toPlain)(e["node"]);
|
|
456
|
+
if (typeof raw !== "string" && ev !== "kill")
|
|
457
|
+
continue; // a node id is a string; anything else names no node
|
|
458
|
+
const n = raw;
|
|
402
459
|
if (ev === "root" || ev === "spawn") {
|
|
460
|
+
if (Object.hasOwn(meta, n))
|
|
461
|
+
continue; // defined once: the first definition stands
|
|
403
462
|
const a = auth.get(n);
|
|
404
|
-
meta
|
|
463
|
+
setOwn(meta, n, {
|
|
405
464
|
agent: orNull(e["agent"]),
|
|
406
465
|
task: orNull(e["task"]),
|
|
407
466
|
parent: orNull(e["parent"]),
|
|
@@ -411,30 +470,32 @@ function delegationGraph(bundle) {
|
|
|
411
470
|
revoked: false,
|
|
412
471
|
complete: false,
|
|
413
472
|
denials_by_disposition: {},
|
|
414
|
-
};
|
|
473
|
+
});
|
|
415
474
|
}
|
|
416
|
-
else if (ev === "allow" && meta
|
|
475
|
+
else if (ev === "allow" && Object.hasOwn(meta, n)) {
|
|
417
476
|
meta[n].allows += 1;
|
|
418
477
|
}
|
|
419
|
-
else if (ev === "deny" && meta
|
|
478
|
+
else if (ev === "deny" && Object.hasOwn(meta, n)) {
|
|
420
479
|
meta[n].denies += 1;
|
|
421
480
|
// A deny without a disposition is named by its reason.
|
|
422
481
|
const d = ((0, canonical_js_1.toPlain)(e["disposition"]) ?? (0, canonical_js_1.toPlain)(e["reason"]) ?? "unstated");
|
|
423
482
|
meta[n].denials_by_disposition[d] = (meta[n].denials_by_disposition[d] ?? 0) + 1;
|
|
424
483
|
}
|
|
425
|
-
else if (ev === "done" && meta
|
|
484
|
+
else if (ev === "done" && Object.hasOwn(meta, n)) {
|
|
426
485
|
meta[n].complete = true;
|
|
427
486
|
}
|
|
428
487
|
else if (ev === "kill") {
|
|
429
|
-
|
|
430
|
-
|
|
488
|
+
const revoked = new Map();
|
|
489
|
+
noteRevoked(e, revoked);
|
|
490
|
+
for (const r of revoked.keys()) {
|
|
491
|
+
if (Object.hasOwn(meta, r))
|
|
431
492
|
meta[r].revoked = true;
|
|
432
493
|
}
|
|
433
494
|
}
|
|
434
495
|
}
|
|
435
496
|
const edges = [];
|
|
436
497
|
for (const [child, p] of parent) {
|
|
437
|
-
if (p)
|
|
498
|
+
if (typeof p === "string" && p !== "")
|
|
438
499
|
edges.push({ parent: p, child });
|
|
439
500
|
}
|
|
440
501
|
return { chain_id: orNull(bundle.chain_id), nodes: meta, edges };
|
|
@@ -502,37 +563,9 @@ function denials(bundle) {
|
|
|
502
563
|
}
|
|
503
564
|
return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
|
|
504
565
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return "None";
|
|
509
|
-
if (typeof value === "boolean")
|
|
510
|
-
return value ? "True" : "False";
|
|
511
|
-
if (typeof value === "number")
|
|
512
|
-
return String(value);
|
|
513
|
-
if (typeof value === "string")
|
|
514
|
-
return `'${value}'`;
|
|
515
|
-
// Containers reach here only on hostile input — a subject member that is a list or an object
|
|
516
|
-
// where the contract wants a scalar. Rendered the way Python's repr renders them, because the
|
|
517
|
-
// two implementations report the same failure strings and a JSON spelling would not match.
|
|
518
|
-
if (Array.isArray(value))
|
|
519
|
-
return `[${value.map((v) => pyRepr(v)).join(", ")}]`;
|
|
520
|
-
if (typeof value === "object") {
|
|
521
|
-
const body = Object.entries(value)
|
|
522
|
-
.map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`)
|
|
523
|
-
.join(", ");
|
|
524
|
-
return `{${body}}`;
|
|
525
|
-
}
|
|
526
|
-
return JSON.stringify(value);
|
|
527
|
-
}
|
|
528
|
-
/**
|
|
529
|
-
* Python's `str()` where a failure string interpolates a value bare (`{x}`) rather than through
|
|
530
|
-
* `repr` (`{x!r}`) — the same rendering for every type except a string, which `str` leaves
|
|
531
|
-
* unquoted. Both implementations report the same failure bytes, so the distinction is load-bearing.
|
|
532
|
-
*/
|
|
533
|
-
function pyStr(value) {
|
|
534
|
-
return typeof value === "string" ? value : pyRepr(value);
|
|
535
|
-
}
|
|
566
|
+
// `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
|
|
567
|
+
// messages carry. Both implementations report the same failure bytes, so the distinction between
|
|
568
|
+
// `{x}` and `{x!r}` is load-bearing, and `pyRepr` escapes a string exactly as `repr` does.
|
|
536
569
|
// =============================================================================================
|
|
537
570
|
// Observer envelopes (envelope v1) — the TypeScript half of `attenu_guard.evidence`'s.
|
|
538
571
|
//
|
|
@@ -587,7 +620,9 @@ exports.ENVELOPE_ALG = "EdDSA";
|
|
|
587
620
|
/**
|
|
588
621
|
* A verifying envelope's state. It says where the signature came from and NOTHING about
|
|
589
622
|
* authority — the witness is whoever holds the key `witness.kid` names, which nothing in the
|
|
590
|
-
* envelope makes the delegation parent.
|
|
623
|
+
* envelope makes the delegation parent. The signature covers the entry's hash and chain position
|
|
624
|
+
* plus what the witness-key holder observed; it does not attest that the action was permitted.
|
|
625
|
+
* Monotonicity and containment answer that, from the ledger.
|
|
591
626
|
*/
|
|
592
627
|
exports.WITNESS_SIGNED = "witness-signed";
|
|
593
628
|
/**
|
|
@@ -620,15 +655,25 @@ function envelopeSigningInput(envelope) {
|
|
|
620
655
|
return (0, canonical_js_1.canonicalBytes)(body);
|
|
621
656
|
}
|
|
622
657
|
/**
|
|
623
|
-
*
|
|
658
|
+
* Where `states` and `results` file an entry: its seq when it has one, as the integer when it is
|
|
659
|
+
* integral (`intOr`: `1.0` is filed as 1), and its index when it has none. The Python
|
|
660
|
+
* implementation's `_state_key`, rendered as a record key. It is a report key, never a lookup: an
|
|
661
|
+
* envelope finds the entry it covers through `subjectIndex`.
|
|
662
|
+
*/
|
|
663
|
+
function stateKey(e, index) {
|
|
664
|
+
return String("seq" in e ? orNull((0, canonical_js_1.intOr)(e["seq"])) : index);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
|
|
624
668
|
*
|
|
625
669
|
* `entry_hash` in a subject is checked against this. The walk mirrors `AuditLog.verify`, so an
|
|
626
670
|
* entry whose stored `hash` was replaced does not get to supply the value it is compared against.
|
|
671
|
+
* By index, not by seq: an entry's seq can be missing, another entry's, or not an integer.
|
|
627
672
|
*/
|
|
628
673
|
function recomputedHashes(entries) {
|
|
629
|
-
const out =
|
|
674
|
+
const out = [];
|
|
630
675
|
let prev = audit_js_1.GENESIS;
|
|
631
|
-
|
|
676
|
+
for (const e of entries) {
|
|
632
677
|
const payload = {};
|
|
633
678
|
for (const [k, v] of Object.entries(e))
|
|
634
679
|
if (k !== "hash")
|
|
@@ -641,20 +686,134 @@ function recomputedHashes(entries) {
|
|
|
641
686
|
// An unhashable payload has no recomputable hash; that IS the break, at this entry.
|
|
642
687
|
computed = null;
|
|
643
688
|
}
|
|
644
|
-
out.
|
|
689
|
+
out.push(computed);
|
|
645
690
|
prev = computed ?? audit_js_1.GENESIS;
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* subject.seq -> the index of the entry an envelope naming that seq covers.
|
|
696
|
+
*
|
|
697
|
+
* An entry is keyed by its own seq when that is an integral number and not a boolean, as the
|
|
698
|
+
* schema's integer type defines (`integral`: `1.0` is 1), and by its index when it has no seq
|
|
699
|
+
* member at all. An entry whose seq is a boolean, a string, null, or a fractional number is keyed
|
|
700
|
+
* by nothing, so no envelope covers it: in Python, keyed by the raw value, `"seq": true` took the
|
|
701
|
+
* envelope written for seq 1, and here a null seq was keyed by its index. Where two entries share
|
|
702
|
+
* a key the later one is covered, as it always was. The Python implementation's `_subject_index`.
|
|
703
|
+
*/
|
|
704
|
+
function subjectIndex(entries) {
|
|
705
|
+
const keyed = new Map();
|
|
706
|
+
entries.forEach((e, i) => {
|
|
707
|
+
if (!("seq" in e)) {
|
|
708
|
+
keyed.set(i, i);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const seq = (0, canonical_js_1.integral)(e["seq"]);
|
|
712
|
+
if (seq !== null)
|
|
713
|
+
keyed.set(seq, i);
|
|
646
714
|
});
|
|
715
|
+
return keyed;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Two `seq` or `v` values are the same: equal integers when either is integral (`integral`), so
|
|
719
|
+
* `1.0` is 1; otherwise the same value of the same type, so `true` is not 1 and `"1"` is not 1. An
|
|
720
|
+
* absent value is null, as Python's `dict.get` reads it. The Python implementation's
|
|
721
|
+
* `_same_number`.
|
|
722
|
+
*/
|
|
723
|
+
function sameNumber(a, b) {
|
|
724
|
+
const ia = (0, canonical_js_1.integral)(a);
|
|
725
|
+
const ib = (0, canonical_js_1.integral)(b);
|
|
726
|
+
if (ia !== null || ib !== null)
|
|
727
|
+
return ia === ib;
|
|
728
|
+
const pa = orNull(a);
|
|
729
|
+
const pb = orNull(b);
|
|
730
|
+
return pyTypeOf(pa) === pyTypeOf(pb) && pyEquals(pa, pb);
|
|
731
|
+
}
|
|
732
|
+
/** The Python type of a parsed JSON value, which tells a boolean from a number as `type()` does. */
|
|
733
|
+
function pyTypeOf(value) {
|
|
734
|
+
if (value === null)
|
|
735
|
+
return "NoneType";
|
|
736
|
+
if (Array.isArray(value))
|
|
737
|
+
return "list";
|
|
738
|
+
if (typeof value === "number")
|
|
739
|
+
return Number.isInteger(value) ? "int" : "float";
|
|
740
|
+
return typeof value === "object" ? "dict" : typeof value;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Python's `==` on two parsed JSON values: numbers and booleans by value (`True == 1`), strings by
|
|
744
|
+
* text, lists and objects member by member.
|
|
745
|
+
*/
|
|
746
|
+
function pyEquals(a, b) {
|
|
747
|
+
const x = typeof a === "boolean" ? Number(a) : a;
|
|
748
|
+
const y = typeof b === "boolean" ? Number(b) : b;
|
|
749
|
+
if (typeof x !== "object" || typeof y !== "object" || x === null || y === null)
|
|
750
|
+
return x === y;
|
|
751
|
+
if (Array.isArray(x) || Array.isArray(y)) {
|
|
752
|
+
return Array.isArray(x) && Array.isArray(y) && x.length === y.length && x.every((v, i) => pyEquals(v, y[i]));
|
|
753
|
+
}
|
|
754
|
+
const keys = Object.keys(x);
|
|
755
|
+
return (keys.length === Object.keys(y).length && keys.every((k) => Object.hasOwn(y, k) && pyEquals(x[k], y[k])));
|
|
756
|
+
}
|
|
757
|
+
/** Python's `{value!r}` of an integral-or-not `seq` or `v`: the integer when it is integral (`intOr`). */
|
|
758
|
+
function reprIntOr(value) {
|
|
759
|
+
return (0, display_js_1.pyRepr)(((0, canonical_js_1.intOr)(value) ?? null));
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* The distinct versions of `values`, as a Python set holds them: two that Python's `==` calls equal
|
|
763
|
+
* are one (`True` and 1 included), and the first of them stays. A list or an object has no place
|
|
764
|
+
* in a Python set (Python raises there), and each is kept on its own.
|
|
765
|
+
*/
|
|
766
|
+
function pySetOf(values) {
|
|
767
|
+
const seen = new Set();
|
|
768
|
+
const out = [];
|
|
769
|
+
for (const value of values) {
|
|
770
|
+
const plain = (0, canonical_js_1.toPlain)(value);
|
|
771
|
+
const key = plain === null
|
|
772
|
+
? "None"
|
|
773
|
+
: typeof plain === "boolean" || typeof plain === "number"
|
|
774
|
+
? `n:${Number(plain) === 0 ? 0 : Number(plain)}`
|
|
775
|
+
: typeof plain === "string"
|
|
776
|
+
? `s:${plain}`
|
|
777
|
+
: null;
|
|
778
|
+
if (key !== null) {
|
|
779
|
+
if (seen.has(key))
|
|
780
|
+
continue;
|
|
781
|
+
seen.add(key);
|
|
782
|
+
}
|
|
783
|
+
out.push(value);
|
|
784
|
+
}
|
|
647
785
|
return out;
|
|
648
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* The order `mixed_entry_versions` lists versions in: numbers in numeric order, and anything else
|
|
789
|
+
* after them by its Python `repr`, so a boolean beside a string still sorts. The Python
|
|
790
|
+
* implementation's `_version_order`.
|
|
791
|
+
*/
|
|
792
|
+
function compareVersions(a, b) {
|
|
793
|
+
const numberOf = (v) => v instanceof canonical_js_1.RawNumber ? v.value : typeof v === "number" ? v : null;
|
|
794
|
+
const na = numberOf(a);
|
|
795
|
+
const nb = numberOf(b);
|
|
796
|
+
if (na !== null && nb !== null)
|
|
797
|
+
return na - nb;
|
|
798
|
+
if (na !== null)
|
|
799
|
+
return -1;
|
|
800
|
+
if (nb !== null)
|
|
801
|
+
return 1;
|
|
802
|
+
return (0, canonical_js_1.compareCodePoints)((0, display_js_1.pyRepr)(a), (0, display_js_1.pyRepr)(b));
|
|
803
|
+
}
|
|
649
804
|
/**
|
|
650
805
|
* The v1 subject for the entry at `seq`, recomputed from the ledger.
|
|
651
806
|
*
|
|
652
|
-
*
|
|
807
|
+
* `seq` finds its entry the way a verifier finds it (`subjectIndex`), so the subject is the one the
|
|
808
|
+
* verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
|
|
809
|
+
* defines no subject for.
|
|
653
810
|
*/
|
|
654
811
|
function envelopeSubject(entries, seq) {
|
|
655
|
-
const
|
|
656
|
-
|
|
812
|
+
const n = (0, canonical_js_1.integral)(seq);
|
|
813
|
+
const at = n === null ? undefined : subjectIndex(entries).get(n);
|
|
814
|
+
if (n === null || at === undefined)
|
|
657
815
|
throw new Error(`no entry at seq ${seq}`);
|
|
816
|
+
const entry = entries[at];
|
|
658
817
|
const event = (0, canonical_js_1.toPlain)(entry["event"]);
|
|
659
818
|
if (!exports.ENVELOPE_SUBJECT_MEMBERS.has(event)) {
|
|
660
819
|
throw new Error(`envelope v${exports.ENVELOPE_VERSION} defines no subject for event '${event}'`);
|
|
@@ -662,8 +821,8 @@ function envelopeSubject(entries, seq) {
|
|
|
662
821
|
const subject = {
|
|
663
822
|
chain_id: orNull(entry["chain_id"]),
|
|
664
823
|
node: orNull(entry["node"]),
|
|
665
|
-
seq,
|
|
666
|
-
entry_hash: recomputedHashes(entries)
|
|
824
|
+
seq: n,
|
|
825
|
+
entry_hash: recomputedHashes(entries)[at] ?? null,
|
|
667
826
|
event,
|
|
668
827
|
};
|
|
669
828
|
if (event === "allow")
|
|
@@ -706,21 +865,21 @@ function signEnvelope(entries, seq, seed, kid, observed) {
|
|
|
706
865
|
function witnessPublicKey(kid, value) {
|
|
707
866
|
if (typeof value === "string") {
|
|
708
867
|
if (value.length !== 64) {
|
|
709
|
-
throw new Error(`witness key
|
|
868
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
|
|
710
869
|
`got ${value.length}`);
|
|
711
870
|
}
|
|
712
871
|
if (!/^[0-9a-fA-F]{64}$/.test(value)) {
|
|
713
|
-
throw new Error(`witness key
|
|
872
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: public_key_hex is not hexadecimal`);
|
|
714
873
|
}
|
|
715
874
|
return Buffer.from(value, "hex");
|
|
716
875
|
}
|
|
717
876
|
if (value instanceof Uint8Array) {
|
|
718
877
|
if (value.length !== 32) {
|
|
719
|
-
throw new Error(`witness key
|
|
878
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: an Ed25519 public key is 32 bytes, got ${value.length}`);
|
|
720
879
|
}
|
|
721
880
|
return Buffer.from(value);
|
|
722
881
|
}
|
|
723
|
-
throw new Error(`witness key
|
|
882
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: expected 64 hex characters or 32 bytes`);
|
|
724
883
|
}
|
|
725
884
|
/**
|
|
726
885
|
* kid -> `[alg, raw public key]`, from the vector file's own `witness_keys` shape or from a plain
|
|
@@ -735,27 +894,154 @@ function witnessPublicKey(kid, value) {
|
|
|
735
894
|
* reported to the caller rather than folded into a finding about the bundle. v1 defines Ed25519
|
|
736
895
|
* and no other algorithm, so a row declaring anything else is refused too.
|
|
737
896
|
*/
|
|
738
|
-
function trustedWitnesses(witnessKeys) {
|
|
897
|
+
function trustedWitnesses(witnessKeys, now) {
|
|
739
898
|
const trusted = new Map();
|
|
899
|
+
const expired = new Map();
|
|
900
|
+
const seen = new Set();
|
|
740
901
|
if (witnessKeys === null || witnessKeys === undefined)
|
|
741
|
-
return trusted;
|
|
902
|
+
return { trusted, expired };
|
|
742
903
|
const rows = Array.isArray(witnessKeys)
|
|
743
904
|
? witnessKeys.map((k) => [isRecordLike(k) ? k["kid"] : undefined, k])
|
|
744
905
|
: Object.entries(witnessKeys);
|
|
745
906
|
for (const [kid, value] of rows) {
|
|
746
907
|
if (typeof kid !== "string")
|
|
747
908
|
throw new Error("witness key kid must be a string");
|
|
909
|
+
// One row per kid. The later row used to win, so a row added to expire a key could leave it
|
|
910
|
+
// trusted, and an expired row beside a live one did.
|
|
911
|
+
if (seen.has(kid))
|
|
912
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: more than one row names this kid`);
|
|
913
|
+
seen.add(kid);
|
|
748
914
|
let key = value;
|
|
915
|
+
let notAfter = null;
|
|
916
|
+
let until = null;
|
|
749
917
|
if (isRecordLike(value)) {
|
|
918
|
+
// Read whole: a row read by projection let a misspelled `notAfter` leave a key that never
|
|
919
|
+
// expired. In the `{kid: row}` form a row may repeat its kid, and must agree with it.
|
|
920
|
+
const unknown = Object.keys(value)
|
|
921
|
+
.filter((m) => !TRUST_ROW_MEMBERS.has(m))
|
|
922
|
+
.sort(canonical_js_1.compareCodePoints);
|
|
923
|
+
if (unknown.length > 0) {
|
|
924
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: the row carries members this build does not evaluate ` +
|
|
925
|
+
`and will not ignore: ${unknown.map(display_js_1.pyStrRepr).join(", ")}`);
|
|
926
|
+
}
|
|
927
|
+
if ("kid" in value && value["kid"] !== kid) {
|
|
928
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: its row names a different kid, ${(0, display_js_1.pyRepr)(value["kid"])}`);
|
|
929
|
+
}
|
|
750
930
|
const alg = value["alg"];
|
|
751
931
|
if (alg !== exports.ENVELOPE_ALG) {
|
|
752
|
-
throw new Error(`witness key
|
|
932
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: alg must be '${exports.ENVELOPE_ALG}', got ${(0, display_js_1.pyRepr)(alg)}`);
|
|
933
|
+
}
|
|
934
|
+
// Present means checked: `null` is not "no expiry", it is a row that says nothing usable.
|
|
935
|
+
if (value["not_after"] !== undefined) {
|
|
936
|
+
until = rfc3339Utc(value["not_after"]);
|
|
937
|
+
if (until === null) {
|
|
938
|
+
throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: not_after must be an RFC 3339 UTC date-time such as ` +
|
|
939
|
+
`'2026-10-05T00:00:00Z', got ${(0, display_js_1.pyRepr)(value["not_after"])}`);
|
|
940
|
+
}
|
|
941
|
+
notAfter = value["not_after"];
|
|
753
942
|
}
|
|
754
943
|
key = value["public_key_hex"];
|
|
755
944
|
}
|
|
756
|
-
|
|
945
|
+
const publicKey = witnessPublicKey(kid, key);
|
|
946
|
+
if (until !== null && atOrBefore(until, now)) {
|
|
947
|
+
// Validated in full, then left out. An expired row neither adds its kid nor removes one
|
|
948
|
+
// that another row trusts; it is remembered so the failure can say the key expired, and
|
|
949
|
+
// when, instead of reading as a key nobody ever configured.
|
|
950
|
+
expired.set(kid, notAfter);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
trusted.set(kid, [exports.ENVELOPE_ALG, publicKey]);
|
|
757
954
|
}
|
|
758
|
-
return trusted;
|
|
955
|
+
return { trusted, expired };
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Every row of a trust set checked as the verifier checks it — kid, alg, public key, `not_after` —
|
|
959
|
+
* and an `Error` naming the kid for the first bad one. The CLI runs this when it loads a
|
|
960
|
+
* `--witness-keys` file, so a bad row is reported against that file before any bundle is read.
|
|
961
|
+
* Expiry is not an error: an expired row is a row the verifier leaves out, not a malformed one.
|
|
962
|
+
*/
|
|
963
|
+
function validateWitnessKeys(witnessKeys) {
|
|
964
|
+
trustedWitnesses(witnessKeys, verificationTime(null));
|
|
965
|
+
}
|
|
966
|
+
/** A trust-set row's members. A row is read whole: anything else in it is refused. */
|
|
967
|
+
const TRUST_ROW_MEMBERS = new Set(["kid", "alg", "public_key_hex", "not_after"]);
|
|
968
|
+
/**
|
|
969
|
+
* RFC 3339 `date-time` in UTC: `YYYY-MM-DDTHH:MM:SS`, an optional fraction, and `Z`. RFC 3339
|
|
970
|
+
* allows `t` and `z` in lower case, so they are accepted too. A numeric offset, even `+00:00`, is
|
|
971
|
+
* not: a trust-set row says UTC in the one spelling that cannot be misread as local time. ASCII
|
|
972
|
+
* digits only. The Python implementation accepts exactly this grammar, so a row one of them
|
|
973
|
+
* refuses, both refuse.
|
|
974
|
+
*/
|
|
975
|
+
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]$/;
|
|
976
|
+
/**
|
|
977
|
+
* `value` as an instant, or `null` when it is not an RFC 3339 UTC date-time. An out-of-range field
|
|
978
|
+
* (month 13, February 30, hour 24, second 60) is `null`: there is no leap second here, as there is
|
|
979
|
+
* none in Python's `datetime`.
|
|
980
|
+
*/
|
|
981
|
+
function rfc3339Utc(value) {
|
|
982
|
+
const m = typeof value === "string" ? RFC3339_UTC.exec(value) : null;
|
|
983
|
+
if (m !== null) {
|
|
984
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
985
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
986
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
987
|
+
if (year >= 1 && days !== undefined && day >= 1 && day <= days && hour <= 23 && minute <= 59 && second <= 59) {
|
|
988
|
+
// `Date.UTC` reads a year below 100 as 19xx; `setUTCFullYear` does not.
|
|
989
|
+
const at = new Date(0);
|
|
990
|
+
at.setUTCFullYear(year, month - 1, day);
|
|
991
|
+
at.setUTCHours(hour, minute, second, 0);
|
|
992
|
+
return { seconds: at.getTime() / 1000, fraction: (m[7] ?? "").slice(0, 6) };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* The verification time: `now` when given — a `Date`, or a string in the `not_after` grammar — and
|
|
999
|
+
* the current time otherwise.
|
|
1000
|
+
*
|
|
1001
|
+
* A number is refused, never read. The Python implementation takes epoch seconds there; a
|
|
1002
|
+
* JavaScript epoch counts milliseconds, and a number read in the wrong unit moves the verification
|
|
1003
|
+
* time a thousandfold, so this side takes no number at all rather than guess which one it got.
|
|
1004
|
+
*/
|
|
1005
|
+
function verificationTime(now) {
|
|
1006
|
+
if (now === null || now === undefined)
|
|
1007
|
+
return dateInstant(new Date());
|
|
1008
|
+
if (now instanceof Date && !Number.isNaN(now.getTime()))
|
|
1009
|
+
return dateInstant(now);
|
|
1010
|
+
if (typeof now === "number") {
|
|
1011
|
+
throw new Error("now must be a Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z', not a " +
|
|
1012
|
+
"number: a JavaScript epoch counts milliseconds and a Python one seconds");
|
|
1013
|
+
}
|
|
1014
|
+
const parsed = typeof now === "string" ? rfc3339Utc(now) : null;
|
|
1015
|
+
if (parsed === null) {
|
|
1016
|
+
throw new Error("now must be a valid Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z'; got " +
|
|
1017
|
+
(now instanceof Date ? "an invalid Date" : (0, display_js_1.pyRepr)(now)));
|
|
1018
|
+
}
|
|
1019
|
+
return parsed;
|
|
1020
|
+
}
|
|
1021
|
+
function dateInstant(at) {
|
|
1022
|
+
const ms = at.getTime();
|
|
1023
|
+
const seconds = Math.floor(ms / 1000);
|
|
1024
|
+
return { seconds, fraction: String(ms - seconds * 1000).padStart(3, "0") };
|
|
1025
|
+
}
|
|
1026
|
+
/** `a` is at or before `b`: whole seconds first, then the fraction digits, padded to one length. */
|
|
1027
|
+
function atOrBefore(a, b) {
|
|
1028
|
+
if (a.seconds !== b.seconds)
|
|
1029
|
+
return a.seconds < b.seconds;
|
|
1030
|
+
const width = Math.max(a.fraction.length, b.fraction.length);
|
|
1031
|
+
return a.fraction.padEnd(width, "0") <= b.fraction.padEnd(width, "0");
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Python's truthiness for a parsed JSON value, read where the Python implementation writes
|
|
1035
|
+
* `value or default`: absent, null, false, a zero, "", [] and {} are false.
|
|
1036
|
+
*/
|
|
1037
|
+
function pyFalsy(value) {
|
|
1038
|
+
if (value === undefined || value === null || value === false || value === 0 || value === "")
|
|
1039
|
+
return true;
|
|
1040
|
+
if (Array.isArray(value))
|
|
1041
|
+
return value.length === 0;
|
|
1042
|
+
if (typeof value === "object")
|
|
1043
|
+
return Object.keys(value).length === 0;
|
|
1044
|
+
return false;
|
|
759
1045
|
}
|
|
760
1046
|
/** A plain object (not an array, not null, not a Buffer). */
|
|
761
1047
|
function isRecordLike(v) {
|
|
@@ -778,16 +1064,14 @@ function receivedBytes(raw) {
|
|
|
778
1064
|
return Buffer.from(raw);
|
|
779
1065
|
return null;
|
|
780
1066
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return typeof value === "number" && Number.isInteger(value);
|
|
790
|
-
}
|
|
1067
|
+
// A subject `seq` this build looks an entry up by, and an envelope's `v`, must be an integral
|
|
1068
|
+
// number and never a boolean (`integral`, canonical.ts), as the schema's integer type defines:
|
|
1069
|
+
// Python's `_is_seq`. `1.0` is 1, and RFC 8785 writes it as 1, so an envelope signed over one
|
|
1070
|
+
// verifies as if signed over the other. The type check comes first and every use is behind it — in
|
|
1071
|
+
// Python an unguarded lookup raises on a list or an object and finds the entry at seq 1 for
|
|
1072
|
+
// `true`, and the two implementations report the same failure for the same bundle. The entries are
|
|
1073
|
+
// keyed by the same rule (`subjectIndex`), so an entry whose own seq is `true` or `1.5` is covered
|
|
1074
|
+
// by no envelope.
|
|
791
1075
|
/**
|
|
792
1076
|
* The report line: the state and the result together, in the same form for all three results. A
|
|
793
1077
|
* process-asserted entry gets no result.
|
|
@@ -809,43 +1093,45 @@ function envelopeLine(state, result) {
|
|
|
809
1093
|
* whoever appends the second decides what the first said, and an entry whose coverage is
|
|
810
1094
|
* disputed must not read as clean.
|
|
811
1095
|
*/
|
|
812
|
-
function scoreEnvelopes(entries, envelopes,
|
|
1096
|
+
function scoreEnvelopes(entries, envelopes, trust, rawBytes) {
|
|
813
1097
|
const fail = new FailureLog();
|
|
814
1098
|
const states = {};
|
|
815
1099
|
const results = {};
|
|
1100
|
+
const witnesses = {};
|
|
816
1101
|
entries.forEach((e, i) => {
|
|
817
|
-
states
|
|
1102
|
+
setOwn(states, stateKey(e, i), exports.PROCESS_ASSERTED);
|
|
818
1103
|
});
|
|
819
1104
|
// The hash walk is what an envelope's binding member is checked against; a bundle carrying
|
|
820
1105
|
// none does not pay for it. Every entry is process-asserted in that case, which is the status
|
|
821
1106
|
// quo and exactly what this reports.
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
recomputed = recomputedHashes(entries);
|
|
827
|
-
}
|
|
828
|
-
// seq -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts an
|
|
829
|
-
// envelope in as soon as its subject names an entry this bundle has.
|
|
1107
|
+
const subjectAt = envelopes.length > 0 ? subjectIndex(entries) : new Map();
|
|
1108
|
+
const recomputed = envelopes.length > 0 ? recomputedHashes(entries) : [];
|
|
1109
|
+
// entry index -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts
|
|
1110
|
+
// an envelope in as soon as its subject names an entry this bundle has.
|
|
830
1111
|
const claims = new Map();
|
|
831
1112
|
envelopes.forEach((envelope, index) => {
|
|
832
1113
|
const raw = rawBytes !== null && index < rawBytes.length ? rawBytes[index] ?? null : null;
|
|
833
|
-
const covered = scoreEnvelope(envelope, index,
|
|
1114
|
+
const covered = scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims);
|
|
834
1115
|
if (covered === null)
|
|
835
1116
|
return;
|
|
836
|
-
states
|
|
837
|
-
|
|
1117
|
+
// Where `states` files that entry; `witnesses` files it by the index the subject resolved to.
|
|
1118
|
+
const key = stateKey(entries[covered.at], covered.at);
|
|
1119
|
+
setOwn(states, key, exports.WITNESS_SIGNED);
|
|
1120
|
+
setOwn(results, key, covered.result);
|
|
1121
|
+
setOwn(witnesses, String(covered.at), covered.kid);
|
|
838
1122
|
});
|
|
839
1123
|
// The first envelope's result stands in `results` — it is what that witness said, and the
|
|
840
1124
|
// duplicate does not erase it — but the STATE falls back, so a contradicted entry never
|
|
841
|
-
// reports witness-signed and the bundle rejects
|
|
842
|
-
for (const [
|
|
843
|
-
if (count > 1)
|
|
844
|
-
states[
|
|
1125
|
+
// reports witness-signed and the bundle rejects; and it leaves `witnesses`.
|
|
1126
|
+
for (const [at, count] of claims) {
|
|
1127
|
+
if (count > 1) {
|
|
1128
|
+
setOwn(states, stateKey(entries[at], at), exports.PROCESS_ASSERTED);
|
|
1129
|
+
delete witnesses[String(at)];
|
|
1130
|
+
}
|
|
845
1131
|
}
|
|
846
1132
|
const lines = {};
|
|
847
1133
|
for (const [seq, state] of Object.entries(states)) {
|
|
848
|
-
lines
|
|
1134
|
+
setOwn(lines, seq, envelopeLine(state, Object.hasOwn(results, seq) ? results[seq] : null));
|
|
849
1135
|
}
|
|
850
1136
|
const summary = {
|
|
851
1137
|
status: fail.length === 0 ? "verified" : "FAILED",
|
|
@@ -856,47 +1142,58 @@ function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
|
|
|
856
1142
|
.sort((a, b) => a - b),
|
|
857
1143
|
states,
|
|
858
1144
|
results,
|
|
1145
|
+
witnesses,
|
|
859
1146
|
lines,
|
|
860
1147
|
failures: [...fail.messages],
|
|
861
1148
|
};
|
|
862
1149
|
return [summary, fail];
|
|
863
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* `record[key] = value` as an own property, whatever the key. A key is an entry's seq rendered as
|
|
1153
|
+
* text, and a bundle chooses its seqs: a plain assignment of `"__proto__"` would reach the
|
|
1154
|
+
* prototype setter and record nothing.
|
|
1155
|
+
*/
|
|
1156
|
+
function setOwn(record, key, value) {
|
|
1157
|
+
Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
|
|
1158
|
+
}
|
|
864
1159
|
/** Python `repr` for a member set, so both implementations print the same failure strings. */
|
|
865
1160
|
function reprList(values) {
|
|
866
|
-
return `[${values.map(
|
|
1161
|
+
return `[${values.map(display_js_1.pyStrRepr).join(", ")}]`;
|
|
867
1162
|
}
|
|
868
1163
|
/**
|
|
869
1164
|
* One envelope, checked in the order the seven named failures are defined in.
|
|
870
1165
|
*
|
|
871
|
-
* Returns the
|
|
872
|
-
* Every failure is positioned on the entry the envelope COVERS, found by
|
|
873
|
-
* locators are checked against that entry, not used to
|
|
1166
|
+
* Returns the index in `entries` of the entry an envelope that verified covers, and `null` for one
|
|
1167
|
+
* that did not. Every failure is positioned on the entry the envelope COVERS, found by
|
|
1168
|
+
* `subject.seq` through `subjectIndex` — the locators are checked against that entry, not used to
|
|
1169
|
+
* find it.
|
|
874
1170
|
*
|
|
875
|
-
* `claims` is the caller's
|
|
1171
|
+
* `claims` is the caller's entry index -> count of the envelopes that have named it so far, and
|
|
876
1172
|
* this function updates it. An envelope claims its entry as soon as `subject.seq` finds one,
|
|
877
1173
|
* BEFORE the rest of the subject is checked, so a second envelope over an entry an earlier one
|
|
878
1174
|
* already named is `envelope_duplicate_subject` whether either of them is otherwise sound: the
|
|
879
1175
|
* point of the check is that no one can decide what an earlier witness said by appending after
|
|
880
1176
|
* it.
|
|
881
1177
|
*/
|
|
882
|
-
function scoreEnvelope(envelope, index,
|
|
1178
|
+
function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims) {
|
|
883
1179
|
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof canonical_js_1.RawNumber);
|
|
884
1180
|
const subject = isRecord(envelope) ? envelope["subject"] : undefined;
|
|
885
1181
|
function position() {
|
|
886
1182
|
// Every failure is positioned by `subject.seq`, and `subject` is attacker-supplied, so the
|
|
887
1183
|
// lookup is guarded: a seq that is not an integer positions nothing, which is honest — it
|
|
888
1184
|
// names no entry — and it is never used as a key.
|
|
889
|
-
const s = isRecord(subject) ? (0, canonical_js_1.
|
|
890
|
-
if (
|
|
891
|
-
return [null, null];
|
|
892
|
-
const
|
|
893
|
-
if (
|
|
894
|
-
return [s, null];
|
|
895
|
-
|
|
1185
|
+
const s = isRecord(subject) ? (0, canonical_js_1.integral)(subject["seq"]) : null;
|
|
1186
|
+
if (s === null)
|
|
1187
|
+
return [null, null, null];
|
|
1188
|
+
const at = subjectAt.get(s);
|
|
1189
|
+
if (at === undefined)
|
|
1190
|
+
return [s, null, null];
|
|
1191
|
+
const entry = entries[at];
|
|
1192
|
+
return [orNull(entry["seq"]), orNull(entry["node"]), entry];
|
|
896
1193
|
}
|
|
897
1194
|
function report(reason, detail) {
|
|
898
|
-
const [seq, node] = position();
|
|
899
|
-
fail.add(reason, `${reason}: ${detail}`, { seq, node });
|
|
1195
|
+
const [seq, node, entry] = position();
|
|
1196
|
+
fail.add(reason, `${reason}: ${detail}`, { seq, node, entry });
|
|
900
1197
|
return null;
|
|
901
1198
|
}
|
|
902
1199
|
if (!isRecord(envelope)) {
|
|
@@ -905,11 +1202,11 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
905
1202
|
}
|
|
906
1203
|
// (1) version — a `v` or `typ` this build does not know is a DIFFERENT CONTRACT, and nothing
|
|
907
1204
|
// further about it can be read safely.
|
|
908
|
-
|
|
1205
|
+
// The version is read by the seq rule (`integral`): `1.0` is 1, and `true` is not.
|
|
909
1206
|
const typ = (0, canonical_js_1.toPlain)(envelope["typ"]);
|
|
910
|
-
if (v !== exports.ENVELOPE_VERSION || typ !== exports.ENVELOPE_TYP) {
|
|
911
|
-
return report("envelope_unknown_version", `envelope v=${
|
|
912
|
-
`typ='${exports.ENVELOPE_TYP}'`);
|
|
1207
|
+
if ((0, canonical_js_1.integral)(envelope["v"]) !== exports.ENVELOPE_VERSION || typ !== exports.ENVELOPE_TYP) {
|
|
1208
|
+
return report("envelope_unknown_version", `envelope v=${reprIntOr(envelope["v"])} typ=${(0, display_js_1.pyRepr)((envelope["typ"] ?? null))}, ` +
|
|
1209
|
+
`this build knows v=${exports.ENVELOPE_VERSION} typ='${exports.ENVELOPE_TYP}'`);
|
|
913
1210
|
}
|
|
914
1211
|
// (2) member sets — the version commits the exact signed member set of the whole envelope, so
|
|
915
1212
|
// a member added ANYWHERE is a new version that did not declare itself.
|
|
@@ -942,7 +1239,7 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
942
1239
|
}
|
|
943
1240
|
const expectedMembers = exports.ENVELOPE_SUBJECT_MEMBERS.get(event);
|
|
944
1241
|
if (expectedMembers === undefined) {
|
|
945
|
-
return report("envelope_subject_mismatch", `subject event=${pyRepr(event)}; envelope v${exports.ENVELOPE_VERSION} defines a subject for ` +
|
|
1242
|
+
return report("envelope_subject_mismatch", `subject event=${(0, display_js_1.pyRepr)(event)}; envelope v${exports.ENVELOPE_VERSION} defines a subject for ` +
|
|
946
1243
|
`${reprList((0, canonical_js_1.sortedStrings)(exports.ENVELOPE_SUBJECT_MEMBERS.keys()))} and no other event`);
|
|
947
1244
|
}
|
|
948
1245
|
const present = (0, canonical_js_1.sortedStrings)(Object.keys(subject));
|
|
@@ -958,31 +1255,32 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
958
1255
|
// (3a) the binding member. `seq` is the lookup key, so there is nothing to compare it against;
|
|
959
1256
|
// the entry it finds supplies the hash the subject is checked against. It is also the one
|
|
960
1257
|
// subject member used as a KEY, so its type is checked before it is used as one.
|
|
961
|
-
const subjectSeq = (0, canonical_js_1.
|
|
962
|
-
if (
|
|
1258
|
+
const subjectSeq = (0, canonical_js_1.integral)(subject["seq"]);
|
|
1259
|
+
if (subjectSeq === null) {
|
|
963
1260
|
return report("envelope_subject_mismatch", "subject seq is not an integer");
|
|
964
1261
|
}
|
|
965
|
-
const
|
|
966
|
-
if (
|
|
967
|
-
return report("envelope_subject_mismatch", `no entry at seq ${
|
|
1262
|
+
const at = subjectAt.get(subjectSeq);
|
|
1263
|
+
if (at === undefined) {
|
|
1264
|
+
return report("envelope_subject_mismatch", `no entry at seq ${reprIntOr(subject["seq"])} in this bundle`);
|
|
968
1265
|
}
|
|
969
|
-
const
|
|
1266
|
+
const entry = entries[at];
|
|
1267
|
+
// The messages below print an integral seq as its integer, and an entry without one as None.
|
|
1268
|
+
const seq = ((0, canonical_js_1.intOr)(entry["seq"]) ?? null);
|
|
970
1269
|
// (3a') one entry, at most one envelope. Counted here, before anything else about this
|
|
971
1270
|
// envelope is judged, so the rule cannot be sidestepped by making the second envelope
|
|
972
1271
|
// defective in some other way as well.
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
claims.set(claimKey, already + 1);
|
|
1272
|
+
const already = claims.get(at) ?? 0;
|
|
1273
|
+
claims.set(at, already + 1);
|
|
976
1274
|
if (already > 0) {
|
|
977
|
-
return report("envelope_duplicate_subject", `seq ${
|
|
1275
|
+
return report("envelope_duplicate_subject", `seq ${(0, display_js_1.pyStr)(seq)} is already covered by an earlier envelope in this bundle; two ` +
|
|
978
1276
|
"observations of one event contradict each other by construction, so this entry is not " +
|
|
979
1277
|
"witness-signed");
|
|
980
1278
|
}
|
|
981
|
-
const computed = recomputed
|
|
1279
|
+
const computed = recomputed[at] ?? null;
|
|
982
1280
|
const claimed = (0, canonical_js_1.toPlain)(subject["entry_hash"]);
|
|
983
1281
|
if (claimed !== computed) {
|
|
984
|
-
return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${
|
|
985
|
-
`this bundle (${pyRepr(computed)})`);
|
|
1282
|
+
return report("envelope_subject_mismatch", `subject entry_hash ${(0, display_js_1.pyRepr)(claimed)} != the hash recomputed for seq ${(0, display_js_1.pyStr)(seq)} from ` +
|
|
1283
|
+
`this bundle (${(0, display_js_1.pyRepr)(computed)})`);
|
|
986
1284
|
}
|
|
987
1285
|
// (3b) the locators, checked against the SAME entry `seq` found. A matching locator attests
|
|
988
1286
|
// nothing on its own; a disagreeing one is the same failure at the same position.
|
|
@@ -996,7 +1294,7 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
996
1294
|
for (const [member, actual] of locators) {
|
|
997
1295
|
const stated = (0, canonical_js_1.toPlain)(subject[member]);
|
|
998
1296
|
if (stated !== actual) {
|
|
999
|
-
return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${
|
|
1297
|
+
return report("envelope_subject_mismatch", `subject ${member}=${(0, display_js_1.pyRepr)(stated)} != ${(0, display_js_1.pyRepr)(actual)} on the entry at seq ${(0, display_js_1.pyStr)(seq)}`);
|
|
1000
1298
|
}
|
|
1001
1299
|
}
|
|
1002
1300
|
// (4) canonicality — an invariant SEPARATE from the signature: the received bytes must equal
|
|
@@ -1036,13 +1334,18 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
1036
1334
|
if (alg !== exports.ENVELOPE_ALG) {
|
|
1037
1335
|
// v1 defines Ed25519 and nothing else. Without this, `"alg": "none"` on both sides — in the
|
|
1038
1336
|
// envelope and in a trust-set row — agreed with each other and read as witness-signed.
|
|
1039
|
-
return report("envelope_unknown_witness", `witness alg=${pyRepr(alg)} is not '${exports.ENVELOPE_ALG}'; envelope v${exports.ENVELOPE_VERSION} ` +
|
|
1337
|
+
return report("envelope_unknown_witness", `witness alg=${(0, display_js_1.pyRepr)(alg)} is not '${exports.ENVELOPE_ALG}'; envelope v${exports.ENVELOPE_VERSION} ` +
|
|
1040
1338
|
"defines Ed25519 and no other algorithm");
|
|
1041
1339
|
}
|
|
1042
|
-
const known = trusted.get(kid);
|
|
1340
|
+
const known = trust.trusted.get(kid);
|
|
1043
1341
|
if (known === undefined) {
|
|
1044
|
-
|
|
1045
|
-
|
|
1342
|
+
// A kid whose rows have all expired is not in the trust set, so it is the same failure as any
|
|
1343
|
+
// other untrusted kid. The message keeps that wording first and then says why, because "not
|
|
1344
|
+
// trusted" and "trusted until a date that has passed" call for different fixes.
|
|
1345
|
+
const lapsed = trust.expired.get(kid);
|
|
1346
|
+
return report("envelope_unknown_witness", `witness kid=${(0, display_js_1.pyRepr)(kid)} alg=${(0, display_js_1.pyRepr)(alg)} is not in the trusted witness keys ` +
|
|
1347
|
+
`(${reprList((0, canonical_js_1.sortedStrings)(trust.trusted.keys()))})` +
|
|
1348
|
+
(lapsed === undefined ? "" : `: the key expired at not_after=${(0, display_js_1.pyRepr)(lapsed)}`));
|
|
1046
1349
|
}
|
|
1047
1350
|
// (6) the signature, over JCS(envelope minus "sig").
|
|
1048
1351
|
const sigHex = (0, canonical_js_1.toPlain)(envelope["sig"]);
|
|
@@ -1071,21 +1374,26 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
|
|
|
1071
1374
|
verified = false;
|
|
1072
1375
|
}
|
|
1073
1376
|
if (!verified) {
|
|
1074
|
-
return report("envelope_bad_signature", `the signature does not verify under the key kid=${pyRepr(kid)} names`);
|
|
1377
|
+
return report("envelope_bad_signature", `the signature does not verify under the key kid=${(0, display_js_1.pyRepr)(kid)} names`);
|
|
1075
1378
|
}
|
|
1076
1379
|
if (nonCanonical)
|
|
1077
1380
|
return null;
|
|
1078
|
-
return {
|
|
1381
|
+
return { at, node: orNull(entry["node"]), result: (0, canonical_js_1.toPlain)(envelope["observed"]["result"]), kid };
|
|
1079
1382
|
}
|
|
1080
1383
|
/**
|
|
1081
1384
|
* Score a bundle's observer envelopes on their own, without the ledger checks.
|
|
1082
1385
|
*
|
|
1083
|
-
* Returns `{ok, ...summary, failure_details}`. `states` maps every entry's seq to
|
|
1084
|
-
* `witness-signed` or `process-asserted`; `lines` is the report line for each.
|
|
1386
|
+
* Returns `{ok, ...summary, failure_details, failure_entries}`. `states` maps every entry's seq to
|
|
1387
|
+
* `witness-signed` or `process-asserted`; `lines` is the report line for each. `results` maps a
|
|
1388
|
+
* covered seq to the verifying envelope's `observed.result`. `witnesses` maps the index in
|
|
1389
|
+
* `bundle.entries` of every witness-signed entry to that envelope's `witness.kid`: the entry the
|
|
1390
|
+
* subject resolved to, the one `failure_entries` names, so of two entries sharing a seq only the
|
|
1391
|
+
* one the witness signed is in it.
|
|
1085
1392
|
*/
|
|
1086
1393
|
function verifyEnvelopes(bundle, options = {}) {
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1394
|
+
const entries = bundle.entries ?? [];
|
|
1395
|
+
const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
|
|
1396
|
+
return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
|
|
1089
1397
|
}
|
|
1090
1398
|
// =============================================================================================
|
|
1091
1399
|
// Execution binding (0.9.0): offline checks over callId/allow/outcome, from the ledger alone —
|
|
@@ -1110,7 +1418,7 @@ function validCallId(e) {
|
|
|
1110
1418
|
}
|
|
1111
1419
|
const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
|
|
1112
1420
|
if (typeof cid !== "string" || !HEX32.test(cid)) {
|
|
1113
|
-
return `call_id missing or malformed (${pyRepr(cid ?? null)})`;
|
|
1421
|
+
return `call_id missing or malformed (${(0, display_js_1.pyRepr)(cid ?? null)})`;
|
|
1114
1422
|
}
|
|
1115
1423
|
return null;
|
|
1116
1424
|
}
|
|
@@ -1122,7 +1430,7 @@ function validHashField(e, field) {
|
|
|
1122
1430
|
if (v === undefined || v === null)
|
|
1123
1431
|
return null;
|
|
1124
1432
|
if (typeof v !== "string" || !HEX64.test(v)) {
|
|
1125
|
-
return `${field} malformed (${pyRepr(v)})`;
|
|
1433
|
+
return `${field} malformed (${(0, display_js_1.pyRepr)(v)})`;
|
|
1126
1434
|
}
|
|
1127
1435
|
return null;
|
|
1128
1436
|
}
|
|
@@ -1132,7 +1440,7 @@ function validParamsHashReason(e, hashField) {
|
|
|
1132
1440
|
}
|
|
1133
1441
|
const reason = (0, canonical_js_1.toPlain)(e["params_hash_reason"]);
|
|
1134
1442
|
if (reason !== null && reason !== undefined && !params_js_1.PARAMS_HASH_REASONS.has(reason)) {
|
|
1135
|
-
return `params_hash_reason ${pyRepr(reason)} not a known value`;
|
|
1443
|
+
return `params_hash_reason ${(0, display_js_1.pyRepr)(reason)} not a known value`;
|
|
1136
1444
|
}
|
|
1137
1445
|
const hash = (0, canonical_js_1.toPlain)(e[hashField]);
|
|
1138
1446
|
if (reason !== null && reason !== undefined && hash !== null && hash !== undefined) {
|
|
@@ -1163,7 +1471,7 @@ function validateAllow(e) {
|
|
|
1163
1471
|
return "capture is required on every v2 allow";
|
|
1164
1472
|
}
|
|
1165
1473
|
if (!reasons_js_1.CAPTURES.has(capture)) {
|
|
1166
|
-
return `capture ${pyRepr(capture)} not a known value`;
|
|
1474
|
+
return `capture ${(0, display_js_1.pyRepr)(capture)} not a known value`;
|
|
1167
1475
|
}
|
|
1168
1476
|
if (adapter === null || adapter === undefined) {
|
|
1169
1477
|
return "adapter is required alongside capture on every v2 allow";
|
|
@@ -1174,14 +1482,14 @@ function validateAllow(e) {
|
|
|
1174
1482
|
for (const k of ["module", "version", "hook_path"]) {
|
|
1175
1483
|
const v = adapter[k];
|
|
1176
1484
|
if (typeof v !== "string" || !v) {
|
|
1177
|
-
return `adapter[${
|
|
1485
|
+
return `adapter[${(0, display_js_1.pyStrRepr)(k)}] must be a non-empty string`;
|
|
1178
1486
|
}
|
|
1179
1487
|
}
|
|
1180
1488
|
if (presentButNull(e, "policy")) {
|
|
1181
1489
|
return "policy is explicitly null (omit it, or give a valid Policy value)";
|
|
1182
1490
|
}
|
|
1183
1491
|
if ("policy" in e && !isKnownPolicy((0, canonical_js_1.toPlain)(e["policy"]))) {
|
|
1184
|
-
return `policy ${pyRepr((0, canonical_js_1.toPlain)(e["policy"]))} not a known value`;
|
|
1492
|
+
return `policy ${(0, display_js_1.pyRepr)((0, canonical_js_1.toPlain)(e["policy"]))} not a known value`;
|
|
1185
1493
|
}
|
|
1186
1494
|
err = validHashField(e, "authorized_params_hash");
|
|
1187
1495
|
if (err)
|
|
@@ -1202,7 +1510,7 @@ function validateDeny(e) {
|
|
|
1202
1510
|
return err;
|
|
1203
1511
|
const leaked = ALLOW_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
1204
1512
|
if (leaked.length > 0) {
|
|
1205
|
-
return `deny carries allow-only field(s) ${
|
|
1513
|
+
return `deny carries allow-only field(s) ${reprList(leaked)}`;
|
|
1206
1514
|
}
|
|
1207
1515
|
return null;
|
|
1208
1516
|
}
|
|
@@ -1214,7 +1522,7 @@ function validateOutcome(e) {
|
|
|
1214
1522
|
return "body_state is explicitly null";
|
|
1215
1523
|
const bodyState = (0, canonical_js_1.toPlain)(e["body_state"]);
|
|
1216
1524
|
if (typeof bodyState !== "string" || !reasons_js_1.BODY_STATES.has(bodyState)) {
|
|
1217
|
-
return `body_state ${pyRepr(bodyState ?? null)} not a known value`;
|
|
1525
|
+
return `body_state ${(0, display_js_1.pyRepr)(bodyState ?? null)} not a known value`;
|
|
1218
1526
|
}
|
|
1219
1527
|
if (presentButNull(e, "error_code")) {
|
|
1220
1528
|
return "error_code is explicitly null (must be a non-empty string or absent)";
|
|
@@ -1232,7 +1540,7 @@ function validateOutcome(e) {
|
|
|
1232
1540
|
return "duration_ms is explicitly null";
|
|
1233
1541
|
const duration = (0, canonical_js_1.toPlain)(e["duration_ms"]);
|
|
1234
1542
|
if (typeof duration !== "number" || !Number.isInteger(duration) || duration < 0) {
|
|
1235
|
-
return `duration_ms invalid (${pyRepr(duration ?? null)})`;
|
|
1543
|
+
return `duration_ms invalid (${(0, display_js_1.pyRepr)(duration ?? null)})`;
|
|
1236
1544
|
}
|
|
1237
1545
|
const err1 = validHashField(e, "invoked_params_hash");
|
|
1238
1546
|
if (err1)
|
|
@@ -1250,7 +1558,7 @@ function validateOutcome(e) {
|
|
|
1250
1558
|
for (const k of ["type", "ref"]) {
|
|
1251
1559
|
const v = receipt[k];
|
|
1252
1560
|
if (typeof v !== "string" || !v) {
|
|
1253
|
-
return `receipt[${
|
|
1561
|
+
return `receipt[${(0, display_js_1.pyStrRepr)(k)}] must be a non-empty string`;
|
|
1254
1562
|
}
|
|
1255
1563
|
}
|
|
1256
1564
|
const digest = receipt["digest"];
|
|
@@ -1269,7 +1577,7 @@ function validateRoot(e) {
|
|
|
1269
1577
|
return "params_salt is explicitly null";
|
|
1270
1578
|
const salt = (0, canonical_js_1.toPlain)(e["params_salt"]);
|
|
1271
1579
|
if (typeof salt !== "string" || !/^[0-9a-f]{32}$/.test(salt)) {
|
|
1272
|
-
return `params_salt missing or malformed on the v2 root entry (${pyRepr(salt ?? null)})`;
|
|
1580
|
+
return `params_salt missing or malformed on the v2 root entry (${(0, display_js_1.pyRepr)(salt ?? null)})`;
|
|
1273
1581
|
}
|
|
1274
1582
|
return null;
|
|
1275
1583
|
}
|
|
@@ -1282,7 +1590,7 @@ function validateKill(e) {
|
|
|
1282
1590
|
if (pending === null || pending === undefined)
|
|
1283
1591
|
return null;
|
|
1284
1592
|
if (!Array.isArray(pending) || pending.some((c) => typeof c !== "string" || !HEX32.test(c))) {
|
|
1285
|
-
return `pending_at_kill must be a list of call_id-shaped strings (${pyRepr(pending)})`;
|
|
1593
|
+
return `pending_at_kill must be a list of call_id-shaped strings (${(0, display_js_1.pyRepr)(pending)})`;
|
|
1286
1594
|
}
|
|
1287
1595
|
return null;
|
|
1288
1596
|
}
|
|
@@ -1367,19 +1675,19 @@ function policyFailures(entries, bundleV) {
|
|
|
1367
1675
|
if (!("policy" in e))
|
|
1368
1676
|
continue;
|
|
1369
1677
|
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
1370
|
-
const position = { seq: orNull(e["seq"]), node: orNull(e["node"]) };
|
|
1678
|
+
const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
|
|
1371
1679
|
if (ev === "allow") {
|
|
1372
1680
|
if (bundleV === 2)
|
|
1373
1681
|
continue; // validateAllow owns this entry's message
|
|
1374
1682
|
if (!isKnownPolicy((0, canonical_js_1.toPlain)(e["policy"]))) {
|
|
1375
|
-
failures.add("invalid_policy", `invalid_policy: seq=${
|
|
1376
|
-
`${pyRepr(
|
|
1683
|
+
failures.add("invalid_policy", `invalid_policy: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} allow carries policy ` +
|
|
1684
|
+
`${(0, display_js_1.pyRepr)(e["policy"] ?? null)}, not a value this format defines`, position);
|
|
1377
1685
|
}
|
|
1378
1686
|
}
|
|
1379
1687
|
else {
|
|
1380
1688
|
if (ev === "deny" && bundleV === 2)
|
|
1381
1689
|
continue; // validateDeny owns this entry's message
|
|
1382
|
-
failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${
|
|
1690
|
+
failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} event=${(0, display_js_1.pyRepr)(e["event"] ?? null)} ` +
|
|
1383
1691
|
"carries `policy`, which is an allow-only field", position);
|
|
1384
1692
|
}
|
|
1385
1693
|
}
|
|
@@ -1390,8 +1698,8 @@ function v2FieldLeaksOnV1(entries) {
|
|
|
1390
1698
|
for (const e of entries) {
|
|
1391
1699
|
const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
1392
1700
|
if (leaked.length > 0) {
|
|
1393
|
-
failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${
|
|
1394
|
-
`carries v2-only field(s) ${
|
|
1701
|
+
failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} event=${(0, display_js_1.pyRepr)(e["event"] ?? null)} ` +
|
|
1702
|
+
`carries v2-only field(s) ${reprList(leaked)} on a schema_version=1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
1395
1703
|
}
|
|
1396
1704
|
}
|
|
1397
1705
|
return failures;
|
|
@@ -1411,7 +1719,8 @@ function executionBinding(entries, bundleV) {
|
|
|
1411
1719
|
if (bundleV !== 2)
|
|
1412
1720
|
return [{ status: "not applicable" }, new FailureLog()];
|
|
1413
1721
|
const failures = new FailureLog();
|
|
1414
|
-
|
|
1722
|
+
// callId -> [event, node, the entry's own seq value as the bundle wrote it]
|
|
1723
|
+
const seenCallIds = new Map();
|
|
1415
1724
|
const allows = new Map();
|
|
1416
1725
|
const outcomes = new Map();
|
|
1417
1726
|
const invalidAllowIds = new Set();
|
|
@@ -1420,16 +1729,18 @@ function executionBinding(entries, bundleV) {
|
|
|
1420
1729
|
const revokedNodes = new Set();
|
|
1421
1730
|
for (const e of entries) {
|
|
1422
1731
|
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1732
|
+
const raw = (0, canonical_js_1.toPlain)(e["node"]);
|
|
1733
|
+
// A node id is a string; anything else names no node, here as in the Python implementation.
|
|
1734
|
+
const node = typeof raw === "string" ? raw : null;
|
|
1425
1735
|
if (ev === "root") {
|
|
1426
1736
|
if (node !== null)
|
|
1427
1737
|
nodes.add(node);
|
|
1428
1738
|
const err = validateRoot(e);
|
|
1429
1739
|
if (err) {
|
|
1430
|
-
failures.add("invalid_root", `invalid_root: ${err} (seq ${
|
|
1740
|
+
failures.add("invalid_root", `invalid_root: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
|
|
1431
1741
|
seq: orNull(e["seq"]),
|
|
1432
1742
|
node: orNull(e["node"]),
|
|
1743
|
+
entry: e,
|
|
1433
1744
|
});
|
|
1434
1745
|
}
|
|
1435
1746
|
}
|
|
@@ -1442,37 +1753,40 @@ function executionBinding(entries, bundleV) {
|
|
|
1442
1753
|
finalizedNodes.add(node);
|
|
1443
1754
|
}
|
|
1444
1755
|
else if (ev === "kill") {
|
|
1445
|
-
|
|
1756
|
+
const killed = new Map();
|
|
1757
|
+
noteRevoked(e, killed);
|
|
1758
|
+
for (const r of killed.keys())
|
|
1446
1759
|
revokedNodes.add(r);
|
|
1447
1760
|
const err = validateKill(e);
|
|
1448
1761
|
if (err) {
|
|
1449
|
-
failures.add("invalid_kill", `invalid_kill: ${err} (seq ${
|
|
1762
|
+
failures.add("invalid_kill", `invalid_kill: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
|
|
1450
1763
|
seq: orNull(e["seq"]),
|
|
1451
1764
|
node: orNull(e["node"]),
|
|
1765
|
+
entry: e,
|
|
1452
1766
|
});
|
|
1453
1767
|
}
|
|
1454
1768
|
}
|
|
1455
1769
|
if (ev === "allow" || ev === "deny") {
|
|
1456
1770
|
const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
|
|
1457
|
-
const seq = (0, canonical_js_1.toPlain)(e["seq"]);
|
|
1458
1771
|
if (cid !== null && cid !== undefined) {
|
|
1459
1772
|
const prior = seenCallIds.get(cid);
|
|
1460
1773
|
if (prior !== undefined) {
|
|
1461
1774
|
// Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
|
|
1462
1775
|
// record, the first one having been legitimate when it was written.
|
|
1463
|
-
failures.add("duplicate_call_id", `duplicate_call_id: call_id ${
|
|
1464
|
-
`${
|
|
1776
|
+
failures.add("duplicate_call_id", `duplicate_call_id: call_id ${(0, display_js_1.shown)(e["call_id"])} on seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} (${ev}) already used at seq ` +
|
|
1777
|
+
`${(0, display_js_1.shown)((0, canonical_js_1.intOr)(prior[2]))} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
|
|
1465
1778
|
}
|
|
1466
1779
|
else {
|
|
1467
|
-
seenCallIds.set(cid, [ev, node, seq]);
|
|
1780
|
+
seenCallIds.set(cid, [ev, node, e["seq"] ?? null]);
|
|
1468
1781
|
}
|
|
1469
1782
|
}
|
|
1470
1783
|
const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
|
|
1471
1784
|
if (err) {
|
|
1472
|
-
failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${
|
|
1785
|
+
failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
|
|
1473
1786
|
seq: orNull(e["seq"]),
|
|
1474
1787
|
node: orNull(e["node"]),
|
|
1475
1788
|
callId: cid ?? null,
|
|
1789
|
+
entry: e,
|
|
1476
1790
|
});
|
|
1477
1791
|
if (ev === "allow" && cid !== null && cid !== undefined)
|
|
1478
1792
|
invalidAllowIds.add(cid);
|
|
@@ -1483,19 +1797,19 @@ function executionBinding(entries, bundleV) {
|
|
|
1483
1797
|
}
|
|
1484
1798
|
else if (ev === "outcome") {
|
|
1485
1799
|
const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
|
|
1486
|
-
const seq = (0, canonical_js_1.toPlain)(e["seq"]);
|
|
1487
1800
|
const err = validateOutcome(e);
|
|
1488
1801
|
if (err) {
|
|
1489
|
-
failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${
|
|
1802
|
+
failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
|
|
1490
1803
|
seq: orNull(e["seq"]),
|
|
1491
1804
|
node: orNull(e["node"]),
|
|
1492
1805
|
callId: cid ?? null,
|
|
1806
|
+
entry: e,
|
|
1493
1807
|
});
|
|
1494
1808
|
continue;
|
|
1495
1809
|
}
|
|
1496
1810
|
if (cid !== null && outcomes.has(cid)) {
|
|
1497
|
-
failures.add("duplicate_outcome", `duplicate_outcome: call_id ${
|
|
1498
|
-
`${
|
|
1811
|
+
failures.add("duplicate_outcome", `duplicate_outcome: call_id ${(0, display_js_1.shown)(e["call_id"])} at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} (first at seq ` +
|
|
1812
|
+
`${(0, display_js_1.shown)((0, canonical_js_1.intOr)(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
|
|
1499
1813
|
continue;
|
|
1500
1814
|
}
|
|
1501
1815
|
if (cid !== null)
|
|
@@ -1516,25 +1830,26 @@ function executionBinding(entries, bundleV) {
|
|
|
1516
1830
|
for (const [cid, oc] of outcomes) {
|
|
1517
1831
|
const allowE = allows.get(cid);
|
|
1518
1832
|
if (allowE === undefined) {
|
|
1519
|
-
failures.add("outcome_without_allow", `outcome_without_allow: call_id ${
|
|
1833
|
+
failures.add("outcome_without_allow", `outcome_without_allow: call_id ${(0, display_js_1.shown)(oc["call_id"])} at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1520
1834
|
continue;
|
|
1521
1835
|
}
|
|
1522
1836
|
const nodeOk = (0, canonical_js_1.toPlain)(allowE["node"]) === (0, canonical_js_1.toPlain)(oc["node"]);
|
|
1523
1837
|
if (!nodeOk) {
|
|
1524
|
-
failures.add("cross_ref", `cross_ref: call_id ${
|
|
1525
|
-
`outcome on node ${
|
|
1838
|
+
failures.add("cross_ref", `cross_ref: call_id ${(0, display_js_1.shown)(oc["call_id"])} allow on node ${(0, display_js_1.pyRepr)(allowE["node"] ?? null)} but ` +
|
|
1839
|
+
`outcome on node ${(0, display_js_1.pyRepr)(oc["node"] ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1526
1840
|
}
|
|
1527
1841
|
const ocSeq = (0, canonical_js_1.toPlain)(oc["seq"]);
|
|
1528
1842
|
const allowSeq = (0, canonical_js_1.toPlain)(allowE["seq"]);
|
|
1529
1843
|
const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
|
|
1530
1844
|
if (!orderOk) {
|
|
1531
|
-
failures.add("outcome_before_allow", `outcome_before_allow: call_id ${
|
|
1532
|
-
`after allow seq ${
|
|
1845
|
+
failures.add("outcome_before_allow", `outcome_before_allow: call_id ${(0, display_js_1.shown)(oc["call_id"])} outcome seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(oc["seq"]))} not ` +
|
|
1846
|
+
`after allow seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(allowE["seq"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1533
1847
|
}
|
|
1534
1848
|
const ah = (0, canonical_js_1.toPlain)(allowE["authorized_params_hash"]);
|
|
1535
1849
|
const ih = (0, canonical_js_1.toPlain)(oc["invoked_params_hash"]);
|
|
1536
1850
|
if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
|
|
1537
|
-
failures.add("params_mismatch", `params_mismatch: call_id ${
|
|
1851
|
+
failures.add("params_mismatch", `params_mismatch: call_id ${(0, display_js_1.shown)(oc["call_id"])} authorized_params_hash ` +
|
|
1852
|
+
`${(0, display_js_1.shown)(allowE["authorized_params_hash"])} != invoked_params_hash ${(0, display_js_1.shown)(oc["invoked_params_hash"])}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
|
|
1538
1853
|
}
|
|
1539
1854
|
if (nodeOk && orderOk)
|
|
1540
1855
|
boundOk.add(cid);
|
|
@@ -1560,7 +1875,8 @@ function executionBinding(entries, bundleV) {
|
|
|
1560
1875
|
}
|
|
1561
1876
|
else {
|
|
1562
1877
|
perCall[cid] = "unaccounted";
|
|
1563
|
-
const
|
|
1878
|
+
const raw = (0, canonical_js_1.toPlain)(allowE["node"]);
|
|
1879
|
+
const node = typeof raw === "string" ? raw : null;
|
|
1564
1880
|
const list = nodePending.get(node) ?? [];
|
|
1565
1881
|
list.push(cid);
|
|
1566
1882
|
nodePending.set(node, list);
|
|
@@ -1616,16 +1932,16 @@ function executionBinding(entries, bundleV) {
|
|
|
1616
1932
|
];
|
|
1617
1933
|
}
|
|
1618
1934
|
/**
|
|
1619
|
-
*
|
|
1935
|
+
* The index of the FIRST entry the hash chain does not reproduce at — position only.
|
|
1620
1936
|
*
|
|
1621
1937
|
* `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
|
|
1622
1938
|
* module reports; this walk exists so the structured twin of that message can say WHERE, which
|
|
1623
1939
|
* the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
|
|
1624
|
-
* exactly (same seq/prev_hash/hash order
|
|
1625
|
-
*
|
|
1626
|
-
* chain-level.
|
|
1940
|
+
* exactly (same seq/prev_hash/hash order, and the same rule that a seq is an integral number and
|
|
1941
|
+
* never a boolean, `integral`). `null` when nothing entry-local is wrong — a consistently
|
|
1942
|
+
* re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
|
|
1627
1943
|
*/
|
|
1628
|
-
function
|
|
1944
|
+
function integrityBreak(entries) {
|
|
1629
1945
|
let prev = audit_js_1.GENESIS;
|
|
1630
1946
|
for (let i = 0; i < entries.length; i++) {
|
|
1631
1947
|
const e = entries[i];
|
|
@@ -1637,19 +1953,19 @@ function integrityPosition(entries) {
|
|
|
1637
1953
|
let broken;
|
|
1638
1954
|
try {
|
|
1639
1955
|
broken =
|
|
1640
|
-
|
|
1956
|
+
(0, canonical_js_1.integral)(e["seq"]) !== i ||
|
|
1641
1957
|
orNull(payload["prev_hash"]) !== prev ||
|
|
1642
1958
|
(0, audit_js_1.hashEntry)(prev, payload) !== orNull(e["hash"]);
|
|
1643
1959
|
}
|
|
1644
1960
|
catch {
|
|
1645
1961
|
// An unhashable payload is itself the break, at this entry.
|
|
1646
|
-
return
|
|
1962
|
+
return i;
|
|
1647
1963
|
}
|
|
1648
1964
|
if (broken)
|
|
1649
|
-
return
|
|
1965
|
+
return i;
|
|
1650
1966
|
prev = orNull(e["hash"]);
|
|
1651
1967
|
}
|
|
1652
|
-
return
|
|
1968
|
+
return null;
|
|
1653
1969
|
}
|
|
1654
1970
|
function verifyBundle(bundle, signer = null, options = {}) {
|
|
1655
1971
|
const entries = bundle.entries ?? [];
|
|
@@ -1669,17 +1985,18 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1669
1985
|
};
|
|
1670
1986
|
const log = new FailureLog();
|
|
1671
1987
|
// (0) version: the bundle must declare a schema version this build understands, and — when
|
|
1672
|
-
// an anchor is present — the anchor must be anchoring THAT version, not a different one.
|
|
1673
|
-
|
|
1674
|
-
|
|
1988
|
+
// an anchor is present — the anchor must be anchoring THAT version, not a different one. A
|
|
1989
|
+
// version is read by the seq rule (`integral`): `2.0` is 2, and `true` is not.
|
|
1990
|
+
const rawV = bundle.v;
|
|
1991
|
+
const bundleV = (0, canonical_js_1.integral)(rawV);
|
|
1992
|
+
let versionOk = bundleV !== null && exports.SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
|
|
1675
1993
|
if (!versionOk) {
|
|
1676
1994
|
const supported = Array.from(exports.SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
|
|
1677
|
-
log.add("unsupported_version", `unsupported_version: bundle v=${
|
|
1995
|
+
log.add("unsupported_version", `unsupported_version: bundle v=${reprIntOr(rawV)} not in [${supported.join(", ")}]`);
|
|
1678
1996
|
}
|
|
1679
|
-
|
|
1680
|
-
if (anchorPresent && anchorV !== bundleV) {
|
|
1997
|
+
if (anchorPresent && !sameNumber(anchor["v"], rawV)) {
|
|
1681
1998
|
versionOk = false;
|
|
1682
|
-
log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${
|
|
1999
|
+
log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${reprIntOr(anchor["v"])} != bundle v=${reprIntOr(rawV)}`);
|
|
1683
2000
|
}
|
|
1684
2001
|
// (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
|
|
1685
2002
|
// sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
|
|
@@ -1691,17 +2008,19 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1691
2008
|
const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
|
|
1692
2009
|
// 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
|
|
1693
2010
|
// entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
|
|
1694
|
-
if (rootEntry !== undefined && (
|
|
2011
|
+
if (rootEntry !== undefined && !sameNumber(rootEntry["v"], rawV)) {
|
|
1695
2012
|
versionOk = false;
|
|
1696
|
-
log.add("root_version_mismatch", `root_version_mismatch: root v=${
|
|
2013
|
+
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 });
|
|
1697
2014
|
}
|
|
1698
|
-
const mixedEntries = entries.filter((e) => (
|
|
1699
|
-
|
|
2015
|
+
const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
|
|
2016
|
+
// An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
|
|
2017
|
+
// everything else by its repr, compared by code point as Python compares strings, never by locale.
|
|
2018
|
+
const mixed = pySetOf(mixedEntries.map((e) => ((0, canonical_js_1.intOr)(e["v"]) ?? null))).sort(compareVersions);
|
|
1700
2019
|
if (mixed.length > 0) {
|
|
1701
2020
|
versionOk = false;
|
|
1702
2021
|
// One aggregate message over every offending entry (unchanged); the twin is positioned on
|
|
1703
2022
|
// the first of them, which is where a reader looks.
|
|
1704
|
-
log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${
|
|
2023
|
+
log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => (0, display_js_1.pyRepr)(v)).join(", ")}], bundle v=${reprIntOr(rawV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]), entry: mixedEntries[0] });
|
|
1705
2024
|
}
|
|
1706
2025
|
checks.version = versionOk;
|
|
1707
2026
|
// (0b2) every entry must be read WHOLE. `LEDGER_FIELDS` was enforced only on the EXPORT path
|
|
@@ -1727,10 +2046,24 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1727
2046
|
if (extra.length > 0) {
|
|
1728
2047
|
unknownOk = false;
|
|
1729
2048
|
log.add("unknown_ledger_fields", `unknown_ledger_fields: entry carries fields this verifier does not evaluate and will ` +
|
|
1730
|
-
`not ignore: ${extra.join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
|
|
2049
|
+
`not ignore: ${extra.map((f) => (0, display_js_1.shown)(f)).join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
1731
2050
|
}
|
|
1732
2051
|
}
|
|
1733
2052
|
checks.ledger_fields = unknownOk;
|
|
2053
|
+
// (0b3) a node id is a string (the schema's `node`). A root, a spawn and an allow are judged by
|
|
2054
|
+
// the checks that read their node, below; any other entry that carries a node which is a list,
|
|
2055
|
+
// an object, a number or a boolean is reported here, never read by projection and never used as
|
|
2056
|
+
// a key. A null node counts as absent.
|
|
2057
|
+
for (const e of entries) {
|
|
2058
|
+
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
2059
|
+
if (ev === "root" || ev === "spawn" || ev === "allow")
|
|
2060
|
+
continue;
|
|
2061
|
+
const node = (0, canonical_js_1.toPlain)(e["node"]);
|
|
2062
|
+
if (node === undefined || node === null || typeof node === "string")
|
|
2063
|
+
continue;
|
|
2064
|
+
log.add("invalid_node", `invalid_node: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} event=${(0, display_js_1.pyRepr)((e["event"] ?? null))} ` +
|
|
2065
|
+
`carries node ${(0, display_js_1.shown)(e["node"])}, which is not a string`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
|
|
2066
|
+
}
|
|
1734
2067
|
// (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
|
|
1735
2068
|
// computed head, never against its own (possibly forged) enclosed anchor.
|
|
1736
2069
|
const { expectedAnchor = null, expectedHead = null } = options;
|
|
@@ -1742,7 +2075,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1742
2075
|
const [expSeq, expHash] = expectedHead;
|
|
1743
2076
|
if (actualSeq !== expSeq || actualHead !== expHash) {
|
|
1744
2077
|
expectedOk = false;
|
|
1745
|
-
log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
|
|
2078
|
+
log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${(0, display_js_1.shown)(actualHead)}) but the ` +
|
|
1746
2079
|
`independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
|
|
1747
2080
|
}
|
|
1748
2081
|
}
|
|
@@ -1751,7 +2084,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1751
2084
|
if ((0, canonical_js_1.toPlain)(ea["seq"]) !== actualSeq ||
|
|
1752
2085
|
(0, canonical_js_1.toPlain)(ea["head"]) !== actualHead ||
|
|
1753
2086
|
(0, canonical_js_1.toPlain)(ea["chain_id"]) !== (0, canonical_js_1.toPlain)(bundle.chain_id) ||
|
|
1754
|
-
(
|
|
2087
|
+
!sameNumber(ea["v"], rawV)) {
|
|
1755
2088
|
expectedOk = false;
|
|
1756
2089
|
log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
|
|
1757
2090
|
"the independently retained expected anchor");
|
|
@@ -1766,22 +2099,28 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1766
2099
|
const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
|
|
1767
2100
|
const entriesOk = foreign === undefined;
|
|
1768
2101
|
if (foreign !== undefined) {
|
|
1769
|
-
log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
|
|
2102
|
+
log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${(0, display_js_1.pyRepr)(bundleChainId)}`, {
|
|
1770
2103
|
seq: orNull(foreign["seq"]),
|
|
1771
2104
|
node: orNull(foreign["node"]),
|
|
2105
|
+
entry: foreign,
|
|
1772
2106
|
});
|
|
1773
2107
|
}
|
|
1774
2108
|
const anchorChainId = orNull(anchor["chain_id"]);
|
|
1775
2109
|
const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
|
|
1776
2110
|
if (!anchorChainOk) {
|
|
1777
|
-
log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
|
|
2111
|
+
log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${(0, display_js_1.pyRepr)(anchorChainId)} != bundle chain_id=${(0, display_js_1.pyRepr)(bundleChainId)}`);
|
|
1778
2112
|
}
|
|
1779
2113
|
checks.chain_id = entriesOk && anchorChainOk;
|
|
1780
2114
|
// (1) integrity: the hash chain, plus the signed anchor when a key is given.
|
|
1781
2115
|
const [okChain, err] = audit_js_1.AuditLog.verify(entries);
|
|
1782
2116
|
if (!okChain) {
|
|
1783
|
-
const
|
|
1784
|
-
|
|
2117
|
+
const bad = integrityBreak(entries);
|
|
2118
|
+
const badEntry = bad === null ? null : entries[bad];
|
|
2119
|
+
log.add("integrity", `integrity: ${err}`, {
|
|
2120
|
+
seq: badEntry === null ? null : orNull(badEntry["seq"]),
|
|
2121
|
+
node: badEntry === null ? null : orNull(badEntry["node"]),
|
|
2122
|
+
entry: badEntry,
|
|
2123
|
+
});
|
|
1785
2124
|
}
|
|
1786
2125
|
if (signer !== null) {
|
|
1787
2126
|
const [okAnchor, aerr] = audit_js_1.AuditLog.verifyAnchor(entries, anchor, signer);
|
|
@@ -1795,24 +2134,62 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1795
2134
|
else {
|
|
1796
2135
|
checks.integrity = okChain;
|
|
1797
2136
|
}
|
|
1798
|
-
const { auth,
|
|
2137
|
+
const { auth, failures: afail } = nodeAuthorities(entries);
|
|
1799
2138
|
log.extend(afail);
|
|
1800
|
-
// (2) monotonicity: every child ⊆ its parent.
|
|
2139
|
+
// (2) monotonicity: every child ⊆ its parent, read in ledger order. Every spawn is checked, and
|
|
2140
|
+
// the node it names as `parent` has to be one the root or an EARLIER spawn defined, not revoked
|
|
2141
|
+
// by an earlier kill, and not the spawn's own node. Through 0.12.0 a spawn whose parent was
|
|
2142
|
+
// absent, null, or named no node in the bundle was skipped, so a child widened past the
|
|
2143
|
+
// authority it was really given verified OK; the process being watched writes that field. A
|
|
2144
|
+
// node is defined once: a second definition is a failure too, since only one of the two could
|
|
2145
|
+
// be read. The Python implementation's same pass, finding for finding.
|
|
1801
2146
|
let mono = true;
|
|
1802
|
-
|
|
1803
|
-
|
|
2147
|
+
const definedAt = new Map(); // node -> the entry that defined it, as of this point
|
|
2148
|
+
const revokedAt = new Map(); // node -> the seq of the kill that revoked it
|
|
2149
|
+
for (const e of entries) {
|
|
2150
|
+
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
2151
|
+
if (ev === "kill") {
|
|
2152
|
+
noteRevoked(e, revokedAt);
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
if (ev !== "root" && ev !== "spawn")
|
|
1804
2156
|
continue;
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
if (!child.isNarrowerThan(p)) {
|
|
2157
|
+
const node = (0, canonical_js_1.toPlain)(e["node"]);
|
|
2158
|
+
if (typeof node !== "string")
|
|
2159
|
+
continue; // unreadable, reported by nodeAuthorities
|
|
2160
|
+
const position = { seq: orNull(e["seq"]), node, entry: e };
|
|
2161
|
+
const first = definedAt.get(node);
|
|
2162
|
+
if (first !== undefined) {
|
|
1812
2163
|
mono = false;
|
|
1813
|
-
|
|
1814
|
-
|
|
2164
|
+
log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} is defined a second time in this bundle (first at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(first["seq"]))})`, position);
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
if (ev === "spawn") {
|
|
2168
|
+
const pid = (0, canonical_js_1.toPlain)(e["parent"]);
|
|
2169
|
+
if (typeof pid !== "string" || !definedAt.has(pid)) {
|
|
2170
|
+
mono = false;
|
|
2171
|
+
log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} names no parent defined earlier in this bundle (parent ${(0, display_js_1.shown)(e["parent"])})`, position);
|
|
2172
|
+
}
|
|
2173
|
+
else if (revokedAt.has(pid)) {
|
|
2174
|
+
mono = false;
|
|
2175
|
+
log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} is spawned from ${(0, display_js_1.shown)(pid)} after ${(0, display_js_1.shown)(pid)} was revoked at seq ` +
|
|
2176
|
+
(0, display_js_1.shown)((0, canonical_js_1.intOr)(revokedAt.get(pid))), position);
|
|
2177
|
+
}
|
|
2178
|
+
else {
|
|
2179
|
+
// 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
|
|
2180
|
+
// non-wildcard-aware scope difference, which silently accepted a delegation that widened
|
|
2181
|
+
// only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
|
|
2182
|
+
// the parent's. An unreadable authority on either side is reported by nodeAuthorities
|
|
2183
|
+
// and fails this check on its own.
|
|
2184
|
+
const child = auth.get(node);
|
|
2185
|
+
const p = auth.get(pid);
|
|
2186
|
+
if (child !== undefined && p !== undefined && !child.isNarrowerThan(p)) {
|
|
2187
|
+
mono = false;
|
|
2188
|
+
log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} not ⊆ parent ${(0, display_js_1.shown)(pid)} (${monotonicityDetail(child, p)})`, position);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
1815
2191
|
}
|
|
2192
|
+
definedAt.set(node, e);
|
|
1816
2193
|
}
|
|
1817
2194
|
checks.monotonicity = mono && afail.length === 0;
|
|
1818
2195
|
// (3) containment: every allowed action's scope within the acting node's authority.
|
|
@@ -1822,11 +2199,27 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1822
2199
|
// a violation the entry never asserted. Such entries are counted as UNGATED and reported as
|
|
1823
2200
|
// their own number instead: a reader sees how much of the run was actually measured, which is
|
|
1824
2201
|
// the honest answer and never a silent one.
|
|
2202
|
+
// Read in ledger order, like monotonicity: an allow is judged against a node defined EARLIER (one
|
|
2203
|
+
// defined only later is unknown at that point), and an allow on a node an earlier kill revoked
|
|
2204
|
+
// is outside its authority, since a revoked node holds none.
|
|
1825
2205
|
let contained = true;
|
|
1826
2206
|
let actions = 0;
|
|
1827
2207
|
let ungated = 0;
|
|
2208
|
+
const known = new Set();
|
|
2209
|
+
const killedAt = new Map();
|
|
1828
2210
|
for (const e of entries) {
|
|
1829
|
-
|
|
2211
|
+
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
2212
|
+
if (ev === "root" || ev === "spawn") {
|
|
2213
|
+
const defined = (0, canonical_js_1.toPlain)(e["node"]);
|
|
2214
|
+
if (typeof defined === "string")
|
|
2215
|
+
known.add(defined);
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
if (ev === "kill") {
|
|
2219
|
+
noteRevoked(e, killedAt);
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
if (ev !== "allow")
|
|
1830
2223
|
continue;
|
|
1831
2224
|
if (isKnownPolicy((0, canonical_js_1.toPlain)(e["policy"]))) {
|
|
1832
2225
|
// Only a policy value the format DEFINES buys the exemption. An entry carrying anything
|
|
@@ -1838,21 +2231,39 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1838
2231
|
actions += 1;
|
|
1839
2232
|
const node = (0, canonical_js_1.toPlain)(e["node"]);
|
|
1840
2233
|
const scope = (0, canonical_js_1.toPlain)(e["scope"]);
|
|
1841
|
-
|
|
1842
|
-
|
|
2234
|
+
// The Python implementation reads `e.get("context") or {}`, so an absent context and any value
|
|
2235
|
+
// Python counts as false (null, false, 0, "", [] or {}) are no context. Any other value that
|
|
2236
|
+
// is not an object is not a context the allow could have been checked against, and it does not
|
|
2237
|
+
// verify: it is the containment finding below. (Python 0.19.0 raises on it.)
|
|
2238
|
+
const rawContext = (0, canonical_js_1.toPlain)(e["context"]);
|
|
2239
|
+
const noContext = pyFalsy(rawContext);
|
|
2240
|
+
const contextIsObject = isRecordLike(rawContext);
|
|
2241
|
+
const ctx = (noContext ? {} : rawContext);
|
|
2242
|
+
const a = typeof node === "string" && known.has(node) ? auth.get(node) : undefined;
|
|
1843
2243
|
if (a === undefined) {
|
|
1844
2244
|
contained = false;
|
|
1845
|
-
log.add("containment", `containment: allow on unknown node ${node}`, {
|
|
2245
|
+
log.add("containment", `containment: allow on unknown node ${(0, display_js_1.shown)(e["node"])}`, {
|
|
1846
2246
|
seq: orNull(e["seq"]),
|
|
1847
2247
|
node: orNull(e["node"]),
|
|
1848
2248
|
callId: orNull(e["call_id"]),
|
|
2249
|
+
entry: e,
|
|
1849
2250
|
});
|
|
1850
2251
|
continue;
|
|
1851
2252
|
}
|
|
1852
|
-
if (
|
|
2253
|
+
if (killedAt.has(node)) {
|
|
2254
|
+
contained = false;
|
|
2255
|
+
log.add("containment", `containment: allow of ${(0, display_js_1.pyRepr)(e["scope"] ?? null)} on ${(0, display_js_1.shown)(e["node"])} after ${(0, display_js_1.shown)(e["node"])} was ` +
|
|
2256
|
+
`revoked at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(killedAt.get(node)))}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
// A scope that is not a string is no scope the node can hold. Against a wildcard it threw
|
|
2260
|
+
// (`startsWith` on a number or null) out of the verifier; it is this finding either way, as
|
|
2261
|
+
// in the Python implementation. A context that is neither absent, nor false to Python, nor an
|
|
2262
|
+
// object is this finding too.
|
|
2263
|
+
if (typeof scope !== "string" || !(noContext || contextIsObject) || !a.permits(scope, ctx).allowed) {
|
|
1853
2264
|
contained = false;
|
|
1854
|
-
log.add("containment", `containment: allow of
|
|
1855
|
-
|
|
2265
|
+
log.add("containment", `containment: allow of ${(0, display_js_1.pyRepr)(e["scope"] ?? null)} on ${(0, display_js_1.shown)(e["node"])} outside its authority ` +
|
|
2266
|
+
reprList(Array.from(a.scopes).sort(canonical_js_1.compareCodePoints)), { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
|
|
1856
2267
|
}
|
|
1857
2268
|
}
|
|
1858
2269
|
checks.containment = contained;
|
|
@@ -1866,7 +2277,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1866
2277
|
// nothing — but a PRESENT one has to verify, and a broken one lands in this same list. The
|
|
1867
2278
|
// per-entry state is reported either way, so a reader sees which hops were covered before
|
|
1868
2279
|
// reading which one failed.
|
|
1869
|
-
const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
|
|
2280
|
+
const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
|
|
1870
2281
|
if (bundle.envelopes === undefined) {
|
|
1871
2282
|
envelopeSummary.status = "not present";
|
|
1872
2283
|
}
|
|
@@ -1890,6 +2301,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
1890
2301
|
checks,
|
|
1891
2302
|
failures: log.messages,
|
|
1892
2303
|
failure_details: log.details,
|
|
2304
|
+
failure_entries: log.entryIndices(entries),
|
|
1893
2305
|
nodes: auth.size,
|
|
1894
2306
|
actions_checked: actions,
|
|
1895
2307
|
ungated,
|