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 thecatkeys
libraryawait
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 inssl_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.