@blamejs/pki 0.3.15 → 0.3.17

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
@@ -4,6 +4,23 @@ 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.17 — 2026-07-25
8
+
9
+ Refresh two development-only tooling dependencies to clear newly-disclosed advisories.
10
+
11
+ ### Security
12
+
13
+ - Refresh the development/fuzzing-only brace-expansion (5.0.7 -> 5.0.8, GHSA-mh99-v99m-4gvg) and tar (7.5.19 -> 7.5.22, GHSA-r292-9mhp-454m) tooling dependencies to versions clear of two newly-disclosed advisories. Both are used only by the development and fuzzing harnesses and are never part of the published package (the toolkit has zero runtime dependencies), so installed contents are unchanged.
14
+
15
+ ## v0.3.16 — 2026-07-24
16
+
17
+ The EST enrollment client ships -- pki.est fetches CA certificates and enrolls certificates over the wire (RFC 7030), on a new shared node:https transport.
18
+
19
+ ### Added
20
+
21
+ - pki.est.cacerts(baseUrl, opts?), pki.est.simpleenroll(baseUrl, csr, opts?), and pki.est.simplereenroll(baseUrl, csr, opts?) -- the thin RFC 7030 client verbs over pki.transport. cacerts returns the raw, unordered CA certificate set; simpleenroll submits a PKCS#10 CSR (a DER Buffer or PEM) and returns the issued certificate (chosen by public-key match) with its chain; simplereenroll additionally requires opts.oldCert and enforces the byte-identical Subject + SubjectAltName re-enroll check before the request. A 202 is surfaced as { retry, retryAfterSeconds } and never slept on. Trust is fail-closed: an https URL and an explicit opts.tls.anchors (or opts.tls.useSystemStore) are required, HTTP Basic credentials are answered only after the server is authenticated and are dropped on a cross-origin redirect, and a downgrade, redirect loop, oversized response, or unmatched issued certificate each fails closed with a typed est/* error.
22
+ - pki.transport -- the shared, fail-closed node:https transport the enrollment clients drive. pki.transport.https(defaults) returns a transport(request) -> Promise<{ status, headers, body }>. It is the toolkit's only socket: rejectUnauthorized is always on with no disable path, an explicit trust anchor or a system-store opt-in is required, TLS is floored at 1.2, the response body is bounded while it streams (LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward), and connect/read is bounded by a timeout. It carries no HTTP or protocol semantics -- status, content-type, redirect, and authentication decisions live in the message layer -- so it is reused verbatim across enrollment protocols. errors.TransportError is its default fault type; a protocol client may parameterize it to surface domain codes.
23
+
7
24
  ## v0.3.15 — 2026-07-24
8
25
 
9
26
  PKCS#12 public-key integrity is produced and verified (RFC 7292 sec. 4).
package/README.md CHANGED
@@ -216,7 +216,8 @@ 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 transport-agnostic client codecs (the RFC 8951 base64 transfer codec, blind to Content-Transfer-Encoding; the `multipart/mixed` splitter), the certs-only + serverkeygen response validators layered over CMS, the enroll-attribute builders (channel-binding challengePassword, out-of-band key identifiers, SMIMECapabilities, the template-priority enroll plan), and the HTTP response classifier (202 Retry-After surfaced, never slept on; 204/404 on csrattrs a "none available" verdict). No socket, fail-closed — `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `parseServerKeygenResponse`, `classifyResponse`, `paths`, and the builders |
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 |
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 client today (ACME / CMP next) — `https` |
220
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` |
221
222
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — 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). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
222
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` |
package/index.js CHANGED
@@ -53,6 +53,7 @@ var shbs = require("./lib/shbs");
53
53
  var hpke = require("./lib/hpke");
54
54
  var sigstore = require("./lib/sigstore");
55
55
  var est = require("./lib/est");
56
+ var transport = require("./lib/http-transport");
56
57
  var jose = require("./lib/jose");
57
58
  var acme = require("./lib/acme");
58
59
  var trust = require("./lib/trust");
@@ -143,11 +144,17 @@ module.exports = {
143
144
  // keyless Fulcio signature over a DSSE-wrapped in-toto SLSA attestation with a
144
145
  // Rekor inclusion proof -- offline, zero-dep, against caller-pinned trust.
145
146
  sigstore: sigstore,
146
- // `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the
147
- // transport-agnostic client codecs (base64 transfer, multipart splitter),
148
- // certs-only + serverkeygen validators over CMS, the enroll-attribute builders,
149
- // and the HTTP response classifier. No socket; fail-closed.
147
+ // `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the thin
148
+ // client verbs (pki.est.cacerts / simpleenroll / simplereenroll) over pki.transport,
149
+ // plus the transport-agnostic codecs they compose (base64 transfer, multipart
150
+ // splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
151
+ // builders, the HTTP response classifier). est opens no socket; fail-closed.
150
152
  est: est,
153
+ // `transport` is the shared fail-closed node:https transport the enrollment clients
154
+ // drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
155
+ // headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
156
+ // rejectUnauthorized always on, a TLS floor, a streaming response cap, and a timeout.
157
+ transport: transport,
151
158
  // `jose` is the RFC 7515 Flattened JWS + RFC 7638 JWK-thumbprint layer: a strict
152
159
  // base64url codec, a bounded duplicate-key-rejecting JSON reader, profiled
153
160
  // sign/verify (ACME-outer / EAB-inner / keyChange-inner), and an alg registry
