@blamejs/core 0.13.41 → 0.13.42

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.42 (2026-05-29) — **S/MIME trust-chain validation binds the leaf to the key that verified the signature.** When b.mail.crypto.smime.verify is given trust anchors, it validates the supplied certificate chain — but it picked the chain leaf unconditionally (the first cert) and never tied it to signerPublicKey, the key that actually verified the signature. A SignedData blob could therefore carry a validly-chained certificate for one identity while the signature was verified under an unrelated key, and the chain-validated result would imply a cert↔signer binding the code never made. Chain validation now selects the leaf as the certificate whose public key matches signerPublicKey, and refuses (signer-not-in-chain) when no certificate in the chain carries that key — so a chain-validated signature is bound to the cert it claims. **Security:** *Trust-chain leaf is bound to the verified signer key* — `smime.verify({ trustAnchorCertsPem })` validated the supplied chain starting from `chain[0]` without checking that the leaf's public key was the one that verified the signature (the operator-supplied `signerPublicKey`). A crafted `SignedData` could pair a validly-chained certificate for identity A with a signature verified under an unrelated key, and the chain-valid result would assert a binding that didn't hold. The chain walk now selects the leaf as the certificate whose public key equals `signerPublicKey` (matched against the certificate's SPKI, raw key or full-encoding form), and throws `mail-crypto/smime/signer-not-in-chain` when no certificate in `SignedData.certificates` carries that key. A certificate whose key cannot be extracted is treated as a non-match, so validation fails closed rather than trusting an unverifiable binding.
12
+
11
13
  - v0.13.41 (2026-05-29) — **Agent registry reads can be tenant-scoped; compliance-erasure docs clarify actor is an audit field.** The agent orchestrator's registry reads (list and lookup) gated only on the flat agent-registry:read scope, so any holder could enumerate every tenant's agents and resolve a handle to one — even though the event bus already scopes subscribe and delivery by tenant. The orchestrator now mirrors that: with the new tenantScope option enabled, list returns only the actor's own tenant's agents and lookup refuses a cross-tenant name, unless the actor holds the framework cross-tenant-admin scope. Off by default, so single-tenant deployments are unaffected. Separately, the subject-erasure docs now state explicitly that the recorded actor is an audit field, not authentication — the caller must be authorized upstream. **Added:** *Tenant-scoped agent registry reads (opts.tenantScope)* — `b.agent.orchestrator.create({ tenantScope: true })` now scopes `list` and `lookup` to the calling actor's tenant: `list` filters out agents in other tenants and `lookup` returns null for a cross-tenant name, unless the actor holds the cross-tenant-admin scope (`b.agent.tenant.CROSS_TENANT_ADMIN_SCOPE`). This closes a cross-tenant metadata-enumeration and handle-acquisition path — `agent-registry:read` alone no longer exposes other tenants' agents — and mirrors the tenant scoping the event bus enforces on subscribe and delivery. The option defaults off; existing single-tenant orchestrators behave exactly as before. The `tenantId` argument to `list` remains a convenience filter, distinct from this authorization boundary. **Changed:** *Subject-erasure docs clarify the actor is an audit field, not authentication* — `b.subject.erase` and `b.subject.eraseHard` gate the deletion on operator acknowledgements and the legal-hold registry, not on caller identity. Their documentation now states explicitly that the recorded `actor` is an audit-record field, not authentication — the caller MUST be authenticated and authorized by the route before invoking. No behavior change; this removes an implicit assumption that could otherwise be read as the primitive authorizing the call.
12
14
 
13
15
  - v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
@@ -386,6 +386,25 @@ function _verifySignerInfo(si, msgBytes, signerPublicKey, auditHandle) {
386
386
  return { sigAlg: sigAlg, digestAlg: digestAlg };
387
387
  }
388
388
 
389
+ // Does this cert's public key match the raw signer key that verified the
390
+ // signature? signerBytes is the key passed to the PQC/classical verify; a
391
+ // cert exposes its key as SPKI DER. The raw subjectPublicKey is the tail of
392
+ // the SPKI (its last element), so a suffix match catches the raw-key form
393
+ // while a full-length compare catches a caller who passed the SPKI itself.
394
+ // If the key can't be exported (an algorithm this Node build can't parse),
395
+ // the cert can't be bound — return false so the caller fails closed rather
396
+ // than trusting an unverifiable binding.
397
+ function _certKeyMatches(cert, signerBytes) {
398
+ var spki;
399
+ try { spki = Buffer.from(cert.publicKey.export({ format: "der", type: "spki" })); }
400
+ catch (_e) { return false; }
401
+ if (spki.length === signerBytes.length) return Buffer.compare(spki, signerBytes) === 0;
402
+ if (signerBytes.length < spki.length) {
403
+ return Buffer.compare(spki.subarray(spki.length - signerBytes.length), signerBytes) === 0;
404
+ }
405
+ return false;
406
+ }
407
+
389
408
  function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle) {
390
409
  if (sd.certificates.length === 0) {
391
410
  throw new MailCryptoError("mail-crypto/smime/no-certs",
@@ -411,13 +430,24 @@ function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle
411
430
  "trustAnchorCertsPem[" + idx + "] parse failed: " + ((e && e.message) || String(e)));
412
431
  }
413
432
  });
