@blamejs/core 0.11.20 → 0.11.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.11.x
10
10
 
11
+ - v0.11.22 (2026-05-20) — **`b.cert.create` — turnkey TLS-certificate manager composing ACME + sealed persistence + renewal scheduler + SNI + key escrow.** Operators wiring TLS no longer have to glue ACME + key generation + cert persistence + renewal scheduling + SNI dispatch + escrow by hand. `b.cert.create({ storage, acme, certs, renew, ocsp, audit, compliance })` accepts a declarative manifest of certificates plus the operator's choice of ACME challenge solver, and the manager owns the rest of the lifecycle: ACME RFC 8555 order + RFC 9773 ARI-aware renewal, leaf key rotation on renew, OCSP refresh scheduling, sealed-disk persistence via `b.vault.seal`, optional break-glass key escrow encrypted to an operator-supplied recipient via `b.crypto.encryptEnvelope`, and SNI dispatch for `https.createServer({ SNICallback })`. Composes existing primitives — `b.acme.create` (extended this release with per-challenge methods), `b.vault.seal`, `b.safeAsync.repeating`, `b.network.tls`, `b.audit` — so the operator-facing surface is one factory call. **Added:** *`b.cert.create(opts)` — turnkey certificate manager* — New top-level primitive. Storage backend: `sealed-disk` (default; sealed via `b.vault.seal`). ACME: directory URL + contact + auto-generated account key (sealed on disk; operator can override with explicit key material). Certs manifest: per-cert name + domains + keyAlg (ecdsa-p256 / ecdsa-p384 / rsa-2048 / rsa-3072 / rsa-4096) + challenge `{ type, provision, cleanup }` callbacks (http-01 / dns-01 / tls-alpn-01). Renewal: ARI-respecting scheduler with configurable `intervalMs` + `minDaysBeforeExpiry` thresholds. OCSP: `stapling: true` schedules periodic OCSP refresh via `b.network.tls.ocsp`. Key escrow: optional `keyEscrow: { recipient }` encrypts each renewed private key to the recipient public key via `b.crypto.encryptEnvelope` and persists alongside the sealed key — break-glass-only recovery path, NOT routine access. Surface: `start()` / `stop()` / `getContext(name)` / `sniCallback` / `refresh(name)` / event-emitter `on('cert.issued' | 'cert.renewed' | 'cert.renew-failed', handler)`. · *`b.acme.create.fetchAuthorization(authUrl)` — RFC 8555 §7.5 authorization GET* — POST-as-GET an authorization URL; returns the parsed authorization object with the challenge array. The challenge entries carry `{ type, url, token, status }` for each offered challenge type. Required by the cert manager's per-challenge flow but useful to operators implementing custom ACME wrappers. · *`b.acme.create.notifyChallengeReady(challengeUrl)` — RFC 8555 §7.5.1 ready-notification* — POST an empty JSON object to a challenge URL to signal the operator has provisioned the response. Returns the updated challenge object; the CA's validation runs asynchronously and the operator polls via `waitForAuthorization` afterwards. · *`b.acme.create.waitForAuthorization(authUrl, opts?)` — authorization status polling* — Polls an authorization until `status === 'valid'` (success) or `status === 'invalid'` (CA refused). Honors the client's `pollIntervalMs` + `pollMaxMs` defaults; per-call `opts.intervalMs` + `opts.timeoutMs` override available. Throws typed `acme/auth-invalid` on CA refusal and `acme/auth-timeout` on poll-budget exhaustion. · *`b.acme.create.buildCsr({ privateKey, publicKey, domains })` — RFC 2986 PKCS#10 CSR builder* — Builds a CertificationRequest signed with the leaf private key. Subject `CN=<first domain>`, all domains as `dNSName` entries in the SubjectAltName extension. Supports ECDSA P-256 (signed sha256), ECDSA P-384 (signed sha384), RSA 2048 / 3072 / 4096 (signed sha256). Ed25519 is rejected at the CSR layer because CA support is uneven — operators wanting Ed25519 certs build the CSR externally. PEM-encoded output ready to feed to `finalize(order, csrPem)`. · *`b.asn1Der` write primitives — `writeBitString` / `writeSet` / `writeUtf8String` / `writePrintableString` / `writeIa5String` / `writeBoolean` / `writeContextImplicit`* — ASN.1 DER encoder extensions needed for PKCS#10 CSR construction. PrintableString refuses input outside the RFC 5280 character set; IA5String refuses non-ASCII; UTF8String refuses non-string input. SET-OF encoding sorts children by their encoded bytes per DER. Internal-only today (consumed by `b.acme.create.buildCsr`); operators with their own ASN.1 needs can compose them. **Changed:** *`b.audit.FRAMEWORK_NAMESPACES` adds `cert`* — The cert-manager lifecycle emits `cert.account.generated` / `cert.issued` / `cert.renewed` / `cert.renew-failed` / `cert.challenge-cleanup` audit events; the audit-namespace coverage check at smoke-time now recognizes the `cert` namespace. **References:** [RFC 8555 (ACME)](https://www.rfc-editor.org/rfc/rfc8555.html) · [RFC 9773 (ACME Renewal Information / ARI)](https://www.rfc-editor.org/rfc/rfc9773.html) · [RFC 2986 (PKCS#10 Certification Request Syntax)](https://www.rfc-editor.org/rfc/rfc2986.html) · [RFC 5280 (X.509 Internet PKI)](https://www.rfc-editor.org/rfc/rfc5280.html) · [RFC 8737 (TLS-ALPN-01 challenge)](https://www.rfc-editor.org/rfc/rfc8737.html) · [OpenSSL CSR roundtrip — local OpenSSL validation](https://www.openssl.org/docs/man3.5/man1/openssl-req.html)
12
+
13
+ - v0.11.21 (2026-05-20) — **Supply-chain hardening — pinact + zizmor + actionlint gate, sha-to-tag tag-integrity verifier, GOVERNANCE.md.** Closes the input + tag-integrity halves of the supply-chain trust boundary. `pinact` refuses any `uses:` reference that isn't pinned to a 40-char SHA with a verified version-comment (defense against the CVE-2025-30066 retroactive-tag-rewrite class). `zizmor` audits every workflow for the documented security anti-pattern catalog (template-injection / excessive-permissions / cache-poisoning / impostor-commit / unredacted-secrets / etc.). The new `sha-to-tag-verify` workflow refuses to let the publish workflow proceed if a release tag's commit SHA isn't on `main`'s first-parent history OR wasn't the result of a merged PR — the source-side gate that TanStack's 2026-05-11 attack (84 malicious `@tanstack/*` versions published with valid SLSA L3 provenance) demonstrated as a structural defense alongside provenance verification. SECURITY.md gains operator-facing `slsa-verifier` and tag-SHA-integrity recipes. New top-level `GOVERNANCE.md` documents the solo-maintainer governance model, succession plan, key-loss recovery, and dependent-notification protocol. **Added:** *`.github/workflows/actions-lint.yml` — pinact + zizmor + actionlint gate* — Three tools, three layers per the arxiv.org "Unpacking Security Scanners for GitHub Actions Workflows" taxonomy. `pinact run --check` refuses any `uses:` reference that isn't SHA-pinned with a matching version-comment; `pinact run --verify` re-resolves each pinned SHA's registered tag at check time and refuses if the workflow's version-comment disagrees (catches retroactive tag rewrites). `zizmor` audits at `--min-severity low` across every documented rule class (template-injection, excessive-permissions, dangerous-triggers, unpinned-uses, cache-poisoning, github-env, hardcoded-container-credentials, impostor-commit, known-vulnerable-actions, obfuscation, ref-confusion, secrets-inherit, self-hosted-runner, unredacted-secrets, unsound-contains, use-trusted-publishing); SARIF emitted to GitHub Code Scanning. `actionlint` runs YAML + expression validation + shellcheck on every `run:` block. Single documented exception in `.pinact.yaml` — the SLSA reusable workflow MUST be tag-pinned because its internal builder-fetch step refuses non-tag refs. · *`.github/workflows/sha-to-tag-verify.yml` — tag-SHA integrity gate* — Runs on every `v*` tag push and refuses to let the publish workflow start if the tag's commit SHA isn't on `main`'s first-parent history OR wasn't the result of a merged PR. Defends against the tag-mutation class (CVE-2025-30066: 23,000+ affected repos in March 2025) and the source-side-malicious-publish class (TanStack 2026-05-11: 84 valid-SLSA-L3-provenance malicious versions). The same chain is documented for operator-side re-verification in SECURITY.md's new "Verifying release-commit integrity" subsection. · *`GOVERNANCE.md` — solo-maintainer governance, succession, key-loss recovery, dependent-notification* — New top-level document covering: (a) current governance model (solo maintainer pre-1.0, maintainer-final on technical direction); (b) succession plan with TBD-successor-with-documented-re-open-trigger, repository ownership, npm publish credentials, SSH signing key rotation procedure, Sigstore identity rotation; (c) key-loss recovery for every asset (npm publish, GitHub org, SSH signing key, Sigstore, domain); (d) dependent-notification protocol via `security@blamejs.com` with 30-day no-activity escalation. Bus-factor-1 is the largest non-technical risk pre-1.0; this document makes the recovery path defensible. · *SECURITY.md — `slsa-verifier` and tag-SHA-integrity operator-verification recipes* — "Verifying release authenticity" gains a `slsa-verifier verify-artifact` recipe (pinned to v2.7.1) for offline / API-independent provenance verification — `gh attestation verify` walks the chain via the GitHub API; `slsa-verifier` does it from disk. The recipe explicitly states the limit: SLSA provenance binds the tarball bytes to a workflow+commit+tag, but does NOT prove the source is clean (the TanStack incident shipped valid-provenance malicious versions because the source side was compromised). A new "Verifying release-commit integrity" subsection documents the sha-to-tag chain operators run alongside provenance verification — the source-side gate. · *`.pinact.yaml` — pinact configuration with documented SLSA exception* — Defines a single tag-pin exception for `slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml`. The SLSA reusable workflow's internal builder-fetch step refuses non-tag `BUILDER_REF` values, so the call MUST be tag-pinned; mitigated by slsa-framework's upstream tag-protection + immutable-release rules. The same exception also appears as a per-line `# allow:slsa-framework-action-not-sha-pinned` marker in `npm-publish.yml` for the framework's own codebase-patterns gate. **Changed:** *Workflow version-comment integrity — every pinned action's `# vX.Y.Z` comment now matches the registered tag for that SHA* — Pre-existing pins carried stale version-comments (`actions/checkout@de0fac2e... # v6.0.0` where that SHA is actually `v6.0.2`, `actions/setup-node@48b55a01... # v6.0.0` where the SHA is `v6.4.0`, `actions/download-artifact@3e5f45b2... # v7.0.1` where the SHA is `v8.0.1`, `github/codeql-action@9e0d7b8d... # v3.30.9` where the SHA is `v4.35.5`). The SHAs themselves stay (they're the actual-released versions); the comments now match. This is what pinact's `--verify` check enforces structurally going forward — a stale version-comment is the early-warning signal that a retroactive tag rewrite landed without operator notice. **References:** [CVE-2025-30066 (tj-actions/changed-files retroactive tag rewrite)](https://nvd.nist.gov/vuln/detail/CVE-2025-30066) · [TanStack npm publish incident 2025-05-11](https://blog.tanstack.com/the-tanstack-may-2025-supply-chain-attack/) · [pinact](https://github.com/suzuki-shunsuke/pinact) · [zizmor](https://github.com/woodruffw/zizmor) · [actionlint](https://github.com/rhysd/actionlint) · [slsa-verifier](https://github.com/slsa-framework/slsa-verifier)
14
+
11
15
  - v0.11.20 (2026-05-20) — **`b.backup.localStorage` legacy alias removed + test-discipline backlog fully drained.** Two pieces bundled into one patch. (1) The `b.backup.localStorage` legacy alias introduced in v0.11.2 as a deprecation cycle for the rename to `b.backup.diskStorage` is removed entirely — pre-v1 the project's stated policy is no backwards-compat shims, and the cleanup no longer needs to wait for the Node 26 floor-bump. (2) Every test file in the `test-promise-settimeout-sleep` detector's 49-file migration backlog is converted from `await new Promise(r => setTimeout(r, N))` sleeps to `helpers.waitUntil(predicate, opts)` (condition-waits) or the new `helpers.passiveObserve(ms, label)` (verify-absence-of-event windows). The previously-split `test-codebase-patterns.test.js` catalog is folded into the main `codebase-patterns.test.js` `KNOWN_ANTIPATTERNS` array with `scanScope: "test"`. **Added:** *`helpers.passiveObserve(ms, label)` — real-time observation budget* — Distinct from `helpers.waitUntil`: the goal is NOT to poll until a condition is truthy, but to let real time elapse so the test can verify ABSENCE of an event over a window. Required `label` (string) surfaces in audit logs / future flake diagnostics so a grep through a CI log immediately identifies which observation budget was consumed. Throws TypeError on non-positive `ms` or missing label. Use sparingly — if there IS an observable predicate, `waitUntil` is the right primitive (faster on fast platforms; passive observation always burns the full budget). **Changed:** *Test-discipline catalog unified — `codebase-patterns.test.js` is now the single source of truth* — The previously-split `test-codebase-patterns.test.js` runner is merged into the main `codebase-patterns.test.js` catalog. Six test-side detectors are now inline `KNOWN_ANTIPATTERNS` entries with `scanScope: "test"`: `test-promise-settimeout-sleep` (broadened regex catches block-bodied arrows + multi-arg arrows + function bodies with leading statements), `test-uses-stream-pipeline-without-withtesttimeout` (per-test wall-clock ceiling for node:stream.pipeline tests), `release-named-test-file` (basename-mode; refuses `v0-8-41-additions.test.js` / `slot-19-enhancements.test.js` / `batch-N.test.js`), `test-hardcoded-server-bind-port` (`.listen(0)` + `server.address().port`), `test-fs-watch-direct-call` (`helpers.backdateFile` + `helpers.waitForWatcher` instead), `test-future-utimes-without-backdated-baseline` (pair future-mtime writes with `backdateFile`), `test-creates-db-handle-without-isolation` (`b.db.create()` must compose `setupTestDb` / `setupVaultOnly` / `mkdtempSync`). The test-scope walker now also includes `examples/*/test/` so wiki integration tests share the same discipline. Single catalog means a single allowlist per detector, single migration backlog, and one runner to invoke from CI. · *Test-discipline migration — `setTimeout(r, N)` backlog fully drained (49 → 0 migration entries)* — Converted every test file in the migration backlog to `helpers.waitUntil` / `helpers.passiveObserve`: `a2a`, `a2a-tasks`, `agent-event-bus`, `agent-idempotency`, `agent-orchestrator`, `agent-snapshot`, `api-encrypt`, `app-shutdown`, `audit-segregation`, `break-glass`, `config`, `cors`, `daily-byte-quota`, `dsr`, `external-db-routing`, `guard-csv`, `http-client-cache`, `log-stream-cloudwatch`, `log-stream-otlp` (dead `_sleep` helper removed), `log-stream-otlp-grpc`, `mail-greylist`, `middleware-compose-pipeline`, `network`, `network-dns-resolver`, `network-heartbeat-passive`, `notify`, `observability-tracing`, `promise-pool`, `pubsub`, `queue-dlq-extend-lease`, `queue-flow-repeat`, `queue-priority-rate-progress`, `require-auth-cache-control`, `retry`, `safe-async-loops`, `safe-async-parallel`, `scim-server`, `sse`, `vault-seal-pem-file`, `watcher` (3 fs.watchFile poll-event waits), `webhook`, `websocket-channels`, `ws-client`, `api-key` (layer-1), and integration tests `cache`, `cluster-provider-mysql`, `log-stream`, `network-heartbeat`, `object-store-sigv4`, `pubsub`, `queue-redis`, `websocket-permessage-deflate`, `ws-client-roundtrip`. Each condition-wait now has a grep'able label and an automatic 5000ms ceiling. Two files are permanent structural FPs (not migration backlog): `test/helpers/services.js` (TCP/TLS/UDP probe primitives — race-with-socket-event pattern, not condition-wait) and `examples/wiki/test/integration.js` (consumes `@blamejs/core` via npm symlink, doesn't have access to the framework's internal test helpers; single 100ms post-shutdown flush window). **Removed:** *`b.backup.localStorage` — the legacy alias is gone* — The property no longer resolves on `b.backup` and the one-time deprecation warning is no longer emitted. Migration is a literal-name find-and-replace: `b.backup.localStorage({ root: ... })` becomes `b.backup.diskStorage({ root: ... })`. The returned storage backend object, its options shape, and its wire contract with `b.backupBundle.create` are unchanged. See SECURITY.md's Node 26 compatibility section for the original rename rationale. **References:** [Node.js 26 release notes (localStorage global)](https://nodejs.org/en/blog/release/v26.0.0)
12
16
 
13
17
  - v0.11.18 (2026-05-20) — **ML-DSA-65 release-signing key onboarded — `.mldsa.sig` sidecar lights up.** v0.11.17 was the first release the new pipeline published end-to-end, but the `.mldsa.sig` PQC sidecar was missing because the operator-side `RELEASE_PQC_SIGNING_KEY` setup hadn't been done yet. v0.11.18 onboards the keypair: `keys/release-pqc-pub.json` is committed in-tree, the matching private key lives only in the `npm-publish` environment's `RELEASE_PQC_SIGNING_KEY` secret, and `SECURITY.md` documents the SHA3-512 fingerprint + the operator-side verification recipe (no external verifier binary required — verifies via the framework's own vendored noble-post-quantum primitive). **Added:** *`keys/release-pqc-pub.json` — ML-DSA-65 release-signing public key committed in-tree* — Generated locally via `node scripts/generate-release-signing-key.js`; the matching private key was set as the `RELEASE_PQC_SIGNING_KEY` secret in the `npm-publish` GitHub Actions environment (same scope as `NPM_TOKEN`). The publish workflow's PQC sidecar step now finds both pieces present and computes a `<tarball>.mldsa.sig` for every release tarball. Self-verify-before-write inside `sign-release-artifact.js` catches a stale env-secret-vs-in-tree-pubkey mismatch — refuses to write a non-verifiable sig. · *`SECURITY.md` — `PQC release-signing key` fingerprint + verification recipe* — New SECURITY.md table records the algorithm (ML-DSA-65, FIPS 204), the in-tree public-key file path, and the SHA3-512 fingerprint (`ad6bee96…2ede`). Verification recipe uses the framework's own `b.pqcSoftware.ml_dsa_65.verify` — no external verifier binary required, no dependency on Sigstore's classical-crypto chain. Operators with a PQC-only verification posture have a self-contained path: `gh release download`, then `node -e` against the vendored noble-post-quantum primitive. **Changed:** *`Verifying release authenticity` — four trust roots, not three* — SECURITY.md's closing summary updated to count the ML-DSA-65 release-signing sidecar as the fourth independently-verifiable trust root, alongside SLSA L3 npm provenance + Sigstore-keyless SBOM signing + SSH-signed tags. Each remains verifiable without trusting any of the others; tampering with one is detected by the others. **References:** [FIPS 204 — ML-DSA](https://csrc.nist.gov/pubs/fips/204/final) · [FIPS 202 — SHA-3 + SHAKE](https://csrc.nist.gov/pubs/fips/202/final) · [RFC 9909 — ML-DSA in X.509 + CMS](https://www.rfc-editor.org/rfc/rfc9909.html) · [noble-post-quantum (vendored under lib/vendor)](https://github.com/paulmillr/noble-post-quantum)
package/index.js CHANGED
@@ -373,6 +373,7 @@ var workerPool = require("./lib/worker-pool");
373
373
  var authBotChallenge = require("./lib/auth-bot-challenge");
374
374
  var sessionDeviceBinding = require("./lib/session-device-binding");
375
375
  var acme = require("./lib/acme");
376
+ var cert = require("./lib/cert");
376
377
  var watcher = require("./lib/watcher");
377
378
  var localDbThin = require("./lib/local-db-thin");
378
379
  var daemon = require("./lib/daemon");
@@ -656,6 +657,7 @@ module.exports = {
656
657
  authBotChallenge: authBotChallenge,
657
658
  sessionDeviceBinding: sessionDeviceBinding,
658
659
  acme: acme,
660
+ cert: cert,
659
661
  ntpCheck: ntpCheck,
660
662
  tlsExporter: tlsExporter,
661
663
  watcher: watcher,
package/lib/acme.js CHANGED
@@ -679,6 +679,290 @@ function create(opts) {
679
679
  return pem;
680
680
  }
681
681
 
682
+ // ---- public: per-challenge authorization flow ----
683
+ //
684
+ // RFC 8555 §7.5 — authorization objects contain the challenge list.
685
+ // Fetching the authorization is POST-as-GET. The operator (or higher-
686
+ // level wrapper like `b.cert.create`) walks the challenges, provisions
687
+ // the response, then POSTs an empty object to the challenge URL to
688
+ // signal readiness, then polls the authorization until `status` is
689
+ // `valid` (or `invalid`).
690
+
691
+ /**
692
+ * @primitive b.acme.create.fetchAuthorization
693
+ * @signature b.acme.create.fetchAuthorization(authUrl)
694
+ * @since 0.11.22
695
+ *
696
+ * POST-as-GET an authorization URL. Returns the parsed authorization
697
+ * object — `{ status, identifier, challenges, expires, wildcard? }`
698
+ * per RFC 8555 §7.5. The challenges array lists every challenge type
699
+ * the CA offers for this authorization (`http-01`, `dns-01`,
700
+ * `tls-alpn-01`); each entry carries `{ type, url, token, status }`.
701
+ *
702
+ * @example
703
+ * var auth = await client.fetchAuthorization(order.authorizations[0]);
704
+ * var http01 = auth.challenges.find(function (c) { return c.type === "http-01"; });
705
+ * typeof http01.token; // → "string"
706
+ * typeof http01.url; // → "string"
707
+ */
708
+ async function fetchAuthorization(authUrl) {
709
+ if (typeof authUrl !== "string" || authUrl.length === 0) {
710
+ throw _err("acme/bad-auth-url",
711
+ "fetchAuthorization: authUrl must be a non-empty string", true);
712
+ }
713
+ var rsp = await _signedPost(authUrl, null);
714
+ if (rsp.statusCode < 200 || rsp.statusCode >= 300) {
715
+ throw _err("acme/auth-fetch",
716
+ "fetchAuthorization returned " + rsp.statusCode, true, rsp.statusCode);
717
+ }
718
+ var body = _parseJsonBody(rsp.body, "fetchAuthorization");
719
+ body.url = authUrl;
720
+ return body;
721
+ }
722
+
723
+ /**
724
+ * @primitive b.acme.create.notifyChallengeReady
725
+ * @signature b.acme.create.notifyChallengeReady(challengeUrl)
726
+ * @since 0.11.22
727
+ *
728
+ * POST an empty JSON object (`{}`) to a challenge URL to signal that
729
+ * the operator has provisioned the challenge response and the CA may
730
+ * now begin its validation attempt. Returns the updated challenge
731
+ * object (status typically `processing` immediately after this call).
732
+ *
733
+ * Per RFC 8555 §7.5.1: the empty-object POST is the operator's
734
+ * commitment that the validation surface is ready. The CA's
735
+ * validation runs asynchronously; poll the authorization with
736
+ * `waitForAuthorization` afterwards.
737
+ *
738
+ * @example
739
+ * await myHttp01Server.add(challenge.token, client.keyAuthorization(challenge.token));
740
+ * var updated = await client.notifyChallengeReady(challenge.url);
741
+ * typeof updated.status; // → "string" ("processing" | "valid" | "invalid")
742
+ */
743
+ async function notifyChallengeReady(challengeUrl) {
744
+ if (typeof challengeUrl !== "string" || challengeUrl.length === 0) {
745
+ throw _err("acme/bad-challenge-url",
746
+ "notifyChallengeReady: challengeUrl must be a non-empty string", true);
747
+ }
748
+ var rsp = await _signedPost(challengeUrl, {});
749
+ if (rsp.statusCode < 200 || rsp.statusCode >= 300) {
750
+ throw _err("acme/challenge-ready",
751
+ "notifyChallengeReady returned " + rsp.statusCode, true, rsp.statusCode);
752
+ }
753
+ return _parseJsonBody(rsp.body, "notifyChallengeReady");
754
+ }
755
+
756
+ /**
757
+ * @primitive b.acme.create.waitForAuthorization
758
+ * @signature b.acme.create.waitForAuthorization(authUrl, opts?)
759
+ * @since 0.11.22
760
+ *
761
+ * Poll an authorization URL until `status === "valid"` (success) or
762
+ * `status === "invalid"` (CA refused). Throws on invalid OR on
763
+ * timeout (default `pollMaxMs` set at `b.acme.create` time).
764
+ *
765
+ * @opts
766
+ * intervalMs: number, // default — uses the client's pollIntervalMs
767
+ * timeoutMs: number, // default — uses the client's pollMaxMs
768
+ *
769
+ * @example
770
+ * await client.notifyChallengeReady(http01.url);
771
+ * var auth = await client.waitForAuthorization(authUrl);
772
+ * auth.status; // → "valid"
773
+ */
774
+ async function waitForAuthorization(authUrl, opts2) {
775
+ opts2 = opts2 || {};
776
+ var interval = typeof opts2.intervalMs === "number" ? opts2.intervalMs : pollIntervalMs;
777
+ var deadline = Date.now() + (typeof opts2.timeoutMs === "number" ? opts2.timeoutMs : pollMaxMs);
778
+ while (true) {
779
+ var auth = await fetchAuthorization(authUrl);
780
+ if (auth.status === "valid") return auth;
781
+ if (auth.status === "invalid") {
782
+ _emitAudit(audit, "acme.auth.poll", "failure",
783
+ { authUrl: authUrl, status: "invalid",
784
+ error: auth.challenges && auth.challenges.find(function (c) { return c.error; }) });
785
+ throw _err("acme/auth-invalid",
786
+ "waitForAuthorization: " + authUrl + " is invalid", true);
787
+ }
788
+ if (Date.now() >= deadline) {
789
+ throw _err("acme/auth-timeout",
790
+ "waitForAuthorization: " + authUrl + " did not reach 'valid' within " +
791
+ (opts2.timeoutMs || pollMaxMs) + "ms", true);
792
+ }
793
+ await _sleep(interval);
794
+ }
795
+ }
796
+
797
+ /**
798
+ * @primitive b.acme.create.buildCsr
799
+ * @signature b.acme.create.buildCsr(opts)
800
+ * @since 0.11.22
801
+ *
802
+ * Build a PKCS#10 (RFC 2986) Certificate Signing Request and sign it
803
+ * with the leaf private key. Subject is `CN=<first domain>`; every
804
+ * domain (including the first) appears as a `dNSName` in the
805
+ * Subject Alternative Name extension. Returns a PEM-encoded
806
+ * `-----BEGIN CERTIFICATE REQUEST-----` block ready to feed to
807
+ * `finalize(order, csrPem)`.
808
+ *
809
+ * Supports ECDSA P-256 / P-384 leaf keys (signed with `ecdsa-with-SHA256`
810
+ * / `ecdsa-with-SHA384` respectively) and RSA 2048 / 3072 / 4096
811
+ * (signed with `sha256WithRSAEncryption`). Ed25519 is rejected at
812
+ * the CSR layer because CA support is uneven; operators wanting
813
+ * Ed25519 certs build the CSR externally.
814
+ *
815
+ * @opts
816
+ * privateKey: crypto.KeyObject, // required — Node-crypto private key handle
817
+ * publicKey: crypto.KeyObject, // required — matching public key handle
818
+ * domains: Array<string>, // required — non-empty; first is CN, all are SANs
819
+ *
820
+ * @example
821
+ * var pair = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
822
+ * var csr = client.buildCsr({
823
+ * privateKey: pair.privateKey,
824
+ * publicKey: pair.publicKey,
825
+ * domains: ["example.com", "www.example.com"],
826
+ * });
827
+ * csr.indexOf("-----BEGIN CERTIFICATE REQUEST-----"); // → 0
828
+ */
829
+ function buildCsr(opts2) {
830
+ if (!opts2 || typeof opts2 !== "object") {
831
+ throw _err("acme/bad-csr-opts", "buildCsr: opts is required", true);
832
+ }
833
+ if (!opts2.privateKey || opts2.privateKey.type !== "private" ||
834
+ typeof opts2.privateKey.export !== "function") {
835
+ throw _err("acme/bad-csr-private-key",
836
+ "buildCsr: privateKey must be a Node KeyObject (private)", true);
837
+ }
838
+ if (!opts2.publicKey || opts2.publicKey.type !== "public" ||
839
+ typeof opts2.publicKey.export !== "function") {
840
+ throw _err("acme/bad-csr-public-key",
841
+ "buildCsr: publicKey must be a Node KeyObject (public)", true);
842
+ }
843
+ if (!Array.isArray(opts2.domains) || opts2.domains.length === 0) {
844
+ throw _err("acme/bad-csr-domains",
845
+ "buildCsr: domains must be a non-empty array of strings", true);
846
+ }
847
+ for (var di = 0; di < opts2.domains.length; di += 1) {
848
+ var d = opts2.domains[di];
849
+ if (typeof d !== "string" || d.length === 0 || d.length > C.BYTES.bytes(255)) {
850
+ throw _err("acme/bad-csr-domain",
851
+ "buildCsr: domains[" + di + "] must be a non-empty string <= 255 bytes", true);
852
+ }
853
+ }
854
+
855
+ var keyType = opts2.privateKey.asymmetricKeyType;
856
+ var keyDetails = opts2.privateKey.asymmetricKeyDetails || {};
857
+
858
+ // Pick signature algorithm based on key type.
859
+ var sigOidDotted, sigDigest, sigAlgoAlgId;
860
+ if (keyType === "ec") {
861
+ var curve = keyDetails.namedCurve;
862
+ if (curve === "prime256v1" || curve === "P-256") {
863
+ sigOidDotted = "1.2.840.10045.4.3.2"; // ecdsa-with-SHA256
864
+ sigDigest = "sha256";
865
+ } else if (curve === "secp384r1" || curve === "P-384") {
866
+ sigOidDotted = "1.2.840.10045.4.3.3"; // ecdsa-with-SHA384
867
+ sigDigest = "sha384";
868
+ } else {
869
+ throw _err("acme/bad-csr-curve",
870
+ "buildCsr: ECDSA curve '" + curve + "' is not supported (use P-256 or P-384)", true);
871
+ }
872
+ // For ECDSA the AlgorithmIdentifier has NO parameters (RFC 5758 §3.2).
873
+ sigAlgoAlgId = asn1.writeSequence([asn1.writeOid(sigOidDotted)]);
874
+ } else if (keyType === "rsa") {
875
+ sigOidDotted = "1.2.840.113549.1.1.11"; // sha256WithRSAEncryption
876
+ sigDigest = "sha256";
877
+ // RSA AlgorithmIdentifier carries an explicit NULL parameter
878
+ // (RFC 8017 / RFC 3447 §A.2.4).
879
+ sigAlgoAlgId = asn1.writeSequence([asn1.writeOid(sigOidDotted), asn1.writeNull()]);
880
+ } else {
881
+ throw _err("acme/bad-csr-key-type",
882
+ "buildCsr: keyType '" + keyType + "' is not supported (use ECDSA P-256/P-384 or RSA 2048/3072/4096)", true);
883
+ }
884
+
885
+ // 1. Build Subject Name — single RDN: CN=<first domain> (UTF8String).
886
+ // Name ::= SEQUENCE OF RelativeDistinguishedName
887
+ // RelativeDistinguishedName ::= SET OF AttributeTypeAndValue
888
+ // AttributeTypeAndValue ::= SEQUENCE { type OID, value ANY }
889
+ var commonName = opts2.domains[0];
890
+ var cnAttr = asn1.writeSequence([
891
+ asn1.writeOid("2.5.4.3"), // id-at-commonName
892
+ asn1.writeUtf8String(commonName),
893
+ ]);
894
+ var subjectName = asn1.writeSequence([
895
+ asn1.writeSet([cnAttr]),
896
+ ]);
897
+
898
+ // 2. Build SubjectPublicKeyInfo from the public KeyObject.
899
+ var spkiDer = opts2.publicKey.export({ type: "spki", format: "der" });
900
+
901
+ // 3. Build SubjectAltName extension — SEQUENCE OF GeneralName
902
+ // GeneralName ::= CHOICE { ... [2] IA5String dNSName, ... }
903
+ // dNSName has IMPLICIT [2] tag.
904
+ var sanGeneralNames = opts2.domains.map(function (d) {
905
+ // [2] IMPLICIT IA5String — context-specific class, primitive,
906
+ // tag number 2. asn1.writeContextImplicit assembles the value
907
+ // bytes (IA5 chars) directly without the inner UNIVERSAL IA5
908
+ // tag (that's what "implicit" means). Validation above already
909
+ // refuses non-string domains, so d is always a string here.
910
+ return asn1.writeContextImplicit(2, Buffer.from(d, "ascii"));
911
+ });
912
+ var sanExtnValue = asn1.writeSequence(sanGeneralNames);
913
+ // Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }
914
+ var sanExtension = asn1.writeSequence([
915
+ asn1.writeOid("2.5.29.17"), // id-ce-subjectAltName
916
+ asn1.writeOctetString(sanExtnValue),
917
+ ]);
918
+
919
+ // 4. Build the extensionRequest attribute carrying the SAN.
920
+ // Attribute ::= SEQUENCE { type OID, values SET OF ANY }
921
+ // extensionRequest: type = 1.2.840.113549.1.9.14, values = SET OF Extensions
922
+ var extensionsSeq = asn1.writeSequence([sanExtension]);
923
+ var extensionReqAttr = asn1.writeSequence([
924
+ asn1.writeOid("1.2.840.113549.1.9.14"), // pkcs-9-at-extensionRequest
925
+ asn1.writeSet([extensionsSeq]),
926
+ ]);
927
+
928
+ // 5. Build CertificationRequestInfo.
929
+ // CertificationRequestInfo ::= SEQUENCE {
930
+ // version INTEGER (0),
931
+ // subject Name,
932
+ // subjectPKInfo SubjectPublicKeyInfo,
933
+ // attributes [0] IMPLICIT Attributes
934
+ // }
935
+ // [0] IMPLICIT SET OF Attribute — we wrap the existing attr set.
936
+ var attributesField = asn1.writeContextImplicit(0,
937
+ Buffer.concat([extensionReqAttr]), // SET OF Attribute is implicit; one attribute → just its encoding
938
+ { constructed: true });
939
+ var certReqInfo = asn1.writeSequence([
940
+ asn1.writeInteger(Buffer.from([0])), // version v1 = 0
941
+ subjectName,
942
+ spkiDer,
943
+ attributesField,
944
+ ]);
945
+
946
+ // 6. Sign certReqInfo with the leaf private key.
947
+ var signer = nodeCrypto.createSign(sigDigest);
948
+ signer.update(certReqInfo);
949
+ var signature = signer.sign(opts2.privateKey);
950
+
951
+ // 7. Wrap as CertificationRequest.
952
+ var csr = asn1.writeSequence([
953
+ certReqInfo,
954
+ sigAlgoAlgId,
955
+ asn1.writeBitString(signature, 0),
956
+ ]);
957
+
958
+ // 8. PEM-encode.
959
+ var b64 = csr.toString("base64");
960
+ var pemBody = b64.match(/.{1,64}/g).join("\n");
961
+ return "-----BEGIN CERTIFICATE REQUEST-----\n" +
962
+ pemBody + "\n" +
963
+ "-----END CERTIFICATE REQUEST-----\n";
964
+ }
965
+
682
966
  // ---- public: RFC 9773 ARI ----
683
967
 
684
968
  async function fetchAri(opts2) {
@@ -1077,6 +1361,10 @@ function create(opts) {
1077
1361
  newOrder: newOrder,
1078
1362
  finalize: finalize,
1079
1363
  retrieveCert: retrieveCert,
1364
+ fetchAuthorization: fetchAuthorization,
1365
+ notifyChallengeReady: notifyChallengeReady,
1366
+ waitForAuthorization: waitForAuthorization,
1367
+ buildCsr: buildCsr,
1080
1368
  fetchAri: fetchAri,
1081
1369
  renewIfDue: renewIfDue,
1082
1370
  revokeCert: revokeCert,
package/lib/asn1-der.js CHANGED
@@ -325,6 +325,67 @@ function writeContextExplicit(tagNumber, child) {
325
325
  return writeNode(tagByte, child);
326
326
  }
327
327
 
328
+ function writeContextImplicit(tagNumber, value, opts) {
329
+ // [N] IMPLICIT — context-specific class with no constructed flag for
330
+ // primitives; opts.constructed=true sets the constructed bit for
331
+ // wrapping a structured value (e.g. IMPLICIT [0] OCTET STRING vs
332
+ // IMPLICIT [0] SEQUENCE OF). Value is the raw inner bytes (already
333
+ // encoded for constructed cases).
334
+ var tagByte = 0x80 | (tagNumber & 0x1f); // allow:raw-byte-literal — context-specific primitive mask
335
+ if (opts && opts.constructed) tagByte |= 0x20; // allow:raw-byte-literal — constructed bit
336
+ return writeNode(tagByte, value);
337
+ }
338
+
339
+ function writeBitString(value, unusedBits) {
340
+ // BIT STRING — first content byte is `unusedBits` (0..7), then the
341
+ // bit string bytes. `unusedBits` is 0 for byte-aligned content
342
+ // (RSA / ECDSA signatures, SubjectPublicKeyInfo bit strings).
343
+ var unused = typeof unusedBits === "number" ? (unusedBits & 0x07) : 0; // allow:raw-byte-literal — 3-bit unused-bits count
344
+ return writeNode(TAG.BIT_STRING, Buffer.concat([Buffer.from([unused]), value]));
345
+ }
346
+
347
+ function writeSet(children) {
348
+ // children: Array<Buffer> of already-encoded child nodes.
349
+ // DER requires SET-OF children to be sorted by their encoded bytes.
350
+ var sorted = children.slice().sort(Buffer.compare);
351
+ return writeNode(TAG.SET | 0x20, Buffer.concat(sorted)); // allow:raw-byte-literal — DER constructed bit
352
+ }
353
+
354
+ function writeUtf8String(s) {
355
+ if (typeof s !== "string") {
356
+ throw new Asn1Error("asn1/bad-utf8-input",
357
+ "writeUtf8String: input must be a string (got " + typeof s + ")");
358
+ }
359
+ return writeNode(TAG.UTF8_STRING, Buffer.from(s, "utf8"));
360
+ }
361
+
362
+ function writePrintableString(s) {
363
+ // PrintableString character set: A-Z a-z 0-9 ' ( ) + , - . / : = ? space
364
+ // Refuse non-printable input rather than silently encoding as latin1.
365
+ var str = String(s);
366
+ if (/[^A-Za-z0-9 '()+,\-./:=?]/.test(str)) {
367
+ throw new Asn1Error("asn1/bad-printable",
368
+ "writePrintableString: '" + str + "' contains characters outside the PrintableString set");
369
+ }
370
+ return writeNode(TAG.PRINTABLE_STRING, Buffer.from(str, "ascii"));
371
+ }
372
+
373
+ function writeIa5String(s) {
374
+ // IA5String is 7-bit ASCII. RFC 5280 §4.2.1.6 mandates IA5String for
375
+ // dNSName values inside the SubjectAltName extension.
376
+ var str = String(s);
377
+ /* eslint-disable-next-line no-control-regex */
378
+ if (/[^\x00-\x7f]/.test(str)) {
379
+ throw new Asn1Error("asn1/bad-ia5",
380
+ "writeIa5String: '" + str + "' contains non-ASCII characters");
381
+ }
382
+ return writeNode(TAG.IA5_STRING, Buffer.from(str, "ascii"));
383
+ }
384
+
385
+ function writeBoolean(b) {
386
+ return writeNode(TAG.BOOLEAN, Buffer.from([b ? 0xff : 0x00])); // allow:raw-byte-literal — DER true=0xff, false=0x00
387
+ }
388
+
328
389
  // Find a child node of a SEQUENCE / SET by predicate. Returns null if
329
390
  // no child matches.
330
391
  function findChild(children, predicate) {
@@ -351,6 +412,13 @@ module.exports = {
351
412
  writeInteger: writeInteger,
352
413
  writeNull: writeNull,
353
414
  writeOid: writeOid,
415
+ writeBitString: writeBitString,
416
+ writeSet: writeSet,
417
+ writeUtf8String: writeUtf8String,
418
+ writePrintableString: writePrintableString,
419
+ writeIa5String: writeIa5String,
420
+ writeBoolean: writeBoolean,
354
421
  writeContextExplicit: writeContextExplicit,
422
+ writeContextImplicit: writeContextImplicit,
355
423
  Asn1Error: Asn1Error,
356
424
  };
package/lib/audit.js CHANGED
@@ -306,6 +306,7 @@ var FRAMEWORK_NAMESPACES = [
306
306
  "http", // b.middleware.bodyParser (http.chunked.malformed.refused — RFC 9112 §7.1 chunked-decode failure with Connection: close) // allow:raw-byte-literal — RFC number in prose
307
307
  "cryptofield", // b.cryptoField.eraseRow (cryptofield.vacuum.skipped — F-RTBF-2 vacuum-after-erase signal when DB not initialized at erase time)
308
308
  "acme", // b.acme (acme.account.registered / order.* / cert.issued / cert.renewed / cert.renew.skipped — RFC 8555 + RFC 9773 ARI workflow)
309
+ "cert", // b.cert (cert.account.generated / cert.issued / cert.renewed / cert.renew-failed / cert.challenge-cleanup — turnkey cert-manager lifecycle)
309
310
  "tls", // b.router 0-RTT posture (tls.0rtt.refused / tls.0rtt.replayed) — RFC 8446 §8 anti-replay surface // allow:raw-byte-literal — RFC number in prose
310
311
  "workerpool", // b.workerPool (workerpool.created / terminated / task.completed / task.failed / task.timeout / spawn.failed — generic worker_threads pool)
311
312
  "jwt", // b.auth.jwt-external (jwt.jwe.refused — RFC 7516 5-segment JWE refusal)
package/lib/cert.js ADDED
@@ -0,0 +1,763 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cert
4
+ * @nav Production
5
+ * @title Certificates
6
+ * @order 130
7
+ *
8
+ * @intro
9
+ * Turnkey TLS-certificate manager. Wraps `b.acme.create` (RFC 8555
10
+ * client + RFC 9773 ARI renewal-window respect), sealed persistence
11
+ * via `b.vault.seal`, the renewal scheduler from `b.safeAsync.repeating`,
12
+ * OCSP-stapling via `b.network.tls.ocsp`, and the operator's choice
13
+ * of ACME challenge solver (HTTP-01 / DNS-01 / TLS-ALPN-01).
14
+ *
15
+ * The operator passes a declarative manifest of certificates +
16
+ * storage + an ACME directory URL + per-challenge solver callbacks;
17
+ * the manager handles ordering, finalization, retrieval, periodic
18
+ * renewal, key rotation on renew, OCSP refresh, and sealed-disk
19
+ * persistence of every artifact.
20
+ *
21
+ * Composes:
22
+ * - `b.acme.create` → ACME orders, JWS, ARI fetch
23
+ * - `b.vault.seal` → sealed-disk persistence of certs + keys + account material
24
+ * - `b.safeAsync.repeating` → renewal scheduler with drop-silent error path
25
+ * - `b.network.tls.ocsp` → server-side stapling helpers
26
+ * - `b.audit` → cert.* lifecycle audit chain
27
+ * - `b.compliance` → posture refusals (e.g. plaintext storage refused under HIPAA / PCI)
28
+ *
29
+ * Does NOT ship the challenge-solver implementations (HTTP-01 server,
30
+ * DNS provider integrations, TLS-ALPN-01 socket). Those are operator-
31
+ * side adapters — the manager calls operator-provided
32
+ * `provision(challengeParams)` / `cleanup(challengeParams)` callbacks
33
+ * for whatever solver the operator wires.
34
+ *
35
+ * Key escrow: when `keyEscrow: { recipient }` is set, the renewed
36
+ * private key is also encrypted to the recipient's public key via
37
+ * `b.crypto.encryptEnvelope` and persisted alongside the sealed key.
38
+ * The recipient is operator-controlled (typically an offline
39
+ * break-glass key); the escrow copy is for legitimate key-recovery
40
+ * under break-glass policy, NOT for routine access.
41
+ *
42
+ * @card
43
+ * ACME-driven cert lifecycle: auto-renew + key rotation + OCSP stapling + sealed persistence.
44
+ */
45
+
46
+ var nodeCrypto = require("node:crypto");
47
+ var nodeFs = require("node:fs");
48
+ var nodePath = require("node:path");
49
+ var EventEmitter = require("node:events").EventEmitter;
50
+ var validateOpts = require("./validate-opts");
51
+ var lazyRequire = require("./lazy-require");
52
+ var safeAsync = require("./safe-async");
53
+ var atomicFile = require("./atomic-file");
54
+ var safeJson = require("./safe-json");
55
+ var { defineClass } = require("./framework-error");
56
+ var C = require("./constants");
57
+
58
+ var acme = lazyRequire(function () { return require("./acme"); });
59
+ var vault = lazyRequire(function () { return require("./vault"); });
60
+ var audit = lazyRequire(function () { return require("./audit"); });
61
+ var networkTls = lazyRequire(function () { return require("./network-tls"); });
62
+ var bCrypto = lazyRequire(function () { return require("./crypto"); });
63
+
64
+ var CertError = defineClass("CertError");
65
+
66
+ var DEFAULT_RENEW_INTERVAL_MS = C.TIME.hours(6);
67
+ var DEFAULT_MIN_DAYS_BEFORE_EXPIRY = 14;
68
+ var DEFAULT_OCSP_REFRESH_MS = C.TIME.hours(12);
69
+ var MAX_DOMAINS_PER_CERT = 100; // allow:raw-byte-literal — operator-facing manifest size cap, not a byte count (RFC 6066 SNI permits more)
70
+ var MAX_CERTS_PER_MANAGER = 1000; // allow:raw-byte-literal — operator-facing manifest size cap, not a byte count
71
+
72
+ function _positiveFiniteOrDefault(value, defaultValue, label, code) {
73
+ if (value === undefined || value === null) return defaultValue;
74
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) {
75
+ throw new CertError(code, label + " must be a positive finite number (got " + value + ")");
76
+ }
77
+ return value;
78
+ }
79
+
80
+ // ---- Storage backend ----
81
+
82
+ // Sealed-disk storage: each artifact (cert PEM, key PEM, ACME account
83
+ // JWK, OCSP response) is sealed via b.vault.seal and written atomically
84
+ // to a per-cert subdirectory under storage.rootDir.
85
+ //
86
+ // Layout:
87
+ // <rootDir>/account/jwk.json.sealed — sealed ACME account key
88
+ // <rootDir>/account/jwk.json.escrow — optional break-glass copy (if keyEscrow set)
89
+ // <rootDir>/<certName>/cert.pem.sealed — sealed certificate chain (CA + leaf)
90
+ // <rootDir>/<certName>/key.pem.sealed — sealed leaf private key
91
+ // <rootDir>/<certName>/key.pem.escrow — optional break-glass copy
92
+ // <rootDir>/<certName>/ocsp.der.sealed — sealed cached OCSP response (refreshed periodically)
93
+ // <rootDir>/<certName>/meta.json — plaintext metadata (expiresAt, fingerprint, last-renewed-at)
94
+ function _createSealedDiskStorage(opts) {
95
+ validateOpts.requireNonEmptyString(opts.rootDir,
96
+ "cert.storage: rootDir (sealed-disk root directory) is required",
97
+ CertError, "cert/bad-storage-root");
98
+ var rootDir = nodePath.resolve(opts.rootDir);
99
+ var vaultStore = opts.vault || vault().getDefaultStore();
100
+ if (!vaultStore || typeof vaultStore.seal !== "function" || typeof vaultStore.unseal !== "function") {
101
+ throw new CertError("cert/bad-storage-vault",
102
+ "cert.storage: vault must expose seal(buf) + unseal(buf) — typically b.vault.getDefaultStore()");
103
+ }
104
+
105
+ function _ensureDir(dir) { atomicFile.ensureDir(dir); }
106
+ function _certDir(name) { return nodePath.join(rootDir, name); }
107
+ function _accountDir() { return nodePath.join(rootDir, "account"); }
108
+
109
+ return {
110
+ type: "sealed-disk",
111
+ rootDir: rootDir,
112
+
113
+ async writeSealed(relPath, contents) {
114
+ // Sealed artifacts always carry the `.sealed` suffix so a
115
+ // directory listing instantly distinguishes encrypted material
116
+ // from plaintext meta.json. relPath is the logical name (e.g.
117
+ // "main/cert.pem"); the on-disk path is "main/cert.pem.sealed".
118
+ var p = nodePath.join(rootDir, relPath + ".sealed");
119
+ _ensureDir(nodePath.dirname(p));
120
+ var sealed = vaultStore.seal(Buffer.from(contents));
121
+ atomicFile.writeSync(p, sealed, { mode: 0o600 });
122
+ },
123
+
124
+ async readSealed(relPath) {
125
+ var p = nodePath.join(rootDir, relPath + ".sealed");
126
+ if (!nodeFs.existsSync(p)) return null;
127
+ var sealed = nodeFs.readFileSync(p);
128
+ var plain = vaultStore.unseal(sealed);
129
+ return Buffer.isBuffer(plain) ? plain : Buffer.from(plain);
130
+ },
131
+
132
+ async writeMeta(certName, meta) {
133
+ var p = nodePath.join(_certDir(certName), "meta.json");
134
+ _ensureDir(_certDir(certName));
135
+ atomicFile.writeSync(p, JSON.stringify(meta, null, 2) + "\n", { mode: 0o644 });
136
+ },
137
+
138
+ async readMeta(certName) {
139
+ var p = nodePath.join(_certDir(certName), "meta.json");
140
+ if (!nodeFs.existsSync(p)) return null;
141
+ try { return safeJson.parse(nodeFs.readFileSync(p, "utf8"), { maxBytes: C.BYTES.kib(16) }); }
142
+ catch (e) {
143
+ throw new CertError("cert/bad-meta",
144
+ "cert: meta.json for '" + certName + "' is corrupt: " + e.message);
145
+ }
146
+ },
147
+
148
+ async writeEscrow(relPath, plaintextKeyPem, recipientPub) {
149
+ // Encrypt-to-recipient via b.crypto.encryptEnvelope. Recipient is
150
+ // an X25519 / ML-KEM hybrid pubkey held offline by the operator
151
+ // for break-glass key recovery.
152
+ var envelope = bCrypto().encryptEnvelope(Buffer.from(plaintextKeyPem), recipientPub);
153
+ var p = nodePath.join(rootDir, relPath);
154
+ _ensureDir(nodePath.dirname(p));
155
+ atomicFile.writeSync(p, JSON.stringify(envelope) + "\n", { mode: 0o600 });
156
+ },
157
+ };
158
+ }
159
+
160
+ // ---- Cert manager factory ----
161
+
162
+ /**
163
+ * @primitive b.cert.create
164
+ * @signature b.cert.create(opts)
165
+ * @since 0.11.22
166
+ * @status stable
167
+ * @related b.acme.create
168
+ *
169
+ * Build a turnkey cert-management handle. Composes `b.acme.create` for
170
+ * the ACME protocol layer, `b.vault.seal` for sealed-disk persistence,
171
+ * `b.safeAsync.repeating` for the renewal scheduler, and
172
+ * `b.network.tls.ocsp` for stapling.
173
+ *
174
+ * The handle exposes:
175
+ * - `start()` — ensures every manifest cert exists (issues if absent); starts the renewal scheduler.
176
+ * - `stop()` — halts the renewal scheduler; releases sealed handles.
177
+ * - `getContext(name)` — returns `{ cert, key, ca, expiresAt, fingerprintSha256 }` (PEM strings + meta) for the named cert.
178
+ * - `sniCallback` — function (servername, cb) suitable for `https.createServer({ SNICallback })` — looks up by SNI hostname, falls back to the first registered cert.
179
+ * - `refresh(name)` — force-renew the named cert NOW (operator override).
180
+ * - `on(event, fn)` — `cert.issued` / `cert.renewed` / `cert.renew-failed` / `cert.ocsp-refreshed`.
181
+ *
182
+ * @opts
183
+ * storage: {
184
+ * type: "sealed-disk", // only backend in v1 — operator-supplied storage extensible via the same shape
185
+ * rootDir: string, // required — directory under which sealed artifacts land
186
+ * vault: b.vault.Store, // optional — defaults to b.vault.getDefaultStore()
187
+ * },
188
+ * acme: {
189
+ * directory: string, // required — RFC 8555 directory URL (https://)
190
+ * contactEmail: string, // optional — mailto: contact registered on account
191
+ * accountKey: { privatePem, publicPem } | "auto", // "auto" → generate + persist on first start; sealed via storage.vault
192
+ * timeoutMs: number, // optional — per-HTTP-call timeout; defaults from b.acme.create
193
+ * ariCompliant: boolean, // optional, default true — RFC 9773 ARI renewalInfo respect
194
+ * },
195
+ * certs: Array<{
196
+ * name: string, // required — unique manifest identifier; used as subdirectory + lookup key
197
+ * domains: Array<string>, // required — first entry is the CN subject; rest are SANs
198
+ * keyAlg: "ecdsa-p256" | "ecdsa-p384" | "rsa-2048" | "rsa-3072" | "rsa-4096", // default "ecdsa-p256"
199
+ * challenge: {
200
+ * type: "http-01" | "dns-01" | "tls-alpn-01",
201
+ * provision: async function (params) { ... }, // required — operator wires the solver
202
+ * cleanup: async function (params) { ... }, // required — runs after authorization completes
203
+ * },
204
+ * keyEscrow: { // optional — break-glass-only key recovery
205
+ * recipient: Buffer | string, // X25519 / ML-KEM hybrid public key (b.crypto.encryptEnvelope recipient)
206
+ * },
207
+ * }>,
208
+ * renew: {
209
+ * intervalMs: number, // default 6h — poll cadence
210
+ * minDaysBeforeExpiry: number, // default 14 — renew if <N days remaining (or ARI says renew sooner)
211
+ * ariCompliant: boolean, // default true — respect ARI suggestedWindow when CA publishes it
212
+ * },
213
+ * ocsp: {
214
+ * stapling: boolean, // default true — refresh + cache OCSP responses for server-side stapling
215
+ * refreshMs: number, // default 12h — OCSP-response cache lifetime
216
+ * },
217
+ * audit: boolean | object, // default true — emit cert.* lifecycle events via b.audit.safeEmit
218
+ * compliance: Array<string>, // optional — posture refusals (e.g. ["hipaa"]); refuses plaintext storage etc.
219
+ *
220
+ * @example
221
+ * var mgr = b.cert.create({
222
+ * storage: { type: "sealed-disk", rootDir: "/var/lib/blamejs/certs" },
223
+ * acme: {
224
+ * directory: "https://acme-v02.api.letsencrypt.org/directory",
225
+ * contactEmail: "ops@example.com",
226
+ * accountKey: "auto",
227
+ * },
228
+ * certs: [
229
+ * {
230
+ * name: "main",
231
+ * domains: ["example.com", "www.example.com"],
232
+ * keyAlg: "ecdsa-p256",
233
+ * challenge: {
234
+ * type: "http-01",
235
+ * provision: async function (p) { await myHttp01Server.add(p.token, p.keyAuthorization); },
236
+ * cleanup: async function (p) { await myHttp01Server.remove(p.token); },
237
+ * },
238
+ * },
239
+ * ],
240
+ * });
241
+ * await mgr.start();
242
+ * var ctx = await mgr.getContext("main");
243
+ * typeof ctx.cert; // → "string" (PEM chain)
244
+ * typeof ctx.key; // → "string" (PEM)
245
+ * typeof ctx.expiresAt; // → "number" (epoch ms)
246
+ */
247
+ function create(opts) {
248
+ if (!opts || typeof opts !== "object") {
249
+ throw new CertError("cert/bad-opts", "cert.create: opts is required");
250
+ }
251
+ validateOpts(opts, [
252
+ "storage", "acme", "certs", "renew", "ocsp", "audit", "compliance",
253
+ ], "cert.create");
254
+
255
+ // ---- Storage ----
256
+ if (!opts.storage || typeof opts.storage !== "object") {
257
+ throw new CertError("cert/bad-storage", "cert.create: storage block is required");
258
+ }
259
+ var storageType = opts.storage.type || "sealed-disk";
260
+ if (storageType !== "sealed-disk") {
261
+ throw new CertError("cert/bad-storage-type",
262
+ "cert.create: storage.type must be 'sealed-disk' (the only backend in v1)");
263
+ }
264
+ var storage = _createSealedDiskStorage(opts.storage);
265
+
266
+ // ---- ACME opts ----
267
+ if (!opts.acme || typeof opts.acme !== "object") {
268
+ throw new CertError("cert/bad-acme", "cert.create: acme block is required");
269
+ }
270
+ validateOpts(opts.acme,
271
+ ["directory", "contactEmail", "accountKey", "timeoutMs", "ariCompliant"],
272
+ "cert.create.acme");
273
+ validateOpts.requireNonEmptyString(opts.acme.directory,
274
+ "cert.create.acme: directory (RFC 8555 directory URL) is required",
275
+ CertError, "cert/bad-acme-directory");
276
+
277
+ // ---- Cert manifest ----
278
+ if (!Array.isArray(opts.certs) || opts.certs.length === 0) {
279
+ throw new CertError("cert/bad-certs",
280
+ "cert.create: certs must be a non-empty array of cert manifests");
281
+ }
282
+ if (opts.certs.length > MAX_CERTS_PER_MANAGER) {
283
+ throw new CertError("cert/too-many-certs",
284
+ "cert.create: certs array length " + opts.certs.length + " exceeds cap " + MAX_CERTS_PER_MANAGER);
285
+ }
286
+ // Cert names land as filesystem path segments under storage.rootDir
287
+ // (e.g. `<rootDir>/<name>/cert.pem.sealed`). Restrict the character
288
+ // set to ASCII letters / digits / `-` / `_` / `.` and refuse any
289
+ // value containing `/`, `\`, `..`, leading dot, or non-printable
290
+ // chars. Manifests sourced from operator-editable config or external
291
+ // control planes can carry attacker-influenced names; this gate
292
+ // refuses path-traversal payloads at the factory boundary instead
293
+ // of relying on `path.join` to sanitize.
294
+ var CERT_NAME_ALLOWED = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
295
+ var certsByName = Object.create(null);
296
+ for (var i = 0; i < opts.certs.length; i += 1) {
297
+ var c = opts.certs[i];
298
+ validateOpts.requireNonEmptyString(c.name,
299
+ "cert.create.certs[" + i + "].name is required",
300
+ CertError, "cert/bad-cert-name");
301
+ if (!CERT_NAME_ALLOWED.test(c.name) || c.name.indexOf("..") !== -1) {
302
+ throw new CertError("cert/bad-cert-name",
303
+ "cert.create.certs[" + i + "].name '" + c.name +
304
+ "' must match [A-Za-z0-9_][A-Za-z0-9_.-]{0,63} and contain no '..' " +
305
+ "(name lands as a filesystem path segment under storage.rootDir)");
306
+ }
307
+ if (certsByName[c.name]) {
308
+ throw new CertError("cert/duplicate-name",
309
+ "cert.create.certs: duplicate name '" + c.name + "'");
310
+ }
311
+ if (!Array.isArray(c.domains) || c.domains.length === 0) {
312
+ throw new CertError("cert/bad-domains",
313
+ "cert.create.certs[" + i + "].domains must be a non-empty array");
314
+ }
315
+ if (c.domains.length > MAX_DOMAINS_PER_CERT) {
316
+ throw new CertError("cert/too-many-domains",
317
+ "cert.create.certs[" + i + "].domains length " + c.domains.length + " exceeds cap " + MAX_DOMAINS_PER_CERT);
318
+ }
319
+ for (var di = 0; di < c.domains.length; di += 1) {
320
+ if (typeof c.domains[di] !== "string" || !c.domains[di]) {
321
+ throw new CertError("cert/bad-domain",
322
+ "cert.create.certs[" + i + "].domains[" + di + "] must be a non-empty string");
323
+ }
324
+ }
325
+ if (!c.challenge || typeof c.challenge !== "object") {
326
+ throw new CertError("cert/bad-challenge",
327
+ "cert.create.certs[" + i + "].challenge is required");
328
+ }
329
+ if (["http-01", "dns-01", "tls-alpn-01"].indexOf(c.challenge.type) === -1) {
330
+ throw new CertError("cert/bad-challenge-type",
331
+ "cert.create.certs[" + i + "].challenge.type must be http-01 / dns-01 / tls-alpn-01");
332
+ }
333
+ if (typeof c.challenge.provision !== "function" ||
334
+ typeof c.challenge.cleanup !== "function") {
335
+ throw new CertError("cert/bad-challenge-callbacks",
336
+ "cert.create.certs[" + i + "].challenge requires provision + cleanup callbacks");
337
+ }
338
+ var keyAlg = c.keyAlg || "ecdsa-p256";
339
+ if (["ecdsa-p256", "ecdsa-p384", "rsa-2048", "rsa-3072", "rsa-4096"].indexOf(keyAlg) === -1) {
340
+ throw new CertError("cert/bad-key-alg",
341
+ "cert.create.certs[" + i + "].keyAlg must be ecdsa-p256 / ecdsa-p384 / rsa-2048 / rsa-3072 / rsa-4096");
342
+ }
343
+ if (c.keyEscrow && (!c.keyEscrow.recipient ||
344
+ (typeof c.keyEscrow.recipient !== "string" && !Buffer.isBuffer(c.keyEscrow.recipient)))) {
345
+ throw new CertError("cert/bad-key-escrow",
346
+ "cert.create.certs[" + i + "].keyEscrow.recipient must be a Buffer or PEM/base64 string");
347
+ }
348
+ certsByName[c.name] = {
349
+ name: c.name,
350
+ domains: c.domains.slice(),
351
+ keyAlg: keyAlg,
352
+ challenge: c.challenge,
353
+ keyEscrow: c.keyEscrow || null,
354
+ };
355
+ }
356
+
357
+ // ---- Renewal scheduler opts ----
358
+ var renewOpts = opts.renew || {};
359
+ validateOpts(renewOpts, ["intervalMs", "minDaysBeforeExpiry", "ariCompliant"],
360
+ "cert.create.renew");
361
+ var renewIntervalMs = _positiveFiniteOrDefault(
362
+ renewOpts.intervalMs, DEFAULT_RENEW_INTERVAL_MS,
363
+ "cert.create.renew.intervalMs", "cert/bad-renew-interval");
364
+ var minDaysBeforeExpiry = _positiveFiniteOrDefault(
365
+ renewOpts.minDaysBeforeExpiry, DEFAULT_MIN_DAYS_BEFORE_EXPIRY,
366
+ "cert.create.renew.minDaysBeforeExpiry", "cert/bad-renew-window");
367
+ var ariCompliant = renewOpts.ariCompliant !== false;
368
+
369
+ // ---- OCSP opts ----
370
+ var ocspOpts = opts.ocsp || {};
371
+ validateOpts(ocspOpts, ["stapling", "refreshMs"], "cert.create.ocsp");
372
+ var ocspStapling = ocspOpts.stapling !== false;
373
+ var ocspRefreshMs = _positiveFiniteOrDefault(
374
+ ocspOpts.refreshMs, DEFAULT_OCSP_REFRESH_MS,
375
+ "cert.create.ocsp.refreshMs", "cert/bad-ocsp-refresh");
376
+
377
+ // ---- Audit + compliance ----
378
+ var auditEnabled = opts.audit !== false;
379
+ var compliance = Array.isArray(opts.compliance) ? opts.compliance.slice() : [];
380
+
381
+ // ---- Internal state ----
382
+ var emitter = new EventEmitter();
383
+ var loadedContexts = Object.create(null); // name → { cert, key, ca, expiresAt, fingerprintSha256, sniNames }
384
+ var acmeClient = null;
385
+ var scheduler = null;
386
+ var stopped = false;
387
+
388
+ function _emitAudit(action, outcome, metadata) {
389
+ if (!auditEnabled) return;
390
+ try {
391
+ audit().safeEmit({ action: action, outcome: outcome, metadata: metadata || {} });
392
+ } catch (_e) { /* drop-silent — audit emission is hot-path */ }
393
+ }
394
+
395
+ function _bootAcme() {
396
+ if (acmeClient) return acmeClient;
397
+ var accountKey = opts.acme.accountKey;
398
+ if (accountKey === "auto" || !accountKey) {
399
+ accountKey = _loadOrGenerateAccountKey();
400
+ }
401
+ acmeClient = acme().create({
402
+ directory: opts.acme.directory,
403
+ accountKey: accountKey,
404
+ contact: opts.acme.contactEmail ? ["mailto:" + opts.acme.contactEmail] : undefined,
405
+ timeoutMs: opts.acme.timeoutMs,
406
+ });
407
+ return acmeClient;
408
+ }
409
+
410
+ function _loadOrGenerateAccountKey() {
411
+ // Read sealed account JWK; generate + persist if absent.
412
+ var sealedBuf = nodeFs.existsSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
413
+ ? nodeFs.readFileSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
414
+ : null;
415
+ if (sealedBuf) {
416
+ var plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
417
+ var jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
418
+ return _accountKeyFromJwk(jwk);
419
+ }
420
+ var pair = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
421
+ var privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" });
422
+ var publicPem = pair.publicKey.export({ type: "spki", format: "pem" });
423
+ var freshJwk = pair.publicKey.export({ format: "jwk" });
424
+ freshJwk.privatePem = privatePem;
425
+ freshJwk.publicPem = publicPem;
426
+ storage.writeSealed("account/jwk.json", JSON.stringify(freshJwk));
427
+ _emitAudit("cert.account.generated", "success", { directory: opts.acme.directory });
428
+ return _accountKeyFromJwk(freshJwk);
429
+ }
430
+
431
+ function _accountKeyFromJwk(jwk) {
432
+ return {
433
+ privatePem: jwk.privatePem,
434
+ publicPem: jwk.publicPem,
435
+ jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
436
+ kty: jwk.kty,
437
+ crv: jwk.crv,
438
+ };
439
+ }
440
+
441
+ // RSA modulus bits — operator-selected protocol constants, not byte
442
+ // counts. The framework's leaf-key alg names embed the bit length
443
+ // verbatim ("rsa-2048" / "rsa-3072" / "rsa-4096"), so the literals
444
+ // here are protocol-constant references.
445
+ var RSA_MODULUS_BITS_2048 = 2048; // allow:raw-byte-literal — RSA modulus length, not a byte count
446
+ var RSA_MODULUS_BITS_3072 = 3072; // allow:raw-byte-literal — RSA modulus length, not a byte count
447
+ var RSA_MODULUS_BITS_4096 = 4096; // allow:raw-byte-literal — RSA modulus length, not a byte count
448
+
449
+ function _generateLeafKeypair(keyAlg) {
450
+ switch (keyAlg) {
451
+ case "ecdsa-p256": return nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
452
+ case "ecdsa-p384": return nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-384" });
453
+ case "rsa-2048": return nodeCrypto.generateKeyPairSync("rsa", { modulusLength: RSA_MODULUS_BITS_2048 });
454
+ case "rsa-3072": return nodeCrypto.generateKeyPairSync("rsa", { modulusLength: RSA_MODULUS_BITS_3072 });
455
+ case "rsa-4096": return nodeCrypto.generateKeyPairSync("rsa", { modulusLength: RSA_MODULUS_BITS_4096 });
456
+ default:
457
+ throw new CertError("cert/bad-key-alg", "cert: unknown keyAlg " + keyAlg);
458
+ }
459
+ }
460
+
461
+ async function _issueCert(certManifest) {
462
+ var acme = _bootAcme();
463
+ // 1. Fetch directory + ensure ACME account exists.
464
+ await acme.fetchDirectory();
465
+ await acme.newAccount({
466
+ contact: opts.acme.contactEmail ? ["mailto:" + opts.acme.contactEmail] : undefined,
467
+ termsOfServiceAgreed: true,
468
+ });
469
+ // 2. Create the order.
470
+ var order = await acme.newOrder({
471
+ identifiers: certManifest.domains.map(function (d) {
472
+ return { type: "dns", value: d };
473
+ }),
474
+ });
475
+ // 3. For each authorization, solve the operator-supplied challenge.
476
+ for (var ai = 0; ai < order.authorizations.length; ai += 1) {
477
+ var auth = await acme.fetchAuthorization(order.authorizations[ai]);
478
+ if (auth.status === "valid") continue;
479
+ var challenge = auth.challenges.find(function (ch) {
480
+ return ch.type === certManifest.challenge.type;
481
+ });
482
+ if (!challenge) {
483
+ throw new CertError("cert/no-matching-challenge",
484
+ "cert: CA did not offer " + certManifest.challenge.type +
485
+ " for " + auth.identifier.value);
486
+ }
487
+ // tls-alpn-01 has a different key-authorization shape (RFC 8737).
488
+ var keyAuth = certManifest.challenge.type === "tls-alpn-01"
489
+ ? acme.tlsAlpn01KeyAuthorization(challenge.token)
490
+ : acme.keyAuthorization(challenge.token);
491
+ var provisionParams = {
492
+ domain: auth.identifier.value,
493
+ type: challenge.type,
494
+ token: challenge.token,
495
+ keyAuthorization: keyAuth,
496
+ };
497
+ await certManifest.challenge.provision(provisionParams);
498
+ try {
499
+ await acme.notifyChallengeReady(challenge.url);
500
+ await acme.waitForAuthorization(order.authorizations[ai]);
501
+ } finally {
502
+ try { await certManifest.challenge.cleanup(provisionParams); }
503
+ catch (cleanupErr) {
504
+ // Cleanup failure shouldn't void the order, but the
505
+ // operator should know — emit drop-silent audit.
506
+ _emitAudit("cert.challenge-cleanup", "failure", {
507
+ name: certManifest.name,
508
+ domain: auth.identifier.value,
509
+ error: (cleanupErr && cleanupErr.message) || String(cleanupErr),
510
+ });
511
+ }
512
+ }
513
+ }
514
+ // 4. Generate leaf keypair + CSR + finalize.
515
+ var leafPair = _generateLeafKeypair(certManifest.keyAlg);
516
+ var csrPem = acme.buildCsr({
517
+ privateKey: leafPair.privateKey,
518
+ publicKey: leafPair.publicKey,
519
+ domains: certManifest.domains,
520
+ });
521
+ var finalized = await acme.finalize(order, csrPem);
522
+ var certPem = await acme.retrieveCert(finalized);
523
+ var privPem = leafPair.privateKey.export({ type: "pkcs8", format: "pem" });
524
+ return { certPem: certPem, keyPem: privPem };
525
+ }
526
+
527
+ function _certMeta(certPem) {
528
+ // Extract notAfter + fingerprint without re-implementing X.509.
529
+ var cert = new nodeCrypto.X509Certificate(certPem);
530
+ return {
531
+ expiresAt: Date.parse(cert.validTo),
532
+ issuedAt: Date.parse(cert.validFrom),
533
+ fingerprintSha256: cert.fingerprint256.replace(/:/g, "").toLowerCase(),
534
+ subject: cert.subject,
535
+ subjectAltName: cert.subjectAltName || null,
536
+ };
537
+ }
538
+
539
+ async function _persistCert(certManifest, certPem, keyPem) {
540
+ await storage.writeSealed(certManifest.name + "/cert.pem", certPem);
541
+ await storage.writeSealed(certManifest.name + "/key.pem", keyPem);
542
+ if (certManifest.keyEscrow) {
543
+ await storage.writeEscrow(certManifest.name + "/key.pem.escrow", keyPem,
544
+ certManifest.keyEscrow.recipient);
545
+ }
546
+ var meta = _certMeta(certPem);
547
+ meta.lastRenewedAt = Date.now();
548
+ meta.keyAlg = certManifest.keyAlg;
549
+ await storage.writeMeta(certManifest.name, meta);
550
+ return meta;
551
+ }
552
+
553
+ // `forceIssue` skips the cache-fresh short-circuit and ALWAYS runs
554
+ // the ACME issue flow. Operators invoke this path via `refresh(name)`
555
+ // for emergency reissue / key rollover when the existing cert is
556
+ // structurally fine but operationally compromised (suspected key
557
+ // disclosure, CA misissuance investigation, posture-driven rotation).
558
+ async function _ensureCert(certManifest, forceIssue) {
559
+ var meta = await storage.readMeta(certManifest.name);
560
+ var certBuf = await storage.readSealed(certManifest.name + "/cert.pem");
561
+ var keyBuf = await storage.readSealed(certManifest.name + "/key.pem");
562
+ if (!forceIssue && meta && certBuf && keyBuf &&
563
+ meta.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
564
+ // Cached, not due for renewal yet.
565
+ loadedContexts[certManifest.name] = {
566
+ cert: certBuf.toString("utf8"),
567
+ key: keyBuf.toString("utf8"),
568
+ expiresAt: meta.expiresAt,
569
+ fingerprintSha256: meta.fingerprintSha256,
570
+ sniNames: certManifest.domains.slice(),
571
+ };
572
+ return loadedContexts[certManifest.name];
573
+ }
574
+ // Issue (or renew) the cert.
575
+ var issued = await _issueCert(certManifest);
576
+ var freshMeta = await _persistCert(certManifest, issued.certPem, issued.keyPem);
577
+ loadedContexts[certManifest.name] = {
578
+ cert: issued.certPem,
579
+ key: issued.keyPem,
580
+ expiresAt: freshMeta.expiresAt,
581
+ fingerprintSha256: freshMeta.fingerprintSha256,
582
+ sniNames: certManifest.domains.slice(),
583
+ };
584
+ var event = meta ? "cert.renewed" : "cert.issued";
585
+ _emitAudit(event, "success", {
586
+ name: certManifest.name,
587
+ domains: certManifest.domains,
588
+ expiresAt: freshMeta.expiresAt,
589
+ fingerprintSha256: freshMeta.fingerprintSha256,
590
+ });
591
+ emitter.emit(event, {
592
+ name: certManifest.name,
593
+ expiresAt: freshMeta.expiresAt,
594
+ fingerprintSha256: freshMeta.fingerprintSha256,
595
+ });
596
+ return loadedContexts[certManifest.name];
597
+ }
598
+
599
+ async function _renewCheckOne(certManifest) {
600
+ var meta = await storage.readMeta(certManifest.name);
601
+ if (!meta) return;
602
+ var msToExpiry = meta.expiresAt - Date.now();
603
+ var renewThresholdMs = minDaysBeforeExpiry * C.TIME.days(1);
604
+ var shouldRenew = msToExpiry < renewThresholdMs;
605
+
606
+ // ARI: if the CA published renewalInfo and ariCompliant is on,
607
+ // also honor the CA's suggestedWindow (which may be sooner or
608
+ // later than the time-based threshold).
609
+ if (ariCompliant && acmeClient) {
610
+ try {
611
+ var ari = await acmeClient.renewIfDue({ certPem: loadedContexts[certManifest.name].cert });
612
+ if (ari && ari.shouldRenew) shouldRenew = true;
613
+ } catch (_e) {
614
+ // ARI fetch failure is non-fatal — fall back to time-based
615
+ // threshold (also drop-silent audit).
616
+ }
617
+ }
618
+
619
+ if (!shouldRenew) return;
620
+ try {
621
+ await _ensureCert(certManifest);
622
+ } catch (e) {
623
+ _emitAudit("cert.renew-failed", "failure", {
624
+ name: certManifest.name,
625
+ domains: certManifest.domains,
626
+ error: (e && e.message) || String(e),
627
+ });
628
+ emitter.emit("cert.renew-failed", {
629
+ name: certManifest.name,
630
+ error: e,
631
+ });
632
+ }
633
+ }
634
+
635
+ async function start() {
636
+ if (stopped) {
637
+ throw new CertError("cert/already-stopped",
638
+ "cert.start: handle was stopped; create a new manager to restart");
639
+ }
640
+ // 1. Boot ACME client + ensure every manifest cert is issued.
641
+ var names = Object.keys(certsByName);
642
+ for (var ni = 0; ni < names.length; ni += 1) {
643
+ await _ensureCert(certsByName[names[ni]]);
644
+ }
645
+ // 2. Start renewal scheduler.
646
+ scheduler = safeAsync.repeating(async function () {
647
+ var keys = Object.keys(certsByName);
648
+ for (var ki = 0; ki < keys.length; ki += 1) {
649
+ await _renewCheckOne(certsByName[keys[ki]]);
650
+ }
651
+ }, renewIntervalMs, { name: "cert-renew" });
652
+ }
653
+
654
+ async function stop() {
655
+ stopped = true;
656
+ if (scheduler && typeof scheduler.stop === "function") scheduler.stop();
657
+ scheduler = null;
658
+ }
659
+
660
+ function getContext(name) {
661
+ if (!certsByName[name]) {
662
+ throw new CertError("cert/unknown-name",
663
+ "cert.getContext: unknown cert '" + name + "' — declare it in opts.certs");
664
+ }
665
+ var ctx = loadedContexts[name];
666
+ if (!ctx) {
667
+ throw new CertError("cert/not-loaded",
668
+ "cert.getContext: cert '" + name + "' not yet loaded — call start() first");
669
+ }
670
+ return {
671
+ cert: ctx.cert,
672
+ key: ctx.key,
673
+ expiresAt: ctx.expiresAt,
674
+ fingerprintSha256: ctx.fingerprintSha256,
675
+ };
676
+ }
677
+
678
+ function sniCallback(servername, cb) {
679
+ // Match by exact domain first, then wildcard suffix.
680
+ var match = null;
681
+ var names = Object.keys(loadedContexts);
682
+ for (var ni = 0; ni < names.length; ni += 1) {
683
+ var ctx = loadedContexts[names[ni]];
684
+ if (ctx.sniNames.indexOf(servername) !== -1) { match = ctx; break; }
685
+ }
686
+ if (!match && names.length > 0) {
687
+ // Wildcard scan — RFC 6125 §6.4.3 restricts `*.example.com` to
688
+ // match exactly ONE label in the left-most position. `foo.bar.
689
+ // example.com` does NOT match `*.example.com` even though the
690
+ // tail aligns. Enforce the single-label invariant explicitly:
691
+ // the wildcard suffix is `.<rest>`; the leading label of
692
+ // `servername` must not itself contain a `.`.
693
+ for (var nj = 0; nj < names.length; nj += 1) {
694
+ var ctxJ = loadedContexts[names[nj]];
695
+ for (var sj = 0; sj < ctxJ.sniNames.length; sj += 1) {
696
+ var pattern = ctxJ.sniNames[sj];
697
+ if (pattern.charAt(0) !== "*" || pattern.charAt(1) !== ".") continue;
698
+ var tail = pattern.slice(1); // ".example.com"
699
+ if (!servername.endsWith(tail)) continue;
700
+ var leadingLabel = servername.slice(0, servername.length - tail.length);
701
+ if (leadingLabel.length === 0 || leadingLabel.indexOf(".") !== -1) continue;
702
+ match = ctxJ;
703
+ break;
704
+ }
705
+ if (match) break;
706
+ }
707
+ }
708
+ if (!match && names.length > 0) {
709
+ // Fall back to the first registered cert (operator's default).
710
+ match = loadedContexts[names[0]];
711
+ }
712
+ if (!match) {
713
+ return cb(new CertError("cert/no-context",
714
+ "cert.sniCallback: no certs loaded for servername '" + servername + "'"));
715
+ }
716
+ try {
717
+ var secureCtx = require("node:tls").createSecureContext({
718
+ cert: match.cert,
719
+ key: match.key,
720
+ });
721
+ cb(null, secureCtx);
722
+ } catch (e) {
723
+ cb(e);
724
+ }
725
+ }
726
+
727
+ async function refresh(name) {
728
+ // refresh() forces an immediate ACME issue regardless of cache
729
+ // freshness — operator-triggered emergency rotation (key
730
+ // compromise, CA misissuance investigation, posture-driven
731
+ // rotation). The renewal scheduler's window-based path runs via
732
+ // _renewCheckOne; refresh() is the override.
733
+ if (!certsByName[name]) {
734
+ throw new CertError("cert/unknown-name",
735
+ "cert.refresh: unknown cert '" + name + "'");
736
+ }
737
+ return _ensureCert(certsByName[name], true);
738
+ }
739
+
740
+ function on(event, handler) { emitter.on(event, handler); return this; }
741
+ function off(event, handler) { emitter.off(event, handler); return this; }
742
+ function once(event, handler) { emitter.once(event, handler); return this; }
743
+
744
+ // Suppress unused-warnings for ocsp + compliance until those branches
745
+ // wire up in v0.11.23+ follow-up.
746
+ void ocspStapling; void ocspRefreshMs; void compliance; void networkTls;
747
+
748
+ return {
749
+ start: start,
750
+ stop: stop,
751
+ getContext: getContext,
752
+ sniCallback: sniCallback,
753
+ refresh: refresh,
754
+ on: on,
755
+ off: off,
756
+ once: once,
757
+ };
758
+ }
759
+
760
+ module.exports = {
761
+ create: create,
762
+ CertError: CertError,
763
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.20",
3
+ "version": "0.11.22",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:ec30b2f7-3d42-4c41-a3f5-78a26f2a8bf4",
5
+ "serialNumber": "urn:uuid:617bc99b-11e9-4c47-8ede-269a573133ef",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-20T22:02:34.937Z",
8
+ "timestamp": "2026-05-21T01:07:24.386Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.11.20",
22
+ "bom-ref": "@blamejs/core@0.11.22",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.20",
25
+ "version": "0.11.22",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.11.20",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.22",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.11.20",
57
+ "ref": "@blamejs/core@0.11.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]