mTLS - How to set up server and client in NodeJs with mTLS?

How to encrypt the connection between two machines and authorize request from client on server? The article has a script to generate certificates and examples of servers in NodeJS.

Piotr Gawędanodejsintermediate catlsmTlsserver

mTLS - How to set up server and client in NodeJs with mTLS?

You probably have experience with servers to authenticate users. Client connect with your website, click on the link to sign in form and fills it up with own username and password. Browser encrypt credentials and send those secrets to Server. But how to authenticate client when it is a machine and you have to verify both sides of communication? But one by one. You must first understand how regular tls works.

Github repo with examples: https://github.com/gawsoftpl/gawsoft.com/tree/main/blog/mtls

Standard TLS

Standard encrypted connection between server and client. This type of connection happens when you visit facebook, twitter or bank.

TLS
Client
Server

In short, it can be described that:

  1. Browser(Client) send request to Server,

  2. Endpoint will send back to browser, signed by Certificate Authorities (CA), public certificate which browser should encrypt data transmitted to Server.

  3. Browser check that Certificate is signed by known to her CA. Most browsers has list of approved of trusted authorities.
    Most popular are:

    • Verisign
    • DigiCert
    • GeoTrust
    • Entrust
    • Google
    • etc
    • Please note that you can use own CA
  4. Browser create secret key, which will be only use in this session, encrypted with public certificate received from Server.

  5. Server receive encrypted payload and decrypt secret key with own private key. Botch machines Server and Client have unique session key, thanks to which communication will be encrypted.

This type of connection is good when a human connects with a machine. But to connect machine - machine (B2B) you have to verify that the connected machines are the one he claims to be.

Two way encryption (mTLS)

The solution is mutual Transport Layer Security. This is an extension of regular TLS. In additional between points 1 and 2 from previous chapter Client will send own signed certificate. Thanks that Server can verify that client is from verified source.

The above solution makes it possible to authenticate and verify clients, without a prompt username and password. For sign certificates on both sides of connection you can use a paid version of CA, but in this case, we will generate own CA public and private key for sign certificates.

Mutual TLS
Server
Client

A real life example

Imagine that we have an organization ABC inc. In this small company we have two machines:

  1. Invoice server
  2. Mail Server

We want to Mail server connect with Invoice server and download invoice data. For that both server have to encrypt connection.

Additional invoice server have to authorize mail server. We don't want to send our client's data to not verified machine.

For this we will check that certificate of mail server is signed by our company CA. In situation when Mail is not signed by our CA connection will be dropped.

Connection diagram

InvoiceServerMailServerInvoiceServerMailServerHi, This is my certificateHi, This is my certificateOk, your certificate is signed by ABC CA, here our secret keyOk, your certificate is signed by ABC CANow I, can send you user data encryted our secret key

Ok so much theory, now let's move to bash commands which you will generate own CA, Certificate and Key. At the end of this article you find NodeJS scripts for server and client.

First, create a directory hierarchy for all codes, which you will generate in this article

mkdir ca ca-wrong invoice-server mail-server

Generate certificates

1. Generate ABC Inc. CA

This command will generate private key and certificate, which will be valid for 365 days.

openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -subj '/O=ABC Inc.' -keyout ca/ca.key.pem -out ca/ca.cert.pem

After executed command in ca directory should have two files.

ca
├── ca.cert.pem
└── ca.key.pem
  • ca.cert.pem is a Public CA certificate for verify InvoiceServer and MailServer certificates
  • ca.key.pem this is a Private key, this key will sign all certificate in organization ABC. In production this key is stored in offline machine (for example you can use Hashicorp Vault)

2. InvoiceServer Certificate and private key

Generate private key for InvoiceServer and prepare Certificate Signing Request (CSR). This file has information about the entity for which the certificate is to be issued by our CA.

This command has line: /CN=localhost/O=InvoiceServer.

CN means CommonName or FQDN (Fully qualified domain name). For https we use FQDN. Because this is example we will start our nodejs server in localhost, so we have to issue a certificate for localhost.

In production, if you have a server hosted as: invoice.abc.com you should use the parameter: /CN=invoice.abc.com/O=InvoiceServer

openssl req -out invoice-server/server.csr -newkey rsa:2048 -nodes -keyout invoice-server/private.key -subj "/CN=localhost/O=InvoiceServer"

Now we have to issue our certificate request. For this we will use CA generated in step 1

openssl x509 -req -sha256 -days 365 -CA ca/ca.cert.pem -CAkey ca/ca.key.pem -set_serial 0 -in invoice-server/server.csr -out invoice-server/server.cert

Now invoice-server directory looks like this:

invoice-server
├── private.key
├── server.cert # Issued by ABC CA certificate for invoice-server
└── server.csr

For verify that certificate is signed by ABC CA, please use below commands. First will show information about certificate. Second just verify that certificate is signed by ABC Inc CA

openssl x509 -noout -text -in invoice-server/server.cert
openssl verify -CAfile ca/ca.cert.pem invoice-server/server.cert
# invoice-server/server.cert: OK

Great now we have certificates for InvoiceServer, we have to create certificates for MailServer. Skaffold for that process is same as in step 2.

3. MailServer Certificate and private key

