@blamejs/core 0.11.21 → 0.11.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/index.js +2 -0
- package/lib/acme.js +288 -0
- package/lib/asn1-der.js +68 -0
- package/lib/audit.js +1 -0
- package/lib/cert.js +763 -0
- package/lib/mail-agent.js +121 -0
- package/lib/mail-store.js +103 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.23 (2026-05-20) — **`b.mail.agent.expunge` — hard EXPUNGE with legal-hold + retention-floor refusal gates.** Operators (and the future IMAP EXPUNGE + JMAP Email/set destroyed wire-protocol adapters) get a single canonical path for permanent message removal that refuses to delete anything currently under legal hold or still inside the regulator-mandated retention window. The gate runs per-message; refused ids carry an explicit reason (`legal-hold` / `retention-floor` / `not-in-folder`) plus the floor + age + posture metadata that drove the refusal — wire adapters mirror those reasons to operators verbatim. The destructive SQL runs only on the surviving id set, inside a backend transaction that also bumps folder modseq + decrements quota atomically. **Added:** *`b.mail.agent.expunge({ actor, folder, objectIds })` — hard EXPUNGE primitive* — Composes two refusal gates before the destructive SQL runs. (1) Legal-hold gate: any message whose `legal_hold` flag is set refuses with reason `legal-hold`. The mail-store layer surfaces the flag in per-row metadata; this layer maps it to the operator-facing refusal. (2) Retention-floor gate: under a configured compliance posture (`hipaa` / `pci-dss` / `gdpr` / `soc2`), the regulator-mandated minimum retention TTL is read from `b.retention.COMPLIANCE_RETENTION_FLOOR_MS[posture]` and any message whose age (`now - receivedAt`) is below the floor refuses with reason `retention-floor` plus `floorMs` + `ageMs` + `posture` metadata. Returns `{ deleted: <ids>, refused: [{ id, reason, ... }] }`. Audit event `mail.agent.expunge.success` carries the requested / deleted / refused counts and a reason histogram so dashboards can spot abnormal refusal patterns without parsing per-id detail. · *`b.mailStore.create(...).hardExpunge(folder, objectIds)` — destructive SQL primitive* — Removes messages permanently from a folder inside a single backend transaction: deletes the message row + its flag rows, bumps the folder modseq, decrements the per-folder quota by the freed bytes / count. Returns `{ rows, deleted, refused }` where `refused` carries `{ id, reason: 'legal-hold' | 'not-in-folder' }` for each id the SQL gate refused (legal-hold is mirrored from the column; not-in-folder catches stale ids). The agent layer (`b.mail.agent.expunge`) is responsible for the retention-floor gate; this primitive is the wire-protocol-shaped backend surface. · *`b.mailStore.create(...).fetchByObjectId` returns `legalHold: boolean`* — Pre-existing fetch path now consistently exposes the legal-hold flag in its return shape. Previously the field existed in the returned object via a separate path; this commit consolidates the duplicate exports into a single canonical `legalHold` boolean derived from the SQLite `legal_hold` INTEGER column. **References:** [RFC 9051 (IMAP4rev2 — EXPUNGE semantics, §6.4.3)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 8621 (JMAP Mail — Email/set destroyed)](https://www.rfc-editor.org/rfc/rfc8621.html) · [45 CFR §164.316 (HIPAA — retention of records)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.316) · [PCI-DSS v4.0.1 §3.5.1.1 (retention of cardholder data)](https://www.pcisecuritystandards.org/document_library) · [GDPR Art. 17 (right to erasure — operator-side accountability)](https://gdpr-info.eu/art-17-gdpr/)
|
|
12
|
+
|
|
13
|
+
- 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)
|
|
14
|
+
|
|
11
15
|
- 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)
|
|
12
16
|
|
|
13
17
|
- 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)
|
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)
|