HTTP transport for OCSP, CRL, EST and ACME requests

The shared, fail-closed node:https transport the enrollment protocol clients drive: pki.est now, pki.acme and pki.cmp next. This is the only module in the toolkit that opens a socket; every protocol layer stays transport-agnostic and composes it (or an injected substitute) through one contract: transport(request) -> Promise<{ status, headers, body, tls }>. The first three are exactly what a message layer's classifier consumes, so no protocol semantics leak into the socket layer: the transport owns socket lifecycle, the TLS trust policy, the streaming size cap, and the timeout budget; the caller owns HTTP status, content-type, redirect, and authentication decisions. tls reports the negotiated channel, { protocol, cipher, peerCertificate, tlsUnique, tlsExporter }, facts a caller cannot recover from the response bytes. The two bindings are complementary, one per version, so a caller reads whichever the negotiated protocol defines. tlsUnique is the RFC 5929 channel binding (the first TLS Finished) on a TLS 1.2 connection, which pki.est.challengePasswordFromTlsUnique base64-encodes into an EST challengePassword (RFC 7030 sec. 3.5); it is null on TLS 1.3, where RFC 5929 defines no tls-unique. tlsExporter is the RFC 9266 binding on a TLS 1.3 connection: 32 bytes exported under the label EXPORTER-Channel-Binding with a zero-length context, the value both peers derive. It is null below TLS 1.3, where RFC 9266 sec. 2 additionally requires the RFC 7627 extended master secret, which node gives no way to confirm, so the binding is withheld rather than offered unproven; it is also null if the socket cannot export. No shipped verb consumes tls-exporter yet: RFC 7030 sec. 3.5 binds tls-unique specifically, so a caller must not put a tls-exporter value in that challengePassword. An INJECTED substitute should return these too: pki.est.serverkeygen asserts the negotiated cipher can protect the private key it is about to accept, and a transport that reports no cipher is trusted instead of refused (so a loopback test channel works), which means omitting the field silently skips that assertion. Because tlsUnique on the response arrives after the request body was already sent, a request body MAY instead be a function (tls) -> bytes. The transport invokes it once the handshake completes and before it writes the body, passing the same { protocol, cipher, peerCertificate, tlsUnique } object, so the caller builds the request body (a channel-bound CSR) from tlsUnique on the very connection the request is sent over. The callback returns bytes or a string, or a promise of either, so the CSR can be signed with the toolkit's own asynchronous signer while the connection is held open. A throw, a rejection, a missing return, null, or any other value fails the request closed, so a callback that forgets to return cannot post an empty enrollment to a CA. An explicit empty string is an intentional empty body, and the request budget still bounds the whole exchange.

pki.transport.https(defaults?) binds TLS + budget defaults and returns a transport. Trust is EXPLICIT and fail-closed: a request is refused unless it carries an https URL and either a tls.anchors set (an Explicit trust-anchor database, mapped to the node ca option) or an explicit tls.useSystemStore opt-in to node's bundled roots. rejectUnauthorized is always on: there is no code path that disables server-certificate verification. The response body is bounded WHILE it streams: the accumulator aborts the socket the instant the running total crosses maxResponseBytes, before a byte reaches a decoder. A protocol client MAY parameterize the transport with its own (code, message, cause) error factory + code prefix, so the same choke point surfaces domain-specific codes.

pki.transport.https

since 0.3.16 stable
pki.transport.https(defaults?) -> transport

Build a fail-closed node:https transport: transport(request) -> Promise<{ status, headers, body }>. defaults binds a tls policy (anchors -> the node ca; useSystemStore to opt into the bundled roots; cert/key for mutual TLS; minVersion 'TLSv1.2' (default) or 'TLSv1.3'; servername; a checkServerIdentity that may only tighten) plus timeout and maxResponseBytes budgets. Each request ({ method, url, headers, body, tls, timeout, maxResponseBytes }) may override them. A non-https URL (transport/insecure-url), a request with neither an explicit anchor nor useSystemStore (transport/no-trust-anchors), a body over the streaming cap (transport/response-too-large), a stalled socket (transport/timeout), a below -floor negotiation (transport/tls-floor), or a failed server authentication (transport/server-auth-failed) all fail closed; rejectUnauthorized is always on. A protocol client passes its own error factory (defaults.E) + defaults.errPrefix to surface domain codes (est/...). The transport owns no HTTP/redirect/auth semantics; those live in the message layer that consumes the response triple.

Options

- `tls.anchors` -- Explicit trust anchor(s): a DER/PEM Buffer, an array, or PEM string(s) (node `ca`).
- `tls.useSystemStore` -- boolean; the one opt-in to node's bundled CA store (default false).
- `tls.cert` / `tls.key` -- client certificate + key for mutual-TLS re-enrollment.
- `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
- `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
- `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
- `blockPrivateAddresses` -- boolean; when true, an IP-literal host OR a hostname resolving to a private / loopback / link-local address is refused (`transport/blocked-address`), and a resolved address is pinned for the connection. For fetching an untrusted-certificate URL (AIA caIssuers); default false. It cannot be combined with `proxy`: a forward proxy resolves the origin itself, so the transport cannot enforce the block on it, and the combination is refused (`transport/bad-proxy`).
- `proxy` -- reach the https origin through a forward HTTP proxy: `{ url: "http://proxy:3128" | "https://proxy:3128", auth?: { scheme: "basic" | "digest", username, password, allowMD5?, allowLegacyQop? }, tls?: { anchors, useSystemStore, servername, minVersion } }`. A CONNECT tunnel is opened to the proxy and the origin's TLS is negotiated inside it under the identical origin trust policy, so the proxy cannot read the origin's encrypted session or substitute the origin certificate. `auth` requires an `https://` proxy: the credentials ride the authenticated TLS-to-proxy channel (verified against `proxy.tls`, which is required for an https proxy), so a plaintext `http://` proxy carrying `auth` is refused (`transport/proxy-auth-requires-tls`) rather than exposing them to the proxy hop. An `http://` proxy is tunnel-only. Basic (RFC 7617) sends its credential on the first CONNECT. Digest (RFC 7616) answers the proxy's `Proxy-Authenticate` challenge on a `407`, hashing the method `CONNECT` over the authority-form target, and answers exactly once: a proxy that repeats its challenge has rejected the credential. The Digest policy is the one the origin verbs use, so MD5 and a challenge carrying no `qop` are refused unless `allowMD5` / `allowLegacyQop` says otherwise (`transport/proxy-digest-weak-algorithm`, `transport/proxy-digest-unsupported-algorithm`, `transport/proxy-digest-no-qop`, `transport/proxy-digest-bad-challenge`). A `407` is `transport/proxy-auth-failed` (credentials rejected) or `transport/proxy-auth-required` (none supplied, or none of the offered schemes is the configured one); a proxy certificate that does not verify is `transport/proxy-tls-failed`; a non-2xx CONNECT is `transport/proxy-connect-failed`; a malformed option is `transport/bad-proxy`. Off by default (a direct connection).

Example

async function example() {
  var t = pki.transport.https({ tls: { anchors: [caPem] } });
  var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
  res.status;   // 200
}
example();

References

  • spec RFC 7030
  • spec RFC 8996
  • defends tls-downgrade (CWE-757)
  • defends server-impersonation (CWE-297)
  • defends response-flooding (CWE-770)
  • defends ssrf (CWE-918)