@blamejs/core 0.9.46 → 0.10.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
@@ -60,11 +60,20 @@ var agentAudit = require("./agent-audit");
60
60
 
61
61
  var audit = lazyRequire(function () { return require("./audit"); });
62
62
  var cluster = lazyRequire(function () { return require("./cluster"); });
63
+ var vault = lazyRequire(function () { return require("./vault"); });
63
64
 
64
65
  var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
65
66
 
66
67
  var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
67
68
  var STREAM_ID_RAND_BYTES = 8; // allow:raw-byte-literal — stream-id random-suffix byte length, not a size cap
69
+ var DEFAULT_PER_CONSUMER_STOP_MS = C.TIME.seconds(5);
70
+ // SUBSTRATE-20 — FNV-1a offset basis salted with the first 32 bits of
71
+ // SHA3-512(vault master). Attackers who don't have read access to the
72
+ // vault keypair can't compute the salt, so they can't engineer
73
+ // tenantIds that all map to one shard. Cached per-process; rotation
74
+ // of vault keys produces a new basis (operator opts to reshard via
75
+ // rebalance — same property as a manual `vault.rotate`).
76
+ var _saltedFnvBasisCache = null;
68
77
 
69
78
  /**
70
79
  * @primitive b.agent.orchestrator.create
@@ -116,6 +125,19 @@ function create(opts) {
116
125
  // operator-supplied metadata (kind / tenantId / posture / ...);
117
126
  // every consuming process holds its own runtime map of name → agent.
118
127
  liveAgents: new Map(),
128
+ // SUBSTRATE-8 — drain quiesce wiring. Operator passes
129
+ // { outbox, sagaInFlightCount, pubsubFlush } via create() so the
130
+ // drain phase can quiesce real in-flight work, not just stop
131
+ // consumers. Optional — operators with no outbox / saga / pubsub
132
+ // pass nothing and drain falls back to the consumer-stop path.
133
+ outbox: opts.outbox || null,
134
+ sagaInFlightCount: typeof opts.sagaInFlightCount === "function" ? opts.sagaInFlightCount : null,
135
+ pubsubFlush: typeof opts.pubsubFlush === "function" ? opts.pubsubFlush : null,
136
+ perConsumerStopMs: typeof opts.perConsumerStopMs === "number" ? opts.perConsumerStopMs : DEFAULT_PER_CONSUMER_STOP_MS,
137
+ // SUBSTRATE-9 — onTransition handler invalidates election cache
138
+ // on lease-lost / acquired / released. Operator opts out via
139
+ // { cacheElections: false } to always re-query b.cluster.
140
+ cacheElections: opts.cacheElections !== false,
119
141
  };
120
142
 
121
143
  // Wire the drain phase into b.appShutdown if the operator supplied one.
@@ -125,8 +147,33 @@ function create(opts) {
125
147
  });
126
148
  }
127
149
 
150
+ // SUBSTRATE-9 — subscribe to cluster lease transitions so cached
151
+ // election state can't go stale after a partition. b.cluster
152
+ // .onTransition fires for every lease-acquired / lease-lost / lease-
153
+ // released event; we invalidate the affected resource's cached
154
+ // election so the next elect() call re-queries truth. When
155
+ // b.cluster isn't initialized (single-process deployments)
156
+ // onTransition still registers a handler — the dispatcher silently
157
+ // never fires until init completes.
158
+ if (clusterImpl && typeof clusterImpl.onTransition === "function") {
159
+ try {
160
+ clusterImpl.onTransition(function (event) {
161
+ // event.kind ∈ { "lease-acquired" | "lease-lost" |
162
+ // "lease-released" | "lease-renewed" }. Every
163
+ // kind invalidates because membership / fencing-token may
164
+ // have changed.
165
+ ctx.elections.clear();
166
+ agentAudit.safeAudit(ctx.audit, "agent.orchestrator.election_cache_invalidated", null, {
167
+ kind: event && event.kind ? event.kind : "unknown",
168
+ fencingToken: event && event.fencingToken ? event.fencingToken : null,
169
+ });
170
+ });
171
+ } catch (_e) { /* drop-silent — onTransition unavailable in some test stubs */ }
172
+ }
173
+
128
174
  return {
129
175
  register: function (name, agent, regOpts) { return _register(ctx, name, agent, regOpts || {}); },
176
+ hydrate: function (name, agent) { return _hydrate(ctx, name, agent); },
130
177
  unregister: function (name, args) { return _unregister(ctx, name, args || {}); },
131
178
  lookup: function (name, args) { return _lookup(ctx, name, args || {}); },
132
179
  list: function (args) { return _list(ctx, args || {}); },
@@ -142,6 +189,70 @@ function create(opts) {
142
189
  };
143
190
  }