package/lib/constants.js CHANGED
@@ -153,6 +153,14 @@ var LIMITS = {
153
153
  PEM_MAX_BYTES: BYTES.mib(16),
154
154
  DER_MAX_INTEGER_BYTES: BYTES.kib(16),
155
155
  OID_MAX_SUBIDENTIFIER_BYTES: 32,
156
+ // Network transport response ceiling (pki.transport / the EST client). A remote
157
+ // server's HTTP response body is bounded WHILE it streams -- the accumulator
158
+ // aborts the socket the instant the running total crosses this cap, before the
159
+ // body reaches a decoder, so a malicious length field cannot drive unbounded
160
+ // allocation (RFC 7030 sec. 6). Sized above a base64-inflated DER_MAX_BYTES
161
+ // document plus multipart/header overhead (16 MiB * 4/3 ~= 21.3 MiB); 24 MiB
162
+ // clears it. An operator may only tighten it DOWNWARD via opts.maxResponseBytes.
163
+ HTTP_MAX_RESPONSE_BYTES: BYTES.mib(24),
156
164
  // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
157
165
  // a whole-document cap refused before the walk, a nesting cap, and a per-value
158
166
  // bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
package/lib/est.js CHANGED
@@ -9,10 +9,19 @@
9
9
  * @slug est
10
10
  *
11
11
  * @intro
12
- * Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and
13
- * RFC 9908) -- the transport-agnostic codecs, validators, and request builders
14
- * an EST client composes over the shipped CMS / CSR / PKCS#8 / X.509 parsers.
15
- * No socket is opened here: `transferDecode` / `transferEncode` are the RFC 8951
12
+ * Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and RFC 9908).
13
+ * The network verbs -- `cacerts`, `simpleenroll`, `simplereenroll` -- run the thin
14
+ * RFC 7030 client: they compose the codecs below over the shared `pki.transport`
15
+ * (a caller MAY inject `opts.transport`; the default is a fail-closed
16
+ * `pki.transport.https`). This module opens no socket itself -- the sole socket
17
+ * choke point is `pki.transport` -- so the verbs stay a thin, fail-closed shell:
18
+ * https-only (`est/insecure-url`), an explicit trust anchor required
19
+ * (`est/no-trust-anchors`), same-origin redirects followed but a downgrade / loop
20
+ * refused, a 202 Retry-After SURFACED and never slept, HTTP Basic answered only
21
+ * after the transport authenticated the server, and the issued certificate chosen
22
+ * by public-key match. Under them sit the transport-agnostic codecs, validators, and
23
+ * request builders over the shipped CMS / CSR / PKCS#8 / X.509 parsers:
24
+ * `transferDecode` / `transferEncode` are the RFC 8951
16
25
  * sec. 3 base64 transfer codec (RFC 4648, and DELIBERATELY blind to any
17
26
  * Content-Transfer-Encoding header -- errata 5904/5107); `splitMultipartMixed`
18
27
  * is the /serverkeygen `multipart/mixed` splitter; `parseCertsOnly` validates a
@@ -36,9 +45,10 @@
36
45
  * module). DER-only where DER, fail-closed everywhere.
37
46
  *
38
47
  * @card
39
- * EST (RFC 7030 / 8951 / 9908) client codecs -- base64 transfer, multipart
40
- * splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
41
- * builders, and the HTTP response classifier. Transport-agnostic, fail-closed.
48
+ * EST (RFC 7030 / 8951 / 9908) client -- the cacerts / simpleenroll / simplereenroll
49
+ * verbs over the shared pki.transport, plus the codecs they compose: base64 transfer,
50
+ * multipart splitter, certs-only + serverkeygen validators over CMS, the
51
+ * enroll-attribute builders, and the HTTP response classifier. Fail-closed.
42
52
  */
43
53
 
44
54
  var asn1 = require("./asn1-der");
@@ -51,6 +61,7 @@ var pkcs8 = require("./schema-pkcs8");
51
61
  var csr = require("./schema-csr");
52
62
  var frameworkError = require("./framework-error");
53
63
  var guard = require("./guard-all");
64
+ var httpTransport = require("./http-transport");
54
65
 
55
66
  var EstError = frameworkError.EstError;
56
67
  function E(code, message, cause) { return new EstError(code, message, cause); }
@@ -498,7 +509,7 @@ function classifyResponse(status, headers, body, opts) {
498
509
  }
499
510
  if (status === 202) {
500
511
  var ra = h["retry-after"];
501
- if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC 8951 sec. 3.3)");
512
+ if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC 7030 sec. 4.2.3)");
502
513
  var raStr = String(ra).trim();
503
514
  // Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3). A
504
515
  // delay-seconds becomes retryAfterSeconds directly; an HTTP-date is surfaced
@@ -708,7 +719,304 @@ function reenrollGuard(oldCertDer, newCsrDer) {
708
719
  return { subjectDn: oldSubjectDn, subjectAltName: oldSan };
709
720
  }
710
721
 
