Node.js

Development Certificates

In Appendix, we lay out the process for generating a chain that is valid for localhost that can be used for testing. Since it costs money to get a real CA to create a certificate and since a real CA would never sign a certificate for localhost, we also use a custom self-signed root CA. Since this is custom, we must add it to our root trust store when making requests (some may recommend installing this onto the system root store permanently, this should be avoided at all costs).

Server

We’ll use this to run a TLS server (server.js) and connect to it via curl. In particular, we are going to specify the private key and public certificate pair for the server’s leaf certificate:

const options = {
    key: fs.readFileSync(cliOptions.key),
    cert: fs.readFileSync(cliOptions.cert),
}
const server = https.createServer(options, app)

Misconfiguration

We’ll first run the server with an invalid configuration:

cd docs/nodejs/
node server.js \
  --cert ../tls-certs/localhost-server-cert.pem \
  --key ../tls-certs/localhost-server-key.pem
# Example TLS app listening at https://localhost:8443

The certificate file for this server does not contain the intermediate. Using curl to hit this server will fail in most typical ways we may invoke it.

No Extra CA Provided

Using a vanilla curl request fails, as expected:

$ curl https://localhost:8443
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

As mentioned above, we are using a custom CA so we need to add this to curl.

Only Root CA Provided

We “expect” it to work if we can trust the local root that we created

$ curl --cacert ../tls-certs/root-ca-cert.pem https://localhost:8443
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

In particular note that the unable to get local issuer certificate resembles a similar error we saw in openssl Tooling when using openssl verify. The issue here is that when validating the server, curl has no way to follow the leaf certificate to the root because it doesn’t know anything about our (private) intermediate certificate.

Only Intermediate CA Provided

Since we’ve concluded that the missing intermediate certificate is the problem, a common next step may be to specify (only) the intermediate as an extra CA:

$ curl --cacert ../tls-certs/intermediate-ca-cert.pem https://localhost:8443
curl: (60) SSL certificate problem: unable to get issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

The error message here unable to get issuer certificate is slightly different. It indicates that we are able to find the issuer of our leaf certificate, but are not able to follow the chain one more step to find the issuer of the intermediate.

Both Root and Intermediate CA Provided

Since it’s clear now that both the intermediate and root CA are needed, we use the intermediate-ca-chain.pem file, which bundles both certificates together.

$ curl --cacert ../tls-certs/intermediate-ca-chain.pem https://localhost:8443
{"success": true}

Valid Configuration

In Misconfiguration above, we intentionally ran the server with an “invalid” certificate. Instead, we should’ve used a file that contained enough of our chain for clients to do all validation they’d need to. The localhost-server-chain.pem file contains both the leaf and the intermediate certificates.

cd docs/nodejs/
node server.js \
  --cert ../tls-certs/localhost-server-chain.pem \
  --key ../tls-certs/localhost-server-key.pem
# Example TLS app listening at https://localhost:8443

Only Root CA Provided

Now, we can successfully make a request when only adding the root CA as an extra CA:

$ curl --cacert ../tls-certs/root-ca-cert.pem https://localhost:8443
{"success": true}

No Extra CA Provided

Using a vanilla curl request still fails:

$ curl https://localhost:8443
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

Presenting the Root with the Server Chain

If it was good to present the intermediate, is it good to present the root too? The localhost-server-full.pem file contains bundles the leaf, intermediate and root certificates together.

cd docs/nodejs/
node server.js \
  --cert ../tls-certs/localhost-server-full.pem \
  --key ../tls-certs/localhost-server-key.pem
# Example TLS app listening at https://localhost:8443

However, a vanilla curl still fails (but now for a different reason)

$ curl https://localhost:8443
curl: (60) SSL certificate problem: self signed certificate in certificate chain
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

The self signed certificate in certificate chain error makes sense here because we have a custom self-signed root CA.

Tacking on the root CA will continue to work though:

$ curl --cacert ../tls-certs/root-ca-cert.pem https://localhost:8443
{"success": true}

Being familiar with this self signed certificate error in other situations may be very useful. As we’ll see later in Client, it’s possible to — either intentionally or by mistake — to replace the root bundle in Node.js client code. For example, if a client needed to connect to github.com, overriding the root bundle with just the DigiCert High Assurance EV Root CA certificate would still continue to work. But, when GitHub rotates their certificate, the code / configuration that used to work may stop working with this exact error if GitHub rotated to a certificate signed by a different CA.

Client

As we saw above, there is an apparent disagreement between the --cert flag (and the Node.js cert TLS option) and the *-chain.pem suffix. This is not an accident. There is a significant amount of confusion using the TLS options for the typical application developer that doesn’t think about TLS very often (or ever).

The cert option is typically interpreted as “just the leaf certificate” but the documentation makes it clear that chain or certChain would be a better option name:

cert

Cert chains in PEM format … Each cert chain should consist of the PEM formatted certificate for a provided private key, followed by the PEM formatted intermediate certificates (if any) … If the intermediate certificates are not provided, the peer will not be able to validate the certificate, and the handshake will fail.

Similarly, the ca option is typically interpreted as “just the CA for the leaf certificate”, which is ambiguous as-is. However, the interpretation of this field is made worse by its behavior. The default behavior of curl --cacert is to append to the system trust store, e.g. specifying our custom root CA doesn’t stop us from trusting google.com

$ curl --cacert ../tls-certs/root-ca-cert.pem https://google.com
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
...

However, that is not the behavior of the ca TLS option in Node.js:

ca

Optionally override the trusted CA certificates. Default is to trust the well-known CAs curated by Mozilla. Mozilla’s CAs are completely replaced when CAs are explicitly specified using this option.

Making Requests

In order to make an HTTP-over-TLS (typically HTTPS) request via Node.js, the axios library is used. To configure a custom CA, it’s necessary to override the httpsAgent:

const options = {}
if (cliOptions.rootCA) {
    options.ca = [fs.readFileSync(cliOptions.rootCA)]
}
const axiosOptions = {
    httpsAgent: new https.Agent(options),
}

in the request options:

const response = await axios.get(cliOptions.url, axiosOptions)

No Extra CA Provided

Invoking axios in the request.js script without mentioning the custom CA results in a familiar error message:

$ cd docs/nodejs/
$ node request.js
   Error Code: UNABLE_TO_GET_ISSUER_CERT_LOCALLY
Error Message: unable to get local issuer certificate

Root CA Provided

To successfully connect to the locally running server, it’s enough to specify a path to the custom root CA:

$ cd docs/nodejs/
$ node request.js \
>   --root-ca ../tls-certs/root-ca-cert.pem
Response: {"success":true}

CA Override

Herein lies the problem, we have replaced our entire root bundle with a bundle containing only a single root CA (root-ca-cert.pem). Now a request to google.com that would have worked without an override

$ cd docs/nodejs/
$ node request.js --url https://google.com
Response: "<!doctype html>...

will fail in the same way our previous request failed

$ cd docs/nodejs/
$ node request.js \
>   --root-ca ../tls-certs/root-ca-cert.pem \
>   --url https://google.com
   Error Code: UNABLE_TO_GET_ISSUER_CERT_LOCALLY
Error Message: unable to get local issuer certificate