If you want to entirely restrict access to a web server to only those folks that you deem authorized, using client certificates is the way to go. The common expression for such pattern is mutual TLS, or mTLS for short.
Outline
There are 3 components you need for this recipe:
- a web server that supports mTLS
- a certificate authority that issues a client certificate
- a client that will submit such client certificate to the web server as part of a request
In scope of this blog post
The following steps are being explained as part of this blog post:
- generate a certificate request (CSR) with openssl
- issue a client certificate with step ca
- configure NGINX to require mTLS
- issue an HTML request with curl
Not in scope of this blog post
There is a lot of background knowledge required to fully comprehend how mTLS works in detail. The following topics are not being addressed in this blog post and assumed to be understood to a minimum extent at least.
- Core concepts of a Public Key Infrastructure (PKI)
- x509 Certificates
- OpenSSL
- step ca
- Import of x509 client certificates into the operating system you use
Generate a CSR with OpenSSL
For a Certificate Authority (CA) to issue a certificate to a client, a Certificate Signing Request (CSR) from the client is needed if such client wants to keep its private key as a secret to itself. An easy way to generate such is to use OpenSSL with a configuration file. Below configuration file (assumed name: john-csr.cnf) shows the bare minimum for a CSR that can be used by a CA to issue a client certificate. The only 2 net information contained in the CSR are the user name and the user’s email address.
[req] prompt = no distinguished_name = req_dn req_extensions = req_ext [req_dn] CN = John Doe [req_ext] subjectAltName = @alt_names [alt_names] email.1 = john.doe@examplemail.com
While we could have put the email address into the subject (req_dn section), it is important to understand that we deliberately not do this but use the subject alternative name extension to place the email address.
To generate the CSR we use openssl’s req command. We use the existing private key that we have generated beforehand and stored in the key.pem file.
andreas@laptop ➜ ~ openssl req -new -config john-csr.cnf -key key.pem -out johndoe.csr
When visualizing the generated CSR we can see the 2 net information reflected in the CSR.
andreas@laptop ➜ ~ openssl req -noout -text -in johndoe.csr
Certificate Request:
Data:
Version: 1 (0x0)
Subject: CN = John Doe
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (2048 bit)
Modulus:
00:e4:10:b6:3d:82:fa:ca:4b:b7:61:20:a0:33:ed:
...
<<REDACTED>>
...
67:ed
Exponent: 65537 (0x10001)
Attributes:
Requested Extensions:
X509v3 Subject Alternative Name:
email:john.doe@examplemail.com
Signature Algorithm: sha256WithRSAEncryption
0a:13:8b:4a:16:ee:c4:4f:23:86:f6:d8:b2:d3:7c:d6:70:d1:
...
<<REDACTED>>
...
3f:61:4d:cf
Issue a certificate with step ca
We will copy the CSR onto our step ca server (maybe under the incoming folder) and issue a step ca sign command. If we do have multiple provisioners, the sign command will prompt a list of all provisioners and ask us to interactively select a provisioner. After selecting the provisioner, we need to input the passphrase that protects the private key of the issuing intermediate CA.
andreas@acme ➜ ~ step ca sign --ca-url https://acme.local:8443 --root /etc/ssl/tinkivity.pem incoming/johndoe.csr issued/johndoe.pem ✔ Provisioner: 1year (JWK) [kid: SlOHMD00B8-WIyUqa1zQxP9xwG4UQCvOorMU02xThUc] ✔ Please enter the password to decrypt the provisioner key: ✔ CA: https://acme.local:8443 ✔ Certificate: issued/johndoe.pem
As an alternative we pre-select the provisioner already along with sign command.
andreas@acme ➜ ~ step ca sign --ca-url https://acme.local:8443 --root /etc/ssl/tinkivity.pem --provisioner 1year incoming/johndoe.csr issued/johndoe.pem ✔ Provisioner: 1year (JWK) [kid: SlOHMD00B8-WIyUqa1zQxP9xwG4UQCvOorMU02xThUc] ✔ Please enter the password to decrypt the provisioner key: ✔ CA: https://acme.local:8443 ✔ Certificate: issued/johndoe.pem
Another alternative is to generate a token upfront as it allows us to pass in the password file and thus make the command completely interaction free (not shown in this blog, but can be see in this previous blog post).
Either way, we can inspect the generated certificate with OpenSSL.
andreas@acme ➜ ~ openssl x509 -noout -text -in issued/johndoe.pem
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
3f:ed:1f:01:b1:ce:90:66:0f:33:b7:31:fa:ce:b9:8a
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=DE, ST=Saxony, O=Tinkivity, OU=Tinkivity Intermediate Certificate Authority, CN=Smallstep Intermediate CA/emailAddress=xxx@xxx.com
Validity
Not Before: Dec 6 12:58:16 2020 GMT
Not After : Dec 6 12:59:16 2021 GMT
Subject: CN=John Doe
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:e4:10:b6:3d:82:fa:ca:4b:b7:61:20:a0:33:ed:
...
<<REDACTED>>
...
67:ed
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature, Key Encipherment
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Subject Key Identifier:
7D:A9:C5:44:49:EC:CC:54:37:64:46:CF:A9:99:85:D6:23:18:9D:F8
X509v3 Authority Key Identifier:
keyid:87:32:28:49:63:29:06:79:96:13:DE:47:14:9F:EF:C0:DD:EC:4D:C3
X509v3 Subject Alternative Name:
email:john.doe@examplemail.com
1.3.6.1.4.1.37476.9000.64.1:
07.....1year.+SlOHMD00B8-WIyUqa1zQxP9xwG4UQCvOorMU02xThUc
Signature Algorithm: sha256WithRSAEncryption
Signature Algorithm: sha256WithRSAEncryption
76:46:dc:d0:c7:81:ab:f3:c0:3c:0f:5c:99:d1:12:ca:97:a1:
...
<<REDACTED>>
...
a7:e7:56:13:79:3d:3c:b0
Setup NGINX for mTLS
Assuming we re-use the NGINX setup from this previous blog post, we only have to add a few lines to the site configuration at /usr/local/etc/nginx/sites/testclient.conf from our NGINX server.
server {
# listen 80;
server_name testclient;
access_log /var/log/nginx/testclient.access.log;
error_log /var/log/nginx/testclient.error.log;
# location of our own root certificate
ssl_client_certificate /etc/ssl/certs/97efb5b5.0;
ssl_verify_client optional;
location / {
root /usr/local/www/sites/testclient/html;
index index.html;
if ($ssl_client_verify != SUCCESS) {
return 403;
}
}
listen 443 ssl;
ssl_certificate /usr/local/etc/letsencrypt/live/testclient/fullchain.pem;
ssl_certificate_key /usr/local/etc/letsencrypt/live/testclient/privkey.pem;
include /usr/local/etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = testclient) {
return 301 https://$host$request_uri;
}
listen 80;
server_name testclient;
return 404;
}
Now we only have NGINX reload the configuration.
andreas@testclient ➜ ~ sudo service nginx reload Performing sanity check on nginx configuration: nginx: the configuration file /usr/local/etc/nginx/nginx.conf syntax is ok nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful
Issue a request with client authentication
Let’s start by trying to issue a regular request from our laptop. We use a simple curl command to callout to https://testclient. We tell curl where to find our root certificate via the –cacert option, so it will not complain about an unknown root CA.
According to the NGINX configuration, we should immediately receive a 403 error from the web server if we lack client authentication…
andreas@laptop ➜ ~ curl --cacert /etc/ssl/certs/97efb5b5.0 https://testclient <html> <head><title>403 Forbidden</title></head> <body> <center><h1>403 Forbidden</h1></center> <hr><center>nginx/1.18.0</center> </body> </html>
Now, let’s apply our user certificate and the private key we’ve used to generate the CSR.
andreas@laptop ➜ ~ curl --cacert /etc/ssl/certs/97efb5b5.0 --cert johndoe.pem --key key.pem https://testclient
<html>
<head>
<title>TESTCLIENT</title>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
