HTTP transport for OCSP, CRL, EST and ACME requests
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 the platform certificate store. rejectUnauthorized is always on: there is no code path that disables server-certificate verification. tls.distrustedKeys refuses a server whose verified chain carries a listed key. 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
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 platform certificate store; distrustedKeys to refuse listed keys; 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, except distrustedKeys: the request's list is added to the default's. A non-https URL (transport/insecure-url), a request with neither an explicit anchor nor useSystemStore, or with useSystemStore and an empty platform store (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), a failed server authentication (transport/server-auth-failed), a chain carrying a key whose private key is public (transport/server-key-compromised), or a verified chain carrying a distrusted key (transport/server-key-distrusted) all fail closed; rejectUnauthorized is always on. The compromised-key check runs on every connection and hop with no option to turn it off: a certificate on the chain node reports whose key is on pki.trust.knownCompromisedKeys(), or is an elliptic curve key whose private key is an integer below 256, refuses the server (transport/proxy-key-compromised for a proxy). That chain is the one node builds from the certificates the server sent and the trust store, and a link of it is not proven; set tls.distrustedKeys for the proven walk described below, which applies the same compromised keys. A chain longer than C.LIMITS.PATH_MAX_CERTS is refused with the same code, and the chain is read before a tls.checkServerIdentity hook runs. A hook that throws refuses the server with transport/server-auth-failed. A tls.anchors or proxy.tls.anchors certificate whose key is compromised is refused with transport/bad-input or transport/bad-proxy before any connection, and under useSystemStore such a certificate in the platform store is left out of the certificates the connection trusts. 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 the platform certificate store, `tls.getCACertificates("system")` (default false). Node's compiled-in roots are not included; pass them as `tls.anchors` to use them. An empty platform store is refused with `transport/no-trust-anchors` unless `tls.anchors` is also set.
- `tls.distrustedKeys` -- SHA-256 digests of SubjectPublicKeyInfo values (64 hex characters or 32 bytes each, see `pki.trust.spkiDigest`). A server whose verified chain carries a listed key, at any position including the root, is refused with `transport/server-key-distrusted`. The chain node reports is checked as a path of its own: each link is signed by the certificate above it, each certificate below the top is inside its validity period and each one that issues another is a CA, and the top is self-issued with the key of a configured anchor. Where a configured anchor certificate (an intermediate or a cross-certificate, as a platform store can hold) issues a certificate on that chain, the branch up through the anchors is checked too, since OpenSSL may have verified the session that way. A chain that fails any of these, or is longer than `C.LIMITS.PATH_MAX_CERTS`, is refused with the same code while a list is set, so a session can be refused when the chain OpenSSL verified was clean but another chain it could have used is not. A request's list is added to the transport default's, which is read when the transport is built.
- A `tls` bag naming anything other than the options above is refused with `transport/bad-input`, the transport default's when the transport is built and a request's before a socket opens.
- `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, distrustedKeys } }`. 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 proxy chain carrying a key listed in `proxy.tls.distrustedKeys` or the origin's `tls.distrustedKeys` is `transport/proxy-key-distrusted`; 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
pki.transport.peerChain
pki.transport.peerChain(request, defaults?) -> Promise<channel>
Negotiate TLS with an endpoint and return the channel it established, without sending a request: { protocol, cipher, peerCertificate, peerChain, tlsUnique, tlsExporter }. peerChain is the chain the session resolved as DER, leaf first, walked up from the peer certificate and stopping at the one that issues itself. It is not a record of what crossed the wire: the runtime completes the chain from the trust anchors in scope, so an endpoint that sends only its leaf still yields the entries above it, and those came from the configured anchors. Treat the list as the chain this session built, and read peerCertificate for the one certificate the endpoint certainly sent. Nothing reaches the application behind the endpoint, which is the difference from making a request for the same purpose.
The connection is prepared by the same code pki.transport.https prepares a request with, so the trust anchors, the SNI, the version floor, the checkServerIdentity hook, the private-address block and the https-only rule are one policy rather than a second copy of one. A request with neither an explicit anchor nor useSystemStore is refused (transport/no-trust-anchors) before any socket is opened, and an endpoint whose chain does not build to the configured anchors, or whose name does not match the URL, is refused (transport/server-auth-failed). A chain carrying a key listed in tls.distrustedKeys is refused (transport/server-key-distrusted). A configured proxy is refused (transport/bad-proxy) rather than bypassed, since connecting directly would reach an origin the caller asked to reach through the proxy.
**A chain this returns is not a validated one.** The handshake checked that it builds to a configured anchor and matches the name in the URL, and checked no revocation status and no policy. pki.path.validate is what validates a path.
Example
async function example() {
var channel = await pki.transport.peerChain(
{ url: "https://example.com/" }, { tls: { useSystemStore: true } });
channel.peerChain.length; // the chain the session resolved
pki.schema.x509.parse(channel.peerChain[0]).subject.dn; // the leaf's subject
channel.peerCertificate; // the one certificate the endpoint sent
}
example();