144
191
 
192
+ /**
193
+ * @primitive b.agent.orchestrator.hydrate
194
+ * @signature b.agent.orchestrator.hydrate(name, agent)
195
+ * @since 0.9.57
196
+ * @status stable
197
+ * @related b.agent.orchestrator.create
198
+ *
199
+ * SUBSTRATE-3 — attach an in-process live agent reference to a row
200
+ * that already exists in the persistent registry backend. The
201
+ * canonical boot-phase contract: the *first* process to start a new
202
+ * agent calls `register()` (writes the backend row + holds the live
203
+ * ref); every *subsequent* process that picks up the row from durable
204
+ * storage (cross-orchestrator-restart, multi-process deploy, k8s pod
205
+ * recreate) calls `hydrate(name, agent)` to install its local live
206
+ * ref WITHOUT trying to re-write the backend row (which would refuse
207
+ * with `agent-orchestrator/duplicate`).
208
+ *
209
+ * Throws `agent-orchestrator/not-in-registry` when no backend row
210
+ * exists for `name`. Throws `agent-orchestrator/already-hydrated` if
211
+ * the live ref is already installed (operator's boot phase ran
212
+ * twice).
213
+ *
214
+ * Boot-phase contract:
215
+ * 1. Process A calls `register("tenant-acme.mail", agent, regOpts)`
216
+ * → backend row written; A.liveAgents holds the ref.
217
+ * 2. Process A crashes / redeploys.
218
+ * 3. Process B starts: backend row already exists.
219
+ * 4. Process B walks the registry via `list()` → sees rows it
220
+ * hasn't hydrated yet.
221
+ * 5. For each, Process B reconstructs the agent locally (from its
222
+ * operator config) and calls `hydrate(name, agent)`.
223
+ * 6. `lookup("tenant-acme.mail")` from Process B now returns the
224
+ * live ref instead of throwing `not-hydrated`.
225
+ *
226
+ * @example
227
+ * var rows = await orch.list({});
228
+ * for (var i = 0; i < rows.length; i += 1) {
229
+ * var name = rows[i].name;
230
+ * var agent = buildAgent(rows[i]);
231
+ * await orch.hydrate(name, agent);
232
+ * }
233
+ */
234
+ async function _hydrate(ctx, name, agent) {
235
+ guardAgentRegistry.validate({ kind: "register", name: name, agentKind: "hydrate" }, {});
236
+ if (!agent || typeof agent !== "object") {
237
+ throw new AgentOrchestratorError("agent-orchestrator/bad-agent",
238
+ "hydrate: agent object required");
239
+ }
240
+ var row = await ctx.backend.get(name);
241
+ if (!row) {
242
+ throw new AgentOrchestratorError("agent-orchestrator/not-in-registry",
243
+ "hydrate: '" + name + "' not in registry backend — call register() first");
244
+ }
245
+ if (ctx.liveAgents.has(name)) {
246
+ throw new AgentOrchestratorError("agent-orchestrator/already-hydrated",
247
+ "hydrate: '" + name + "' already has a live agent ref in this process");
248
+ }
249
+ ctx.liveAgents.set(name, agent);
250
+ _safeAudit(ctx, "agent.orchestrator.hydrated", null, {
251
+ name: name, agentKind: row.kind, tenantId: row.tenantId,
252
+ });
253
+ return { name: name, agentKind: row.kind };
254
+ }
255
+
145
256
  // ---- Registry -------------------------------------------------------------
146
257
 
