Securing Node services with Client Authenticated TLS using CATKeys

Pommy Mac
ITNEXT
Published in
8 min readFeb 26, 2021

--

Some services are public and should be accessible to everyone. Some services are private and should only be accessible by a select group of clients.

This guide walks through steps to secure a Node based web service with Client Authenticated TLS using CATKeys, so that only authorised clients can access a private web service.

Client Authenticated TLS

Client Authenticated TLS’ is a version of the TLS handshake that provides mutual authentication (also known as 2-way authentication) between clients and servers using client certificates.

Mutual authentication means that a client will only connect to a valid server (as is the case with normal TLS), but also that a server will only allow valid clients to connect.

This makes it useful in situations where only privileged clients should be able to access a web service or RPC endpoint. For example, you might have a public web service that consumes a private web service.

I am going to demonstrate a simple way to protect a Node server with Client Authenticated TLS using a library called CATKeys (of which I am the author).

CATKeys

CATKeys was created after reading a post by Anders Brownworth describing how to use OpenSSL to generate the CAs, certs and keys required to use client certificates to secure HTTPS. It’s a well written, extensive post that is worth a read if you want to understand what is going on behind the scenes.

CATKeys is a simple library that provides mutual authentication with very little effort. It supports HTTPS as well as TLS communication. Generate keys using simple commands, then use CATKeys as a drop in replacement anywhere you have used https.createServer(), https.request(), tls.createServer() or tls.connect().

In this guide we will create a simple web service which will response with a JSON object containing the name of the client key that was used to connect.

Creating a simple Node HTTP server

Let’s start with a simple http server which we will migrate to CATKeys. It will return a JSON object describing whether the server is secure.

Create a server and save it as serve.js:

const http = require('http')
const serve = () => {
http.createServer(
(req, res) => {
res.writeHead(200)
res.write(JSON.stringify({
isSecure: req.socket.authorized === true
}))
res.end()
}
).listen(8080)
}
serve()

Now let’s create a client and save it as request.js:

const http = require('http')
const request = () => {
const req = http.request(
'http://localhost:8080',
(res) => {
const data = []
res.on('data', (chunk) => { data.push(chunk) })
res.on('end', () => { console.log(data.join('')) })
res.on('error', console.error)
}
)
req.end()
}
request()

Test that the files work. In one terminal start the server:

node serve.js

And in another, run the request:

node request.js

You should see the server’s response outputted on the terminal:

$ node request.js
{"isSecure":false}

Migrating to CATKeys

The following command need to be run from the project root directory.

Install CATKeys from NPM:

npm i --save catkeys

Generate the server and client keys:

npx catkeys create-key --keydir catkeys --server
npx catkeys create-key --keydir catkeys

The --keydir arg specifies the location of the directory to store the keys. The --server arg creates a server key. Server keys always need to be created first as client keys are created from server keys.

A directory named catkeys now exists in your project root directory that contains 2 keys:

$ ls -l catkeys
total 32
-rw-r--r-- 1 pommy staff 5372 4 Sep 21:10 client.catkey
-rw-r--r-- 1 pommy staff 7857 4 Sep 21:09 server.catkey

CATKeys is a wrapper for https and tls methods for creating servers and making requests. The signatures of CATKeys methods are the same as those provided by Node. The only difference is that the CATKeys methods are async. This means the following changes need to be made:

  • https should be imported from the catkeys library
  • await should be used with CATKeys methods
  • enclosing functions should use async

After making these changes, the serve.js now looks like this:

const { https } = require('catkeys')
const serve = async () => {
(await https.createServer(
(req, res) => {
res.writeHead(200)
res.write(JSON.stringify({
isSecure: req.socket.authorized === true
}))
res.end()
}
)).listen(8080)
}
serve()

In request.js the URL scheme also needs to change from http to https .

const { https } = require('catkeys')
const request = async () => {
const req = await https.request(
'https://localhost:8080',
(res) => {
const data = []
res.on('data', (chunk) => { data.push(chunk) })
res.on('end', () => { console.log(data.join('')) })
res.on('error', console.error)
}
)
req.end()
}
request()

Let’s restart the server and run the client request again:

$ node request.js
{"isSecure":true}

While the server is running, we can verify that the server requires a client certificate using curl:

$ curl --insecure https://localhost:8080/
curl: (56) OpenSSL SSL_read: error:1409445C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required, errno 0

Currently the client is able to connect to the server because server.js and request.js are sharing the same catkeys directory in the project root. If they were running on different servers you would need to create a catkeys directory in the client's project root directory and copy the catkeys/client.catkey file to it from the server.

Server hostnames

TLS certificates normally contain hostnames. When clients connect to a server, the hostname of the server is compared against the common and alt names in it’s certificate, and an error is thrown if they do not match.

CATKeys uses a slightly different approach. CATKeys expects the server certificate to contain a special, generic hostname that is reserved for server keys. This means that the hostname of the server can be changed and it does not affect the validity of the certificate. The server’s identity is securely validated using the CA stored in the client key.

To see this in effect, try changing the hostname in the request.js from localhost to 127.0.0.1 and repeating the request.

If you would prefer to limit keys for use on a particular hostname, you can generate a server key with a hostname by using the arg--name with the create-keys command:

npx catkeys create-key -k catkeys -s --update --name example.com