openssl req -out mail-server/server.csr -newkey rsa:2048 -nodes -keyout mail-server/private.key -subj "/CN=localhost/O=MailServer"
openssl x509 -req -sha256 -days 365 -CA ca/ca.cert.pem -CAkey ca/ca.key.pem -set_serial 0 -in mail-server/server.csr -out mail-server/server.cert

MailServer directory:

mail-server
├── private.key
├── server.cert
└── server.csr

For check please verify that certificate is signed correctly.

openssl verify -CAfile ca/ca.cert.pem mail-server/server.cert
# Output: mail-server/server.cert: OK

4. Prepare NodeJS scripts

invoice_server.js

cat <<\EOF > invoice_server.js
const https = require('https');
const fs = require('fs');

const hostname = '0.0.0.0';
const port = 3100;

const options = { 
    ca: fs.readFileSync('ca/ca.cert.pem'), 
    cert: fs.readFileSync('invoice-server/server.cert'), 
    key: fs.readFileSync('invoice-server/private.key'), 
    rejectUnauthorized: true, // if certificate is not signed by ca/ca.cert.pem, drop connection
    requestCert: true, // mail_server.js have to send own Cert 
}; 

const server = https.createServer(options, (req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/json');
  const userData = JSON.stringify({
     firstname: 'John',
     lastname: 'Ji',
     email: '[email protected]',
     invoice_number: "00095234",
     invoice_price: "10USD",
  });
  res.end(userData);
});

server.on('error',(e)=>console.log(e));

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

EOF

mail_server.js

cat <<\EOF > mail_server.js
var fs = require('fs');
var https = require('https');
var options = {
    hostname: 'localhost',
    port: 3100,
    path: '/',
    method: 'GET',
    key: fs.readFileSync('mail-server/private.key'),
    cert: fs.readFileSync('mail-server/server.cert'),
    ca: fs.readFileSync('ca/ca.cert.pem'),
};

var req = https.request(options, function(res) {
    res.on('data', function(data) {
        process.stdout.write(data);
    });
});
req.end();
req.on('error', function(e) {
    console.error(e);
});
EOF

Start invoice_server

node invoice_server.js        
# Output: Server running at http://0.0.0.0:3100/

Send request from mail_server

node mail_server.js   
# Output: {"firstname":"John","lastname":"Ji","email":"[email protected]","invoice_number":"00095234","invoice_price":"10USD"}

The scheme of above scripts can be described as follow

  1. Mail_server.js send request to invoice_server.js with own certificate
  2. Invoice_server, check that mail_server send certificate and is signed by ABC .inc CA
  3. If certificate is signed, return user invoice to mail_server.js

5. Unauthorized request

What happen when we send request with certificate signed by CA belongs to other company?

Generate CA for company ZXC (wrong ca)

openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -subj '/O=ZXC Inc./CN=zxc.com' -keyout ca-wrong/ca.key.pem -out ca-wrong/ca.cert.pem

Sign mail_server certificate by CA belongs to ZXC (not ABC)

openssl x509 -req -sha256 -days 365 -CA ca-wrong/ca.cert.pem -CAkey ca-wrong/ca.key.pem -set_serial 0 -in mail-server/server.csr -out mail-server/server-wrong.cert

mail_server_wrong_cert.js

cat <<\EOF > mail_server_wrong_cert.js
var fs = require('fs');
var https = require('https');
var options = {
    hostname: 'localhost',
    port: 3100,
    path: '/',
    method: 'GET',
    key: fs.readFileSync('mail-server/private.key'),
    cert: fs.readFileSync('mail-server/server-wrong.cert'),
    ca: fs.readFileSync('ca/ca.cert.pem'),
};

var req = https.request(options, function(res) {
    res.on('data', function(data) {
        process.stdout.write(data);
    });
});
req.end();
req.on('error', function(e) {
    console.error(e);
});
EOF

Execute test request:

node mail_server_wrong_cert.js
#Error: socket hang up
#    at connResetException (node:internal/errors:704:14)
#    at TLSSocket.socketOnEnd (node:_http_client:505:23)
#    at TLSSocket.emit (node:events:525:35)
#    at endReadableNT (node:internal/streams/readable:1358:12)
#    at processTicksAndRejections (node:internal/process/task_queues:83:21) {
#  code: 'ECONNRESET'
#}

Invoice_server.js block connection from mail_server_wrong_cert.js because, certificate is issued by other CA. Very imporatnt are options which have been set in invoice_server.js

const options = {
    rejectUnauthorized: true, // if certificate is not signed by ca/ca.cert.pem, drop connection
    requestCert: true, // mail_server.js have to send own Cert 
}; 

That's it. You successfully set up mTLS connection in NodeJS and authenticate client which sent request to invoice_server.js

Output directory structure should look like below:

.
├── ca
│   ├── ca.cert.pem
│   └── ca.key.pem
├── ca-wrong
│   ├── ca.cert.pem
│   └── ca.key.pem
├── invoice-server
│   ├── private.key
│   ├── server.cert
│   └── server.csr
├── invoice_server.js
├── mail-server
│   ├── private.key
│   ├── server.cert
│   ├── server.csr
│   └── server-wrong.cert
├── mail_server.js
└── mail_server_wrong_cert.js

References

  1. Github examples: https://github.com/gawsoftpl/gawsoft.com/tree/main/blog/mtls
  2. https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/
  3. https://en.wikipedia.org/wiki/Mutual_authentication
  4. https://www.f5.com/labs/articles/education/what-is-mtls