I wanted to create a secure connection between two servers using TLS. The design has a server with a self-signed cert that requires clients to connect with their own client cert. This restricts connects to only clients that have a pre-shared cert.

To make this easy, I wanted to use self-signed certs but still want to validate the authenticity of the certs. Fortunately you can do this with the Node TLS security context.

So lets get started. The first thing is creating the certs with OpenSSL.

Creating Self-Signed X.509 Certificates

The command below will generate an X.509 cert that expires in 365 days. The cert uses an RSA 4096 key and a SHA-256 digest.

openssl req -x509 -days 365 -nodes -sha256 -newkey rsa:4096 -subj "/C=US/CN=127.0.0.1" -keyout server-key.pem -out server-crt.pem

Breaking this down further, the command makes use of openssl's command req to create the certificate and private key.

The following params are used:

  • -x509 output a x509 structure instead of a cert. req
  • -days 365 number of days a certificate generated by -x509 is valid for
  • -nodes don't encrypt the output key
  • -sha256 digest to sign with, in this case SHA-256
  • -newkey rsa:4096 generate a new RSA key of 'bits' in size
  • -subj "/C=US/CN=127.0.0.1" set or modify request subject, modify CN to match the host or IP of the server
  • -keyout file to send the key to
  • -out output file for the cert

In the this command the -subj argument is used to prevent prompting for the subject (such as the location and organization). In this example, it ties the common name CN to 127.0.0.1. In practice, you would enter the hostname or IP address of the real server you are trying to secure. Additionally, we use the -nodes argument so that the PEM key file does not require a password (although Node.js supports PEM with keys).

Now we can generate another cert for the client to use:

openssl req -x509 -days 365 -nodes -sha256 -newkey rsa:4096 -subj "/C=US" -keyout client-key.pem -out client-crt.pem

Note on this one, I don't set a CN in the Subject.

Server App

The next piece is creating a server app that uses these certs and enforces valid client certificates.

const https = require('https');
const fs    = require('fs');

const options = {
  // use the server key
  key: fs.readFileSync('server-key.pem'),
 
  // use the server cert
  cert: fs.readFileSync('server-crt.pem'),

  // enforce client certs
  requestCert: true,
  rejectUnauthorized: true,

  // add client cert as part of the certificate authority
  ca: [ fs.readFileSync('client-crt.pem') ],
};


https.createServer(options, (req, res) => {
  res.writeHead(200);
  res.end('hello world\n');
}).listen(9000, () => console.log('listening on 9000'));

The server itself is lightweight. We're just going to listen on port 9000 and reply with hello world for every request.

What is of more interest are the options used to start the server. These options are defined in Node's HTTPS module which extends tls.createServer() and tls.createSecureContext(). The latter provide options that can be used to configure the TLS connection.

Of particular note are:

  • requestCert If true the server will request a certificate from clients that connect and attempt to verify that certificate.
  • rejectUnauthorized If not false the server will reject any connection which is not authorized with the list of supplied CAs. This option only has an effect if requestCert is true.
  • key Optional private keys in PEM format
  • passphrase Optional shared passphrase used for a single private key
  • cert Optional cert chains in PEM format
  • ca Optionally override the trusted CA certificates. Default is to trust the well-known CAs curated by Mozilla. For self-signed certificates, the certificate is its own CA, and must be provided.

Breaking this down further, the server will use requestCert to ensure that clients connect with a certificate. The server is configured to use key (optionally passphrase) and cert specific to the server. Under the ca option, we include the client cert to allow connections from the self-signed client cert!

Client App

The client will initiate a TLS connections via the HTTPS module as defined in tls.connect()


const https = require('https');
const fs    = require('fs');

const options = {
  hostname: '127.0.0.1',
  port: 9000,
  path: '/',
  method: 'GET',

  // validate server cert
  rejectUnauthorized: true,

  // key for client cert
  key: fs.readFileSync('client-key.pem'),
 
  // client cert
  cert: fs.readFileSync('client-crt.pem'),

  // server cert as CA to ensure valid server cert
  ca: fs.readFileSync('server-crt.pem'),

  // To allow connection via IP's
  // https://github.com/nodejs/node/blob/v7.10.0/lib/tls.js
  // https://stackoverflow.com/questions/33558076/cannot-use-ip-in-node-js-for-self-signed-certificate
  checkServerIdentity: (servername, crt) => {
    if(servername !== crt.subject.CN) {
      throw new Error(`Servername ${servername} does not match CN ${crt.subject.CN}`);
    }
  }
};

const req = https.request(options, (res) => {
  res.on('data', (d) => process.stdout.write(d));
});

req.on('error', (e) => console.error(e))

req.end();

This connection will be initiated with the connection from our client cert as supplied in key and certs options. We also set the server's cert as part of the ca option to allow that the server's cert is recognized as a valid cert.

Additionally we set two options:

  • rejectUnauthorized If not false the server will reject any connection which is not authorized with the list of supplied CAs.
  • checkServerIdentity(servername, cert) A callback function to be used when checking the server's hostname against the certificate. This should throw an error if verification fails. The method should return undefined if the servername and cert are verified.

The above allow us to initiate connections where the cert is signed to an IP address.

Connecting with curl

You can also validate connections to the server with curl. The easiest way to do this is to bundle the client cert and key into a PKCS12 file using openssl.

openssl pkcs12 -export -nodes -inkey client-key.pem -in client-crt.pem -out client-crt.p12 -passout pass:test

Then you can send requests using standard curl commands by specifying the p12 cert + password and including the server's cert as the CA.

curl --cert client-crt.p12:test --cacert server-crt.pem https://127.0.0.1:9000

That's all there is to it! You can view the full example here: https://github.com/bmancini55/node-tls/