@blamejs/core 0.18.1 → 0.18.3
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 +4 -0
- package/NOTICE +1 -1
- package/README.md +1 -1
- package/lib/db-collection.js +25 -1
- package/lib/middleware/require-mtls.js +78 -2
- package/lib/mtls-ca.js +2387 -110
- package/lib/mtls-engine-default.js +77 -0
- package/lib/sql.js +7 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +8419 -7097
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.3 (2026-07-30) — **`b.mtlsCa` gains non-breaking CA algorithm-migration primitives -- rotate the CA, keep the superseded root trusted during re-enrollment, self-test that the runtime verifies the algorithm over TLS, and revoke by CA generation -- and `b.db.collection`'s `$like` operator now honours SQL `%` / `_` wildcards instead of matching them as literal characters.** b.mtlsCa can now migrate a certificate authority to a new signature algorithm without an outage. status() reports the stored CA's algorithm and key type; rotate({ generation, algorithm }) generates and atomically commits a new CA and returns the superseded certificate, without the algorithm-mismatch initCA() raises once a CA already exists; await commit({ retainPrevious: true }) (the public commit is the locked primitive — it resolves to a promise, takes the rotation lock so a direct caller cannot race a concurrent rotate/init, and refreshes the handle's algorithm pin to the committed CA like rotate() so a pinned handle that migrates algorithms via commit() stays usable) together with await loadTrustBundle() and dropRetained() keep the old root trusted through a re-enrollment grace window; canVerifyInTls() runs a loopback mutual-TLS self-test proving node:tls verifies the CA's algorithm on the running Node; and revokeGeneration(n) revokes every certificate the issuance ledger recorded under a CA generation below n. Separately, b.db.collection's $like operator emitted an escaped LIKE, so the caller's % and _ were matched as literal characters and $like collapsed to an exact match; it now emits a caller-controlled-wildcard LIKE, so find({ name: { $like: "Al%" } }) matches as SQL LIKE documents. The release also brings error and adversarial branches of the scheduler, session device-binding, body parser, notifier, and document collection under test. **Added:** *b.mtlsCa non-breaking CA algorithm-migration primitives* — A CA handle can now carry an operator through a signature-algorithm migration (for example a classical-to-post-quantum move) with no trust outage. status() reports the stored CA's algorithm and keyType (null before initCA(), and null for an EC CA whose curve is not the framework's P-384 OR whose signature digest is not SHA-384, so a custom P-256/P-521 or SHA-256/SHA-512-signed CA is not mislabeled ECDSA-P384-SHA384). An unpinned rotate({ generation }) preserves the stored CA's algorithm (it does not silently adopt the engine's post-quantum default), so advancing a generation on a classical ECDSA CA keeps issuing classical chains; changing algorithm stays explicit via rotate({ algorithm }). rotate({ generation, algorithm }) generates a new CA at a strictly higher integer generation and atomically commits it -- returning { caCertPem, previousCaCertPem, generation, algorithm } -- so an operator does not hit the algorithm-mismatch initCA() raises when a CA is already present, and the handle keeps issuing under the new algorithm afterward. A rotation also invalidates a persisted CRL (it was signed by the superseded CA), so regenerate it under the new CA with generateCrl(). The commit path gained retainPrevious (defaulted on for rotate): the outgoing certificate is snapshotted to ca.prev.crt, and await loadTrustBundle() returns [current, ...retained] so both roots are trusted while clients re-enroll (it resolves to a promise, taking the rotation lock so its snapshot is serialized with rotation and dropRetained()); dropRetained(), or rotate({ retainPrevious: false }), ends that window. There is one retained window at a time: a second retained rotation while a root is still retained is refused (mtls-ca/retained-root-exists) rather than silently overwriting the retained root and rejecting clients still enrolled under it -- end the window first, and a plain commit() that omits retainPrevious while a window is open is likewise refused (mtls-ca/retention-intent-required) rather than replacing the active CA while leaving the old root, which would drop trust for the just-superseded generation. A rotation also aborts if it cannot capture the existing key for its rollback journal, and loadTrustBundle() still returns a retained root a crashed rotation left only in that journal. canVerifyInTls(algorithm?) performs a loopback mutual-TLS handshake with a freshly issued leaf and resolves true only if node:tls actually authenticates the client against a CA on the running runtime -- pass the prospective algorithm to pre-flight a migration (canVerifyInTls("ML-DSA-87") probes the target chain, not the current CA) before rotating to an algorithm the deployed Node cannot verify; with no argument it probes the stored CA -- and requires an explicit algorithm when the stored CA's algorithm cannot be derived (a custom-engine CA this runtime does not classify, with no create-time pin), rather than probing an undeterminable one that the engine could resolve to a different algorithm. revokeGeneration(n) walks an issuance ledger (recorded automatically as certificates are issued, or supplied via the new issuanceStore option) and revokes every certificate issued under a CA generation below n -- with an undeterminable generation (a custom engine whose CA certificate this runtime cannot parse, such as an opaque post-quantum cert) recorded as null and skipped by that sweep, still revocable by serial or fingerprint, rather than misfiled as generation 0 (which would sweep current leaves and self-revoke future issuances); issuance fails closed if the ledger cannot be written, so no untracked credential escapes revocation, and importIssuance(entries) backfills leaf identities the ledger does not have (certificates from a pre-upgrade dataDir or issued out of band) so revokeGeneration can sweep them too. Rotation is crash-atomic: because the CA key, certificate, and retained root are separate files that cannot be swapped in a single rename, a rotation that is interrupted between them (a crash or power loss) is reconciled the next time the CA is opened -- the prior key AND the prior retained root are journaled durably before either is overwritten, so an interrupted rotation rolls back to the still-usable previous CA (and the trust bundle its retained root still serves) instead of stranding it as an unrecoverable new-key/old-certificate pair or dropping trust for clients enrolled under the formerly-retained generation. A rollback journal that is unreadable or is not a valid manifest fails the open closed (mtls-ca/rollback-journal-corrupt) so an unresolved rotation marker is never overwritten, rather than being silently discarded and losing the prior key. Establishing the requested retention is part of the commit: a rotation that cannot write the retained root (for example an unwritable ca.prev.crt directory) aborts and rolls back rather than publishing a new CA that silently omits the outgoing root, and a rotate({ retainPrevious: false }) that cannot remove the old root aborts rather than leaving a root the operator asked to hard-cut still trusted. The engine interface adds canVerifyInTls(algorithm) for pluggable engines. · *b.middleware.requireMtls enforces a live revocation source* — requireMtls({ revocationSource: caHandle }) consults a b.mtlsCa handle (or any { isRevoked(fingerprintHex) } object) on every request, refusing a peer whose fingerprint the CA reports revoked. This enforces revoke() and revokeGeneration() at the gate without mirroring the registry into denyList, and it is CA-generation-independent (fingerprint-keyed) -- so a revoked superseded-generation cert is refused even though a CRL signed by the new CA could not cover the old cohort. The lookup fails closed: a source that throws refuses the request. **Changed:** *b.mtlsCa revoke() and revokeGeneration() return a promise* — The revocation registry's read-modify-write now runs under a cross-process file lock so concurrent revocations against the same data directory cannot lose each other's entry. b.mtlsCa revoke() -- previously synchronous -- and revokeGeneration() therefore return a promise resolving to their result; input validation still throws synchronously. Callers must await them: await ca.revoke(serial) / const { revoked } = await ca.revokeGeneration(n). isRevoked() stays synchronous and now reads an in-memory index kept fresh against the store, so a revocationSource-wired require-mtls gate does O(1) per-request lookups. An issuance whose signing straddles a rotate()+revokeGeneration() for its own generation -- or a rotate({ retainPrevious: false }) / dropRetained() that removes the root it was signed under -- is now refused (mtls-ca/issuance-superseded) rather than returned as a credential the sweep missed or a leaf that chains to a root no longer trusted; a clustered revocation store (shared across hosts with per-host dataDirs) can share that supersede watermark by additionally exposing { readGenerationWatermark(), bumpGenerationWatermark(n) }. A clustered revocation store now also requires a shared issuanceStore -- constructing one with the default per-host issuance ledger is refused, since revokeGeneration() would otherwise miss certificates issued on another host and leave them accepted by the shared revocation gate. loadTrustBundle() reads a stable snapshot of the current certificate (re-reading and retrying if a retained rotation publishes mid-read), so it never returns a bundle that omits the newly active root, and it deduplicates an identical retained root. · *Error- and edge-path tests for the scheduler, session device-binding, body parser, notifier, and document collection* — The scheduler's register / schedule validation, start / stop / status lifecycle, and task fire-and-failure recording; b.session device-binding storage and its store-error path; the body parser across its supported content types; the notifier's transport surface; and b.db.collection's query operators now have explicit tests for their error and boundary branches. Behaviour is unchanged -- these pin guarantees the primitives already met. · *Vendored @blamejs/pki refreshed to 0.3.27* — The bundled PKI toolkit (lib/vendor/blamejs-pki.cjs, the mTLS CA engine's certificate/CMS backend) is refreshed from 0.3.25 to 0.3.27. It adds no runtime dependency and no public framework API; the upstream releases add CMP certificate-enrollment protocol support (pki.cmp.verify checks a PKIMessage's signature/PBMAC1 protection; pki.cmp.session drives a full RFC 9810 ir/cr/kur/p10cr enrollment exchange) and fixes across the X.509 / CMS / PKCS#12 surface (subjectAltName IP-address string GeneralNames, RSASSA-PKCS1-v1_5 WebCrypto algorithm-name casing, public-key-integrity PKCS#12, CMS countersignature verification, cross-origin SNI/pin retention in the EST/ACME clients). **Fixed:** *b.db.collection $like honours SQL % / _ wildcards instead of matching them literally* — find({ field: { $like: "Al%" } }) routed the pattern through the query builder's escaping LIKE comparator, which escapes % and _ so a bound value cannot smuggle wildcards. For $like -- whose contract is a SQL LIKE pattern -- that neutralised the caller's wildcards and collapsed the operator to an exact match ($like: "Al%" matched only the literal string "Al%", never "Alice"). $like now emits a caller-controlled-wildcard LIKE, so %, _, prefix, suffix, infix, and single-character patterns all resolve as SQL LIKE defines. Exact patterns with no wildcard are unchanged. Callers that relied on the previous literal-match behaviour should pass the field value directly ({ field: value }) or escape their own wildcards. · *b.mtlsCa fails closed on a schema-corrupt issuance ledger instead of silently emptying it* — A present issuance ledger that was valid JSON but carried no `issued` array (an accidental `{}`, or a truncated / externally-rewritten file) was treated as an empty ledger. The next issuance then rewrote the file with only its own entry, permanently dropping every prior certificate from the sole index revokeGeneration() consults -- so those certificates would survive a later generation revocation. A present ledger with a missing or non-array `issued` now fails closed (mtls-ca/issuance-corrupt), exactly as malformed JSON already did; the operator restores or removes the file. An absent ledger still reads as empty. **Detectors:** *b.db.collection $like must use the verbatim caller-wildcard LIKE* — A source check asserts the collection's $like operator routes through the caller-controlled-wildcard LIKE and never the value-escaping LIKE comparator, so the wildcard-escaping regression cannot return unnoticed. · *b.mtlsCa issuance fingerprint must hash the certificate DER* — A source check asserts the issued-certificate fingerprint is computed as the DER hash the require-mtls gate pins, not the PEM-text hash -- a mismatch would silently make revoke() / revokeGeneration() by fingerprint unenforceable at the gate. · *b.mtlsCa CA rotation must journal the prior key before overwriting it* — A source check asserts the commit path persists a durable rollback copy of the prior CA key before the rename that overwrites it, and that both the read and the rotate paths reconcile a leftover journal on open -- so an interrupted rotation can never regress to leaving the new key beside the old certificate with the prior key lost.
|
|
12
|
+
|
|
13
|
+
- v0.18.2 (2026-07-30) — **`b.sql` andWhere() now accepts the 2-argument andWhere(field, value) shorthand it previously rejected; the SQL builder, i18n, and static file server also gain error/adversarial-path tests.** b.sql query builders support two call shapes for their WHERE helpers -- where(field, value) and where(field, op, value), distinguished by argument count. orWhere honoured both, but andWhere re-passed three fixed positional arguments internally, so a two-argument andWhere(field, value) was misread as three: the value became the operator and the call threw sql-builder/bad-operator. andWhere now forwards its arguments like where/orWhere, so andWhere("col", value) appends `AND "col" = ?` as documented. The three- and object-argument forms are unchanged. Alongside the fix, the error, boundary, and adversarial branches of the SQL builder, the i18n primitive, and the static file server are brought under test; no other defects surfaced. **Changed:** *Error-path tests for the SQL builder, i18n, and the static file server* — The b.sql builder's dialect / cast / raw-SQL guards, its emitted-SQL size and encoding guards, and the full set of chained WHERE / HAVING / JOIN delegators now have explicit tests; b.i18n's option-validation and interpolation error contracts are pinned to their exact typed error codes; and b.staticServe's path-traversal, symlink-escape, reserved-name, content-disposition, conditional-request, and HEAD-sanitize branches are asserted through the wired middleware. Behaviour is unchanged -- these assert guarantees the primitives already met (a served-traversal or wrong content-type would be a real defect; none were found) -- so no migration is needed. **Fixed:** *b.sql andWhere() honours the 2-argument andWhere(field, value) shorthand* — A chained builder call such as b.sql.select("t").where("a", 1).andWhere("b", 2) previously threw sql-builder/bad-operator because andWhere forwarded three fixed positional parameters to where(), defeating where()'s argument-count check for the two-argument shorthand (the value was interpreted as the operator). andWhere now forwards its arguments verbatim, matching where() and orWhere(); andWhere("b", 2) emits `AND "b" = ?`. The three-argument andWhere(field, op, value) and object andWhere({ field: value }) forms are unaffected.
|
|
14
|
+
|
|
11
15
|
- v0.18.1 (2026-07-30) — **The error and adversarial paths of nine more primitives -- pagination, CloudEvents, JSONPath, structured fields, the CSV guard, async control, schema validation, incident reporting, and the S3 SigV4 signer -- are now under test.** This release adds no behaviour change. The fail-closed error paths, boundary conditions, and adversarial-input handling of b.pagination, b.cloudEvents, b.jsonPath, b.structuredFields, b.guardCsv, b.safeAsync, b.safeSchema, b.incident.report, and the object-store S3 SigV4 request signer -- previously exercised mostly on their happy paths -- are now asserted, verifying that each rejects malformed input, hostile payloads, and the edge cases the documented contract already promised. No defects were found; the primitives behaved as specified. Genuinely-unreachable defensive fallbacks (a base64 decode Node never throws on, a String method always present on the supported Node LTS, exhaustive switch defaults over a fixed token set) are documented rather than contorted into coverage. **Changed:** *Verified error-path behaviour for pagination, CloudEvents, JSONPath, structured fields, the CSV guard, async control, schema validation, incident reporting, and S3 SigV4* — b.pagination's cursor tamper/overflow/legacy-shape decode and orderBy validation; b.cloudEvents' envelope, spec-version, data-conflict, and HTTP binary-mode decode branches; b.jsonPath's filter-expression parser, function-argument literals, slice bounds, and node-cap guards; b.structuredFields' RFC 8941/9651 parse and serialize error branches; b.guardCsv's formula-injection, homoglyph, BOM, schema-validation, and serialize size-cap branches; b.safeAsync's bad-argument, abort-signal, and release-on-unheld paths across withTimeout / Mutex / Semaphore / the batching sinks; b.safeSchema's construction-time misuse throws and validation-failure arms across every builder; b.incident.report's deadline math, drop-silent persist/notify catches, and validation throws; and the S3 SigV4 signer's config validation, presigned-URL and POST-policy generation, and key-sanitization branches now have explicit tests for their failure and boundary behaviour. Behaviour is unchanged -- these assert guarantees the primitives already met -- so no migration is needed; the value is regression protection for the fail-closed paths of security-relevant primitives.
|
|
12
16
|
|
|
13
17
|
- v0.18.0 (2026-07-26) — **The mTLS CA engine moves to the zero-dependency @blamejs/pki toolkit and issues post-quantum ML-DSA-87 certificates by default; the @peculiar/x509 + pkijs bundle is removed.** b.mtlsCa's default certificate engine is rebuilt on the vendored, zero-runtime-dependency @blamejs/pki toolkit, replacing the @peculiar/x509 + pkijs meta-bundle (which is removed entirely). CA and client certificates are now issued under ML-DSA-87 (FIPS 204) by default: node:tls verifies ML-DSA certificate chains and the CertificateVerify signature on the supported Node LTS (OpenSSL 3.5), so a post-quantum client certificate completes a real mutual-auth handshake -- not just issuance. Operators whose mTLS peers are not yet on OpenSSL 3.5 pass b.mtlsCa.create({ algorithm: "ECDSA-P384-SHA384" }) for a universally-interoperable classical CA -- the pin covers both the CA and every leaf it issues, and never changes the default other CAs use; SLH-DSA is intentionally not offered for TLS. A PKCS#12 export from the PQC default carries a PBMAC1 integrity MAC (RFC 9579); a classical-bridge export uses the traditional RFC 7292 MacData so a pre-OpenSSL-3.5 peer -- the reason to choose the bridge -- can still verify it. Alongside the engine, this release closes a batch of primitive-hardening issues and adds a public PKCE generator; the vendor-currency gate now hard-fails a stale or unverifiable @blamejs/pki so the vendored copy can never lag the latest published release. **Added:** *b.auth.oauth.generatePkce -- a public PKCE (RFC 7636) generator* — b.auth.oauth.generatePkce() returns a { verifier, challenge } pair for a hand-rolled authorization-code flow that does not go through create(). The verifier is 43 base64url characters (32 CSPRNG bytes) and the code_challenge_method is always S256 (the challenge is base64url of SHA-256 over the verifier). · *b.audit.useStore redirect mode* — b.audit.useStore({ record, replaceChain: true }) routes framework audit events directly into a consumer-supplied store, skipping the b.db tamper-evident chain append. A consumer with no initialized b.db can now receive audit events (via record() and emit()/safeEmit()) instead of getting a db-not-initialized error or having events silently dropped. The default remains shadow-replication with the framework chain authoritative. **Changed:** *The default mTLS CA engine is @blamejs/pki with an ML-DSA-87 post-quantum default* — lib/mtls-engine-default.js (b.mtlsCa's default engine) is rebuilt on the vendored zero-dependency @blamejs/pki toolkit. CA and leaf certificates are issued under ML-DSA-87 by default; node:tls completes a real mutual-auth handshake with them on the supported Node LTS. Operators pin the classical bridge with b.mtlsCa.create({ algorithm: "ECDSA-P384-SHA384" }) when a peer predates OpenSSL 3.5; the pin flows into both CA generation and leaf issuance, and is per-call -- it never downgrades the default other CAs use. A pin that disagrees with an already-stored CA is refused (mtls-ca/algorithm-mismatch) -- including a stored EC CA on the wrong curve (a P-256/P-521 CA under the ECDSA-P384 pin, which every ECDSA label would otherwise satisfy by key type alone) -- rather than issuing a leaf the mismatched CA would sign into an unverifiable chain -- rotate to a fresh CA to change algorithms. A per-issuance opts.algorithm that conflicts with the CA's resolved algorithm is likewise refused (mtls-ca/algorithm-conflict) rather than issuing a leaf the pinned CA can't back for its intended peers. When no algorithm is pinned, leaves (and their PKCS#12 MAC tier) follow the stored CA's OWN algorithm under the bundled engine, not the ML-DSA-87 default -- so a deployment that upgrades with an existing classical CA and never sets algorithm keeps issuing classical leaves its established peers can verify. That inference is the bundled engine's alone: a custom b.mtlsCa.create({ engine }) resolves its own leaf algorithm from its own key and is never handed the bundled ECDSA-P384-SHA384 label (which would break a custom engine running a P-256/P-521 CA or its own label set). The classical bridge signs the CA, every leaf, and every CRL with ECDSA-with-SHA-384 (matching its label and the pre-flip release), not the toolkit's EC default of SHA-256. blamejs mtls init --algorithm ECDSA-P384-SHA384 reaches the same bridge from the CLI. Issued certificates carry a structured subject DN: the CA's common name and its OU=CAv{N} generation tag are distinct RDN attributes, and a leaf's subject is exactly CN=<cn> -- so mTLS authorization that maps the certificate CN to an identity reads the bare cn (not a doubled CN), and a policy inspecting the OU=CAv{N} generation attribute finds a real OU. A bare IPv6 SAN now encodes as an iPAddress GeneralName (it was previously mis-encoded as a DNS name). b.mtlsCa's revoke() refuses the removeFromCRL reason (RFC 5280 code 8, a delta-CRL un-revocation directive) -- accepting it would persist a code-8 entry the toolkit rejects in a full CRL, making every later generateCrl() fail and blocking all revocation publishing. A legacy code-8 entry already sitting in a registry (from a pre-release build or a hand-edited store) is dropped when a full CRL is signed -- the serial stays revoked, but the invalid reason no longer blocks publishing every other revocation. PKCS#12 packaging keeps the AES-256-CBC + PBKDF2-HMAC-SHA-512 @ 2,000,000-iteration bag protection; the outer integrity MAC follows the cert tier -- PBMAC1 (RFC 9579) for the PQC default, and the traditional RFC 7292 HMAC MacData for a classical-bridge (ECDSA-P384) export so a peer predating OpenSSL 3.5 can verify the file that PBMAC1 would leave unreadable. · *The vendor-currency gate hard-fails a stale or unverifiable @blamejs/pki* — @blamejs/pki is now always-strict in the vendored-dependency currency check: a stale copy fails the gate (as before) and a registry error while checking it is also a hard failure, rather than staying advisory. The required Vendor currency CI check therefore refuses to pass unless it can prove the vendored @blamejs/pki equals the latest published release, keeping the fast-moving toolkit current on every build. **Removed:** *The vendored @peculiar/x509 + pkijs bundle* — lib/vendor/pki.cjs (the @peculiar/x509 + pkijs + reflect-metadata + ASN.1 meta-bundle) has no remaining consumer after the engine and the test fixtures moved to @blamejs/pki, and is removed along with its MANIFEST, NOTICE, and vendor tooling entries. The framework's PKI now runs entirely on the single zero-dependency @blamejs/pki toolkit. **Fixed:** *b.outbox and b.webhook quote operator-supplied table names* — Operator-supplied table names in b.outbox and b.webhook were emitted unquoted, so a reserved-word or case-sensitive operator table broke the generated SQL. Both now quote operator table names with the same allowReserved parity b.db.from() already provides. On PostgreSQL a bare mixed-case name is folded to lowercase before quoting, so a deployment upgrading from an unquoted-name build keeps resolving to the same folded table PostgreSQL already created (a mixed-case table config does not strand its existing rows in a new case-sensitive table). · *b.selfUpdate.rollback replaces a running Windows executable and honors a maxBytes override* — b.selfUpdate.rollback could not replace a locked/running Windows image (unlike swap) and had no way to raise the 64 MiB copy cap for a large binary. rollback now moves the outgoing file aside via a rename before restoring the backup, and accepts a maxBytes override. If the restore copy then fails (over maxBytes, unreadable source, write error), the moved-aside image is put back over the target so a failed rollback never leaves the target absent -- no next-launch outage; and a backupTo that aliases the reserved quarantine path -- including via a symlink or a symlinked parent dir (resolved with realpath), or by letter case on a case-insensitive volume -- is refused before any file is touched (it would otherwise delete the backup and restore the bad binary). Separately, selfUpdate.poll pairs a detached signature to the asset it actually signs: it recognises both the appended (asset.bin -> asset.bin.sig) and -- when the asset is the SOLE artifact with its extension-stripped stem -- the extension-replacing (asset.bin -> asset.sig) one-signature conventions, and fails closed on an ambiguous sidecar (a lone asset.sig shared by asset.bin and asset.exe) or one whose name is unrelated to the asset rather than mispairing it. **Security:** *b.safeJson.parse no longer leaks a window of input bytes in its error message* — b.safeJson.parse interpolated V8's raw SyntaxError message into the thrown error, which could echo a short window of the parsed input -- including secret bytes -- to any caller that logs the error (CWE-532). It now emits only the stable error code and the numeric position, never the input substring. · *b.redact.redactText strips underscore-joined bearer tokens in free-text messages* — The message-path redactor missed id_token / refresh_token assignments (an underscore-joined name a bare-token word boundary could not match) and vault-sealed / empty-username connection-string credentials, so a credential embedded in a log message could reach a file or SIEM sink verbatim while the structured meta path stripped it. redactText now covers those shapes -- including the JSON / quoted forms ({"refresh_token":"..."}), where the opaque value sits behind a quote the value class had excluded -- matching the meta path. · *b.guardTenantId's reserved-name check is prototype-safe* — The reserved-tenant-id lookup used a bare property access, so a tenant id equal to an Object.prototype member (a prototype key) spuriously matched a reserved name. It now uses an own-property test, and separately refuses the prototype-pollution key names (__proto__ / constructor / prototype) via the framework's shared poisoned-key guard -- a tenant id used to key a plain-object store must never be one of those. · *b.daemon detached start reports a boot-time death instead of unconditional success* — b.daemon.start in detached mode reported success even when the spawned child died at boot, leaving a stale pidfile a later daemon.stop could misread. A one-shot exit handler now reaps the stale pidfile (only when it still records that child's pid) and audits a spawn failure ONLY for a boot death -- an abnormal exit (non-zero code or a terminating signal) within a boot window while the pidfile is still the child's. A clean exit or an operator stop() is no longer mislabeled as a spawn failure. The parent keeps its event loop alive through the boot window (a ref'd timer, cleared the instant the child exits) so even a short-lived launcher that spawns the daemon and returns still observes a boot death -- child.unref() alone would let it exit first and strand the pidfile. A stop() within the window -- from the starting process or a different one (e.g. a `daemon stop` CLI) -- is not misread as a boot death: stop() publishes a short-lived `<pidFile>.stopping` marker (holding the pid it is stopping) that the boot-death handler consults, so no contradictory spawn-failed audit lands before the stop record. The window is tunable via daemon.start({ bootDeathWindowMs }) (default 5s, bounded to the setTimeout 32-bit maximum; 0 opts out for immediate fire-and-forget). The .stopping marker is read through the hardened pid-sidecar reader (symlink-refused, size-capped) and cleared on a fresh start so a stale marker cannot suppress a pid-reused child's boot death. The reap itself atomically claims the sidecar (renaming it aside) before verifying ownership, so a fast operator restart that rewrites the pidfile between the check and the removal can never make the boot-death handler delete the NEW daemon's pidfile. The same discipline covers the asynchronous spawn-error path: a no-pid launch failure throws synchronously before any pidfile is written, so its late 'error' callback no longer deletes a pidfile that a caught-and-retried start has since written.
|
package/NOTICE
CHANGED
|
@@ -60,7 +60,7 @@ Used for: WebAuthn / passkey registration + authentication response
|
|
|
60
60
|
server.cjs).
|
|
61
61
|
--------------------------------------------------------------------------------
|
|
62
62
|
Component: @blamejs/pki
|
|
63
|
-
Version: 0.3.
|
|
63
|
+
Version: 0.3.27
|
|
64
64
|
Source: https://github.com/blamejs/pki
|
|
65
65
|
License: Apache-2.0
|
|
66
66
|
Copyright: Copyright (c) blamejs contributors
|
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
120
120
|
- **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
|
|
121
121
|
- **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
|
|
122
122
|
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`); RFC 6960 OCSP stapling — the cert manager (`b.cert`) fetches + validates each managed certificate's OCSP response (`b.network.tls.ocsp.fetch`) on a refresh cadence and exposes it on the served context for a TLS server's `OCSPRequest` handler to staple
|
|
123
|
-
- **mTLS CA** — pure-JS on the zero-dependency `@blamejs/pki` toolkit, issues clientAuth / serverAuth / dual-EKU certs with SAN under a post-quantum ML-DSA-87 (FIPS 204) default that node:tls verifies in a real mutual-auth handshake on OpenSSL 3.5; pin the classical `ECDSA-P384-SHA384` bridge via `b.mtlsCa.create({ algorithm })` for peers that predate it; PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
|
|
123
|
+
- **mTLS CA** — pure-JS on the zero-dependency `@blamejs/pki` toolkit, issues clientAuth / serverAuth / dual-EKU certs with SAN under a post-quantum ML-DSA-87 (FIPS 204) default that node:tls verifies in a real mutual-auth handshake on OpenSSL 3.5; pin the classical `ECDSA-P384-SHA384` bridge via `b.mtlsCa.create({ algorithm })` for peers that predate it; migrate the CA to a new algorithm without a trust outage — `rotate()` plus a retained-previous trust bundle, a `canVerifyInTls()` loopback pre-flight, and revoke-by-generation; PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
|
|
124
124
|
### HTTP
|
|
125
125
|
|
|
126
126
|
- **Router + API specs** — schema-validated routes; OpenAPI 3.1 / 3.2 publication (`b.openapi` — webhooks + `jsonSchemaDialect`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
|
package/lib/db-collection.js
CHANGED
|
@@ -396,7 +396,31 @@ function collection(name, opts) {
|
|
|
396
396
|
if (typeof val !== "string") {
|
|
397
397
|
throw new TypeError("collection: $like requires a string");
|
|
398
398
|
}
|
|
399
|
-
|
|
399
|
+
// Consult the GLOBAL registry (via the builder's crypto-field key),
|
|
400
|
+
// not just this collection instance's opts.sealedFields — the field
|
|
401
|
+
// may have been sealed through b.db.init, cryptoField.registerTable,
|
|
402
|
+
// or an earlier collection instance, and Query rewrites on that same
|
|
403
|
+
// registry. Checking only local opts would send a globally-sealed
|
|
404
|
+
// field through the raw LIKE (against ciphertext).
|
|
405
|
+
if (cryptoField.getSealedFields(builder._cryptoFieldKey()).indexOf(k) !== -1) {
|
|
406
|
+
// A sealed field is stored as a derived HASH, so a wildcard LIKE
|
|
407
|
+
// cannot be represented (a hash has no substructure to match).
|
|
408
|
+
// An EXACT pattern (no % / _) routes through the equality path so
|
|
409
|
+
// the sealed-field rewrite maps it to the hash column and matches
|
|
410
|
+
// like $eq did before; a wildcard pattern is refused.
|
|
411
|
+
if (/[%_]/.test(val)) {
|
|
412
|
+
throw new TypeError("collection: $like with a wildcard is not supported on the sealed " +
|
|
413
|
+
"field '" + k + "' (stored as a derived hash) — use an exact value, or query the hash column directly");
|
|
414
|
+
}
|
|
415
|
+
builder.where(k, "=", val);
|
|
416
|
+
} else {
|
|
417
|
+
// $like is a SQL LIKE: the caller's % / _ are wildcards. Route
|
|
418
|
+
// through the verbatim caller-wildcard LIKE (WhereBuilder.like,
|
|
419
|
+
// no _escapeLike) — the general builder's LIKE comparator escapes
|
|
420
|
+
// the value, which would neutralize the wildcards and collapse
|
|
421
|
+
// $like to an exact match.
|
|
422
|
+
builder.whereGroup(function (g) { g.like(k, val); });
|
|
423
|
+
}
|
|
400
424
|
break;
|
|
401
425
|
default:
|
|
402
426
|
throw new TypeError("collection: unsupported query operator '" + op +
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* allow-list ALSO requires the fingerprint to match.
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
+
var nodeCrypto = require("node:crypto");
|
|
50
51
|
var defineClass = require("../framework-error").defineClass;
|
|
51
52
|
var lazyRequire = require("../lazy-require");
|
|
52
53
|
var validateOpts = require("../validate-opts");
|
|
@@ -76,16 +77,25 @@ function _normalizeFingerprintEntry(entry) {
|
|
|
76
77
|
* client certificate. Refuses with HTTP 401 when no peer cert is
|
|
77
78
|
* presented, when the TLS layer marks `req.client.authorized ===
|
|
78
79
|
* false`, when the SHA3-512 fingerprint isn't on the operator
|
|
79
|
-
* allowlist,
|
|
80
|
+
* allowlist, when it appears on the denylist, or when a
|
|
81
|
+
* `revocationSource` reports it revoked. Allowlist of
|
|
80
82
|
* null / empty means "any peer cert authorized at the TLS layer
|
|
81
83
|
* is fine"; non-empty allowlist additionally requires fingerprint
|
|
82
84
|
* match. Pair with `b.app({ tlsOptions: { requestCert: true, ca:
|
|
83
85
|
* [...] } })` so the TLS layer captures the client cert.
|
|
84
86
|
*
|
|
87
|
+
* Pass `revocationSource: caHandle` (a `b.mtlsCa` handle, or any
|
|
88
|
+
* `{ isRevoked(fingerprintHex) }` object) to enforce the CA's live
|
|
89
|
+
* revocation registry at the gate — `revoke()` and `revokeGeneration()`
|
|
90
|
+
* take effect without mirroring the registry into `denyList`, and the
|
|
91
|
+
* check is CA-generation-independent (fingerprint-keyed). The lookup
|
|
92
|
+
* fails closed: a source that throws refuses the request.
|
|
93
|
+
*
|
|
85
94
|
* @opts
|
|
86
95
|
* {
|
|
87
96
|
* fingerprintAllowList: string[],
|
|
88
97
|
* denyList: string[],
|
|
98
|
+
* revocationSource: object, // { isRevoked(fingerprintHex): boolean } — e.g. a b.mtlsCa handle; enforces revoke()/revokeGeneration() live (fail-closed)
|
|
89
99
|
* onAuthenticated: function(req, res, next): void,
|
|
90
100
|
* onDeny: function(req, res, info): void, // own the refusal (mirrors onAuthenticated); info = { status, reason, ...metadata }
|
|
91
101
|
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
@@ -104,7 +114,7 @@ function _normalizeFingerprintEntry(entry) {
|
|
|
104
114
|
function create(opts) {
|
|
105
115
|
opts = opts || {};
|
|
106
116
|
validateOpts(opts, [
|
|
107
|
-
"fingerprintAllowList", "denyList",
|
|
117
|
+
"fingerprintAllowList", "denyList", "revocationSource",
|
|
108
118
|
"onAuthenticated", "onDeny", "problemDetails", "audit",
|
|
109
119
|
"auditAction", "errorMessage",
|
|
110
120
|
], "middleware.requireMtls");
|
|
@@ -113,6 +123,22 @@ function create(opts) {
|
|
|
113
123
|
? opts.fingerprintAllowList.map(_normalizeFingerprintEntry) : null;
|
|
114
124
|
var denyList = Array.isArray(opts.denyList)
|
|
115
125
|
? opts.denyList.map(_normalizeFingerprintEntry) : [];
|
|
126
|
+
// Live revocation source — any object exposing isRevoked(fingerprintHex),
|
|
127
|
+
// e.g. a b.mtlsCa handle. The static denyList catches a fixed set; this makes
|
|
128
|
+
// the CA's revocation registry (revoke() / revokeGeneration()) enforced at the
|
|
129
|
+
// gate WITHOUT the operator mirroring it into denyList, and it is CA-generation
|
|
130
|
+
// -independent (fingerprint-keyed), so revoking a superseded generation is
|
|
131
|
+
// honored even though a CRL signed by the new CA could not cover the old cohort.
|
|
132
|
+
// Only omission (undefined) or an explicit null means "no source". A falsy
|
|
133
|
+
// non-object (false, 0, "" from a mis-derived env var) is NOT silently
|
|
134
|
+
// dropped via `|| null` — it flows into validation so invalid security
|
|
135
|
+
// configuration fails at construction instead of quietly disabling the check.
|
|
136
|
+
var revocationSource = (opts.revocationSource === undefined || opts.revocationSource === null)
|
|
137
|
+
? null : opts.revocationSource;
|
|
138
|
+
if (revocationSource !== null) {
|
|
139
|
+
validateOpts.requireMethods(revocationSource, ["isRevoked"],
|
|
140
|
+
"requireMtls: opts.revocationSource", RequireMtlsError, "require-mtls/bad-revocation-source");
|
|
141
|
+
}
|
|
116
142
|
var onAuthenticated = typeof opts.onAuthenticated === "function" ? opts.onAuthenticated : null;
|
|
117
143
|
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
118
144
|
var problemMode = opts.problemDetails === true;
|
|
@@ -195,6 +221,56 @@ function create(opts) {
|
|
|
195
221
|
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
196
222
|
});
|
|
197
223
|
}
|
|
224
|
+
// Live revocation registry (e.g. a b.mtlsCa handle). Fail CLOSED: a source
|
|
225
|
+
// that throws denies rather than silently admitting a possibly-revoked cert.
|
|
226
|
+
// Checked before the allow-list so a revoked-but-allowlisted cert is denied.
|
|
227
|
+
if (revocationSource) {
|
|
228
|
+
var revoked;
|
|
229
|
+
try {
|
|
230
|
+
// The revocationSource contract is a SYNCHRONOUS boolean. A non-boolean result — a Promise
|
|
231
|
+
// from an async / DB-backed source, undefined, or any other garbage — is NEVER === true, so
|
|
232
|
+
// silently treating it as not-revoked would ADMIT a possibly-revoked peer. This gate is
|
|
233
|
+
// documented fail-CLOSED, so refuse the request on any non-boolean result (front an async
|
|
234
|
+
// source with a synchronous cache).
|
|
235
|
+
var byFp = revocationSource.isRevoked(fp.hex);
|
|
236
|
+
if (typeof byFp !== "boolean") {
|
|
237
|
+
return _refuse(req, res, "revocation-source-invalid",
|
|
238
|
+
{ method: "isRevoked", type: (byFp === null ? "null" : typeof byFp) });
|
|
239
|
+
}
|
|
240
|
+
revoked = byFp;
|
|
241
|
+
// A revoke(serial) / serial-only entry carries fingerprint:null and can't
|
|
242
|
+
// match by fingerprint. Check the peer certificate's serial number ONLY
|
|
243
|
+
// when the source opts in with isSerialRevoked() — a documented
|
|
244
|
+
// fingerprint-only isRevoked(fingerprintHex) that strictly length-validates
|
|
245
|
+
// its input would throw on a shorter serial and fail-close every request.
|
|
246
|
+
// A b.mtlsCa handle exposes isSerialRevoked. When serial enforcement is enabled the serial MUST
|
|
247
|
+
// be checkable: a cert whose serial cannot be extracted (raw unparseable — e.g. prevalidated by
|
|
248
|
+
// a TLS-terminating proxy, or an algorithm the local X.509 parser rejects) cannot be cleared
|
|
249
|
+
// against the serial-only revoke(serial) path, so admitting it would break the fail-closed
|
|
250
|
+
// contract. Refuse rather than silently skip the lookup.
|
|
251
|
+
if (!revoked && typeof revocationSource.isSerialRevoked === "function") {
|
|
252
|
+
var serial = null;
|
|
253
|
+
try { serial = new nodeCrypto.X509Certificate(peerCert.raw).serialNumber; }
|
|
254
|
+
catch (_se) { serial = null; }
|
|
255
|
+
if (!serial) {
|
|
256
|
+
return _refuse(req, res, "serial-unresolved",
|
|
257
|
+
{ detail: "certificate serial could not be extracted for the serial-revocation check" });
|
|
258
|
+
}
|
|
259
|
+
var bySerial = revocationSource.isSerialRevoked(serial);
|
|
260
|
+
if (typeof bySerial !== "boolean") {
|
|
261
|
+
return _refuse(req, res, "revocation-source-invalid",
|
|
262
|
+
{ method: "isSerialRevoked", type: (bySerial === null ? "null" : typeof bySerial) });
|
|
263
|
+
}
|
|
264
|
+
revoked = bySerial;
|
|
265
|
+
}
|
|
266
|
+
} catch (e) { return _refuse(req, res, "revocation-check-failed", { error: (e && e.message) || String(e) }); }
|
|
267
|
+
if (revoked) {
|
|
268
|
+
return _refuse(req, res, "fingerprint-revoked", {
|
|
269
|
+
fingerprint: fp.colon,
|
|
270
|
+
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
198
274
|
if (allowList && allowList.length > 0 && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
|
|
199
275
|
return _refuse(req, res, "fingerprint-not-allowed", {
|
|
200
276
|
fingerprint: fp.colon,
|