@blamejs/core 0.17.24 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/NOTICE +12 -13
- package/README.md +3 -3
- package/lib/audit.js +69 -1
- package/lib/auth/oauth.js +57 -0
- package/lib/cli.js +15 -5
- package/lib/daemon.js +192 -2
- package/lib/guard-tenant-id.js +12 -1
- package/lib/mtls-ca.js +173 -18
- package/lib/mtls-engine-default.js +339 -314
- package/lib/outbox.js +43 -13
- package/lib/redact.js +39 -6
- package/lib/safe-json.js +9 -1
- package/lib/self-update.js +215 -24
- package/lib/vendor/MANIFEST.json +27 -30
- package/lib/vendor/blamejs-pki.cjs +28419 -0
- package/lib/webhook-dispatcher.js +55 -24
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/lib/vendor/pki.cjs +0 -39718
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,10 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.18.x
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
9
13
|
## v0.17.x
|
|
10
14
|
|
|
11
15
|
- v0.17.24 (2026-07-25) — **The coverage-guided fuzz harness pins its `tar` dependency to 7.5.22, clearing a recursion-based denial-of-service advisory in dev-only tooling.** A maintenance update to development tooling with no change to the framework's library or runtime code. The fuzz harness (jazzer.js, under fuzz/) pulled tar transitively at 7.5.19, which is affected by an advisory: uncontrolled recursion in tar's mapHas / filesFilter path lets a crafted entry set trigger an uncatchable stack overflow (fixed upstream in 7.5.21; 7.5.22 is the current release, which caps mapHas recursion at 100 levels and hardens the decompressor teardown). The harness now pins tar to ^7.5.22 via an npm override so the resolved version cannot regress below the patched line. The fuzz harness is not part of the @blamejs/core tarball, so no shipped library or runtime code changes and operators need take no action; the update exists to keep the repository's development dependencies free of known advisories. **Security:** *Fuzz harness pins tar to 7.5.22 (dev-only tooling; not in the published package)* — The coverage-guided fuzz harness (fuzz/, jazzer.js) resolved tar transitively at 7.5.19, affected by an uncontrolled-recursion advisory in the mapHas / filesFilter path that permits an uncatchable stack overflow. An npm override pins tar to ^7.5.22 (the patched line caps recursion depth and hardens decompressor teardown), so the harness lockfile cannot resolve a vulnerable tar. The fuzz harness is not included in the published @blamejs/core package, so this changes only development tooling -- no shipped library or runtime code is affected.
|
package/NOTICE
CHANGED
|
@@ -59,19 +59,18 @@ Used for: WebAuthn / passkey registration + authentication response
|
|
|
59
59
|
verification (lib/passkey.js, via lib/vendor/simplewebauthn-
|
|
60
60
|
server.cjs).
|
|
61
61
|
--------------------------------------------------------------------------------
|
|
62
|
-
Component: @
|
|
63
|
-
Version:
|
|
64
|
-
Source: https://github.com/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
(PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
chain).
|
|
62
|
+
Component: @blamejs/pki
|
|
63
|
+
Version: 0.3.25
|
|
64
|
+
Source: https://github.com/blamejs/pki
|
|
65
|
+
License: Apache-2.0
|
|
66
|
+
Copyright: Copyright (c) blamejs contributors
|
|
67
|
+
Used for: Pure-JS mTLS CA (default engine). Self-signed CA generation
|
|
68
|
+
(ML-DSA-87 by default, ECDSA P-384 bridge on opt-in), leaf-cert
|
|
69
|
+
signing for client certificates, X.509 CRL generation, and
|
|
70
|
+
PKCS#12 packaging (PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512,
|
|
71
|
+
2,000,000 iterations; PBMAC1 integrity MAC). Zero runtime
|
|
72
|
+
dependencies. Wired into b.mtlsCa via lib/mtls-engine-default.js,
|
|
73
|
+
backed by the bundle lib/vendor/blamejs-pki.cjs.
|
|
75
74
|
--------------------------------------------------------------------------------
|
|
76
75
|
Component: SecLists — 10k-most-common.txt
|
|
77
76
|
Version: master snapshot (bundled 2026-05-02)
|
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, issues clientAuth / serverAuth / dual-EKU certs with SAN
|
|
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`)
|
|
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`)
|
|
@@ -316,11 +316,11 @@ All runtime dependencies are committed to the repo — no transitive npm install
|
|
|
316
316
|
| [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.2.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD |
|
|
317
317
|
| [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.6.1 | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. |
|
|
318
318
|
| [`@simplewebauthn/server`](https://github.com/MasterKale/SimpleWebAuthn) | 13.3.0 | [Matthew Miller](https://github.com/MasterKale) | WebAuthn / passkey verification |
|
|
319
|
-
| [`@
|
|
319
|
+
| [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.3.25 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) |
|
|
320
320
|
| [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
|
|
321
321
|
| [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
|
|
322
322
|
|
|
323
|
-
These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed (the SecLists password list
|
|
323
|
+
These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed, except `@blamejs/pki` (Apache-2.0) and the SecLists password list (CC-BY-3.0). Per-package version, license, and provenance live in two manifests: [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json) for the framework's server-side bundles and [`examples/wiki/public/vendor/MANIFEST.json`](examples/wiki/public/vendor/MANIFEST.json) for the wiki app's browser-side bundle. The framework's [`NOTICE`](NOTICE) file carries the upstream attributions.
|
|
324
324
|
|
|
325
325
|
## Why "blamejs"
|
|
326
326
|
|
package/lib/audit.js
CHANGED
|
@@ -99,6 +99,15 @@ var EXTERNAL_STORE_TIMEOUT_MS = C.TIME.seconds(30);
|
|
|
99
99
|
// verifyChain still works against the framework store.
|
|
100
100
|
var _externalStore = null;
|
|
101
101
|
|
|
102
|
+
// Mode of the registered external store: "shadow" (default) replicates
|
|
103
|
+
// each framework chain.append to the store AFTER the authoritative b.db
|
|
104
|
+
// write; "redirect" (useStore({ record, replaceChain: true })) routes the
|
|
105
|
+
// shaped event STRAIGHT to the store and SKIPS the b.db chain append — for
|
|
106
|
+
// a consumer that owns its own DB + tamper-evident audit layer and has no
|
|
107
|
+
// b.db, so a chain append would only throw db/not-initialized on every
|
|
108
|
+
// emit. Reset to "shadow" whenever the store is unregistered.
|
|
109
|
+
var _externalStoreMode = "shadow";
|
|
110
|
+
|
|
102
111
|
// Per-operation timeout for framework-state SQL. A misbehaving
|
|
103
112
|
// external-db driver hanging on a query shouldn't hang audit forever.
|
|
104
113
|
// 30s is generous for genuinely slow networks while still bounding
|
|
@@ -621,6 +630,24 @@ async function record(event) {
|
|
|
621
630
|
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
|
|
622
631
|
requestId: event.requestId || null,
|
|
623
632
|
};
|
|
633
|
+
// Redirect mode: the consumer store is authoritative (it owns the DB
|
|
634
|
+
// + tamper-evident audit layer; there is no b.db to append to). Hand
|
|
635
|
+
// the shaped logical event straight to the operator's record() and
|
|
636
|
+
// SKIP the framework chain append entirely — no db/not-initialized.
|
|
637
|
+
// The 30s timeout bounds a stalled callback so a hung consumer store
|
|
638
|
+
// can't wedge the audit critical path; a genuine store failure
|
|
639
|
+
// PROPAGATES (record() is the await-durability surface — a direct
|
|
640
|
+
// caller must see it), while the emit()/safeEmit() handler-flush path
|
|
641
|
+
// catches it drop-silent so the request that emitted can't be crashed.
|
|
642
|
+
if (_externalStore && _externalStoreMode === "redirect" &&
|
|
643
|
+
typeof _externalStore.record === "function") {
|
|
644
|
+
await safeAsync.withTimeout(
|
|
645
|
+
Promise.resolve().then(function () { return _externalStore.record(logical); }),
|
|
646
|
+
EXTERNAL_STORE_TIMEOUT_MS,
|
|
647
|
+
{ name: "audit.redirectRecord" }
|
|
648
|
+
);
|
|
649
|
+
return logical;
|
|
650
|
+
}
|
|
624
651
|
var appended = await _chainWriter.append(logical);
|
|
625
652
|
// Operator-registered shadow store: replicate the fully-formed
|
|
626
653
|
// row to an immutable external destination. Drop-silent on
|
|
@@ -705,8 +732,29 @@ async function record(event) {
|
|
|
705
732
|
* Pass `null` (or `{ record: null }`) to unregister and revert to
|
|
706
733
|
* chain-only mode.
|
|
707
734
|
*
|
|
735
|
+
* Redirect mode — `useStore({ record, replaceChain: true })`: a
|
|
736
|
+
* consumer that owns its own database + audit layer and does NOT use
|
|
737
|
+
* `b.db` has no chain to shadow. In shadow mode every `record()` /
|
|
738
|
+
* `emit()` / `safeEmit()` still tries the `b.db` chain append first,
|
|
739
|
+
* which throws `db/not-initialized` on every emit (or silently drops
|
|
740
|
+
* from the emit handler). With `replaceChain: true` the shaped audit
|
|
741
|
+
* event is handed STRAIGHT to `record(event)` and the `b.db` chain
|
|
742
|
+
* append is skipped, so framework audit events (an SMTP insecure-TLS
|
|
743
|
+
* escape-hatch, mTLS negotiation at boot) land in the consumer's own
|
|
744
|
+
* tamper-evident log instead of erroring. In redirect mode the
|
|
745
|
+
* consumer store is authoritative: `record()`'s 30s timeout bounds a
|
|
746
|
+
* stalled callback, but a genuine store failure PROPAGATES to the
|
|
747
|
+
* caller (record() is the await-durability surface), while the
|
|
748
|
+
* `emit()` / `safeEmit()` handler-flush path drop-silent-catches it.
|
|
749
|
+
* The event passed to `record` is the shaped logical event
|
|
750
|
+
* (`{ action, outcome, actorUserId, actorIp, resourceKind, resourceId,
|
|
751
|
+
* reason, metadata, requestId, ... }`) — no framework `_id` /
|
|
752
|
+
* `monotonicCounter` / `prevHash` / `rowHash`, since there is no
|
|
753
|
+
* framework chain to hash against.
|
|
754
|
+
*
|
|
708
755
|
* @opts
|
|
709
|
-
* record:
|
|
756
|
+
* record: async function (row), // operator's persistence callback
|
|
757
|
+
* replaceChain: boolean, // default: false (shadow). true → redirect (skip the b.db chain)
|
|
710
758
|
*
|
|
711
759
|
* @example
|
|
712
760
|
* var b = require("@blamejs/core");
|
|
@@ -732,6 +780,7 @@ async function record(event) {
|
|
|
732
780
|
function useStore(store) {
|
|
733
781
|
if (store === null || store === undefined) {
|
|
734
782
|
_externalStore = null;
|
|
783
|
+
_externalStoreMode = "shadow";
|
|
735
784
|
return;
|
|
736
785
|
}
|
|
737
786
|
if (typeof store !== "object") {
|
|
@@ -740,12 +789,19 @@ function useStore(store) {
|
|
|
740
789
|
// `{ record: null }` unregisters explicitly (mirrors the null arg path).
|
|
741
790
|
if (store.record === null || store.record === undefined) {
|
|
742
791
|
_externalStore = null;
|
|
792
|
+
_externalStoreMode = "shadow";
|
|
743
793
|
return;
|
|
744
794
|
}
|
|
745
795
|
if (typeof store.record !== "function") {
|
|
746
796
|
throw new Error("audit.useStore: store.record must be an async function (row) => void");
|
|
747
797
|
}
|
|
798
|
+
// Boot-time config input: a bad replaceChain flag is an operator typo —
|
|
799
|
+
// throw loudly rather than silently coerce it.
|
|
800
|
+
if (store.replaceChain !== undefined && typeof store.replaceChain !== "boolean") {
|
|
801
|
+
throw new Error("audit.useStore: store.replaceChain must be a boolean (true routes events to record() and skips the b.db chain)");
|
|
802
|
+
}
|
|
748
803
|
_externalStore = store;
|
|
804
|
+
_externalStoreMode = store.replaceChain === true ? "redirect" : "shadow";
|
|
749
805
|
}
|
|
750
806
|
|
|
751
807
|
// ---- Query ----
|
|
@@ -824,6 +880,7 @@ async function query(criteria) {
|
|
|
824
880
|
// In single-node mode the query builder gives us field-crypto unsealing
|
|
825
881
|
// for free. In cluster mode we read raw rows from external-db and
|
|
826
882
|
// unseal manually.
|
|
883
|
+
/* c8 ignore next 3 -- cluster-mode topology (configured externalDb backend); single-node query() always takes the else path. Cluster read is exercised in test/integration/audit-stack-{postgres,mysql}. */
|
|
827
884
|
if (cluster.isClusterMode()) {
|
|
828
885
|
return await _queryCluster(criteria);
|
|
829
886
|
}
|
|
@@ -1057,6 +1114,7 @@ async function checkpoint(opts) {
|
|
|
1057
1114
|
// null. If the fence passes (a same-node concurrent race), it is idempotent.
|
|
1058
1115
|
// Any non-duplicate error rethrows.
|
|
1059
1116
|
if (_isDuplicateCheckpointCounter(e)) {
|
|
1117
|
+
/* c8 ignore next 3 -- cluster-only fence: single-node dup-anchor is idempotent and returns null below; the cluster fencing-token step-down is exercised in test/integration/audit-stack-{postgres,mysql}. */
|
|
1060
1118
|
if (cluster.isClusterMode()) {
|
|
1061
1119
|
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
1062
1120
|
}
|
|
@@ -1080,6 +1138,7 @@ async function checkpoint(opts) {
|
|
|
1080
1138
|
// also means the caller can audit the leader-lost transition and
|
|
1081
1139
|
// step down. Other audit-tip errors (network blip, transient DB)
|
|
1082
1140
|
// also surface so the operator can react.
|
|
1141
|
+
/* c8 ignore next 2 -- cluster-mode audit-tip upsert; single-node takes the else (durable-tip sidecar) branch below. Cluster path exercised in test/integration/audit-stack-{postgres,mysql}. */
|
|
1083
1142
|
if (cluster.isClusterMode()) {
|
|
1084
1143
|
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
1085
1144
|
} else {
|
|
@@ -1175,6 +1234,7 @@ async function verifyCheckpoints() {
|
|
|
1175
1234
|
};
|
|
1176
1235
|
}
|
|
1177
1236
|
var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
|
|
1237
|
+
/* c8 ignore next -- the isBuffer arm is pg/mysql-only (those drivers return a Buffer for the signature BLOB); node:sqlite single-node returns a Uint8Array, so the Buffer.from arm is the one taken here. */
|
|
1178
1238
|
var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
|
|
1179
1239
|
if (!auditSign.verify(payload, sigBuf, pub)) {
|
|
1180
1240
|
return {
|
|
@@ -1261,6 +1321,7 @@ async function verify(opts) {
|
|
|
1261
1321
|
function (sql, params) {
|
|
1262
1322
|
return safeAsync.withTimeout(
|
|
1263
1323
|
safeAsync.asyncRetry(function () {
|
|
1324
|
+
/* c8 ignore next -- auditChain.verifyChain always invokes this reader with a params array from the sql builder; the `|| []` is a defensive fallback that is never taken. */
|
|
1264
1325
|
return clusterStorage.executeAll(sql, params || []);
|
|
1265
1326
|
}),
|
|
1266
1327
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
@@ -1277,6 +1338,7 @@ async function verify(opts) {
|
|
|
1277
1338
|
function _resetForTest() {
|
|
1278
1339
|
registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
1279
1340
|
_externalStore = null;
|
|
1341
|
+
_externalStoreMode = "shadow";
|
|
1280
1342
|
db.reset();
|
|
1281
1343
|
_chainWriter._resetForTest();
|
|
1282
1344
|
// Drop pending buffered emits and cancel the age-flush timer on the
|
|
@@ -1292,6 +1354,7 @@ function _resetForTest() {
|
|
|
1292
1354
|
// instead of writing the rest of the batch to the new database.
|
|
1293
1355
|
if (_auditHandler) {
|
|
1294
1356
|
try { _auditHandler.shutdownSync("audit._resetForTest"); }
|
|
1357
|
+
/* c8 ignore next -- shutdownSync only splices the buffer + cancels a timer; it has no input-driven throw path, so this defensive catch never runs. */
|
|
1295
1358
|
catch (e) { log.debug("reset-handler-shutdown-failed: " + (e && e.message || e)); }
|
|
1296
1359
|
_auditHandler = null;
|
|
1297
1360
|
}
|
|
@@ -1336,6 +1399,7 @@ function _ensureHandler() {
|
|
|
1336
1399
|
var firstDropAction = null;
|
|
1337
1400
|
var firstDropMessage = null;
|
|
1338
1401
|
for (var i = 0; i < batch.length; i++) {
|
|
1402
|
+
/* c8 ignore next -- mid-drain shutdown early-exit: fires only when the handler is shut down (test reset) while a batch is in flight, a timing race not deterministically forceable from the public API. */
|
|
1339
1403
|
if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
|
|
1340
1404
|
try { await record(batch[i]); }
|
|
1341
1405
|
catch (e) {
|
|
@@ -1353,6 +1417,7 @@ function _ensureHandler() {
|
|
|
1353
1417
|
// representative sample without per-line log spam.
|
|
1354
1418
|
if (firstDropAction === null) {
|
|
1355
1419
|
firstDropAction = (batch[i] && batch[i].action) || null;
|
|
1420
|
+
/* c8 ignore next -- record() only ever rejects with an Error carrying a non-empty message, so the String(e) fallback (and the `e` falsy short-circuit) is unreachable. */
|
|
1356
1421
|
firstDropMessage = (e && e.message) ? e.message : String(e);
|
|
1357
1422
|
}
|
|
1358
1423
|
}
|
|
@@ -1443,6 +1508,7 @@ function _normalizeOutcome(o) {
|
|
|
1443
1508
|
// before the first dot) is left strict — namespaces are
|
|
1444
1509
|
// operator-registered and should be plain identifiers.
|
|
1445
1510
|
function _normalizeAction(action) {
|
|
1511
|
+
/* c8 ignore next -- only caller is safeEmit(), which returns early unless event.action is a string, so action is always a string here. */
|
|
1446
1512
|
if (typeof action !== "string") return action;
|
|
1447
1513
|
return action.replace(/-/g, "_");
|
|
1448
1514
|
}
|
|
@@ -1636,6 +1702,7 @@ async function flush() {
|
|
|
1636
1702
|
// when they boot under sox-404 / soc2 / pci-dss posture so a non-
|
|
1637
1703
|
// framework writer can't INSERT rows under a different role.
|
|
1638
1704
|
function _checkActorBinding(actorId, eventActorId, opts) {
|
|
1705
|
+
/* c8 ignore next -- only callers are bindActor()'s bound wrappers, and bindActor() throws unless actorId is a non-empty string, so actorId is always truthy here. */
|
|
1639
1706
|
if (!actorId) return true; // unbound — no enforcement
|
|
1640
1707
|
if (!eventActorId) {
|
|
1641
1708
|
return { ok: false, reason: "event missing actor.userId — refused under bound emit" };
|
|
@@ -1709,6 +1776,7 @@ function bindActor(actorId, opts) {
|
|
|
1709
1776
|
actor: { userId: actorId },
|
|
1710
1777
|
metadata: { attemptedAction: eventAction, reason: reason },
|
|
1711
1778
|
});
|
|
1779
|
+
/* c8 ignore next -- handlers' emit() never throws (it routes bad input to onError/dead-letter internally) and the violation event carries no throwing accessors, so this defensive catch never runs. */
|
|
1712
1780
|
} catch (_e) { /* drop-silent — never break the caller */ }
|
|
1713
1781
|
}
|
|
1714
1782
|
function boundSafeEmit(event) {
|
package/lib/auth/oauth.js
CHANGED
|
@@ -272,6 +272,27 @@ function _generatePkce() {
|
|
|
272
272
|
return { verifier: verifier, challenge: challenge };
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* @primitive b.auth.oauth.generatePkce
|
|
277
|
+
* @signature b.auth.oauth.generatePkce()
|
|
278
|
+
* @since 0.18.0
|
|
279
|
+
* @status stable
|
|
280
|
+
* @related b.auth.oauth.parseCallback
|
|
281
|
+
*
|
|
282
|
+
* Generate a PKCE (RFC 7636) verifier/challenge pair for a hand-rolled
|
|
283
|
+
* authorization-code flow that does not go through `create()`. The
|
|
284
|
+
* `code_challenge_method` is always `S256`: the challenge is the base64url
|
|
285
|
+
* of SHA-256 over the verifier, and the verifier is 43 base64url characters
|
|
286
|
+
* (32 CSPRNG bytes).
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* var pkce = b.auth.oauth.generatePkce();
|
|
290
|
+
* // → { verifier: "…43 chars…", challenge: "…base64url(SHA-256(verifier))…" }
|
|
291
|
+
*/
|
|
292
|
+
function generatePkce() {
|
|
293
|
+
return _generatePkce();
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
function _validateUrl(url, allowHttp, label) {
|
|
276
297
|
if (typeof url !== "string" || url.length === 0) {
|
|
277
298
|
throw new OAuthError("auth-oauth/bad-url", label + ": URL is required");
|
|
@@ -550,6 +571,7 @@ function _toAttestationPrivateKey(value, label) {
|
|
|
550
571
|
catch (e) {
|
|
551
572
|
var code = (e && e.code) === "auth-jwt-external/sign-no-key"
|
|
552
573
|
? "auth-oauth/attestation-no-key" : "auth-oauth/attestation-bad-key";
|
|
574
|
+
/* c8 ignore next -- String(e) fallback: the jwt-external error always carries a message */
|
|
553
575
|
throw new OAuthError(code, (e && e.message) || String(e));
|
|
554
576
|
}
|
|
555
577
|
}
|
|
@@ -567,8 +589,10 @@ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
|
|
|
567
589
|
try {
|
|
568
590
|
return jwtExternal._resolveSignAlg(explicitAlg, privateKey, label);
|
|
569
591
|
} catch (e) {
|
|
592
|
+
/* c8 ignore next -- || "" is unreachable: _resolveSignAlg always throws a coded AuthError */
|
|
570
593
|
var ec = (e && e.code) || "";
|
|
571
594
|
if (ec === "auth-jwt-external/sign-alg-key-mismatch") {
|
|
595
|
+
/* c8 ignore next -- String(e) fallback: the mapped error always carries a message */
|
|
572
596
|
throw new OAuthError("auth-oauth/attestation-alg-key-mismatch", (e && e.message) || String(e));
|
|
573
597
|
}
|
|
574
598
|
if (ec === "auth-jwt-external/sign-alg-refused" || ec === "auth-jwt-external/sign-alg-unsupported") {
|
|
@@ -576,8 +600,10 @@ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
|
|
|
576
600
|
label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
|
|
577
601
|
}
|
|
578
602
|
if (ec === "auth-jwt-external/sign-key-unsupported") {
|
|
603
|
+
/* c8 ignore next -- String(e) fallback: the mapped error always carries a message */
|
|
579
604
|
throw new OAuthError("auth-oauth/attestation-key-unsupported", (e && e.message) || String(e));
|
|
580
605
|
}
|
|
606
|
+
/* c8 ignore next -- unreachable: _resolveSignAlg only emits the four codes handled above */
|
|
581
607
|
throw new OAuthError("auth-oauth/attestation-bad-key", (e && e.message) || String(e));
|
|
582
608
|
}
|
|
583
609
|
}
|
|
@@ -613,6 +639,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
|
613
639
|
header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
|
|
614
640
|
payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
|
|
615
641
|
} catch (e) {
|
|
642
|
+
/* c8 ignore next 2 -- String(e) fallback: the decode error always carries a message */
|
|
616
643
|
throw new OAuthError("auth-oauth/attestation-malformed",
|
|
617
644
|
label + ": header/payload decode failed: " + ((e && e.message) || String(e)));
|
|
618
645
|
}
|
|
@@ -653,6 +680,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
|
653
680
|
var ok;
|
|
654
681
|
try {
|
|
655
682
|
ok = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
|
|
683
|
+
/* c8 ignore next 4 -- verify cannot raise: the alg/kty/crv cross-check above guarantees a compatible key/params pairing, so a bad signature returns false rather than throwing */
|
|
656
684
|
} catch (verifyErr) {
|
|
657
685
|
throw new OAuthError("auth-oauth/attestation-bad-signature",
|
|
658
686
|
label + ": signature verification raised: " + ((verifyErr && verifyErr.message) || String(verifyErr)));
|
|
@@ -667,6 +695,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
|
667
695
|
// never reach the attestation's cnf claim. Mirrors the dpop.buildProof
|
|
668
696
|
// public-only embed.
|
|
669
697
|
function _publicCnfJwk(jwk, label) {
|
|
698
|
+
/* c8 ignore next 4 -- unreachable: the sole caller validates instanceKeyJwk as a required object before this call */
|
|
670
699
|
if (!jwk || typeof jwk !== "object") {
|
|
671
700
|
throw new OAuthError("auth-oauth/attestation-bad-cnf",
|
|
672
701
|
label + ": instanceKeyJwk (public JWK for the cnf claim) is required");
|
|
@@ -1006,6 +1035,7 @@ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
|
|
|
1006
1035
|
// strings, returns false (never throws) on length mismatch, and refuses
|
|
1007
1036
|
// non-string/Buffer input — so it carries the timing + type discipline.
|
|
1008
1037
|
function _constantTimeStrEq(a, b) {
|
|
1038
|
+
/* c8 ignore next -- every caller passes a String()-wrapped first argument, so the typeof-a arm never short-circuits */
|
|
1009
1039
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
1010
1040
|
return cryptoTimingSafeEqual(a, b);
|
|
1011
1041
|
}
|
|
@@ -1040,6 +1070,7 @@ var RESERVED_AUTHZ_PARAMS = {
|
|
|
1040
1070
|
// closed; shared by authorizationUrl, pushAuthorizationRequest, and
|
|
1041
1071
|
// endSessionUrl so every URL/PAR builder guards the same way.
|
|
1042
1072
|
function _assertNoReservedExtraParams(extraParams, reserved, errCode, ctx) {
|
|
1073
|
+
/* c8 ignore next -- every caller guards `extraParams && typeof === "object"` first, so the !extraParams arm never short-circuits */
|
|
1043
1074
|
if (!extraParams || typeof extraParams !== "object") return;
|
|
1044
1075
|
var ek = Object.keys(extraParams);
|
|
1045
1076
|
for (var i = 0; i < ek.length; i++) {
|
|
@@ -1187,13 +1218,16 @@ function create(opts) {
|
|
|
1187
1218
|
Object.assign(req, httpClientOpts);
|
|
1188
1219
|
var res = await hc.request(req);
|
|
1189
1220
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1221
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
1190
1222
|
var bodyText = res.body ? res.body.toString("utf8") : "";
|
|
1191
1223
|
throw new OAuthError("auth-oauth/http-" + res.statusCode,
|
|
1192
1224
|
url + " returned " + res.statusCode + ": " + bodyText.slice(0, 500));
|
|
1193
1225
|
}
|
|
1226
|
+
/* c8 ignore next -- httpClient always yields a Buffer body (empty Buffer for no content), so this never returns null */
|
|
1194
1227
|
if (!res.body) return null;
|
|
1195
1228
|
try { return safeJson.parse(res.body.toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
1196
1229
|
catch (e) {
|
|
1230
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
1197
1231
|
throw new OAuthError("auth-oauth/bad-json",
|
|
1198
1232
|
url + " response not JSON: " + ((e && e.message) || String(e)));
|
|
1199
1233
|
}
|
|
@@ -1262,6 +1296,7 @@ function create(opts) {
|
|
|
1262
1296
|
async function _peekDiscovery() {
|
|
1263
1297
|
if (!isOidc || !issuer) return null;
|
|
1264
1298
|
try { return (await _discoveryCache.get("config")) || null; }
|
|
1299
|
+
/* c8 ignore next -- defensive: the in-memory discovery cache get() does not throw */
|
|
1265
1300
|
catch (_e) { return null; }
|
|
1266
1301
|
}
|
|
1267
1302
|
|
|
@@ -1312,6 +1347,7 @@ function create(opts) {
|
|
|
1312
1347
|
// base64url(SHA-256(verifier)) per RFC 7636.
|
|
1313
1348
|
var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
|
|
1314
1349
|
var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
|
|
1350
|
+
/* c8 ignore next -- pkce is always true (create() refuses pkce:false), so the : null alternate is dead */
|
|
1315
1351
|
var pkceVals = pkce ? _generatePkce() : null;
|
|
1316
1352
|
var params = new URLSearchParams();
|
|
1317
1353
|
params.set("response_type", "code");
|
|
@@ -1356,6 +1392,7 @@ function create(opts) {
|
|
|
1356
1392
|
url: endpoint + sep + params.toString(),
|
|
1357
1393
|
state: state,
|
|
1358
1394
|
nonce: nonce,
|
|
1395
|
+
/* c8 ignore next 2 -- pkceVals is always truthy (pkce is always on), so the : null alternates are dead */
|
|
1359
1396
|
verifier: pkceVals ? pkceVals.verifier : null,
|
|
1360
1397
|
challenge: pkceVals ? pkceVals.challenge : null,
|
|
1361
1398
|
authorizationDetails: requestedAuthzDetails,
|
|
@@ -1764,6 +1801,7 @@ function create(opts) {
|
|
|
1764
1801
|
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
1765
1802
|
Object.assign(req, httpClientOpts);
|
|
1766
1803
|
var res = await hc.request(req);
|
|
1804
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
1767
1805
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
1768
1806
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1769
1807
|
throw new OAuthError("auth-oauth/token-error-" + res.statusCode,
|
|
@@ -1772,6 +1810,7 @@ function create(opts) {
|
|
|
1772
1810
|
var parsed;
|
|
1773
1811
|
try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
1774
1812
|
catch (e) {
|
|
1813
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
1775
1814
|
throw new OAuthError("auth-oauth/bad-token-json",
|
|
1776
1815
|
"token endpoint response not JSON: " + ((e && e.message) || String(e)));
|
|
1777
1816
|
}
|
|
@@ -1779,6 +1818,7 @@ function create(opts) {
|
|
|
1779
1818
|
}
|
|
1780
1819
|
|
|
1781
1820
|
async function _normalizeTokens(raw, vopts) {
|
|
1821
|
+
/* c8 ignore next -- every caller passes a vopts object, so the || {} default is unreachable */
|
|
1782
1822
|
vopts = vopts || {};
|
|
1783
1823
|
// RFC 6749 §3.3 — scope is space-separated, ONLY U+0020. `\s+` previously
|
|
1784
1824
|
// matched U+0085 NEL, U+00A0 NBSP, etc., so a hostile AS returning
|
|
@@ -1879,6 +1919,7 @@ function create(opts) {
|
|
|
1879
1919
|
action: "jwt.jwe.refused",
|
|
1880
1920
|
outcome: "denied",
|
|
1881
1921
|
metadata: { reason: "jwe-on-jws-verifier", primitive: "oauth.verifyIdToken" },
|
|
1922
|
+
/* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
|
|
1882
1923
|
}); } catch (_e) { /* drop-silent — observability sink */ }
|
|
1883
1924
|
throw new OAuthError("auth-oauth/jwe-refused",
|
|
1884
1925
|
"5-segment JWE id_token refused — verifyIdToken only handles JWS " +
|
|
@@ -1892,6 +1933,7 @@ function create(opts) {
|
|
|
1892
1933
|
header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES });
|
|
1893
1934
|
payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES });
|
|
1894
1935
|
} catch (e) {
|
|
1936
|
+
/* c8 ignore next 2 -- String(e) fallback: the decode error always carries a message */
|
|
1895
1937
|
throw new OAuthError("auth-oauth/malformed-jwt",
|
|
1896
1938
|
"ID token header/payload base64 decode failed: " + ((e && e.message) || String(e)));
|
|
1897
1939
|
}
|
|
@@ -1980,6 +2022,7 @@ function create(opts) {
|
|
|
1980
2022
|
var verified;
|
|
1981
2023
|
try {
|
|
1982
2024
|
verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
|
|
2025
|
+
/* c8 ignore next 5 -- verify cannot raise: the alg/kty/crv cross-check above guarantees a compatible key/params pairing, so a bad signature returns false rather than throwing */
|
|
1983
2026
|
} catch (verifyErr) {
|
|
1984
2027
|
throw new OAuthError("auth-oauth/bad-signature",
|
|
1985
2028
|
"ID token signature verification raised: " +
|
|
@@ -2055,6 +2098,7 @@ function create(opts) {
|
|
|
2055
2098
|
reason: "cross-realm-jwt-refused",
|
|
2056
2099
|
primitive: "oauth.verifyIdToken",
|
|
2057
2100
|
},
|
|
2101
|
+
/* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
|
|
2058
2102
|
}); } catch (_e) { /* drop-silent — observability sink */ }
|
|
2059
2103
|
throw new OAuthError("auth-oauth/iss-mismatch",
|
|
2060
2104
|
"ID token iss '" + payload.iss + "' does not match expected '" + issuer +
|
|
@@ -2335,6 +2379,7 @@ function create(opts) {
|
|
|
2335
2379
|
reason: "frontchannel-logout-cross-realm",
|
|
2336
2380
|
primitive: "oauth.parseFrontchannelLogoutRequest",
|
|
2337
2381
|
},
|
|
2382
|
+
/* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
|
|
2338
2383
|
}); } catch (_e) { /* drop-silent — observability sink */ }
|
|
2339
2384
|
throw new OAuthError("auth-oauth/frontchannel-logout-iss-mismatch",
|
|
2340
2385
|
"parseFrontchannelLogoutRequest: iss \"" + iss +
|
|
@@ -2423,6 +2468,7 @@ function create(opts) {
|
|
|
2423
2468
|
var claims = verified.claims;
|
|
2424
2469
|
|
|
2425
2470
|
// §2.6 — events claim presence + correct shape
|
|
2471
|
+
/* c8 ignore next 5 -- defense-in-depth: verifyIdToken's skipExpCheck self-guard already enforced the backchannel-logout event before returning, so this re-check never fires */
|
|
2426
2472
|
if (!claims.events || typeof claims.events !== "object" ||
|
|
2427
2473
|
!claims.events["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
2428
2474
|
throw new OAuthError("auth-oauth/missing-logout-event",
|
|
@@ -2449,6 +2495,7 @@ function create(opts) {
|
|
|
2449
2495
|
? vopts.maxAgeSec
|
|
2450
2496
|
: DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC;
|
|
2451
2497
|
var nowSecLogout = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
2498
|
+
/* c8 ignore next 4 -- defense-in-depth: verifyIdToken's skipExpCheck freshness gate already required a numeric iat before returning, so this re-check never fires */
|
|
2452
2499
|
if (typeof claims.iat !== "number") {
|
|
2453
2500
|
throw new OAuthError("auth-oauth/logout-token-no-iat",
|
|
2454
2501
|
"verifyBackchannelLogoutToken: payload.iat required (OIDC BCL §2.4)");
|
|
@@ -2513,6 +2560,7 @@ function create(opts) {
|
|
|
2513
2560
|
sub: claims.sub || null,
|
|
2514
2561
|
sid: claims.sid || null,
|
|
2515
2562
|
jti: claims.jti || null,
|
|
2563
|
+
/* c8 ignore next -- iat is always a positive number here (enforced above), so the || null arm is unreachable */
|
|
2516
2564
|
iat: claims.iat || null,
|
|
2517
2565
|
events: claims.events,
|
|
2518
2566
|
claims: claims,
|
|
@@ -2672,6 +2720,7 @@ function create(opts) {
|
|
|
2672
2720
|
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
2673
2721
|
Object.assign(req, httpClientOpts);
|
|
2674
2722
|
var res = await hc.request(req);
|
|
2723
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2675
2724
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
2676
2725
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
2677
2726
|
throw new OAuthError("auth-oauth/register-failed-" + res.statusCode,
|
|
@@ -2680,6 +2729,7 @@ function create(opts) {
|
|
|
2680
2729
|
var parsed;
|
|
2681
2730
|
try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
2682
2731
|
catch (e) {
|
|
2732
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
2683
2733
|
throw new OAuthError("auth-oauth/bad-register-response",
|
|
2684
2734
|
"registerClient: response not JSON: " + ((e && e.message) || String(e)));
|
|
2685
2735
|
}
|
|
@@ -2803,13 +2853,16 @@ function create(opts) {
|
|
|
2803
2853
|
"deleteClient: " + res.statusCode);
|
|
2804
2854
|
}
|
|
2805
2855
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
2856
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2806
2857
|
var errText = res.body ? res.body.toString("utf8").slice(0, 500) : "";
|
|
2807
2858
|
throw new OAuthError("auth-oauth/dcr-" + method.toLowerCase() + "-failed-" + res.statusCode,
|
|
2808
2859
|
method.toLowerCase() + "Client: " + res.statusCode + ": " + errText);
|
|
2809
2860
|
}
|
|
2861
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2810
2862
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
2811
2863
|
try { return safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
2812
2864
|
catch (e) {
|
|
2865
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
2813
2866
|
throw new OAuthError("auth-oauth/dcr-bad-response",
|
|
2814
2867
|
method.toLowerCase() + "Client: response not JSON: " + ((e && e.message) || String(e)));
|
|
2815
2868
|
}
|
|
@@ -2937,6 +2990,7 @@ function create(opts) {
|
|
|
2937
2990
|
// reinstate buffer mode.
|
|
2938
2991
|
req.responseMode = "always-resolve";
|
|
2939
2992
|
var res = await hc.request(req);
|
|
2993
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2940
2994
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
2941
2995
|
var parsed;
|
|
2942
2996
|
try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
@@ -2959,6 +3013,7 @@ function create(opts) {
|
|
|
2959
3013
|
throw new OAuthError("auth-oauth/device-" + (err || "unknown"),
|
|
2960
3014
|
"pollDeviceCode: " + (parsed && parsed.error_description ? parsed.error_description : text.slice(0, 200))); // 200-char error-snippet cap, not bytes
|
|
2961
3015
|
}
|
|
3016
|
+
/* c8 ignore next 2 -- the || fallback is unreachable: reaching this timeout requires a truthy (small) maxWaitMs; a falsy one yields the 10-minute deadline that never expires within a test window */
|
|
2962
3017
|
throw new OAuthError("auth-oauth/device-poll-timeout",
|
|
2963
3018
|
"pollDeviceCode: exceeded maxWaitMs " + (popts.maxWaitMs || C.TIME.minutes(10)));
|
|
2964
3019
|
}
|
|
@@ -3180,6 +3235,8 @@ module.exports = {
|
|
|
3180
3235
|
buildClientAttestation: buildClientAttestation,
|
|
3181
3236
|
buildClientAttestationPop: buildClientAttestationPop,
|
|
3182
3237
|
verifyClientAttestation: verifyClientAttestation,
|
|
3238
|
+
// PKCE (RFC 7636) generator for hand-rolled authorization-code flows.
|
|
3239
|
+
generatePkce: generatePkce,
|
|
3183
3240
|
// Internal helpers exposed for tests
|
|
3184
3241
|
_generatePkce: _generatePkce,
|
|
3185
3242
|
_generateRandomToken: _generateRandomToken,
|
package/lib/cli.js
CHANGED
|
@@ -1425,15 +1425,21 @@ var MTLS_USAGE = [
|
|
|
1425
1425
|
" 'auto' loads whichever form exists.",
|
|
1426
1426
|
"",
|
|
1427
1427
|
"Subcommand flags:",
|
|
1428
|
+
" init: [--algorithm ECDSA-P384-SHA384]",
|
|
1428
1429
|
" issue: --subject <CN> [--days <N>]",
|
|
1429
1430
|
" issue-p12: --subject <CN> --password <pkcs12-passphrase> [--days <N>] [--out <path>]",
|
|
1430
1431
|
"",
|
|
1431
1432
|
"Cert issuance ('init', 'issue', 'issue-p12') uses the framework's",
|
|
1432
|
-
"bundled pure-JS engine (lib/mtls-engine-default.js
|
|
1433
|
-
"
|
|
1434
|
-
"
|
|
1435
|
-
"
|
|
1436
|
-
"
|
|
1433
|
+
"bundled pure-JS engine (lib/mtls-engine-default.js) on the vendored",
|
|
1434
|
+
"@blamejs/pki toolkit: post-quantum ML-DSA-87 (FIPS 204) certificate",
|
|
1435
|
+
"signatures by default, which node:tls verifies in a real mutual-auth",
|
|
1436
|
+
"handshake on OpenSSL 3.5. Pass 'init --algorithm ECDSA-P384-SHA384'",
|
|
1437
|
+
"for a classical CA a peer predating OpenSSL 3.5 can verify (the pin",
|
|
1438
|
+
"covers the CA and its leaves). PKCS#12 export keeps AES-256-CBC +",
|
|
1439
|
+
"PBKDF2-HMAC-SHA-512 @ 2,000,000-iter bag protection with a",
|
|
1440
|
+
"tier-dependent integrity MAC (PBMAC1/RFC 9579 for the PQC default,",
|
|
1441
|
+
"RFC 7292 HMAC MacData for the classical bridge). Operators with custom",
|
|
1442
|
+
"requirements pass a different engine via b.mtlsCa.create({ engine: ... }).",
|
|
1437
1443
|
].join("\n");
|
|
1438
1444
|
|
|
1439
1445
|
async function _runMtls(args, ctx) {
|
|
@@ -1481,6 +1487,10 @@ async function _runMtls(args, ctx) {
|
|
|
1481
1487
|
dataDir: dataDir,
|
|
1482
1488
|
vault: booted.b.vault,
|
|
1483
1489
|
caKeySealedMode: sealedMode,
|
|
1490
|
+
// --algorithm pins the CA + its leaves; omitted -> the ML-DSA-87 default.
|
|
1491
|
+
// Pass "ECDSA-P384-SHA384" for the classical bridge (a peer predating
|
|
1492
|
+
// OpenSSL 3.5). undefined when the flag is absent.
|
|
1493
|
+
algorithm: args.flags.algorithm,
|
|
1484
1494
|
// No engine passed — b.mtlsCa falls back to the bundled default
|
|
1485
1495
|
// (lib/mtls-engine-default.js).
|
|
1486
1496
|
});
|