@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 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: @peculiar/x509 + pkijs (peculiar-pki bundle)
63
- Version: @peculiar/x509 2.0.0, pkijs 3.4.0
64
- Source: https://github.com/PeculiarVentures/x509
65
- https://github.com/PeculiarVentures/PKI.js
66
- License: MIT
67
- Copyright: Copyright (c) Peculiar Ventures
68
- Used for: Pure-JS mTLS CA. Self-signed CA generation (ECDSA P-384, SHA-384),
69
- leaf-cert signing for client certificates, PKCS#12 packaging
70
- (PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512, 2,000,000 iterations).
71
- Wired into b.mtlsCa via lib/mtls-engine-default.js, backed by the
72
- bundle lib/vendor/pki.cjs (also includes reflect-metadata,
73
- asn1js, pvtsutils, pvutils, and the @peculiar/asn1-* schema
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; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); 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; 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
- | [`@peculiar/x509`](https://github.com/PeculiarVentures/x509) + [`pkijs`](https://github.com/PeculiarVentures/PKI.js) | 2.0.0 + 3.4.0 | [Peculiar Ventures](https://github.com/PeculiarVentures) | Pure-JS mTLS CAECDSA P-384 cert signing, PKCS#12 packaging (no openssl CLI) |
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 is 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.
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: async function (row), // operator's persistence callback
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, ECDSA P-384",
1433
- "signatures, AES-256-CBC + HMAC-SHA-512 PBKDF2 PKCS#12 with 2,000,000",
1434
- "iterations). Operators with custom requirements pass a different",
1435
- "engine via b.mtlsCa.create({ engine: ... }) when wiring their app;",
1436
- "the CLI always uses the default.",
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
  });