Mutual TLS
A demonstration of how to setup mutual TLS for a simple Java server.
See my mTLS repo for scripts and source code.
In mutual TLS, the TLS handshake includes a “Request CERT” message from the server to the client, which asks the client to send its certificate for authentication so each side of the connection can verify the identity of the other (usually only the client can verify the server’s identity).
A TCP handshake consists of three messages: syn, syn+ack and ack. A TLS handshake consists of the same messages plus additional message to established an encrypted connection. Let’s look at (the main bits of) a cURL output:
$ curl -v https://www.google.com
...
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=www.google.com
* start date: Dec 8 22:50:34 2021 GMT
* expire date: Mar 2 22:50:33 2022 GMT
* subjectAltName: host "www.google.com" matched cert's "www.google.com"
* issuer: C=US; O=Google Trust Services LLC; CN=GTS CA 1C3
* SSL certificate verify ok.
...
From the above, we can see the server sends us its certificate but we never send a certificate of our own.
Main steps in configuring mutual TLS:
- Create a self-signed root certificate, trusted by all parties
- Create keys and certificate requests for all parties
- Sign the certificate requests with the root certificate
- Configure the server to send a “Request CERT” message to the client in the TLS handshake
Creating the root certificate
Both sides of the connection need to verify certificates using a certificate authority (CA). For this demo I configured a private CA using openssl req
, which is a “self-signed” certificate which I make the server/client trust later on. Then
The gen_ca_cert.sh script does this. The main command is:
openssl req \
-newkey rsa:2048 \
-x509 \
-keyout cakey.pem \
-out cacert.pem \
-nodes \
-subj "/CN=mtls-demo-root"
In plain English: “create a 2048-bit RSA key (newkey); output a self-signed certificate (x509); the subject name/issuer name of the CA is ‘CN=mtls-demo-root’ (subj); save the key in cakey.pem (keyout); and the certificate in cacert.pem (out)”.
Creating private keys and certificate requests
Use the genrsa
command to create private keys:
openssl genrsa -out server.pem 2048
Then use the req
command to create a certificate request (you can think of it as a certificate that hasn’t been signed by the CA yet). Pass in the key we just created and specify the subject name of the holder of this certificate (“OU” = “organizational unit”). It’s important that the common name (CN) matches the actual hostname we use later on: when we try to connect to “https://localhost”, the client will check that the server’s CN matches the domain we meant to connect to in the first place, e.g. “curl: (60) SSL: certificate subject name ‘test’ does not match target host name ‘localhost’”.
openssl req -new \
-key server.pem \
-out server-req.pem \
-subj "/OU=server/CN=localhost"
Do the above for both the server and the client so we have 4 files in total:
- server.pem/client.pem: the private keys
- server-req.pem/client-req.pem: the certificate requests
Sign the certificate requests
The openssl x509
is the “certificate display and signing utility”. We can sign our certificate requests as follows:
openssl x509 -req \
-in server-req.pem \
-CA cacert.pem \
-CAkey cakey.pem \
-CAcreateserial \
-out server-signed.pem
In plain English: “expect a certificate request (req) in server-req.pem (in); the CA’s certificate is in cacert.pem (CA); the CA’s private key is in cakey.pem (CAkey); create a serial number file if it doesn’t already exist (CAcreateserial); output to server-signed.pem (out).”
Do the above for both the server and the client so we have 2 signed certificates: server-signed.pem and client-signed.pem.
We can inspect these certificates to understand what’s happened. Using openssl verify
we can see the chain of certificates up to the issuer:
$ openssl verify -CAfile cacert.pem -show_chain server-signed.pem
server-signed.pem: OK
Chain:
depth=0: OU = server, CN = localhost (untrusted)
depth=1: CN = mtls-demo-root
And openssl x509
shows the contents of the certificate:
$ openssl x509 -noout -text -in server-signed.pem
Certificate:
Data:
Serial Number:
49:af:e1:e3:93:3c:5c:b1:0c:2f:92:eb:29:a5:6c:a5:c1:19:95:49
Issuer: CN = mtls-demo-root
Validity
Not Before: Jan 15 23:15:55 2022 GMT
Not After : Feb 14 23:15:55 2022 GMT
Subject: OU = server, CN = localhost
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (2048 bit)
...
It includes:
- A per-issuer unique serial number
- The issuer subject name (
CN = mtls-demo-root
) - The validity window
- The subject of the certificate itself (
OU = server, CN = localhost
) - The 2048-bit public key (omitted)
Creating keystores
For our Java application we need keystores so we need to convert our existing certificates/private keys. We can do this by saving the server’s, for example, certificate and private key into a single server.p12
file with openssl pkcs12
and then import this into a keystore.
openssl pkcs12 -export \
-in server-signed.pem \
-inkey server.pem \
-out server.p12 \
-name server
keytool -importkeystore \
-srcstorepass '' \
-srckeystore server.p12 \
-keystore server.jks \
-storepass password
Note: all of the steps in this post could have been done with keytool
in the first place but I wanted to learn more about openssl
…
Configuring a Java server for mutual TLS
Since the client always expects the server to send its certificate, we just need to configure the server to ask the client to send its certificate.
- Set the
javax.net.ssl.trustStore
VM argument to the path of the keystore containing the CA certificate, so the application knows to verify certificates with our private CA - Set the
javax.net.ssl.keyStore
VM argument to the path of the keystore containing the server’s certificate and private key - Set the
javax.net.ssl.trustStorePassword
andjavax.net.ssl.keyStorePassword
so the server can open the truststore and keystore. - Configure the
SSLServerSocket
to request a certificate from the client by runningsetNeedClientAuth(true)
See a full example in Server.java
.
Running the demo
You can see mutual TLS in action by first running the server (./run.sh server
) and then running the client, either by using cURL (see below) or .run.sh client
.
No CA: if we try to connect without even specifying the CA certificate, the client can’t verify the server’s certificate so we would get an error.
$ curl https://localhost:5000
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.
No certificate: if we try to connect without specifying a valid client certificate then the server can’t verify the client so the handshake fails. This proves we’ve achieved one of the key objectives of mutual TLS. Using the --cacert
argument we can verify the server’s certificate but the client will send an empty certificate chain and so the server throws an exception: javax.net.ssl.SSLHandshakeException: Empty client certificate chain
.
curl --cacert cacert.pem https://localhost:5000
curl: (56) OpenSSL SSL_read: error:14094412:SSL routines:ssl3_read_bytes:sslv3 alert bad certificate, errno 0
CA, certificate and key: when the client is configured with the CA certificate, its own certificate and private key, then it can verify the server and the server can verify the client! The --cert
argument is the client’s certificate and the --key
argument is the client’s private key:
curl --cacert cacert.pem \
--cert client-signed.pem \
--key client.pem \
https://localhost:5000
<h1>Hello, World!</h1>