@blamejs/core 0.9.49 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -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 +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
@@ -138,6 +138,14 @@ function _makeIterator(ctx) {
138
138
  var done = false;
139
139
  var closed = false;
140
140
  var drained = false;
141
+ // SUBSTRATE-14 — track the cursor of the LAST row actually yielded
142
+ // to the consumer. The prior shape called cursor.lastSeenCursor()
143
+ // at drain-marker emit, which returned the position of the last
144
+ // FETCHED batch — clients resuming from that cursor SKIPPED every
145
+ // row still in `buffer` that hadn't been yielded yet. Now we record
146
+ // the per-row cursor at yield time so the marker carries the
147
+ // correct resume point.
148
+ var lastYieldedCursor = null;
141
149
  _safeAudit(ctx.audit, "agent.stream.opened", ctx.actor, { kind: ctx.kind, streamId: streamId });
142
150
 
143
151
  async function _closeOnce(reason) {
@@ -159,6 +167,13 @@ function _makeIterator(ctx) {
159
167
  try {
160
168
  if (buffer.length > 0) {
161
169
  var row = buffer.shift();
170
+ // SUBSTRATE-14 — record the cursor for this yielded row so a
171
+ // drain that fires BETWEEN buffered yields emits a marker
172
+ // whose lastSeenCursor matches what the client actually
173
+ // received. The cursor extraction shape mirrors the
174
+ // operator's `cursor.lastSeenCursor()` contract: row carries
175
+ // `_cursor` OR the cursor object exposes a per-row helper.
176
+ lastYieldedCursor = _resumeCursorFor(row, lastYieldedCursor);
162
177
  return { value: row, done: false };
163
178
  }
164
179
  if (done) {
@@ -171,14 +186,18 @@ function _makeIterator(ctx) {
171
186
  drained = true;
172
187
  var marker = {
173
188
  _drainMarker: true,
174
- lastSeenCursor: cursor && typeof cursor.lastSeenCursor === "function"
175
- ? cursor.lastSeenCursor() : null,
189
+ lastSeenCursor: lastYieldedCursor,
176
190
  reason: "drain",
177
191
  };
178
192
  _safeAudit(ctx.audit, "agent.stream.drain_marker_emitted", ctx.actor, {
179
193
  kind: ctx.kind, streamId: streamId,
194
+ bufferedRowsDropped: buffer.length,
195
+ lastSeenCursor: lastYieldedCursor,
180
196
  });
181
197
  done = true;
198
+ // Discard any buffered rows so the consumer doesn't see
199
+ // post-drain rows after the marker.
200
+ buffer.length = 0;
182
201
  return { value: marker, done: false };
183
202
  }
184
203
  await _closeOnce("drain");
@@ -204,6 +223,7 @@ function _makeIterator(ctx) {
204
223
  }
205
224
  // Push all but the first into the buffer; return the first.
206
225
  for (var i = 1; i < rows.length; i += 1) buffer.push(rows[i]);
226
+ lastYieldedCursor = _resumeCursorFor(rows[0], lastYieldedCursor);
207
227
  return { value: rows[0], done: false };
208
228
  } catch (e) {
209
229
  // Any error closes the cursor + emits an audit. Re-throw to
@@ -228,6 +248,18 @@ function _safeAudit(auditImpl, action, actor, metadata) {
228
248
  agentAudit.safeAudit(auditImpl, action, actor, metadata);
229
249
  }
230
250
 
251
+ // SUBSTRATE-14 — resolve the resume cursor for a row about to be
252
+ // yielded. Operators may attach the cursor per-row (`row._cursor` /
253
+ // `row.cursor`) OR rely on the cursor's own per-row tracker
254
+ // (`cursor.cursorForRow(row)`) — both shapes supported.
255
+ function _resumeCursorFor(row, fallback) {
256
+ if (row && typeof row === "object") {
257
+ if (row._cursor != null) return row._cursor;
258
+ if (row.cursor != null) return row.cursor;
259
+ }
260
+ return fallback;
261
+ }
262
+
231
263
  module.exports = {
232
264
  create: create,
233
265
  AgentStreamError: AgentStreamError,
@@ -58,11 +58,22 @@ var agentAudit = require("./agent-audit");
58
58
 
59
59
  var audit = lazyRequire(function () { return require("./audit"); });
60
60
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
61
+ var vault = lazyRequire(function () { return require("./vault"); });
61
62
 
62
63
  var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
63
64
 
64
65
  var CROSS_TENANT_ADMIN_SCOPE = "framework-cross-tenant-admin";
65
66
 
67
+ // Per-tenant key derivation domain separators. NIST SP 800-108 r1 §5.1
68
+ // KDF-in-Counter shape — fixed "label" + tenantId-as-salt + purpose-as-
69
+ // info. Operator passphrase rotation produces a fresh master, breaking
70
+ // every prior tenant ciphertext (operator intent: rotation = re-seal).
71
+ var TENANT_KDF_LABEL = "blamejs.agent.tenant/v1";
72
+ // 32 bytes — XChaCha20-Poly1305 key length. Distinct from the audit
73
+ // truncation buffer so future key-length bumps don't have to chase a
74
+ // magic constant.
75
+ var TENANT_KEY_BYTES = 32; // allow:raw-byte-literal — XChaCha20-Poly1305 key length (256 bits)
76
+
66
77
  /**
67
78
  * @primitive b.agent.tenant.create
68
79
  * @signature b.agent.tenant.create(opts)
@@ -111,8 +122,8 @@ function create(opts) {
111
122
  sealField: function (tenantId, table, field, plaintext) { return _sealField(tenantId, table, field, plaintext); },
112
123
  unsealField: function (tenantId, table, field, ciphertext) { return _unsealField(tenantId, table, field, ciphertext); },
113
124
  sealRowForTenant: function (tenantId, table, row) { return _sealRowForTenant(tenantId, table, row); },
114
- unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(tenantId, table, row); },
115
- listArchived: function () { var out = []; ctx.archive.forEach(function (v) { out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy }); }); return out; },
125
+ unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(ctx, tenantId, table, row); },
126
+ listArchived: function () { return _listArchived(ctx); },
116
127
  CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
117
128
  AgentTenantError: AgentTenantError,
118
129
  _ctx: ctx,
@@ -160,14 +171,40 @@ async function _unregister(ctx, tenantId, args) {
160
171
  }
161
172
  // Archive default — retain the key + metadata for retention-mandated
162
173
  // restoration. Operator's compliance regime drives archivePolicy.
174
+ // SUBSTRATE-19 — persist as a `status: "archived"` row in the same
175
+ // backend rather than only the process-local Map. GDPR Art. 17 +
176
+ // HIPAA §164.530(j) require the archived state to survive process
177
+ // restart (auditor pulls a deleted tenant's archival record years
178
+ // later); a Map-only archive evaporated on every redeploy.
179
+ var archivedRow = {
180
+ tenantId: tenantId,
181
+ posture: row.posture,
182
+ archivePolicy: row.archivePolicy || "default-archive",
183
+ metadata: row.metadata,
184
+ registeredAt: row.registeredAt,
185
+ archivedAt: Date.now(),
186
+ status: "archived",
187
+ };
163
188
  ctx.archive.set(tenantId, {
164
- tenantId: tenantId, archivedAt: Date.now(),
165
- policy: row.archivePolicy || "default-archive",
166
- row: row,
189
+ tenantId: tenantId, archivedAt: archivedRow.archivedAt,
190
+ policy: archivedRow.archivePolicy, row: row,
167
191
  });
192
+ // Two-phase: persist the archived row first, then delete the live
193
+ // row. If the persist fails, the live row stays and the operator
194
+ // can retry; the inverse (delete-then-persist) loses the row on
195
+ // a backend hiccup. Operators wiring a durable backend get
196
+ // restart-survival for free.
197
+ if (typeof ctx.backend.archive === "function") {
198
+ await ctx.backend.archive(tenantId, archivedRow);
199
+ } else {
200
+ // Backends without a dedicated archive() API store the archived
201
+ // row under a sentinel-prefixed key so list() can find it and
202
+ // lookup() (which checks status) refuses live-row access.
203
+ await ctx.backend.set("__archived__/" + tenantId, archivedRow);
204
+ }
168
205
  await ctx.backend.delete(tenantId);
169
206
  agentAudit.safeAudit(ctx.audit, "agent.tenant.archived", args.actor, {
170
- tenantId: tenantId, policy: row.archivePolicy,
207
+ tenantId: tenantId, policy: archivedRow.archivePolicy,
171
208
  });
172
209
  return { tenantId: tenantId, mode: "archived" };
173
210
  }
@@ -187,7 +224,14 @@ async function _lookup(ctx, tenantId, args) {
187
224
 
188
225
  async function _list(ctx, args) {
189
226
  var rows = await ctx.backend.list();
190
- return rows.map(function (r) {
227
+ return rows.filter(function (r) {
228
+ // Skip archived rows from the live list; listArchived surfaces
229
+ // them separately. Backends without an `archive()` API stored the
230
+ // archived row under "__archived__/<tenantId>" via the fallback;
231
+ // the row's status === "archived" sentinel is the canonical
232
+ // discriminator regardless of backend.
233
+ return r && r.status !== "archived";
234
+ }).map(function (r) {
191
235
  return {
192
236
  tenantId: r.tenantId,
193
237
  posture: r.posture,
@@ -197,6 +241,51 @@ async function _list(ctx, args) {
197
241
  });
198
242
  }
199
243
 
244
+ async function _listArchived(ctx) {
245
+ // Prefer the operator's `listArchived()` when available — durable
246
+ // backends that need an index can implement it cheaper than walking
247
+ // every row. Fall back to scanning list() for `status: "archived"`
248
+ // rows (the in-memory + sentinel-prefix path).
249
+ var out = [];
250
+ if (typeof ctx.backend.listArchived === "function") {
251
+ var rows = await ctx.backend.listArchived();
252
+ if (Array.isArray(rows)) {
253
+ for (var i = 0; i < rows.length; i += 1) {
254
+ out.push({
255
+ tenantId: rows[i].tenantId,
256
+ archivedAt: rows[i].archivedAt,
257
+ policy: rows[i].archivePolicy || rows[i].policy || "default-archive",
258
+ });
259
+ }
260
+ }
261
+ } else {
262
+ var allRows = await ctx.backend.list();
263
+ if (Array.isArray(allRows)) {
264
+ for (var j = 0; j < allRows.length; j += 1) {
265
+ var r = allRows[j];
266
+ if (r && r.status === "archived") {
267
+ out.push({
268
+ tenantId: r.tenantId,
269
+ archivedAt: r.archivedAt,
270
+ policy: r.archivePolicy || "default-archive",
271
+ });
272
+ }
273
+ }
274
+ }
275
+ }
276
+ // Process-local cache (set on archive() in this process) for the
277
+ // common case of `archive→listArchived` within one boot before the
278
+ // backend list() index catches up.
279
+ ctx.archive.forEach(function (v) {
280
+ var found = false;
281
+ for (var k = 0; k < out.length; k += 1) {
282
+ if (out[k].tenantId === v.tenantId) { found = true; break; }
283
+ }
284
+ if (!found) out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy });
285
+ });
286
+ return out;
287
+ }
288
+
200
289
  // ---- Cross-tenant gate ----------------------------------------------------
201
290
 
202
291
  function _check(ctx, actor, agentTenantId) {
@@ -231,18 +320,78 @@ function _check(ctx, actor, agentTenantId) {
231
320
  }
232
321
 
233
322
  // ---- Per-tenant derived key -----------------------------------------------
323
+ //
324
+ // SUBSTRATE-5 — `namespaceHash(label, tenantId)` is a PUBLIC function
325
+ // over PUBLIC inputs; an attacker who learns `tenantId` (an account id
326
+ // surfaced in URLs / API responses) reconstructs every per-tenant key
327
+ // without any secret material. The defense the docstring promises —
328
+ // "cross-tenant decrypt refused at the vault boundary" — never bound
329
+ // to anything secret.
330
+ //
331
+ // NIST SP 800-108 r1 §5.1 (KDF in Counter / Feedback Mode): a key
332
+ // derivation function MUST consume a secret KDK (Key Derivation Key)
333
+ // alongside the public label + context. GDPR Art. 32 (Security of
334
+ // processing) requires "the pseudonymisation and encryption of
335
+ // personal data" — keys derived purely from public per-record
336
+ // identifiers are pseudonymisation-only, not encryption.
337
+ //
338
+ // New shape: SHAKE256(label || rootKey || tenantId || purpose), where
339
+ // rootKey is SHA3-512(vault.getKeysJson()). Same derivation `b.vault.aad`
340
+ // uses internally — the vault's master keypair PEM is the secret KDK.
341
+ // Rotating the vault passphrase / keypair (b.vaultRotate.rotate)
342
+ // changes rootKey, which changes every derived tenant key — operator
343
+ // intent for rotation IS re-seal.
344
+ //
345
+ // `derivedKey` returns a hex-encoded 32-byte key (64 chars) to keep
346
+ // the wire shape compatible with prior callers. Internal callers that
347
+ // need the raw key use `_tenantFieldKey` directly.
234
348
 
235
- function _derivedKey(tenantId, purpose) {
349
+ function _vaultRootBytes() {
350
+ // Vault.getKeysJson() throws when the vault hasn't been init'd. That
351
+ // is the right secure-by-default posture: tenant-derived keys cannot
352
+ // be produced before the operator has bootstrapped the vault. The
353
+ // error reaches the caller (sealField / register) so an operator
354
+ // mis-ordering boot (start agents before vault.init) sees a clear
355
+ // refusal rather than getting weakened-but-deterministic keys.
356
+ var keysJson;
357
+ try { keysJson = vault().getKeysJson(); }
358
+ catch (e) {
359
+ throw new AgentTenantError("agent-tenant/vault-not-initialized",
360
+ "derivedKey: vault must be initialized before per-tenant keys can be " +
361
+ "derived (vault.getKeysJson threw: " + (e && e.message ? e.message : String(e)) + ")");
362
+ }
363
+ return Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
364
+ }
365
+
366
+ function _deriveTenantKeyBytes(tenantId, purpose) {
236
367
  guardTenantId.validate(tenantId);
237
368
  if (typeof purpose !== "string" || purpose.length === 0) {
238
369
  throw new AgentTenantError("agent-tenant/bad-purpose",
239
370
  "derivedKey: purpose required (e.g. 'seal' / 'audit' / 'session')");
240
371
  }
241
- // Composes b.crypto.namespaceHash for deterministic per-tenant key
242
- // derivation. Cross-tenant decrypt is refused at the vault boundary
243
- // because each tenant's seal-key derivation differs — even with
244
- // disk access an attacker can't cross-decrypt.
245
- return bCrypto.namespaceHash("agent.tenant.derive." + purpose, tenantId);
372
+ // Domain-separated KDF input. NUL separators between fields prevent
373
+ // (label, tenantId="x\0y", purpose="z") colliding with
374
+ // (label, tenantId="x", purpose="y\0z") same byte concatenation,
375
+ // different logical context.
376
+ var rootBytes = _vaultRootBytes();
377
+ var input = Buffer.concat([
378
+ Buffer.from(TENANT_KDF_LABEL, "utf8"),
379
+ Buffer.from([0x00]),
380
+ rootBytes,
381
+ Buffer.from([0x00]),
382
+ Buffer.from(tenantId, "utf8"),
383
+ Buffer.from([0x00]),
384
+ Buffer.from(purpose, "utf8"),
385
+ ]);
386
+ return bCrypto.kdf(input, TENANT_KEY_BYTES);
387
+ }
388
+
389
+ function _derivedKey(tenantId, purpose) {
390
+ // Public API — returns hex so the existing wire shape (operators
391
+ // storing the derived key string in their DB) is unchanged. Internal
392
+ // AEAD callers use `_deriveTenantKeyBytes` directly to skip the
393
+ // hex/Buffer round-trip.
394
+ return _deriveTenantKeyBytes(tenantId, purpose).toString("hex");
246
395
  }
247
396
 
248
397
  // ---- Per-tenant audit -----------------------------------------------------
@@ -323,11 +472,11 @@ function _auditFor(ctx, tenantId) {
323
472
  var TENANT_FIELD_PREFIX = "tnt-v1:";
324
473
 
325
474
  function _tenantFieldKey(tenantId, table) {
326
- // 32-byte symmetric key for XChaCha20-Poly1305. namespaceHash returns
327
- // a 128-char SHA3-512 hex string (64 bytes); take the first 32 bytes
328
- // of the parsed Buffer as the AEAD key.
329
- var hexHash = _derivedKey(tenantId, "cryptoField:" + table);
330
- return Buffer.from(hexHash, "hex").subarray(0, 32); // allow:raw-byte-literal — XChaCha20-Poly1305 key length (256 bits)
475
+ // 32-byte symmetric key for XChaCha20-Poly1305. _deriveTenantKeyBytes
476
+ // returns the raw key bound to the vault master + tenantId + purpose
477
+ // see SUBSTRATE-5 commentary above _derivedKey for the threat
478
+ // model that drove this away from public-input-only derivation.
479
+ return _deriveTenantKeyBytes(tenantId, "cryptoField:" + table);
331
480
  }
332
481
 
333
482
  function _tenantFieldAad(tenantId, table, field) {
@@ -400,7 +549,7 @@ function _sealRowForTenant(tenantId, table, row) {
400
549
  return out;
401
550
  }
402
551
 
403
- function _unsealRowForTenant(tenantId, table, row) {
552
+ function _unsealRowForTenant(ctx, tenantId, table, row) {
404
553
  if (!row) return row;
405
554
  guardTenantId.validate(tenantId);
406
555
  var cf = cryptoField();
@@ -415,10 +564,17 @@ function _unsealRowForTenant(tenantId, table, row) {
415
564
  var f = fields[i];
416
565
  if (out[f] !== undefined && out[f] !== null) {
417
566
  try { out[f] = _unsealField(tenantId, table, f, out[f]); }
418
- catch (_e) {
419
- // Cross-tenant decrypt OR wrong-prefix null the field
420
- // and let the audit chain surface the failure. Matches the
421
- // safe-fail posture of b.cryptoField.unsealRow.
567
+ catch (e) {
568
+ // BUG-4 null-on-decrypt-failure was silent; the docstring
569
+ // promised "audit chain surfaces the failure" but no emit ever
570
+ // ran. Cross-tenant ciphertext replay / tampered row / wrong-
571
+ // prefix all hit this path; operator audit pipelines need the
572
+ // signal to alert. CWE-778 (Insufficient Logging) — defense-
573
+ // in-depth that the field nulled silently.
574
+ agentAudit.safeAudit(ctx.audit, "agent.tenant.cross_tenant_decrypt_refused", null, {
575
+ tenantId: tenantId, table: table, field: f,
576
+ reason: (e && e.message) || String(e),
577
+ });
422
578
  out[f] = null;
423
579
  }
424
580
  }
@@ -63,11 +63,25 @@
63
63
  var lazyRequire = require("./lazy-require");
64
64
  var { defineClass } = require("./framework-error");
65
65
  var guardTraceContext = require("./guard-trace-context");
66
+ var agentAudit = require("./agent-audit");
66
67
 
67
68
  var audit = lazyRequire(function () { return require("./audit"); });
68
69
 
69
70
  var AgentTraceError = defineClass("AgentTraceError", { alwaysPermanent: true });
70
71
 
72
+ // SUBSTRATE-24 — once-per-process audit emit on the first tracer
73
+ // failure each install fires. Operators get the signal even when
74
+ // individual span calls are best-effort suppressed.
75
+ var _failureAuditEmittedFor = Object.create(null);
76
+ function _emitFirstFailureAudit(auditImpl, kind, message) {
77
+ if (_failureAuditEmittedFor[kind]) return;
78
+ _failureAuditEmittedFor[kind] = true;
79
+ agentAudit.safeAudit(auditImpl, "agent.trace.tracer_failure", null, {
80
+ kind: kind, message: message ? String(message).slice(0, 256) : "", // allow:raw-byte-literal — audit-message char cap
81
+ rateLimited: "first-occurrence-only",
82
+ });
83
+ }
84
+
71
85
  /**
72
86
  * @primitive b.agent.trace.create
73
87
  * @signature b.agent.trace.create(opts)
@@ -103,18 +117,22 @@ function create(opts) {
103
117
  var auditImpl = opts.audit || audit();
104
118
 
105
119
  return {
106
- startSpan: function (name, sopts) { return _startSpan(opts.tracing, name, sopts || {}); },
120
+ startSpan: function (name, sopts) { return _startSpan(opts.tracing, name, sopts || {}, auditImpl); },
107
121
  injectIntoEnvelope: function (envelope, span) { return _injectIntoEnvelope(opts.tracing, envelope, span); },
108
122
  extractFromEnvelope: function (envelope) { return _extractFromEnvelope(envelope); },
109
- recordResult: function (span, result, error) { return _recordResult(span, result, error); },
110
- shouldSample: function (method) { return _shouldSample(sampleRate, perMethod, method); },
123
+ recordResult: function (span, result, error) { return _recordResult(span, result, error, auditImpl); },
124
+ // SUBSTRATE-17 — `shouldSample` now takes a traceId so the same
125
+ // trace gets the same decision across hops. Operator-supplied
126
+ // traceId comes from the W3C `traceparent` header at request-
127
+ // entry; absent that, falls back to Math.random (start of trace).
128
+ shouldSample: function (method, traceId) { return _shouldSample(sampleRate, perMethod, method, traceId); },
111
129
  formatAttributes: function (info) { return _formatAttributes(info); },
112
130
  AgentTraceError: AgentTraceError,
113
131
  _ctx: { sampleRate: sampleRate, perMethod: perMethod, audit: auditImpl },
114
132
  };
115
133
  }
116
134
 
117
- function _startSpan(tracing, name, sopts) {
135
+ function _startSpan(tracing, name, sopts, auditImpl) {
118
136
  if (typeof name !== "string" || name.length === 0) {
119
137
  throw new AgentTraceError("agent-trace/bad-span-name",
120
138
  "startSpan: name required");
@@ -123,19 +141,36 @@ function _startSpan(tracing, name, sopts) {
123
141
  // on the registry stack so tracing.contextHeaders() / currentSpan()
124
142
  // see it, then exposes end() so the agent boundary controls
125
143
  // lifetime across publish → consume.
126
- if (typeof tracing.manualSpan === "function") {
127
- return tracing.manualSpan(name, sopts);
128
- }
129
- // Operator passed a non-b.tracing object (operator-supplied OTel
130
- // tracer directly) — try its native startSpan. Refuse if neither.
131
- if (typeof tracing.startSpan === "function") {
132
- return tracing.startSpan(name, sopts);
144
+ try {
145
+ if (typeof tracing.manualSpan === "function") {
146
+ return tracing.manualSpan(name, sopts);
147
+ }
148
+ if (typeof tracing.startSpan === "function") {
149
+ return tracing.startSpan(name, sopts);
150
+ }
151
+ } catch (e) {
152
+ // SUBSTRATE-24 — tracer failures should not crash the agent's
153
+ // method call; surface the first failure to the audit chain
154
+ // (rate-limited) so operators get the signal.
155
+ _emitFirstFailureAudit(auditImpl, "startSpan", e && e.message);
156
+ return _noopSpan();
133
157
  }
134
158
  throw new AgentTraceError("agent-trace/bad-tracing",
135
159
  "startSpan: opts.tracing must expose manualSpan() (b.tracing.create()) " +
136
160
  "or startSpan() (raw OTel tracer); neither found");
137
161
  }
138
162
 
163
+ function _noopSpan() {
164
+ // Returned when the tracer threw — caller can still call
165
+ // recordException / setStatus / end without further errors.
166
+ return {
167
+ end: function () {},
168
+ setStatus: function () {},
169
+ recordException: function () {},
170
+ isNoop: true,
171
+ };
172
+ }
173
+
139
174
  function _injectIntoEnvelope(tracing, envelope, span) {
140
175
  if (!envelope || typeof envelope !== "object") {
141
176
  throw new AgentTraceError("agent-trace/bad-envelope",
@@ -168,32 +203,59 @@ function _extractFromEnvelope(envelope) {
168
203
  };
169
204
  }
170
205
 
171
- function _recordResult(span, result, error) {
206
+ function _recordResult(span, result, error, auditImpl) {
172
207
  if (!span || typeof span !== "object") return;
208
+ // SUBSTRATE-24 — surface first occurrence of each span-op failure
209
+ // via audit so the operator gets the signal. Subsequent failures
210
+ // stay silent (best-effort) per the operational spec.
173
211
  if (error) {
174
212
  if (typeof span.recordException === "function") {
175
- try { span.recordException(error); } catch (_e) { /* best-effort */ }
213
+ try { span.recordException(error); }
214
+ catch (e) { _emitFirstFailureAudit(auditImpl, "recordException", e && e.message); }
176
215
  }
177
216
  if (typeof span.setStatus === "function") {
178
217
  try { span.setStatus({ code: 2, message: error.message || String(error) }); }
179
- catch (_e) { /* best-effort */ }
218
+ catch (e) { _emitFirstFailureAudit(auditImpl, "setStatus", e && e.message); }
180
219
  }
181
220
  } else if (typeof span.setStatus === "function") {
182
- try { span.setStatus({ code: 1 }); } catch (_e) { /* best-effort */ }
221
+ try { span.setStatus({ code: 1 }); }
222
+ catch (e) { _emitFirstFailureAudit(auditImpl, "setStatus", e && e.message); }
183
223
  }
184
224
  if (typeof span.end === "function") {
185
- try { span.end(); } catch (_e) { /* best-effort */ }
225
+ try { span.end(); }
226
+ catch (e) { _emitFirstFailureAudit(auditImpl, "end", e && e.message); }
186
227
  }
187
228
  }
188
229
 
189
- function _shouldSample(globalRate, perMethod, method) {
230
+ // SUBSTRATE-17 deterministic sampling per W3C Trace Context §3.2.3.1.
231
+ // `Math.random` makes child-vs-parent sampling decisions non-coherent:
232
+ // a parent span sampled OUT can still have child spans sampled IN,
233
+ // producing orphaned spans operators can't correlate. Hashing the
234
+ // 16-byte trace-id deterministically routes every span in a trace to
235
+ // the same decision. When traceId is absent (start of a trace at
236
+ // request-entry boundary) we still use Math.random as the seeding
237
+ // roll; downstream callers pass the resulting traceId so children
238
+ // inherit the decision.
239
+ function _shouldSample(globalRate, perMethod, method, traceId) {
240
+ var rate = globalRate;
190
241
  if (typeof method === "string" && Object.prototype.hasOwnProperty.call(perMethod, method)) {
191
242
  var r = perMethod[method];
192
- if (typeof r === "number" && isFinite(r) && r >= 0 && r <= 1) {
193
- return Math.random() < r; // allow:math-random-noncrypto — sampling is statistical, not security-sensitive
194
- }
243
+ if (typeof r === "number" && isFinite(r) && r >= 0 && r <= 1) rate = r;
244
+ }
245
+ if (rate <= 0) return false;
246
+ if (rate >= 1) return true;
247
+ if (typeof traceId === "string" && /^[0-9a-fA-F]{32}$/.test(traceId)) {
248
+ // Use the low 32 bits of the trace-id as the sampling roll
249
+ // (W3C-compatible). Hash modulo 1e9 → divide by 1e9 puts the
250
+ // result in [0,1) deterministically.
251
+ var lo = parseInt(traceId.slice(-8), 16); // allow:raw-byte-literal — low 32 bits of trace-id hex
252
+ var roll = (lo >>> 0) / 0x100000000; // allow:raw-byte-literal — 2^32 normalization divisor
253
+ return roll < rate;
195
254
  }
196
- return Math.random() < globalRate; // allow:math-random-noncrypto sampling is statistical, not security-sensitive
255
+ // No trace-id suppliedstart of a new trace. Operators wire
256
+ // shouldSample(method, ctx.traceId) on every downstream hop so
257
+ // children inherit the decision deterministically.
258
+ return Math.random() < rate; // allow:math-random-noncrypto — start-of-trace seed only; downstream hops pass traceId for deterministic propagation
197
259
  }
198
260
 
199
261
  function _formatAttributes(info) {
@@ -215,4 +277,5 @@ module.exports = {
215
277
  guards: {
216
278
  context: guardTraceContext,
217
279
  },
280
+ _resetForTest: function () { _failureAuditEmittedFor = Object.create(null); },
218
281
  };
package/lib/auth/aal.js CHANGED
@@ -136,12 +136,19 @@ function meets(actualBand, requiredBand) {
136
136
  // access tokens with AAL info typically use `acr` / `amr` (RFC 9068
137
137
  // §3 / OpenID Connect Core §2). The framework leaves that wiring to
138
138
  // the operator — but the constants make the AMR strings consistent.
139
+ // RFC 8176 §2 — registered AMR values. Pre-v0.9.x mapped WEBAUTHN to
140
+ // `fido-u2f`; that's the OLD U2F protocol AMR. Modern WebAuthn
141
+ // authenticators use the `hwk` ("proof-of-possession of a hardware-
142
+ // secured key") AMR — the same one HARDWARE uses, since WebAuthn IS
143
+ // a hardware-cryptographic-authenticator binding. PASSKEY remains a
144
+ // distinct AMR for the synced multi-device case (operators using the
145
+ // FIDO-published "passkey" AMR can disambiguate from one-device hwk).
139
146
  var AMR = Object.freeze({
140
147
  PASSWORD: "pwd",
141
148
  PIN: "pin",
142
149
  TOTP: "otp",
143
150
  SMS: "sms",
144
- WEBAUTHN: "fido-u2f",
151
+ WEBAUTHN: "hwk",
145
152
  PASSKEY: "passkey",
146
153
  HARDWARE: "hwk",
147
154
  MTLS: "mtls",
package/lib/auth/ciba.js CHANGED
@@ -561,7 +561,12 @@ function create(opts) {
561
561
  throw new AuthError("auth-ciba/bad-notification-req",
562
562
  "ciba.parseNotification: req with headers required");
563
563
  }
564
- var authzHeader = req.headers["authorization"] || req.headers["Authorization"];
564
+ // Node's http module lowercases all incoming header names per RFC
565
+ // 7230 §3.2; the capital-A fallback is structurally dead code and a
566
+ // future-integration footgun (a middleware that bypasses
567
+ // node:http's normalization could re-introduce the unreachable
568
+ // branch). Read lowercase only.
569
+ var authzHeader = req.headers["authorization"];
565
570
  if (!authzHeader || authzHeader.indexOf("Bearer ") !== 0) {
566
571
  throw new AuthError("auth-ciba/missing-bearer",
567
572
  "ciba.parseNotification: Authorization: Bearer header missing");
package/lib/auth/dpop.js CHANGED
@@ -462,13 +462,18 @@ async function verify(proof, opts) {
462
462
  }
463
463
  }
464
464
 
465
- // nonce — when caller supplies expected nonce, payload MUST match
465
+ // nonce — when caller supplies expected nonce, payload MUST match.
466
+ // Constant-time compare (audit 2026-05-15): the nonce is a server-
467
+ // issued secret-shaped value matched against attacker-controlled
468
+ // payload bytes. RFC 9449 §8 mandates the value be unpredictable;
469
+ // a leaking compare reveals prefix bytes over many attempts. ath
470
+ // already used timingSafeEqual; nonce now matches.
466
471
  if (typeof opts.nonce === "string" && opts.nonce.length > 0) {
467
472
  if (typeof payload.nonce !== "string" || payload.nonce.length === 0) {
468
473
  throw new AuthError("auth-dpop/missing-nonce",
469
474
  "nonce expected but proof has no nonce claim");
470
475
  }
471
- if (payload.nonce !== opts.nonce) {
476
+ if (!bCrypto.timingSafeEqual(payload.nonce, opts.nonce)) {
472
477
  throw new AuthError("auth-dpop/nonce-mismatch",
473
478
  "payload.nonce does not match expected");
474
479
  }
package/lib/auth/fal.js CHANGED
@@ -121,12 +121,13 @@ function meets(actualBand, requiredBand) {
121
121
  * nonce / jti binding before back-channel can claim FAL2.
122
122
  *
123
123
  * @opts
124
- * channel: "front" | "back", // REQUIRED
125
- * encrypted: boolean, // assertion encrypted to RP
126
- * replayProtected: boolean, // nonce / jti / iat binding present
127
- * hokBinding: "mtls" | "dpop" | "saml-hok" | null,
128
- * // proof-of-possession binding present
129
- * bearerOnly: boolean, // alias for hokBinding === null
124
+ * channel: "front" | "back", // REQUIRED
125
+ * encrypted: boolean, // assertion encrypted to RP
126
+ * replayProtected: boolean, // nonce / jti / iat binding present
127
+ * backChannelAuthenticated: boolean, // back-channel transport-auth'd (mTLS / signed) — required for FAL2 over plain back-channel
128
+ * hokBinding: "mtls" | "dpop" | "saml-hok" | null,
129
+ * // proof-of-possession binding present
130
+ * bearerOnly: boolean, // alias for hokBinding === null
130
131
  *
131
132
  * @example
132
133
  * var fal = b.auth.fal.fromAssertion({
@@ -166,9 +167,17 @@ function fromAssertion(opts) {
166
167
  return FAL3;
167
168
  }
168
169
 
169
- // FAL2back-channel OR encrypted front-channel, with replay protection.
170
+ // AUTH-19FAL2 per NIST SP 800-63C-4 §5.2 requires "injection
171
+ // protection" on the back-channel: either the back-channel itself is
172
+ // encrypted-and-authenticated (mTLS / signed transport) OR the
173
+ // assertion is encrypted to the RP. A plain HTTP back-channel with
174
+ // only nonce/jti replay protection is FAL1 — `replayProtected` alone
175
+ // doesn't satisfy the §5.2 MUST. Operators using a plain
176
+ // back-channel set `backChannelAuthenticated: true` when their
177
+ // transport carries server-to-server mTLS / signed-JWT auth.
170
178
  var replaySafe = opts.replayProtected === true;
171
- if (replaySafe && (opts.channel === "back" || opts.encrypted === true)) {
179
+ var injectionProtected = opts.encrypted === true || opts.backChannelAuthenticated === true;
180
+ if (replaySafe && injectionProtected && (opts.channel === "back" || opts.encrypted === true)) {
172
181
  return FAL2;
173
182
  }
174
183