722
+ // ---- the thin RFC 7030 client: network verbs over the shared pki.transport ----
723
+
724
+ var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
725
+ var MAX_TIMEOUT = constants.TIME.seconds(600);
726
+
727
+ // The DER of a caller-supplied CSR: a DER Buffer as-is, or a PEM "CERTIFICATE REQUEST"
728
+ // decoded. Any other input is a config-time est/bad-input.
729
+ function _csrDer(input) {
730
+ if (Buffer.isBuffer(input)) return input;
731
+ if (typeof input === "string") return csr.pemDecode(input);
732
+ throw E("est/bad-input", "a CSR must be a DER Buffer or a PEM CERTIFICATE REQUEST string");
733
+ }
734
+
735
+ // Parse + validate the request URL BEFORE any transport is called: a URL that will not
736
+ // parse is est/bad-url, a non-https URL is est/insecure-url (RFC 7030 sec. 3.3). Runs
737
+ // even when a transport is injected, so an insecure URL never reaches the wire.
738
+ function _parseUrl(urlStr) {
739
+ var url;
740
+ try { url = new URL(String(urlStr)); }
741
+ catch (e) { throw E("est/bad-url", "the EST server URL did not parse: " + String(urlStr), e); }
742
+ if (url.protocol !== "https:") throw E("est/insecure-url", "EST requires https (RFC 7030 sec. 3.3), got " + url.protocol + " for " + urlStr);
743
+ return url;
744
+ }
745
+
746
+ // Map opts.tls (operator-facing) to the transport request.tls shape (anchors -> ca).
747
+ // rejectUnauthorized is NOT here -- the transport forces it on unconditionally.
748
+ function _tlsForRequest(opts) {
749
+ var t = opts.tls || {};
750
+ return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
751
+ }
752
+
753
+ // Resolve a redirect target: reject a scheme downgrade (est/insecure-redirect) or a
754
+ // cross-origin redirect on a non-GET/HEAD method without opt-in
755
+ // (est/cross-origin-redirect); a Location that will not parse is est/bad-url. Same-origin
756
+ // (and cross-origin GET/HEAD) are followed -- the transport re-runs every TLS check on the
757
+ // new connection (RFC 7030 sec. 3.2.1).
758
+ function _redirectTarget(current, location, method, opts) {
759
+ if (location == null || String(location).trim() === "") throw E("est/http-error", "a redirect response carried no Location header");
760
+ var resolved;
761
+ try { resolved = new URL(String(location), current.href); }
762
+ catch (e) { throw E("est/bad-url", "a redirect Location did not parse: " + location, e); }
763
+ if (resolved.protocol !== "https:") throw E("est/insecure-redirect", "a redirect to a non-https URL is refused (RFC 7030 sec. 3.2.1): " + resolved.protocol);
764
+ var safeMethod = method === "GET" || method === "HEAD";
765
+ if (resolved.origin !== current.origin && !safeMethod && !opts.allowCrossOriginRedirect) {
766
+ throw E("est/cross-origin-redirect", "a cross-origin redirect on a " + method + " needs opts.allowCrossOriginRedirect (RFC 7030 sec. 3.2.1)");
767
+ }
768
+ return resolved;
769
+ }
770
+
771
+ // Does WWW-Authenticate advertise a Basic challenge? The quoted-string contents are blanked first
772
+ // (a quoted auth-param may contain a comma + "Basic", e.g. `Digest realm="x, Basic required"`, which
773
+ // must NOT be read as a Basic challenge), then Basic is matched as an auth-scheme TOKEN (at the start
774
+ // or after a comma-separated challenge, followed by whitespace / a comma / end) -- never a substring,
775
+ // so `Digest realm="basic"` / `Bearer error="basic required"` are not taken as Basic either.
776
+ function _hasBasicChallenge(www) {
777
+ var s = String(www || "").replace(/"(?:[^"\\]|\\.)*"/g, "\"\"");
778
+ return /(?:^|,)\s*Basic(?=[\s,]|$)/i.test(s);
779
+ }
780
+
781
+ // The redirect-follow + HTTP-auth-retry loop. Returns the terminal transport response
782
+ // ({status, headers, body}) -- a 2xx, a 202, or a non-401 4xx/5xx. A 3xx is followed
783
+ // (bounded by maxRedirects); a 401 is answered ONCE with Basic credentials, and ONLY when
784
+ // the caller supplied them and the challenge is Basic (RFC 7030 sec. 3.2.3) -- the server
785
+ // was authenticated by the transport's rejectUnauthorized TLS before any credential is
786
+ // transmitted (no credential ever precedes server authorization).
787
+ function _drive(method, url, body, headers, opts, transport, budgets) {
788
+ var redirects = 0;
789
+ var authTried = false;
790
+ var initialOrigin = url.origin; // the origin the caller intended to authenticate to
791
+ var tls = budgets.tls; // narrowed (mTLS client identity stripped) on a cross-origin hop
792
+ function step() {
793
+ return transport({ method: method, url: url.href, headers: headers, body: body, tls: tls, timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
794
+ res = res || {};
795
+ var blen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "latin1");
796
+ if (blen > budgets.maxResponseBytes) throw E("est/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap (RFC 7030 sec. 6)");
797
+ // The transport contract returns lowercased headers, but the injectable seam only promises
798
+ // { status, headers, body }; normalize here so a redirect Location / WWW-Authenticate from an
799
+ // injected transport using ordinary HTTP casing is read correctly (never missed as absent).
800
+ var h = {};
801
+ Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
802
+ var status = res.status;
803
+ if (status >= 300 && status < 400) {
804
+ if (redirects >= budgets.maxRedirects) throw E("est/too-many-redirects", "the redirect chain exceeded maxRedirects=" + budgets.maxRedirects + " (RFC 7030 sec. 3.2.1)");
805
+ // 303 See Other directs a non-GET/HEAD request to retrieve the target with GET and no body
806
+ // (RFC 7231 sec. 6.4.4); drop the request body + its entity content-type before following, so
807
+ // the CSR is not re-POSTed to the redirect target as a duplicate enrollment.
808
+ if (status === 303 && method !== "GET" && method !== "HEAD") {
809
+ method = "GET";
810
+ body = null;
811
+ if (headers["content-type"]) { headers = Object.assign({}, headers); delete headers["content-type"]; }
812
+ }
813
+ var prevOrigin = url.origin;
814
+ url = _redirectTarget(url, h.location, method, opts);
815
+ // Credentials MUST NOT cross an origin boundary. A redirect to a different web origin (even a
816
+ // permitted cross-origin GET) drops BOTH the HTTP Basic Authorization header AND the mTLS
817
+ // client identity (cert/key), so a prior 401's password and the caller's certificate + proof
818
+ // of private-key possession are never presented to a different server.
819
+ if (url.origin !== prevOrigin) {
820
+ if (headers.authorization) { headers = Object.assign({}, headers); delete headers.authorization; }
821
+ if (tls && (tls.cert || tls.key)) { tls = Object.assign({}, tls); delete tls.cert; delete tls.key; }
822
+ }
823
+ redirects += 1;
824
+ return step();
825
+ }
826
+ if (status === 401) {
827
+ if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
828
+ // Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a
829
+ // cross-origin redirect is a different server, and the client MUST NOT send its credentials
830
+ // there (RFC 7030 sec. 3.6 -- credentials go only to an authorized server the caller chose).
831
+ if (url.origin !== initialOrigin) throw E("est/auth-required", "refusing to send HTTP credentials to a redirected origin (RFC 7030 sec. 3.6)");
832
+ var www = String(h["www-authenticate"] || "");
833
+ if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
834
+ if (opts.username === undefined && opts.password === undefined) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
835
+ headers = Object.assign({}, headers, { authorization: "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64") });
836
+ authTried = true;
837
+ return step();
838
+ }
839
+ return res;
840
+ });
841
+ }
842
+ return step();
843
+ }
844
+
845
+ // The shared client entry: build the op URL, validate it + the trust config + the budgets
846
+ // (ALL before any transport call so a config fault proves the transport was never reached),
847
+ // then drive the request through the redirect/auth loop.
848
+ function _client(op, method, baseUrl, body, headers, opts) {
849
+ // paths() concatenates onto the base string, so a query / fragment on the base URL would capture
850
+ // the operation path (sending the request -- possibly an enrollment CSR -- to the wrong resource).
851
+ var base;
852
+ try { base = new URL(String(baseUrl)); }
853
+ catch (e) { throw E("est/bad-url", "the EST base URL did not parse: " + String(baseUrl), e); }
854
+ if (base.search || base.hash) throw E("est/bad-url", "the EST base URL must not carry a query or fragment component (RFC 7030 sec. 3.2.2)");
855
+ var url = _parseUrl(paths(baseUrl, { label: opts.label })[op]);
856
+ var transport = opts.transport;
857
+ if (!transport) {
858
+ var t = opts.tls || {};
859
+ var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
860
+ if (!hasAnchors && t.useSystemStore !== true) throw E("est/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned server (RFC 7030 sec. 3.6)");
861
+ transport = httpTransport.https({ E: E, errPrefix: "est" });
862
+ }
863
+ var budgets = {
864
+ tls: _tlsForRequest(opts),
865
+ timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: E, code: "est/bad-input", min: 1, max: MAX_TIMEOUT }),
866
+ maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: E, code: "est/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
867
+ maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "est/bad-input", min: 0, max: 32 }),
868
+ };
869
+ return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
870
+ }
871
+
872
+ // Turn a terminal response into the verb result: classifyResponse gates status +
873
+ // content-type (202 -> a surfaced retry, never slept; 4xx/5xx -> est/http-error), then an
874
+ // empty 200 is rejected and the certs-only body is decoded + validated. For an enroll, the
875
+ // issued certificate is the public-key match (findIssuedCert), never a positional guess;
876
+ // the remaining certificates are the chain.
877
+ function _certsResult(op, res, opts, csrSpki) {
878
+ var verdict = classifyResponse(res.status, res.headers, res.body, { op: op, now: opts.now });
879
+ if (verdict.status === "retry") {
880
+ // 202 Retry-After is an ENROLLMENT response (RFC 7030 sec. 4.2.3); a successful /cacerts is 200
881
+ // only (sec. 4.1.3), so a 202 there is a nonconforming server -> fail closed rather than "retry".
882
+ if (op === "cacerts") throw E("est/http-error", "a /cacerts response must be HTTP 200, not 202 (RFC 7030 sec. 4.1.3)");
883
+ return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
884
+ }
885
+ // Only the RFC-defined success statuses are accepted: a non-standard 2xx (201, 206, ...) that the
886
+ // classifier reports as "unexpected" must NOT have its body decoded and accepted as certificates.
887
+ if (verdict.status !== "ok") throw E("est/http-error", "an EST " + op + " response must be HTTP 200 or 202 (RFC 7030 sec. 4.1.3 / 4.2.3), got " + res.status);
888
+ var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "latin1");
889
+ if (bodyLen === 0) throw E("est/empty-body", "a 200 " + op + " response carried an empty body (RFC 7030 sec. 4.1.3 / 4.2.3)");
890
+ var parsed = parseCertsOnly(transferDecode(res.body));
891
+ if (op === "cacerts") return { certificates: parsed.certificates, crls: parsed.crls };
892
+ var issued = findIssuedCert(parsed.certificates, csrSpki);
893
+ if (!issued) throw E("est/issued-cert-not-found", "no returned certificate matched the submitted CSR public key (RFC 5272 sec. 4.1)");
894
+ // The chain is every OTHER response certificate (by reference, so a byte-identical duplicate is
895
+ // NOT silently collapsed into the issued cert). Exactly one certificate may match the submitted
896
+ // public key -- a second match (a renewed cert sharing the key, or a duplicate entry) makes the
897
+ // issued certificate ambiguous, since the certs-only set has no issuance ordering (RFC 5272 sec. 4.1).
898
+ var chain = parsed.certificates.filter(function (c) { return c !== issued; });
899
+ if (findIssuedCert(chain, csrSpki)) throw E("est/ambiguous-issued-cert", "the enroll response carried more than one certificate matching the submitted CSR public key; the issued certificate is ambiguous (RFC 5272 sec. 4.1)");
900
+ if (opts.strict && chain.length > 0) throw E("est/unexpected-certs", "strict: the enroll response carried " + parsed.certificates.length + " certificates, expected exactly the issued one");
901
+ return { certificate: issued, chain: chain, certificates: parsed.certificates };
902
+ }
903
+
904
+ function _enroll(op, baseUrl, csrInput, opts) {
905
+ var csrDer = _csrDer(csrInput);
906
+ var spki = csr.parse(csrDer).subjectPublicKeyInfo;
907
+ var body = transferEncode(csrDer);
908
+ return _client(op, "POST", baseUrl, body, { accept: "application/pkcs7-mime", "content-type": "application/pkcs10" }, opts)
909
+ .then(function (res) { return _certsResult(op, res, opts, spki); });
910
+ }
911
+
912
+ /**
913
+ * @primitive pki.est.cacerts
914
+ * @signature pki.est.cacerts(baseUrl, opts?) -> Promise<{ certificates, crls } | { retry, retryAfterSeconds }>
915
+ * @since 0.3.16
916
+ * @status experimental
917
+ * @spec RFC 7030, RFC 8951
918
+ * @related pki.est.simpleenroll, pki.transport.https
919
+ *
920
+ * Fetch a CA's certificates over the wire: GET `<baseUrl>/.well-known/est/cacerts` through
921
+ * the shared `pki.transport` (inject `opts.transport`, else a fail-closed
922
+ * `pki.transport.https`). Returns the raw, unordered certs-only set
923
+ * (`{ certificates, crls }`), or `{ retry: true, retryAfterSeconds }` on a 202 (surfaced,
924
+ * never slept). https-only (`est/insecure-url`); an explicit `opts.tls.anchors` (or an
925
+ * `opts.tls.useSystemStore` opt-in) is required (`est/no-trust-anchors`); the returned CA
926
+ * certificate is NOT auto-trusted -- the caller path-validates it and supplies the accepted
927
+ * anchor on the next call.
928
+ *
929
+ * @opts
930
+ * - `transport` -- an injected transport(request) -> {status, headers, body}; default pki.transport.https.
931
+ * - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }.
932
+ * - `label` -- an OPTIONAL CA label path segment; `timeout` / `maxResponseBytes` / `maxRedirects` -- budgets.
933
+ * - `now` -- receipt time (epoch ms) to render a 202 Retry-After HTTP-date as seconds.
934
+ * @example
935
+ * // a live CA uses the default pki.transport.https; here an injected transport returns a canned bag
936
+ * var r = await pki.est.cacerts("https://ca.example",
937
+ * { transport: function () { return Promise.resolve({ status: 200, headers: { "content-type": "application/pkcs7-mime" }, body: caCertsDer.toString("base64") }); } });
938
+ * r.certificates; // -> [Buffer, ...] raw, unordered
939
+ */
940
+ function cacerts(baseUrl, opts) {
941
+ opts = opts || {};
942
+ return Promise.resolve().then(function () {
943
+ return _client("cacerts", "GET", baseUrl, null, { accept: "application/pkcs7-mime" }, opts);
944
+ }).then(function (res) { return _certsResult("cacerts", res, opts, null); });
945
+ }
946
+
947
+ /**
948
+ * @primitive pki.est.simpleenroll
949
+ * @signature pki.est.simpleenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
950
+ * @since 0.3.16
951
+ * @status experimental
952
+ * @spec RFC 7030, RFC 5272
953
+ * @related pki.csr.sign, pki.est.simplereenroll, pki.est.cacerts
954
+ *
955
+ * Enroll for a certificate: POST a PKCS#10 `csr` (a DER Buffer or a PEM CERTIFICATE REQUEST,
956
+ * e.g. from `pki.csr.sign`) to `<baseUrl>/.well-known/est/simpleenroll` as
957
+ * `application/pkcs10`, over the shared `pki.transport`. Returns the issued certificate
958
+ * chosen by public-key match against the submitted CSR (`certificate`), the remaining
959
+ * certificates (`chain`), and the raw set (`certificates`); or `{ retry: true,
960
+ * retryAfterSeconds }` on a 202. No returned certificate matching the CSR key fails closed
961
+ * (`est/issued-cert-not-found`); `opts.strict` requires exactly the issued certificate. A
962
+ * 401 is answered once with HTTP Basic ONLY when `opts.username`/`password` are supplied and
963
+ * the transport already authenticated the server.
964
+ *
965
+ * @opts
966
+ * - `transport` / `tls` / `label` / `timeout` / `maxResponseBytes` / `maxRedirects` / `now` -- as pki.est.cacerts.
967
+ * - `strict` -- reject an enroll response that carries more than the single issued certificate.
968
+ * - `username` / `password` -- HTTP Basic credentials, answered only after server authorization (empty username allowed).
969
+ * - `allowCrossOriginRedirect` -- opt in to following a cross-origin redirect on this POST.
970
+ * @example
971
+ * var req = await pki.csr.sign({ subject: "device.example", subjectPublicKey: signerSpki }, { key: signerKeyPkcs8 });
972
+ * // a 202 means the CA queued the request -- the verb surfaces the delay, never sleeps
973
+ * var r = await pki.est.simpleenroll("https://ca.example", req,
974
+ * { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
975
+ * r.retry && r.retryAfterSeconds; // 60
976
+ */
977
+ function simpleenroll(baseUrl, csrInput, opts) {
978
+ opts = opts || {};
979
+ return Promise.resolve().then(function () { return _enroll("simpleenroll", baseUrl, csrInput, opts); });
980
+ }
981
+
982
+ /**
983
+ * @primitive pki.est.simplereenroll
984
+ * @signature pki.est.simplereenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
985
+ * @since 0.3.16
986
+ * @status experimental
987
+ * @spec RFC 7030
988
+ * @related pki.est.simpleenroll, pki.est.reenrollGuard
989
+ *
990
+ * Renew / rekey a certificate: identical to `pki.est.simpleenroll` but POSTs to
991
+ * `/.well-known/est/simplereenroll` and REQUIRES `opts.oldCert` (the certificate being
992
+ * renewed). Before anything crosses the wire, `reenrollGuard` enforces that the CSR's
993
+ * Subject and SubjectAltName (names and criticality) are byte-identical to `opts.oldCert`
994
+ * (RFC 7030 sec. 4.2.2) -- a mismatch fails closed (`est/reenroll-subject-mismatch` /
995
+ * `est/reenroll-san-mismatch`) and the transport is never called. A missing `opts.oldCert`
996
+ * is `est/bad-input`.
997
+ *
998
+ * @opts
999
+ * - `oldCert` -- REQUIRED, the DER certificate being renewed (the re-enroll identity check).
1000
+ * - every option of pki.est.simpleenroll (transport, tls, label, budgets, strict, credentials).
1001
+ * @example
1002
+ * // reenrollGuard enforces the RFC 7030 sec. 4.2.2 identity check before anything is sent
1003
+ * var r = await pki.est.simplereenroll("https://ca.example", renewCsr,
1004
+ * { oldCert: signerCertDer, transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
1005
+ * r.retry; // true
1006
+ */
1007
+ function simplereenroll(baseUrl, csrInput, opts) {
1008
+ opts = opts || {};
1009
+ return Promise.resolve().then(function () {
1010
+ if (!opts.oldCert) throw E("est/bad-input", "simplereenroll requires opts.oldCert (the certificate being renewed, RFC 7030 sec. 4.2.2)");
1011
+ reenrollGuard(opts.oldCert, _csrDer(csrInput)); // est/reenroll-* on mismatch, BEFORE the POST
1012
+ return _enroll("simplereenroll", baseUrl, csrInput, opts);
1013
+ });
1014
+ }
1015
+
711
1016
  module.exports = {
1017
+ cacerts: cacerts,
1018
+ simpleenroll: simpleenroll,
1019
+ simplereenroll: simplereenroll,
712
1020
  transferDecode: transferDecode,
713
1021
  transferEncode: transferEncode,
714
1022
  splitMultipartMixed: splitMultipartMixed,
@@ -264,6 +264,15 @@ var CsrattrsError = defineClass("CsrattrsError", { withCause: true });
264
264
  // `.cause`.
265
265
  var EstError = defineClass("EstError", { withCause: true });
266
266
 
267
+ // TransportError -- a fault from the shared node:https transport (pki.transport):
268
+ // a non-https / unparseable request URL, a missing trust anchor, a TLS handshake /
269
+ // server-authentication failure, a negotiated protocol below the floor, a response
270
+ // over the size cap, or a connect / read timeout. A protocol client (est / acme /
271
+ // cmp) may parameterize the transport with its OWN error factory + code prefix so
272
+ // the same choke point surfaces domain codes; this is the default identity when the
273
+ // transport is used directly. Carries the underlying socket / TLS fault as `.cause`.
274
+ var TransportError = defineClass("TransportError", { withCause: true });
275
+
267
276
  // JoseError -- a JOSE (RFC 7515 JWS / RFC 7518 JWA / RFC 7638 thumbprint)
268
277
  // fault: a non-canonical base64url field, a duplicate JSON member, a
269
278
  // bounds violation, a header that fails its profile (alg/nonce/url/jwk-kid),
@@ -361,6 +370,7 @@ module.exports = {
361
370
  SmimeError: SmimeError,
362
371
  CsrattrsError: CsrattrsError,
363
372
  EstError: EstError,
373
+ TransportError: TransportError,
364
374
  JoseError: JoseError,
365
375
  AcmeError: AcmeError,
366
376
  TrustError: TrustError,
@@ -0,0 +1,310 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.transport
6
+ * @nav Protocols
7
+ * @title Transport
8
+ * @order 195
9
+ * @slug transport
10
+ *
11
+ * @intro
12
+ * The shared, fail-closed `node:https` transport the enrollment protocol clients
13
+ * drive -- `pki.est` now, `pki.acme` and `pki.cmp` next. This is the ONLY module in
14
+ * the toolkit that opens a socket; every protocol layer stays transport-agnostic and
15
+ * composes it (or an injected substitute) through one contract:
16
+ * `transport(request) -> Promise<{ status, headers, body }>`. The response triple is
17
+ * exactly what a message layer's classifier consumes, so no protocol semantics leak
18
+ * into the socket layer -- the transport owns socket lifecycle, the TLS trust policy,
19
+ * the streaming size cap, and the timeout budget; the caller owns HTTP status,
20
+ * content-type, redirect, and authentication decisions.
21
+ *
22
+ * `pki.transport.https(defaults?)` binds TLS + budget defaults and returns a
23
+ * transport. Trust is EXPLICIT and fail-closed: a request is refused unless it
24
+ * carries an https URL and either a `tls.anchors` set (an Explicit trust-anchor
25
+ * database, mapped to the node `ca` option) or an explicit `tls.useSystemStore`
26
+ * opt-in to node's bundled roots. `rejectUnauthorized` is ALWAYS on -- there is no
27
+ * code path that disables server-certificate verification. The response body is
28
+ * bounded WHILE it streams: the accumulator aborts the socket the instant the running
29
+ * total crosses `maxResponseBytes`, before a byte reaches a decoder. A protocol
30
+ * client MAY parameterize the transport with its own `(code, message, cause)` error
31
+ * factory + code prefix, so the same choke point surfaces domain-specific codes.
32
+ *
33
+ * @card
34
+ * The shared fail-closed node:https transport (est / acme / cmp): explicit trust
35
+ * anchors, rejectUnauthorized always on, a TLS floor, a streaming response-size cap,
36
+ * and a timeout -- behind one `transport(request) -> {status, headers, body}` seam.
37
+ */
38
+
39
+ var nodeHttps = require("node:https");
40
+ var nodeNet = require("node:net");
41
+ var nodeTls = require("node:tls");
42
+ var constants = require("./constants");
43
+ var guard = require("./guard-all");
44
+ var frameworkError = require("./framework-error");
45
+
46
+ var TransportError = frameworkError.TransportError;
47
+ function defaultE(code, message, cause) { return new TransportError(code, message, cause); }
48
+
49
+ var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
50
+ var MAX_TIMEOUT = constants.TIME.seconds(600);
51
+ var DEFAULT_MIN_VERSION = "TLSv1.2";
52
+
53
+ // A config-time budget: cap() with the caller's typed factory + a >= 1 floor and an
54
+ // upper bound (maxResponseBytes may only be tightened DOWNWARD from the default). A
55
+ // NaN / negative / over-max value is a typed reject, never a silently-disabled bound.
56
+ function _budget(value, key, dflt, max, E, code) {
57
+ return guard.limits.cap(value, key, dflt, { E: E, code: code, min: 1, max: max, label: key });
58
+ }
59
+
60
+ // Node's `ca` option expects PEM; a raw DER certificate Buffer -- the toolkit's native trust-anchor
61
+ // form (e.g. what pki.est.cacerts / parseCertsOnly return) -- is silently ignored there, so the
62
+ // connection would fail server authentication despite a valid anchor. Wrap a DER anchor as PEM
63
+ // (a Buffer already carrying PEM text, or a PEM string, passes through unchanged).
64
+ function _pemifyAnchor(a) {
65
+ if (typeof a === "string") return a;
66
+ if (Buffer.isBuffer(a)) {
67
+ // Detect PEM by its armor at the START (after optional whitespace) only: a DER certificate begins
68
+ // with the SEQUENCE tag 0x30 and may itself contain the bytes "-----BEGIN" in a subject / extension
69
+ // value, so an anywhere-search would leave such a DER anchor unwrapped and unusable.
70
+ if (/^\s*-----BEGIN /.test(a.toString("latin1", 0, 64))) return a;
71
+ var b64 = a.toString("base64").replace(/.{1,64}/g, "$&\n");
72
+ return "-----BEGIN CERTIFICATE-----\n" + b64 + "-----END CERTIFICATE-----\n";
73
+ }
74
+ return a;
75
+ }
76
+ function _pemifyAnchors(anchors) {
77
+ var list = Array.isArray(anchors) ? anchors.map(_pemifyAnchor) : [_pemifyAnchor(anchors)];
78
+ return list;
79
+ }
80
+
81
+ // The OS system certificate store plus node's bundled roots (cached; the read is not free), for a
82
+ // tls.useSystemStore opt-in. Node's default (no `ca`) is ONLY the bundled Mozilla set, so a server
83
+ // chaining to an OS-installed enterprise CA would fail without this. On a node too old for
84
+ // tls.getCACertificates the cache stays empty and the caller falls back to node's default store.
85
+ var _systemCaCache = null;
86
+ function _systemCa() {
87
+ if (_systemCaCache !== null) return _systemCaCache;
88
+ var out = [];
89
+ if (typeof nodeTls.getCACertificates === "function") {
90
+ ["system", "bundled"].forEach(function (t) {
91
+ // allow:swallow-unverified a store TYPE unsupported on this node is skipped; the other type
92
+ // (and, if both fail, node's default-store fallback) still applies -- a best-effort store load.
93
+ try { var c = nodeTls.getCACertificates(t); if (Array.isArray(c)) out = out.concat(c); } catch (_e) { /* unsupported type */ }
94
+ });
95
+ }
96
+ _systemCaCache = out;
97
+ return out;
98
+ }
99
+
100
+ // Classify a node request/TLS error into the transport's fail-closed verdict: a
101
+ // protocol-version mismatch is the TLS floor; a certificate / identity / handshake
102
+ // failure is a server-authentication failure; anything else is a generic transport
103
+ // error. Every arm threads the raw fault as `.cause`, so the diagnostic survives.
104
+ function _classifyError(e, C) {
105
+ var s = String((e && e.code) || "") + " " + String((e && e.message) || "");
106
+ if (/PROTOCOL_VERSION|UNSUPPORTED_PROTOCOL|VERSION_TOO_LOW|WRONG_VERSION|NO_PROTOCOLS_AVAILABLE|INAPPROPRIATE_FALLBACK/i.test(s)) return C("tls-floor");
107
+ if (/CERT|SELF.?SIGNED|VERIFY|ALTNAME|HOSTNAME|DEPTH_ZERO|LOCAL_ISSUER|HANDSHAKE|\bSSL\b|\bTLS\b/i.test(s)) return C("server-auth-failed");
108
+ return C("transport-error");
109
+ }
110
+
111
+ /**
112
+ * @primitive pki.transport.https
113
+ * @signature pki.transport.https(defaults?) -> transport
114
+ * @since 0.3.16
115
+ * @status experimental
116
+ * @spec RFC 7030, RFC 8996
117
+ * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770)
118
+ * @related pki.est.cacerts, pki.est.simpleenroll
119
+ *
120
+ * Build a fail-closed `node:https` transport: `transport(request) -> Promise<{ status,
121
+ * headers, body }>`. `defaults` binds a `tls` policy (`anchors` -> the node `ca`;
122
+ * `useSystemStore` to opt into the bundled roots; `cert`/`key` for mutual TLS;
123
+ * `minVersion` 'TLSv1.2' (default) or 'TLSv1.3'; `servername`; a `checkServerIdentity`
124
+ * that may only tighten) plus `timeout` and `maxResponseBytes` budgets. Each `request`
125
+ * ({ method, url, headers, body, tls, timeout, maxResponseBytes }) may override them.
126
+ * A non-https URL (`transport/insecure-url`), a request with neither an explicit
127
+ * anchor nor `useSystemStore` (`transport/no-trust-anchors`), a body over the streaming
128
+ * cap (`transport/response-too-large`), a stalled socket (`transport/timeout`), a below
129
+ * -floor negotiation (`transport/tls-floor`), or a failed server authentication
130
+ * (`transport/server-auth-failed`) all fail closed; `rejectUnauthorized` is always on.
131
+ * A protocol client passes its own error factory (`defaults.E`) + `defaults.errPrefix`
132
+ * to surface domain codes (`est/...`). The transport owns no HTTP/redirect/auth
133
+ * semantics -- those live in the message layer that consumes the response triple.
134
+ *
135
+ * @opts
136
+ * - `tls.anchors` -- Explicit trust anchor(s): a DER/PEM Buffer, an array, or PEM string(s) (node `ca`).
137
+ * - `tls.useSystemStore` -- boolean; the ONLY opt-in to node's bundled CA store (default false).
138
+ * - `tls.cert` / `tls.key` -- client certificate + key for mutual-TLS re-enrollment.
139
+ * - `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
140
+ * - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
141
+ * - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
142
+ * @example
143
+ * var t = pki.transport.https({ tls: { anchors: [caPem] } });
144
+ * var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
145
+ * res.status; // 200
146
+ */
147
+ function httpsTransport(defaults) {
148
+ defaults = defaults || {};
149
+ var E = typeof defaults.E === "function" ? defaults.E : defaultE;
150
+ var prefix = defaults.errPrefix || "transport";
151
+ function C(name) { return prefix + "/" + name; }
152
+ var tlsDefaults = defaults.tls || {};
153
+
154
+ // Synchronous config validation + node-options build. Throws the typed verdict on any
155
+ // fault (a non-https URL, no trust anchor, a bad budget, a sub-floor minVersion) so the
156
+ // async executor below never has to `return reject(...)`. rejectUnauthorized is forced on.
157
+ function _prepare(request) {
158
+ var url;
159
+ try { url = new URL(String(request.url)); }
160
+ catch (e) { throw E(C("bad-url"), "the request URL did not parse: " + String(request.url), e); }
161
+ if (url.protocol !== "https:") throw E(C("insecure-url"), "transport requires https (RFC 7030 sec. 3.3), got " + url.protocol);
162
+
163
+ var reqTls = request.tls || {};
164
+ var anchors = reqTls.anchors !== undefined ? reqTls.anchors : tlsDefaults.anchors;
165
+ // System-store trust requires a STRICT boolean true; a non-boolean (a "false" / "true" string from
166
+ // JSON/env, a truthy object) is treated as absent, so a malformed config fails closed rather than
167
+ // silently trusting node's bundled roots.
168
+ var useSystem = (reqTls.useSystemStore !== undefined ? reqTls.useSystemStore : tlsDefaults.useSystemStore) === true;
169
+ var hasAnchors = anchors !== undefined && anchors !== null && !(Array.isArray(anchors) && anchors.length === 0);
170
+ if (!hasAnchors && !useSystem) throw E(C("no-trust-anchors"), "no explicit trust anchor and useSystemStore not set -- refusing an unpinned server (RFC 7030 sec. 3.6)");
171
+
172
+ var timeout = _budget(request.timeout !== undefined ? request.timeout : defaults.timeout, "timeout", DEFAULT_TIMEOUT, MAX_TIMEOUT, E, C("bad-input"));
173
+ var maxBytes = _budget(request.maxResponseBytes !== undefined ? request.maxResponseBytes : defaults.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, E, C("bad-input"));
174
+
175
+ var minVersion = reqTls.minVersion || tlsDefaults.minVersion || DEFAULT_MIN_VERSION;
176
+ if (minVersion !== "TLSv1.2" && minVersion !== "TLSv1.3") throw E(C("bad-input"), "tls.minVersion must be 'TLSv1.2' or 'TLSv1.3' (never below the floor), got " + minVersion);
177
+
178
+ var body = request.body;
179
+ if (Buffer.isBuffer(body)) body = guard.bytes.view(body, E, C("bad-input"), "the request body");
180
+
181
+ // URL.hostname keeps the square brackets on an IPv6 literal ([2001:db8::1]); node's `hostname`
182
+ // option and its IP-detection expect the bare address, so strip the brackets once here.
183
+ var host = url.hostname.replace(/^\[(.*)\]$/, "$1");
184
+ var options = {
185
+ method: request.method || "GET",
186
+ hostname: host,
187
+ port: url.port || 443,
188
+ path: url.pathname + url.search,
189
+ headers: request.headers || {},
190
+ minVersion: minVersion,
191
+ rejectUnauthorized: true, // ALWAYS on -- there is no code path that disables server verification
192
+ // Opt out of connection pooling: node's keep-alive agent keys the socket pool on host + TLS
193
+ // options but NOT on checkServerIdentity, so a reused socket would skip a per-request identity /
194
+ // pinning callback. A fresh connection per request guarantees the identity check always runs.
195
+ agent: false,
196
+ };
197
+ // Every TLS field falls back to the factory defaults (as anchors / minVersion already do), so a
198
+ // reusable transport built with pki.transport.https({ tls: { cert, key, servername } }) applies
199
+ // them to a request that carries no per-request tls (mTLS enrollment, a configured SNI).
200
+ var cert = reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert;
201
+ var key = reqTls.key !== undefined ? reqTls.key : tlsDefaults.key;
202
+ var callerCsi = typeof reqTls.checkServerIdentity === "function" ? reqTls.checkServerIdentity
203
+ : (typeof tlsDefaults.checkServerIdentity === "function" ? tlsDefaults.checkServerIdentity : null);
204
+ // SNI (and RFC 6125 identity) uses a hostname; node forbids a servername that is an IP
205
+ // literal, so it is omitted for an IP host (node then matches the IP against the cert's
206
+ // IP SANs). A caller connecting by hostname gets SNI + name verification by default.
207
+ var sni = reqTls.servername || tlsDefaults.servername || host;
208
+ if (sni && !nodeNet.isIP(sni)) options.servername = sni;
209
+ // Build node's `ca`: the explicit anchors (DER wrapped as PEM) and, when useSystemStore is opted
210
+ // in, the OS system store + bundled roots. Node's `ca` REPLACES the default set, so both sources
211
+ // are merged here. Left unset only when no anchors and (on an older node) the system store is
212
+ // empty -- then node's default bundled store applies (the documented fallback).
213
+ var caList = [];
214
+ if (hasAnchors) caList = caList.concat(_pemifyAnchors(anchors));
215
+ if (useSystem) caList = caList.concat(_systemCa());
216
+ if (caList.length) options.ca = caList;
217
+ if (cert) options.cert = cert;
218
+ if (key) options.key = key;
219
+ // A caller checkServerIdentity may only TIGHTEN, never REPLACE, name verification: node uses a
220
+ // supplied callback in place of its default RFC 6125 check, so a hook that returns undefined
221
+ // (pinning/metrics only) would silently disable hostname verification. Run node's default first
222
+ // and reject on its verdict; the caller's hook adds checks only after the default has passed.
223
+ if (callerCsi) {
224
+ options.checkServerIdentity = function (host, cert2) {
225
+ var baseErr = nodeTls.checkServerIdentity(host, cert2);
226
+ if (baseErr) return baseErr;
227
+ return callerCsi(host, cert2);
228
+ };
229
+ }
230
+
231
+ return { options: options, timeout: timeout, maxBytes: maxBytes, body: body };
232
+ }
233
+
234
+ return function transport(request) {
235
+ request = request || {};
236
+ var prep;
237
+ try { prep = _prepare(request); }
238
+ catch (e) { return Promise.reject(e); }
239
+ return new Promise(function (resolve, reject) {
240
+ var settled = false;
241
+ var req;
242
+ var timer = null;
243
+ function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } }
244
+ function fail(code, msg, cause) {
245
+ if (settled) return;
246
+ settled = true;
247
+ clearTimer();
248
+ // allow:swallow-unverified req.destroy() is idempotent and does not throw in practice; a
249
+ // raw throw from this best-effort abort would mask the real fail-closed verdict below.
250
+ try { if (req) req.destroy(); } catch (_e) { /* best-effort abort */ }
251
+ reject(E(code, msg, cause));
252
+ }
253
+ // An independent wall-clock deadline bounds DNS resolution and connection setup as well as
254
+ // socket inactivity: node's request/socket timeout does not run during a DNS lookup, so a
255
+ // stalled resolver would otherwise leave this promise pending forever despite a finite timeout.
256
+ timer = setTimeout(function () { fail(C("timeout"), "the request timed out after " + prep.timeout + "ms"); }, prep.timeout);
257
+ try {
258
+ req = nodeHttps.request(prep.options, function (res) {
259
+ // Capture the TLS session facts while the socket is live -- getProtocol() /
260
+ // getPeerCertificate() return null once the socket detaches at stream end.
261
+ var proto = res.socket && res.socket.getProtocol ? res.socket.getProtocol() : null;
262
+ var peer = res.socket && res.socket.getPeerCertificate ? res.socket.getPeerCertificate() : null;
263
+ // Pre-check a declared content-length so an oversized body is refused before it streams.
264
+ var declared = parseInt((res.headers || {})["content-length"], 10);
265
+ if (Number.isFinite(declared) && declared > prep.maxBytes) { fail(C("response-too-large"), "the declared content-length " + declared + " exceeds the " + prep.maxBytes + "-byte cap (RFC 7030 sec. 6)"); return; }
266
+ // Accumulate into a single doubling buffer rather than an array of chunks: a body sent as
267
+ // many tiny chunks (one-byte TLS records) would otherwise retain millions of Buffer objects
268
+ // under the byte cap and exhaust the heap. One live buffer bounds the metadata as well as the
269
+ // bytes; only the written prefix [0, len) is ever surfaced (the unwritten tail is never read).
270
+ var buf = Buffer.allocUnsafe(0);
271
+ var len = 0;
272
+ var total = 0;
273
+ res.on("data", function (chunk) {
274
+ total += chunk.length;
275
+ if (total > prep.maxBytes) { fail(C("response-too-large"), "the response exceeded the " + prep.maxBytes + "-byte cap (RFC 7030 sec. 6)"); return; }
276
+ if (len + chunk.length > buf.length) {
277
+ var want = Math.min(prep.maxBytes, Math.max(buf.length ? buf.length * 2 : 8192, len + chunk.length));
278
+ var grown = Buffer.allocUnsafe(want);
279
+ buf.copy(grown, 0, 0, len);
280
+ buf = grown;
281
+ }
282
+ chunk.copy(buf, len);
283
+ len += chunk.length;
284
+ });
285
+ res.on("end", function () {
286
+ if (settled) return;
287
+ settled = true;
288
+ clearTimer();
289
+ var lower = {};
290
+ Object.keys(res.headers || {}).forEach(function (k) { lower[k.toLowerCase()] = res.headers[k]; });
291
+ resolve({
292
+ status: res.statusCode,
293
+ headers: lower,
294
+ body: Buffer.from(buf.subarray(0, len)),
295
+ tls: { protocol: proto, peerCertificate: peer && peer.raw ? peer.raw : null },
296
+ });
297
+ });
298
+ res.on("error", function (e) { fail(C("transport-error"), "the response stream failed", e); });
299
+ });
300
+ req.on("error", function (e) { fail(_classifyError(e, C), "the request failed: " + ((e && e.message) || String(e)), e); });
301
+ // Write a provided body for ANY method (POST / PUT / PATCH / ...), not only POST, so a body
302
+ // is never silently dropped on a body-bearing request through this generic transport seam.
303
+ if (prep.body != null && prep.body !== "") req.write(prep.body);
304
+ req.end();
305
+ } catch (e) { fail(C("transport-error"), "the request could not be initiated: " + ((e && e.message) || String(e)), e); }
306
+ });
307
+ };
308
+ }
309
+
310
+ module.exports = { https: httpsTransport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
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:535d6fbb-3650-4575-9c45-a256806e58cf",
5
+ "serialNumber": "urn:uuid:d422e0bf-7707-4f70-8e04-0d467ade9599",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-24T11:36:26.019Z",
8
+ "timestamp": "2026-07-25T04:02:51.828Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.3.15",
22
+ "bom-ref": "@blamejs/pki@0.3.17",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.15",
25
+ "version": "0.3.17",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.3.15",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.17",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.3.15",
57
+ "ref": "@blamejs/pki@0.3.17",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]