Now you can only host this key on example.com. Hosting the server on any other name will cause a client to throw an error when a client connects. If you restart the server and run the client request, you will get an error with type ERR_TLS_CERT_ALTNAME_INVALID .

$ node request.js
events.js:288
throw er; // Unhandled 'error' event
^
Error [ERR_TLS_CERT_ALTNAME_INVALID]: Hostname/IP does not match certificate's altnames: IP: 127.0.0.1 is not in the cert's list:

To remove example.com from the server key’s hostname, update the key again and remove the --name arg:

npx catkeys create-key -k catkeys -s --update

If you want tell clients to reject the special, generic hostnames normally present in server keys, and only connect to servers with certificates that match their hostname, you can provide the option catRejectMismatchedHostname: true:

const req = await https.request(
'https://localhost:8080',
{ catRejectMismatchedHostname: true },
(res) => { ... }
)

Client names

Client keys are named client by default. You can share a single client key between many clients if you like. Or you can create several keys in order to differentiate between clients.

To a create client key with a specific name, pass --name to the create-key command:

npx catkeys create-key --keydir catkeys --name client-key-2

If there is only 1 client key then CATKeys will always use that to connect. If there is more than 1 than you will need to define which key to use when connecting:

const req1 = await https.request(
'https://host1.example.com/',
{ catKey: 'client' }
(res) => { ... }
)
const req2 = await https.request(
'https://host2.example.com/',
{ catKey: 'client-key-2' }
(res) => { ... }
)

Accessing client names

Suppose you want to implement some kind of access control based on the name of the client, or maybe you just want to log the name of the client that connected to a server. You can get the name of the client by accessing the certificate using req.connection.getPeerCertificate() in the request handler on the server:

(await https.createServer(
(req, res) => {
const clientCert = req.connection.getPeerCertificate()
console.log('Connection from client: ' + clientCert.subject.CN)
...
}
)).listen(8080)

Revoking a client’s access

There are 2 ways to revoke access to a client.

If you still have access to the client key that you want to revoke in your catkeys directory, you can revoke the key using the revoke-key cli command.

For example, if you created a key named client-to-revoke like this:

npx catkeys create-key --keydir catkeys --name client-to-revoke

then you can revoke it like this:

npx catkeys revoke-key --keydir catkeys --name client-to-revoke

The revoke-key cli command can only be used if you have access to a client key. If you no longer have access to the client key, you can effectively revoke it by limiting access to only the keys that exist in the catkeys directory by passing the option catCheckKeyExists: true when calling createServer():

(await https.createServer(
{ catCheckKeyExists: true },
(req, res) => {
...
}
)).listen(8080)

CATKeys will check the keys directory to see if the client key is present. Deleting the clients key will revoke access. If the key is not on disk then the connection will be closed and the request handler will not be called.

Using keys with servers other than Node

CATKeys can be used with servers other than Node. Keys are just TAR archives and can be expanded. The comprising CAs, certs and keys can be extracted for use with many servers that support TLS/SSL.

Extract the server key we created earlier:

npx catkeys extract-key catkeys/server.catkey

The server key has been extracted to a directory named server in the current directory.

$ ls -l server
total 32
-rw-r--r-- 1 pommy staff 2053 24 Feb 19:44 ca-crt.pem
-rw-r--r-- 1 pommy staff 2009 24 Feb 19:44 crt.pem
-rw-r--r-- 1 pommy staff 3243 24 Feb 19:44 key.pem

These files can be used in any web server that supports pem formats.

For example, Nginx can be configured to request client certificates on port 8080 and proxy to an HTTP upstream server on port 8081 like this:

server {
listen 8080 ssl;
ssl_certificate /path/to/server/crt.pem;
ssl_certificate_key /path/to/server/key.pem;
ssl_client_certificate /path/to/server/ca-crt.pem;
ssl_verify_client on;
location / {
proxy_pass http://localhost:8081;
}
}

⚠️ The ssl_verify_client on; is important. It validates clients using the certificate specified in ssl_client_certificate and rejects clients that fail validation.

Because the TLS connection is terminated at the server, you will not be able to use the option catCheckKeyExists: true to reject clients without a key present on disk.

TLS

We have only covered HTTPS, but CATKeys can also support communication over TLS upgraded sockets. This is useful for real-time communication.

You can import the tls module using const { tls } = require('catkeys'). I am not going to dive into how to use tls.createServer() and tls.connect() as they have the same signature as they do in Node's tls module (the difference again being that they are async methods) and the same cat* options we looked at with https.createServer() and https.request() can be used.

The CATKeys documentation includes examples for creating a plain TLS server and client and also for use with JsonSocket — which allows for sending structured data using JSON over a TLS connection.

Why another security library?

For those wondering what’s the point of something new — CATKeys doesn’t really invent anything new. Client authenticated TLS has been supported for a long time in Node, and are supported by many web servers that can be used as reverse proxies (Nginx, HAProxy, Apache). Security is handled by the TLS protocol — CATKeys is acting simply as a utility to create and package keys for easy distribution, plus it includes a bunch of wrapper methods for loading those keys.

Any questions?

Hopefully that gives you an idea of how to implement client authenticated TLS using CATKeys. Did I miss anything or confuse you? Don’t hesitate to post questions and suggestions in the comments.

--

--