@blamejs/pki 0.3.27 → 0.3.29
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 +25 -0
- package/README.md +5 -5
- package/lib/acme.js +674 -42
- package/lib/constants.js +4 -0
- package/lib/est.js +352 -29
- package/lib/http-digest.js +379 -0
- package/lib/http-retry-after.js +13 -4
- package/lib/http-transport.js +5 -3
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.3.29 — 2026-08-02
|
|
8
|
+
|
|
9
|
+
The pki.acme client rounds out its RFC 8555 / RFC 9773 surface -- pre-authorize an identifier with client.newAuthz, choose among alternate issuance chains in client.downloadCertificate, and schedule renewal from the CA's ARI window with client.renewalWindow.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- client.newAuthz(identifier) pre-authorizes a single identifier ahead of placing an order (RFC 8555 sec. 7.4.1): it POSTs the identifier to the directory's newAuthz resource (kid-signed) and returns { authorization, url } for the created authorization -- normally pending, or an already-valid one when the CA has out-of-band authorization for the identifier. A wildcard identifier is refused before any request (pre-authorization of a wildcard is not defined), an unadvertised newAuthz resource fails closed, a 201 without a Location fails closed, and the returned authorization is validated to name exactly the identifier requested and to be a non-wildcard authorization the flow can proceed with -- one that names a different identifier, is marked as a broader wildcard grant, or is in a terminal failed state is rejected rather than acted on.
|
|
14
|
+
- client.downloadCertificate(url, opts?) gains selectChain + maxAlternates to choose among the alternate issuance chains a CA offers (RFC 8555 sec. 7.4.2, RFC 8288 Link). Without a selector it returns the primary chain and now also alternates -- the resolved URLs of every Link rel="alternate" the certificate response advertised. With selectChain, it evaluates the primary chain first, then each alternate in header order, and resolves to the first chain the predicate accepts (selectChain receives { certificate, chain, certificates }); none accepted fails closed. The alternate Link header is untrusted: it is parsed strictly against RFC 8288 (rel matched as a whole token, case-insensitively; a malformed header or a non-https target fails closed), the extra signed fetches are bounded by maxAlternates (default 8, over-budget fails closed), duplicate resolved URLs are de-duplicated, and an alternate whose end-entity certificate differs from the primary's is rejected rather than substituted. Each alternate is fetched by the same POST-as-GET path, inheriting the media-type, size, and strict-chain-parse gates.
|
|
15
|
+
- client.renewalWindow(certDer, opts?) turns the CA's ARI renewal window into a scheduling decision (RFC 9773 sec. 4.2 / 4.3). It composes the unauthenticated renewalInfo GET, selects a uniform-random instant within the CA's suggested window -- bounded by the certificate's own expiry, so the chosen time is never after notAfter -- (an injectable random and clock make the decision deterministic and let a caller ask 'renew as of time T?') so many clients do not renew at the same edge, and returns { suggestedWindow, selectedTime, renewNow, retryAfterSeconds, explanationURL } -- renewNow is set when the selected instant is already in the past. It refuses before any request for a certificate already past its notAfter (nothing to renew) or one the caller marks with replaced: true (already superseded), and retryAfterSeconds always carries a poll delay: the CA's Retry-After clamped to [60s, 24h], or a sensible default when the CA omits it. Pass a prior result back as opts.previous to REUSE its selectedTime while the CA's window is unchanged (RFC 9773 sec. 4.2), so a client that refreshes ARI on each poll keeps one stable renewal instant instead of re-randomizing it. The helper returns the decision as data; it never sleeps or schedules on a background timer.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- client.renewalInfo(certDer) now refuses an already-expired certificate before issuing the unauthenticated RenewalInfo GET (RFC 9773 sec. 4.3: a client MUST NOT check a certificate's RenewalInfo after it has expired), throwing acme/certificate-expired -- the same pre-fetch expiry gate renewalWindow applies.
|
|
20
|
+
- pki.acme.client now rejects a server-provided URL whose host is an IPv4-address form (hex, octal, decimal, or shorthand) that the WHATWG URL parser would coerce to a different, often loopback or internal, address -- the account-signed JWS url (RFC 8555 sec. 6.4) must name the exact authority the request connects to, so such a URL can no longer steer an authenticated request to an unintended host (SSRF hardening).
|
|
21
|
+
|
|
22
|
+
## v0.3.28 — 2026-08-01
|
|
23
|
+
|
|
24
|
+
pki.est gains its remaining RFC 7030 network verbs -- request a server-generated key pair with pki.est.serverkeygen, fetch the CA's CSR-attributes policy with pki.est.csrattrs, and authenticate with HTTP Digest as an alternative to HTTP Basic.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- pki.est.serverkeygen(baseUrl, csr, opts?) requests a server-generated key pair + certificate (RFC 7030 sec. 4.4): it POSTs the CSR (application/pkcs10, identical encoding to simpleenroll) to /.well-known/est/serverkeygen and returns { certificates, privateKey } for a cleartext PKCS#8 PrivateKeyInfo key part, { certificates, encryptedKey } for a CMS EnvelopedData key part (surfaced structurally, never decrypted), or { retry, retryAfterSeconds, retryAfterDate } on a 202. The certificates are returned raw (no leaf is selected -- the CA generated the key, so the issued certificate carries the generated public key, not the throwaway CSR key). A cleartext key is bound to its certificate before it is surfaced: the delivered private key's public half must match exactly one returned certificate, so a key mis-associated with the returned certificate set is refused rather than handed back unusable. Whether the key part must be encrypted, and to which recipient, is derived from the CSR's DecryptKeyIdentifier (a symmetric key-encryption key) or AsymmetricDecryptKeyIdentifier (an asymmetric key) attribute; the recipient MECHANISM is preserved and required to match the RecipientInfo arm, so a symmetric KEK recipient never satisfies an advertised asymmetric key (or vice versa) merely because the identifier bytes coincide. An opts value that contradicts the CSR is refused, and a cleartext key delivered where the CSR requested encryption is refused, as is an encrypted key delivered where the CSR advertised no decryption key to open it (an unusable, unsolicited credential). The delivered key's channel must negotiate a confidentiality-bearing cipher (a NULL / anonymous / EXPORT suite is refused). https-only, explicit-anchor, and the full redirect / auth / budget machinery of the enrollment verbs apply.
|
|
29
|
+
- pki.est.csrattrs(baseUrl, opts?) fetches the CA's CSR-attributes policy (RFC 7030 sec. 4.5, RFC 9908): a 200 application/csrattrs body is parsed and returned as { available: true, attrs, plan }, where plan is the enroll-attribute plan the caller builds its next CSR from; a 204 or 404 is { available: false } (a valid "CSR Attributes Response not available"); an empty CsrAttrs is a complete empty policy. The verb never applies attributes to a CSR itself. Server authentication is not required for this policy GET, but a 401 is honored so the auth path stays available.
|
|
30
|
+
- HTTP Digest access authentication (RFC 7616) is available on every EST verb as an alternative to HTTP Basic via opts.auth = { scheme: "digest", username, password }. SHA-256 and SHA-512-256 are supported; MD5 and MD5-sess are refused unless opts.auth.allowMD5 is set, and a legacy no-qop (RFC 2069) challenge is refused unless opts.auth.allowLegacyQop is set. Among multiple offered challenges the most secure USABLE one is chosen -- a challenge this client cannot answer under its policy (an unsupported or disallowed algorithm, or a qop mode it does not accept) never shadows a lower-ranked usable one, so the client authenticates on a usable offer instead of picking the strongest and failing; when no offer is usable it fails closed with the specific reason rather than downgrading to a weaker scheme. A server stale=true re-challenge is answered under a bounded opts.auth.maxStaleRetries budget. The response is scoped to the challenge's protection space: within an origin it is preemptively reused only for the URIs the challenge's domain directive covers (a quoted directive -- a malformed unquoted one is refused, not silently widened; an absolute-URI entry is matched by its authority as well as its path, so an entry naming a different host never widens the scope to this one; the query is kept in the comparison, so an entry scoped to one query does not cover a different one), and a same-origin resource outside that scope is authenticated afresh (bounded) -- classified by whether the request actually carried a credential, so a resource sent unauthenticated is never mistaken for a rejected one -- rather than sent the first realm's response or blocked outright. A non-ASCII username under a charset=UTF-8 challenge is carried in the RFC 5987 username* extended form (percent-encoded UTF-8) rather than the legacy quoted field a server would read as ISO-8859-1, and a non-ASCII realm's incoming header octets are hashed unchanged, so a UTF-8 realm contributes the same octets the server used and the response matches an RFC 7616 server. As with Basic, the credential is answered only on the origin the caller authenticated to -- never sent to a redirected origin, and never as a silent Basic downgrade of a Digest challenge.
|
|
31
|
+
|
|
7
32
|
## v0.3.27 — 2026-07-31
|
|
8
33
|
|
|
9
34
|
pki.cmp.session drives a full CMP certificate enrollment end to end -- build, transfer, and verify every leg of an ir/cr/kur/p10cr exchange, with every response protection-checked before its body is read.
|
package/README.md
CHANGED
|
@@ -216,10 +216,10 @@ is callable today; nothing below is a stub.
|
|
|
216
216
|
| `pki.schema.pkcs12` | Parse DER / BER / PEM RFC 7292 PKCS#12 (PFX) stores — key bags via the PKCS#8 parser, shrouded keys (algorithm surfaced, ciphertext opaque), cert / CRL / secret bags raw and byte-exact, encrypted and enveloped safes structurally via CMS, `friendlyName` / `localKeyId` decoded, and the exact MAC byte range (`macedBytes`) plus RFC 9579 PBMAC1 recognition for external verification; BER accepted exactly where §4.1 requires it, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
217
217
|
| `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
218
218
|
| `pki.schema.csrattrs` | Parse DER EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC/RSA key-type conventions, the certification-request-info template). Unknown types tolerated (surfaced raw), fail-closed on structure and the RFC 9908 semantic MUSTs — `parse` |
|
|
219
|
-
| `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic answered only after the server is authenticated, and the issued certificate chosen by public-key match. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
219
|
+
| `pki.est` | RFC 7030 / 8951 / 9908 / 7616 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` / `serverkeygen` / `csrattrs` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic or Digest (RFC 7616; SHA-256 / SHA-512-256, MD5 and no-qop refused by default) answered only after the server is authenticated, and the issued certificate chosen by public-key match. `serverkeygen` requests a server-generated key (cleartext or an opaque CMS EnvelopedData, encryption bound to the CSR's key-identifier attribute, over a confidentiality-bearing cipher); `csrattrs` fetches the CA's RFC 9908 attributes policy. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
220
220
|
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive — `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body }`. The toolkit's sole socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. Reused verbatim by the EST, ACME, and CMP clients — `https` |
|
|
221
221
|
| `pki.jose` | RFC 7515 Flattened JWS + RFC 7638 JWK thumbprints — `sign` / `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required/forbidden header rules as data; `base64url` is the strict RFC 4648 §5 codec (padding, non-alphabet, and non-canonical trailing bits rejected); `parseJson` is a bounded reader that refuses duplicate members at any depth; `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), so `alg:none`, an RS256→HS256 key confusion, and an all-zero ECDSA signature have no code path; `assertPublicJwk` refuses a JWK carrying private material so an exported private key is never published — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
222
|
-
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow, and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`,
|
|
222
|
+
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `newAuthz` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow — `newAuthz` pre-authorizes a single identifier (§7.4.1) and `downloadCertificate` picks among alternate issuance chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`) — and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. Over the message layer it composes: resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
223
223
|
| `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
224
224
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
225
225
|
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; every accept flows through `validate` and its verdict is cross-checked against `openssl verify`. **Opt-in AIA `caIssuers` fetching** (`opts.fetchAia: true`) discovers a *missing* intermediate from a certificate's Authority Information Access URL (RFC 5280 §4.2.2.1) over `pki.transport`, triggered only on a pool miss — SSRF/amplification-bounded (https-only, a total fetch budget that silently caps fetching rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size + certificate-count cap, no redirect following; every fault a silent skip), with the TLS trust (`opts.tls`) kept distinct from the PKI `trustAnchors` and every fetched certificate remaining untrusted pool material that still flows through `validate` (never a trust anchor). Off by default — the default build is byte-identical offline. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
|
|
@@ -272,9 +272,9 @@ certificate and writes a DER (or `--pem`) SignedData to `--out` or stdout.
|
|
|
272
272
|
|
|
273
273
|
### What's coming
|
|
274
274
|
|
|
275
|
-
The remaining EST network
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
The remaining EST network verb (`/fullcmc`), SCEP and CMC enrollment, and
|
|
276
|
+
additional NIST-on-ramp PQC signatures as the OID registry admits them are on
|
|
277
|
+
the roadmap and ride this same
|
|
278
278
|
core. See
|
|
279
279
|
[ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
|
|
280
280
|
[CHANGELOG.md](CHANGELOG.md) for what has landed.
|