147
258
  async function _register(ctx, name, agent, regOpts) {
@@ -310,11 +421,41 @@ function _spawnSingleConsumer(ctx, agent, queue, topic, maxConcurrency) {
310
421
  * var shard = b.agent.orchestrator.shardFor("tenant-acme", 8);
311
422
  * // → integer in [0, 8)
312
423
  */
424
+ function _saltedFnvBasis() {
425
+ // SUBSTRATE-20 — salt FNV-1a offset basis with the vault master so
426
+ // an attacker can't engineer tenantIds that all hash to one shard.
427
+ // Vault-less path (single-process tests / dev) falls back to the
428
+ // standard FNV offset basis; production deployments with vault
429
+ // initialized get the salted variant for free.
430
+ if (_saltedFnvBasisCache !== null) return _saltedFnvBasisCache;
431
+ var v;
432
+ try { v = vault(); } catch (_e) { v = null; }
433
+ if (!v || typeof v.getKeysJson !== "function") {
434
+ _saltedFnvBasisCache = 2166136261; // allow:raw-byte-literal — FNV-1a offset basis (vault-less fallback)
435
+ return _saltedFnvBasisCache;
436
+ }
437
+ var keysJson;
438
+ try { keysJson = v.getKeysJson(); }
439
+ catch (_e) {
440
+ _saltedFnvBasisCache = 2166136261; // allow:raw-byte-literal — FNV-1a offset basis (vault-init-pending fallback)
441
+ return _saltedFnvBasisCache;
442
+ }
443
+ var hashHex = bCrypto.sha3Hash(keysJson);
444
+ // Read the first 32 bits as the salt; mix into the offset basis via
445
+ // XOR so the distribution properties of FNV are preserved.
446
+ var saltBuf = Buffer.from(hashHex.slice(0, 8), "hex"); // allow:raw-byte-literal — 32-bit prefix of SHA3-512 hex (4 bytes = 8 hex chars)
447
+ var salt = saltBuf.readUInt32BE(0);
448
+ _saltedFnvBasisCache = ((2166136261 ^ salt) >>> 0); // allow:raw-byte-literal — FNV-1a offset basis (vault-salted)
449
+ return _saltedFnvBasisCache;
450
+ }
451
+
313
452
  function shardFor(shardKey, shards) {
314
453
  if (typeof shardKey !== "string" || shardKey.length === 0) return 0;
315
454
  if (shards <= 1) return 0;
316
- // FNV-1a 32-bit — fast + good distribution for short keys.
317
- var h = 2166136261; // allow:raw-byte-literal FNV-1a offset basis
455
+ // FNV-1a 32-bit — fast + good distribution for short keys; salted
456
+ // offset basis defends algorithmic-complexity DoS via attacker-
457
+ // chosen tenantIds. See _saltedFnvBasis above.
458
+ var h = _saltedFnvBasis();
318
459
  for (var i = 0; i < shardKey.length; i += 1) {
319
460
  h ^= shardKey.charCodeAt(i);
320
461
  h = (h * 16777619) >>> 0; // allow:raw-byte-literal — FNV-1a prime
@@ -337,13 +478,21 @@ async function _elect(ctx, args) {
337
478
  if (!isClusterMode) {
338
479
  // Single-process trivial leader.
339
480
  var elec = { isLeader: true, fencingToken: 1, resource: args.resource };
340
- ctx.elections.set(args.resource, elec);
481
+ if (ctx.cacheElections) ctx.elections.set(args.resource, elec);
341
482
  _safeAudit(ctx, "agent.orchestrator.elected", args.actor, {
342
483
  resource: args.resource, mode: "single-process",
343
484
  });
344
485
  return elec;
345
486
  }
346
- // Cluster mode: query current leader state via b.cluster.
487
+ // SUBSTRATE-9 — cluster mode: ALWAYS query truth from b.cluster.
488
+ // The onTransition handler installed in create() invalidates the
489
+ // cache on every lease event, so a cache hit here is safe (it
490
+ // means no lease event has fired since the last query). But the
491
+ // cache hit MUST be invalidated by a transition first; we never
492
+ // return stale isLeader:true after a lease-lost without re-asking.
493
+ if (ctx.cacheElections && ctx.elections.has(args.resource)) {
494
+ return ctx.elections.get(args.resource);
495
+ }
347
496
  var leaderRow = null;
348
497
  try { leaderRow = await ctx.cluster.currentLeader(); } catch (_e) { leaderRow = null; }
349
498
  var amLeader = false;
@@ -358,7 +507,7 @@ async function _elect(ctx, args) {
358
507
  resource: args.resource,
359
508
  leaderId: leaderRow && leaderRow.nodeId ? leaderRow.nodeId : null,
360
509
  };
361
- ctx.elections.set(args.resource, elec2);
510
+ if (ctx.cacheElections) ctx.elections.set(args.resource, elec2);
362
511
  _safeAudit(ctx, "agent.orchestrator.elected", args.actor, {
363
512
  resource: args.resource, mode: "cluster",
364
513
  amLeader: amLeader, leaderId: elec2.leaderId,
@@ -373,20 +522,122 @@ async function _drain(ctx, args) {
373
522
  var timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : DEFAULT_DRAIN_TIMEOUT_MS;
374
523
  var drained = 0;
375
524
  var startedAt = Date.now();
376
- // Stop every spawned consumer + collect timing.
525
+ var perConsumerMs = ctx.perConsumerStopMs;
526
+ // SUBSTRATE-8 — drain phases:
527
+ // 1. set ctx.draining so streams emit drain-markers + new task
528
+ // dispatches refuse (consumers re-check on every envelope).
529
+ // 2. stop each consumer with BUG-6 per-consumer timeout race —
530
+ // one hung consumer can't block the full drain budget.
531
+ // 3. quiesce in-flight: poll outbox.pendingCount + sagaInFlightCount
532
+ // until 0 OR remaining-budget-ms elapses.
533
+ // 4. flush pubsub if operator wired it (delivers buffered events).
534
+ // ---- Phase 2: stop consumers (each capped) ----
377
535
  for (var i = 0; i < ctx.spawnedConsumers.length; i += 1) {
536
+ var remaining = timeoutMs - (Date.now() - startedAt);
537
+ if (remaining <= 0) break;
378
538
  var c = ctx.spawnedConsumers[i];
379
- try { await c.stop(); drained += 1; } catch (_e) { /* best-effort */ }
380
- if (Date.now() - startedAt > timeoutMs) break;
539
+ var consumerBudget = Math.min(perConsumerMs, remaining);
540
+ try {
541
+ await _raceTimeout(c.stop(), consumerBudget,
542
+ "consumer '" + c.topic + "' stop");
543
+ drained += 1;
544
+ } catch (e) {
545
+ _safeAudit(ctx, "agent.orchestrator.consumer_stop_timeout", null, {
546
+ topic: c.topic, budgetMs: consumerBudget,
547
+ reason: (e && e.message) || String(e),
548
+ });
549
+ // Continue with next consumer — one hung shouldn't strand the
550
+ // rest. The hung work will be reaped at process exit.
551
+ }
552
+ }
553
+
554
+ // ---- Phase 3: quiesce in-flight work ----
555
+ var inFlightQuiescent = await _quiesceInFlight(ctx, startedAt, timeoutMs);
556
+
557
+ // ---- Phase 4: pubsub flush (optional) ----
558
+ if (ctx.pubsubFlush) {
559
+ var flushRemaining = timeoutMs - (Date.now() - startedAt);
560
+ if (flushRemaining > 0) {
561
+ try {
562
+ await _raceTimeout(ctx.pubsubFlush(), flushRemaining, "pubsub flush");
563
+ } catch (e) {
564
+ _safeAudit(ctx, "agent.orchestrator.pubsub_flush_timeout", null, {
565
+ reason: (e && e.message) || String(e),
566
+ });
567
+ }
568
+ }
381
569
  }
570
+
382
571
  // Streams: signal each to wrap up (the streams check ctx.draining
383
572
  // and emit a drain-marker themselves; orchestrator just sets the flag).
384
573
  var streamCount = ctx.streams.size;
385
574
  _safeAudit(ctx, "agent.orchestrator.drained", null, {
386
575
  drainedConsumers: drained, totalConsumers: ctx.spawnedConsumers.length,
387
576
  streamCount: streamCount, elapsedMs: Date.now() - startedAt,
577
+ inFlightQuiescent: inFlightQuiescent,
578
+ });
579
+ return {
580
+ drained: drained,
581
+ elapsedMs: Date.now() - startedAt,
582
+ inFlightQuiescent: inFlightQuiescent,
583
+ };
584
+ }
585
+
586
+ function _raceTimeout(p, budgetMs, label) {
587
+ // Promise.race against a setTimeout. Pure-JS, no `b.safeAsync.withTimeout`
588
+ // dependency to avoid a load-time circular require. Note: a rejected
589
+ // race doesn't cancel the original promise (Node has no cancellation
590
+ // primitive); the consumer's stop() keeps running in the background
591
+ // and the orchestrator continues. This is the right behavior for
592
+ // drain — best-effort partial quiesce is better than hanging on one
593
+ // misbehaving consumer.
594
+ return new Promise(function (resolve, reject) {
595
+ var settled = false;
596
+ var t = setTimeout(function () {
597
+ if (settled) return;
598
+ settled = true;
599
+ reject(new AgentOrchestratorError("agent-orchestrator/drain-timeout",
600
+ label + " did not finish within " + budgetMs + "ms"));
601
+ }, budgetMs);
602
+ // The setTimeout is the timeout signal: when stop() never resolves,
603
+ // this timer is the ONLY event-loop work tracking drain's progress.
604
+ // Unref'ing it would let Node exit before the timer fires, leaving
605
+ // the awaiting drain() promise pending forever (process exits while
606
+ // the caller's await chain has no driver).
607
+ Promise.resolve(p).then(
608
+ function (v) { if (!settled) { settled = true; clearTimeout(t); resolve(v); } },
609
+ function (e) { if (!settled) { settled = true; clearTimeout(t); reject(e); } }
610
+ );
388
611
  });
389
- return { drained: drained, elapsedMs: Date.now() - startedAt };
612
+ }
613
+
614
+ async function _quiesceInFlight(ctx, startedAt, timeoutMs) {
615
+ // Outbox + saga in-flight quiesce loop. Polls every 50ms (cheap;
616
+ // we already paid the consumer-stop budget so this is mostly a
617
+ // few ticks of waiting for the publisher to mark in-flight rows
618
+ // 'published'). Returns true if quiescent, false on timeout.
619
+ if (!ctx.outbox && !ctx.sagaInFlightCount) return true;
620
+ while (Date.now() - startedAt < timeoutMs) {
621
+ var anyInFlight = false;
622
+ if (ctx.outbox && typeof ctx.outbox.pendingCount === "function") {
623
+ var pending;
624
+ try { pending = await ctx.outbox.pendingCount(); }
625
+ catch (_e) { pending = 0; }
626
+ if (pending > 0) anyInFlight = true;
627
+ }
628
+ if (ctx.sagaInFlightCount) {
629
+ var sagaPending;
630
+ try { sagaPending = await ctx.sagaInFlightCount(); }
631
+ catch (_e) { sagaPending = 0; }
632
+ if (sagaPending > 0) anyInFlight = true;
633
+ }
634
+ if (!anyInFlight) return true;
635
+ await new Promise(function (r) {
636
+ var t = setTimeout(r, 50); // allow:raw-byte-literal — 50ms in-flight poll interval
637
+ if (t && typeof t.unref === "function") t.unref();
638
+ });
639
+ }
640
+ return false;
390
641
  }
391
642
 
392
643
  // ---- Streams (v0.9.23 substrate hook) -------------------------------------
@@ -460,4 +711,7 @@ module.exports = {
460
711
  guards: {
461
712
  registry: guardAgentRegistry,
462
713
  },
714
+ // Test-only — flush the salted FNV basis cache so a vault reset
715
+ // between tests forces re-derivation.
716
+ _resetForTest: function () { _saltedFnvBasisCache = null; },
463
717
  };
@@ -51,16 +51,90 @@
51
51
  */
52
52
 
53
53
  var lazyRequire = require("./lazy-require");
54
+ var nodeCrypto = require("node:crypto");
54
55
  var { defineClass } = require("./framework-error");
55
56
  var guardPostureChain = require("./guard-posture-chain");
56
57
  var agentAudit = require("./agent-audit");
58
+ var safeJson = require("./safe-json");
59
+ var bCrypto = require("./crypto");
57
60
 
58
61
  var audit = lazyRequire(function () { return require("./audit"); });
62
+ var vault = lazyRequire(function () { return require("./vault"); });
59
63
 
60
64
  var AgentPostureChainError = defineClass("AgentPostureChainError", { alwaysPermanent: true });
61
65
 
62
66
  var BUILTIN_REGIMES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
63
67
 
68
+ // SUBSTRATE-10 — envelope MAC vocabulary. Cross-process envelope
69
+ // integrity: an attacker with queue / event-bus write access who
70
+ // strips postureSet to [] and re-sends a saga / sub-agent envelope
71
+ // can bypass the downgrade refusal in _validate (which only checks
72
+ // SHAPE, not authenticity). Defense is a keyed MAC over the canonical
73
+ // envelope bytes, computed at appendHop and verified at validate.
74
+ var ENVELOPE_MAC_LABEL = "blamejs.agent.postureChain/v1";
75
+ var ENVELOPE_MAC_KEY_BYTES = 32; // allow:raw-byte-literal — HMAC-SHA3-512 keyed bytes
76
+ // SUBSTRATE-21 — hop count cap defends infinite recursion across
77
+ // agent delegation. 16 is the spec default; operators can lower via
78
+ // opts.maxHopCount but never raise (audit fan-out without a cap is a
79
+ // DoS class).
80
+ var DEFAULT_MAX_HOP_COUNT = 16; // allow:raw-byte-literal — hop count cap
81
+ var _macKeyCache = null; // memoized per-vault-master key
82
+
83
+ function _resolveMacKey() {
84
+ // Lazy derivation keyed off the vault master. Operator rotating
85
+ // vault keys invalidates every in-flight MAC — desired property.
86
+ // Memoization is process-local; if vault rotates within the same
87
+ // process the operator restarts (vault rotation already implies it).
88
+ if (_macKeyCache) return _macKeyCache;
89
+ var v;
90
+ try { v = vault(); } catch (_e) { v = null; }
91
+ if (!v || typeof v.getKeysJson !== "function") {
92
+ throw new AgentPostureChainError("agent-posture-chain/vault-not-initialized",
93
+ "envelope MAC: vault must be initialized before posture-chain envelopes can be authenticated " +
94
+ "(operator wires b.vault.init() at boot)");
95
+ }
96
+ var keysJson;
97
+ try { keysJson = v.getKeysJson(); }
98
+ catch (e) {
99
+ throw new AgentPostureChainError("agent-posture-chain/vault-not-initialized",
100
+ "envelope MAC: vault.getKeysJson threw — " + (e && e.message ? e.message : String(e)));
101
+ }
102
+ var rootBytes = Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
103
+ var input = Buffer.concat([
104
+ Buffer.from(ENVELOPE_MAC_LABEL, "utf8"),
105
+ Buffer.from([0x00]),
106
+ rootBytes,
107
+ ]);
108
+ _macKeyCache = bCrypto.kdf(input, ENVELOPE_MAC_KEY_BYTES);
109
+ return _macKeyCache;
110
+ }
111
+
112
+ function _envelopeMacBytes(envelope) {
113
+ // Sign every field that downstream consumers verify off the wire,
114
+ // except the `_mac` field itself. SUBSTRATE-21 — also includes
115
+ // hopCount + chainTrail so a hostile rewriter can't roll back the
116
+ // trail to evade the cap.
117
+ var payload = {
118
+ postureSet: Array.isArray(envelope.postureSet) ? envelope.postureSet.slice().sort() : [],
119
+ chainTrail: Array.isArray(envelope.chainTrail) ? envelope.chainTrail.slice() : [],
120
+ enteredAt: Array.isArray(envelope.enteredAt) ? envelope.enteredAt.slice() : [],
121
+ hopCount: typeof envelope.hopCount === "number" ? envelope.hopCount : 0,
122
+ };
123
+ return Buffer.from(safeJson.canonical(payload), "utf8");
124
+ }
125
+
126
+ function _signEnvelope(envelope) {
127
+ var key = _resolveMacKey();
128
+ var mac = nodeCrypto.createHmac("sha3-512", key).update(_envelopeMacBytes(envelope)).digest();
129
+ return mac.toString("base64");
130
+ }
131
+
132
+ function _verifyEnvelopeMac(envelope) {
133
+ if (typeof envelope._mac !== "string" || envelope._mac.length === 0) return false;
134
+ var expected = _signEnvelope(envelope);
135
+ return bCrypto.timingSafeEqual(envelope._mac, expected);
136
+ }
137
+
64
138
  /**
65
139
  * @primitive b.agent.postureChain.create
66
140
  * @signature b.agent.postureChain.create(opts)
@@ -84,14 +158,32 @@ function create(opts) {
84
158
  var auditImpl = opts.audit || audit();
85
159
  var declaredRegimes = Object.create(null);
86
160
  for (var i = 0; i < BUILTIN_REGIMES.length; i += 1) declaredRegimes[BUILTIN_REGIMES[i]] = true;
161
+ // allow:numeric-opt-Infinity — operator opt clamped to [1, DEFAULT_MAX_HOP_COUNT]; bad input falls back to default
162
+ var maxHopCount = typeof opts.maxHopCount === "number" && opts.maxHopCount > 0 &&
163
+ opts.maxHopCount <= DEFAULT_MAX_HOP_COUNT
164
+ ? Math.floor(opts.maxHopCount)
165
+ : DEFAULT_MAX_HOP_COUNT;
166
+ // SUBSTRATE-10 escape hatch — only operator-confirmed single-process
167
+ // unit tests should opt out of envelope MAC. Production / multi-
168
+ // process / queue-spanning deployments leave the default on; the
169
+ // gate audit-emits when bypassed so the posture is visible.
170
+ var requireMac = opts.requireMac !== false;
171
+ var ctx = {
172
+ audit: auditImpl,
173
+ maxHopCount: maxHopCount,
174
+ requireMac: requireMac,
175
+ };
87
176
  return {
88
177
  declareRegime: function (name) { return _declareRegime(declaredRegimes, name); },
89
178
  isSubset: function (targetSet, sourceSet) { return _isSubset(targetSet, sourceSet); },
90
179
  union: function () { return _union.apply(null, arguments); },
91
180
  canDelegate: function (sourceSet, targetSet, method) { return _canDelegate(sourceSet, targetSet, method, auditImpl); },
92
- appendHop: function (envelope, hopName) { return _appendHop(envelope, hopName); },
93
- validate: function (envelope, agentPostureSet) { return _validate(envelope, agentPostureSet, auditImpl); },
181
+ appendHop: function (envelope, hopName) { return _appendHop(ctx, envelope, hopName); },
182
+ validate: function (envelope, agentPostureSet) { return _validate(ctx, envelope, agentPostureSet); },
183
+ sign: function (envelope) { return _signEnvelope(envelope); },
184
+ verify: function (envelope) { return _verifyEnvelopeMac(envelope); },
94
185
  REGIMES: Object.freeze(Object.keys(declaredRegimes)),
186
+ MAX_HOP_COUNT: maxHopCount,
95
187
  AgentPostureChainError: AgentPostureChainError,
96
188
  _declaredRegimes: declaredRegimes,
97
189
  };
@@ -155,7 +247,7 @@ function _missing(targetSet, sourceSet) {
155
247
  return out;
156
248
  }
157
249
 
158
- function _appendHop(envelope, hopName) {
250
+ function _appendHop(ctx, envelope, hopName) {
159
251
  if (!envelope || typeof envelope !== "object") {
160
252
  throw new AgentPostureChainError("agent-posture-chain/bad-envelope",
161
253
  "appendHop: envelope required");
@@ -165,6 +257,19 @@ function _appendHop(envelope, hopName) {
165
257
  "appendHop: hopName must be a non-empty string");
166
258
  }
167
259
  var trail = Array.isArray(envelope.chainTrail) ? envelope.chainTrail.slice() : [];
260
+ // SUBSTRATE-21 — cap enforced BEFORE the push so the hop-cap throw
261
+ // fires consistently regardless of whether the operator inspects
262
+ // trail.length first. Cap is a hard refusal (no truncation) because
263
+ // a silently-dropped hop loses audit provenance for the call.
264
+ if (trail.length >= ctx.maxHopCount) {
265
+ agentAudit.safeAudit(ctx.audit, "agent.posture_chain.hop_cap_refused", null, {
266
+ hopName: hopName, hopCount: trail.length, maxHopCount: ctx.maxHopCount,
267
+ chainTrail: trail,
268
+ });
269
+ throw new AgentPostureChainError("agent-posture-chain/hop-cap-exceeded",
270
+ "appendHop: chain trail has " + trail.length + " hops; cap is " + ctx.maxHopCount +
271
+ " — refusing to extend (operator delegation cycle?)");
272
+ }
168
273
  trail.push(hopName);
169
274
  var enteredAt = Array.isArray(envelope.enteredAt) ? envelope.enteredAt.slice() : [];
170
275
  enteredAt.push(Date.now());
@@ -174,11 +279,61 @@ function _appendHop(envelope, hopName) {
174
279
  hopCount: trail.length,
175
280
  });
176
281
  guardPostureChain.validate(newEnvelope);
282
+ // SUBSTRATE-10 — sign at every hop. Verify-side enforces requireMac.
283
+ // ctx.requireMac=false (operator-confirmed test escape hatch) skips
284
+ // the sign so a vault-less test path still works.
285
+ if (ctx.requireMac) {
286
+ try {
287
+ newEnvelope._mac = _signEnvelope(newEnvelope);
288
+ } catch (e) {
289
+ // Vault not initialized at boot — surface the error to the
290
+ // operator. Without the MAC the envelope is unauthenticated and
291
+ // every downstream _validate would refuse it; better to refuse
292
+ // here with a clear message.
293
+ throw new AgentPostureChainError("agent-posture-chain/mac-sign-failed",
294
+ "appendHop: envelope MAC sign failed — " + (e && e.message ? e.message : String(e)) +
295
+ " — operator wires b.vault.init() before agent-posture-chain.appendHop OR " +
296
+ "passes create({ requireMac: false }) for vault-less unit tests");
297
+ }
298
+ }
177
299
  return newEnvelope;
178
300
  }
179
301
 
180
- function _validate(envelope, agentPostureSet, auditImpl) {
302
+ function _validate(ctx, envelope, agentPostureSet) {
181
303
  guardPostureChain.validate(envelope);
304
+ // SUBSTRATE-10 — MAC verification BEFORE any field-based decision so
305
+ // the wire-rewrite attack (postureSet:[] downgrade with valid SHAPE
306
+ // but no integrity binding) is refused. ctx.requireMac=false skips
307
+ // verification and emits an audit so the bypass is visible.
308
+ if (ctx.requireMac) {
309
+ if (typeof envelope._mac !== "string" || envelope._mac.length === 0) {
310
+ agentAudit.safeAudit(ctx.audit, "agent.posture_chain.unauthenticated_envelope", null, {
311
+ chainTrail: envelope.chainTrail, postureSet: envelope.postureSet,
312
+ });
313
+ throw new AgentPostureChainError("agent-posture-chain/missing-mac",
314
+ "validate: envelope is unauthenticated (no _mac field) — refusing under requireMac=true");
315
+ }
316
+ if (!_verifyEnvelopeMac(envelope)) {
317
+ agentAudit.safeAudit(ctx.audit, "agent.posture_chain.mac_verify_failed", null, {
318
+ chainTrail: envelope.chainTrail, postureSet: envelope.postureSet,
319
+ });
320
+ throw new AgentPostureChainError("agent-posture-chain/mac-verify-failed",
321
+ "validate: envelope MAC verification failed — bytes tampered, " +
322
+ "chain trail rewritten, or signed under a different vault keypair");
323
+ }
324
+ } else {
325
+ agentAudit.safeAudit(ctx.audit, "agent.posture_chain.mac_skipped", null, {
326
+ chainTrail: envelope.chainTrail,
327
+ });
328
+ }
329
+ // SUBSTRATE-21 — hop cap also enforced at validate-time. A hostile
330
+ // envelope might arrive with hopCount > cap if a prior hop's
331
+ // requireMac was off; refuse here regardless.
332
+ if (Array.isArray(envelope.chainTrail) && envelope.chainTrail.length > ctx.maxHopCount) {
333
+ throw new AgentPostureChainError("agent-posture-chain/hop-cap-exceeded",
334
+ "validate: chain trail length " + envelope.chainTrail.length +
335
+ " exceeds cap " + ctx.maxHopCount);
336
+ }
182
337
  // The source (envelope) carries a postureSet; the target (the agent
183
338
  // we're entering) declares its own posture set. Target must be a
184
339
  // superset of source — i.e., the agent covers every regime the
@@ -186,7 +341,7 @@ function _validate(envelope, agentPostureSet, auditImpl) {
186
341
  if (Array.isArray(agentPostureSet)) {
187
342
  if (!_isSubset(agentPostureSet, envelope.postureSet)) {
188
343
  var missing = _missing(agentPostureSet, envelope.postureSet);
189
- agentAudit.safeAudit(auditImpl, "agent.posture_chain.downgrade_refused", null, {
344
+ agentAudit.safeAudit(ctx.audit, "agent.posture_chain.downgrade_refused", null, {
190
345
  sourceSet: envelope.postureSet, targetSet: agentPostureSet, missing: missing,
191
346
  chainTrail: envelope.chainTrail,
192
347
  });
@@ -201,8 +356,11 @@ function _validate(envelope, agentPostureSet, auditImpl) {
201
356
  module.exports = {
202
357
  create: create,
203
358
  BUILTIN_REGIMES: BUILTIN_REGIMES,
359
+ MAX_HOP_COUNT: DEFAULT_MAX_HOP_COUNT,
204
360
  AgentPostureChainError: AgentPostureChainError,
205
361
  guards: {
206
362
  chain: guardPostureChain,
207
363
  },
364
+ // Test-only — flush the memoized MAC key after a vault reset.
365
+ _resetForTest: function () { _macKeyCache = null; },
208
366
  };