414
- // Pick the leaf the cert whose public key matches the verified
415
- // signature. signerPublicKey is the PQC raw bytes; we compare
416
- // against each chain cert's exported jwk x / SPKI. Hardest path:
417
- // PQC isn't in node:crypto X509Certificate yet, so the leaf might
418
- // be ECDSA / RSA. Fall back to picking the first cert when no
419
- // other comparison applies (operator's chain is operator-curated).
420
- var leaf = chain[0];
433
+ // Bind the leaf to the key that ACTUALLY verified the signature
434
+ // (signerPublicKey). Without this, a validly-chained certificate for a
435
+ // DIFFERENT identity would pass chain validation while the signature was
436
+ // verified under an unrelated key chainVerified would assert a binding
437
+ // the code never made. Find the chain cert whose public key matches
438
+ // signerPublicKey; if none does, the supplied chain does not correspond
439
+ // to the verified signer, so fail closed.
440
+ var signerBytes = Buffer.from(signerPublicKey);
441
+ var leaf = null;
442
+ for (var lci = 0; lci < chain.length; lci += 1) {
443
+ if (_certKeyMatches(chain[lci], signerBytes)) { leaf = chain[lci]; break; }
444
+ }
445
+ if (!leaf) {
446
+ throw new MailCryptoError("mail-crypto/smime/signer-not-in-chain",
447
+ "trust-chain validation: no certificate in SignedData.certificates carries the " +
448
+ "public key that verified the signature — the supplied chain does not correspond " +
449
+ "to the verified signer");
450
+ }
421
451
  // Validity window check (RFC 5280 §4.1.2.5) — every cert in chain
422
452
  // must be within validFrom..validTo at the current wall-clock.
423
453
  var nowMs = Date.now();
@@ -444,8 +474,8 @@ function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle
444
474
  if (current.issuer === r.subject) {
445
475
  try {
446
476
  if (current.verify(r.publicKey)) {
447
- void signerPublicKey; void auditHandle;
448
- return; // chain validates
477
+ void auditHandle;
478
+ return; // chain validates (and the leaf is the verified signer)
449
479
  }
450
480
  } catch (_e) { /* fall through to next root */ }
451
481
  }
@@ -787,4 +817,8 @@ module.exports = {
787
817
  ALLOWED_HASHES: ALLOWED_HASHES,
788
818
  REFUSED_HASHES: REFUSED_HASHES,
789
819
  RSA_MIN_BITS: RSA_MIN_BITS,
820
+ // Exposed for tests — the leaf↔signer-key binding used by trust-chain
821
+ // validation (a cert matches the verified signer key iff its SPKI public
822
+ // key equals, or has as a suffix, the raw signer key bytes).
823
+ _certKeyMatches: _certKeyMatches,
790
824
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.41",
3
+ "version": "0.13.42",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:857f3ba9-146d-4174-ba61-fed4e89f9c16",
5
+ "serialNumber": "urn:uuid:57bde5fd-6321-44f5-9f42-0bb7c66aa56f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T18:38:58.148Z",
8
+ "timestamp": "2026-05-29T19:13:48.640Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.41",
22
+ "bom-ref": "@blamejs/core@0.13.42",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.41",
25
+ "version": "0.13.42",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.41",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.42",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.41",
57
+ "ref": "@blamejs/core@0.13.42",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]