ECC Certificates and mTLS with Nginx

If you want to be truly paranoid about authentication to services, you can implement your own Public Key Infrastructure (PKI). Many large organizations that are privacy focused have developed a digital/physical PKI strategy, for example the DoD's Common Access Card. OpenSSL is a software that can be used to setup a "simple" PKI, however it's command complexity is easy to get lost within. In this guide we'll set up a "simple" PKI that we'll use to authenticate users with, while still using the legitimately issued Let's Encrypt Domain Validation certificates. As the title says, we'll be using Elliptic Curve Cryptography to implement mutual TLS with nginx.

In this guide we will:

  • set up a self signed PKI infrastructure with a root and intermediary authortiy
  • issue client certificates that can be imported into a browser
  • validate client certificates against and nginx websever

On Conceptual Security

This author brought up a key consideration about multi-level security where you very likely want to consider establishing your CA on an air-gapped machine. There are myriad choices that should be under consideration when implementing PKI. It's no wonder that with the relatively complexity allowed through all these choices, there exists a significant industry out there to assist in implementations. We'll proceed here assuming that you're making these choices intelligently. Please be aware that this is a notional guide and should only be literally used if you're interested in testing out these principles.

PKI Infrastructure Root->Intermediary->Client

In searching for guides I combined works from many places, all show up in the end of this post. We'll schematically follow this guide as the layout and organization were understandable to me. We will start with a monolithic openssl.cnf that establishes many things, but here are some key parameters to be aware of:

  • Root CA Certificate lasts for 10 years: [ ca_root ] default_days = 3650
  • Root CA CRL Updates yearly: [ ca_root ] default_crl_days = 365
  • Intermediary CA Certificate lasts for 10 years: [ ca_intermediate ] default_days = 3650
  • Intermediary CA CRL Updates yearly: [ ca_intermediate ] default_crl_days = 365

Many of these parameters are not sane for doing "production" PKI, however this is aimed to be "simple" and based on that keyword I'm making a distinction to make it relatively low maintenance (e.g. CRL annual updates). We're also not hosting a CRL (e.g. nsCaRevocationUrl) because we're targeting only doing mTLS between server and client where the server will have a deployed copy of the CRL.

Naturally this file is daunting, and the documentation for OpenSSL is even more daunting. I'd encourage you to examine the documentation and other guides to ensure that these parameters are acceptable for your needs.

[ ca ]
default_ca = ca_intermediate

[ ca_root ]
dir               = root
certs             = $dir/certs
crl_dir           = $dir/crl
new_certs_dir     = $dir/newcerts
database          = $dir/database
serial            = $dir/serial
crlnumber         = $dir/crlnumber
private_key       = $dir/private/root.key.pem
certificate       = $dir/certs/root.cert.pem
crl               = $dir/crl/root.crl.pem
crl_extensions    = ext_crl
default_md        = sha512
name_opt          = ca_default
cert_opt          = ca_default
default_crl_days  = 365
default_days      = 3650
preserve          = no
policy            = policy_strict

[ ca_intermediate ]
dir               = intermediate
certs             = $dir/certs
crl_dir           = $dir/crl
new_certs_dir     = $dir/newcerts
database          = $dir/database
serial            = $dir/serial
crlnumber         = $dir/crlnumber
private_key       = $dir/private/intermediate.key.pem
certificate       = $dir/certs/intermediate.cert.pem
crl               = $dir/crl/intermediate.crl.pem
crl_extensions    = ext_crl
default_md        = sha512
name_opt          = ca_default
cert_opt          = ca_default
default_crl_days  = 365
default_days      = 3650
preserve          = no
policy            = policy_loose

[ policy_strict ]
countryName             = match
stateOrProvinceName     = match
organizationName        = match
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ policy_loose ]
countryName             = optional
stateOrProvinceName     = optional
localityName            = optional
organizationName        = optional
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ req ]
default_bits        = 4096
string_mask         = utf8only
default_md          = sha512
distinguished_name  = req_distinguished_name

[ req_distinguished_name ]
countryName                     = Country Name (2 letter code)
stateOrProvinceName             = State or Province Name
localityName                    = Locality Name
0.organizationName              = Organization Name
organizationalUnitName          = Organizational Unit Name
commonName                      = Common Name
emailAddress                    = Email Address

[ ext_root ]
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid:always, issuer
basicConstraints        = critical, CA:true
keyUsage                = critical, digitalSignature, cRLSign, keyCertSign

[ ext_intermediate ]
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid:always, issuer
basicConstraints        = critical, CA:true, pathlen:0
keyUsage                = critical, digitalSignature, cRLSign, keyCertSign

[ ext_client ]
basicConstraints        = CA:FALSE
nsCertType              = client
nsComment               = "OpenSSL Generated Client Certificate"
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid, issuer
keyUsage                = critical, nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage        = clientAuth, emailProtection

[ ext_server ]
basicConstraints        = CA:FALSE
nsCertType              = server
nsComment               = "OpenSSL Generated Server Certificate"
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid, issuer:always
keyUsage                = critical, digitalSignature, keyEncipherment
extendedKeyUsage        = serverAuth

[ ext_crl ]
basicConstraints        = CA:FALSE
authorityKeyIdentifier  = keyid:always
keyUsage                = digitalSignature, keyEncipherment
nsComment               = "OpenSSL generated CRL"

First let's set up a directory structure:

mkdir ca
cd ca
mkdir -p {root,intermediate}/{certs,crl,csr,newcerts,private}
mkdir -p {client,server}/{certs,csr,pfx,private}
touch {root,intermediate}/database
echo 1000 | tee {root,intermediate}/{serial,crlnumber}
chmod 700 {root,intermediate,client,server}/private

Create the Root CA Key:

We choose to use secp384r1 based on these discussions here.

openssl ecparam -name secp384r1 -genkey | openssl ec -aes-256-cbc -out root/private/root.key.pem
# Create strong root key password
chmod 400 root/private/root.key.pem

From here forward I'll use the name "WanderWriter", which is my wife's blog as an exemplary name for arguments.

Create a Self Signed Root Certificate:

openssl req -config openssl.cnf -key root/private/root.key.pem -new -extensions ext_root -out root/certs/root.cert.pem -x509 -subj '/C=US/ST=Michigan/O=WanderWriter/OU=WanderWriter Certificate Authority/CN=WanderWriter Root CA' -days 3650
# Enter root key password
chmod 444 root/certs/root.cert.pem

Verify the Root Certificate:

openssl x509 -noout -text -in root/certs/root.cert.pem

Examine the output and see:

  • Expiration Not After is 10 years from today
  • Public-Key is 384 bit
  • Signature Algoroithm is ecdsa-with-SHA512
  • CA is TRUE

Create an Intermediary CA Key:

openssl ecparam -name secp384r1 -genkey | openssl ec -aes-256-cbc -out intermediate/private/intermediate.key.pem
# Create strong intermediate key password
chmod 400 intermediate/private/intermediate.key.pem

Create an Intermediary CSR:

openssl req -config openssl.cnf -new -key intermediate/private/intermediate.key.pem -out intermediate/csr/intermediate.csr.pem  -subj '/C=US/ST=Michigan/O=WanderWriter/OU=WanderWriter Certificate Authority/CN=WanderWriter Intermediate CA' -days 3650
# Enter intermediate key password

Sign Intermediary CA Certificate with Root Certificate:

openssl ca -config openssl.cnf -name ca_root -extensions ext_intermediate -notext -in intermediate/csr/intermediate.csr.pem -out intermediate/certs/intermediate.cert.pem
# Enter root key password
chmod 444 intermediate/certs/intermediate.cert.pem

Verify Intermediary CA Certificate:

openssl x509 -noout -text -in intermediate/certs/intermediate.cert.pem

Examine the output and see:

  • Expiration Not After is 10 years from today
  • Public-Key is 384 bit
  • Signature Algoroithm is ecdsa-with-SHA512
  • CA is TRUE
openssl verify -CAfile root/certs/root.cert.pem intermediate/certs/intermediate.cert.pem

Acceptable state here is to see OK.

Create a Chain Certificate File:

cat intermediate/certs/intermediate.cert.pem root/certs/root.cert.pem > intermediate/certs/chain.cert.pem
chmod 444 intermediate/certs/chain.cert.pem

This file is what we will deploy later to our nginx server under the ssl_client_certificate directive, which requires "trusted CA certificates in the PEM format used to verify client certificates".

Create a Client Key:

I will be creating a client certificate for myself as "agd". You can also have clients create this client key and CSR themselves. It is entirely likely that you'll want to create and issue these from a central device (intermediary).

openssl ecparam -name secp384r1 -genkey | openssl ec -aes-256-cbc -out client/private/agd.key.pem
# Create client key password
chmod 400 client/private/agd.key.pem

Create a Client CSR:

openssl req -config openssl.cnf -new -key client/private/agd.key.pem -out client/csr/agd.csr.pem  -subj '/C=US/ST=Michigan/O=WanderWriter/OU=Andrew G. Dunn/CN=agd@wanderwriter.ink' -days 365

Sign Client Certifcate with Intermediary Certificate:

openssl ca -config openssl.cnf -extensions ext_client -notext -in client/csr/agd.csr.pem -out client/certs/agd.cert.pem
chmod 444 client/certs/agd.cert.pem

Verify Client Certificate:

openssl x509 -noout -text -in client/certs/agd.cert.pem

Examine the output and see:

  • Expiration Not After is 1 years from today
  • Public-Key is 384 bit
  • Signature Algoroithm is ecdsa-with-SHA512
  • CA is FALSE
openssl verify -CAfile intermediate/certs/chain.cert.pem client/certs/agd.cert.pem

Acceptable state here is to see OK.

Create a PKCS#12 Bundle for the client:
This is an easier way to get all the necessary keys and certificates into a single package:

openssl pkcs12 -export -out client/pfx/agd.pfx -inkey client/private/agd.key.pem -in client/certs/agd.cert.pem -certfile intermediate/certs/chain.cert.pem

This will require you to set an "export" password which will have to be given to the client so that they can import into their device.

PKI Infrastructure Certificate Revocation List

We will use the CRL to ensure that certificates we no longer want to be active will not authenticate.

Generate a CRL:

openssl ca -config openssl.cnf -gencrl -out intermediate/crl/intermediate.crl.pem -crldays 365 

Verify CRL:

openssl crl -in intermediate/crl/intermediate.crl.pem -noout -text

Examine the output and see:

  • Expiration Not After is 1 years from today
  • Signature Algoroithm is ecdsa-with-SHA512
  • No Revoked Certificates

Optional: Revoke a Certificate:
Locate the certificate from the database:

cat intermediate/database

You'll need the hex-formatted serial number in the third field, We'll use X to denote where to use that serial in the next command:

openssl ca -config openssl.cnf -revoke intermediate/newcerts/X.pem

Then generate a new CRL:

openssl ca -config openssl.cnf -gencrl -out intermediate/crl/intermediate.crl.pem -crldays 365

Deploying PKI on Client and Server

Client is fairly simple, you just need to import the pex file that we created earlier, it will require that you enter the export key. You will then be able to see an installed software security device and inspect it.

On the Server there are two things we want to cover:

  • Server Wide Authentication: requiring a valid certificate to get to any location
  • Location Based Authentication: allowing non-certificate users through, but validating certificates based on specific locations. This is useful if you have a particular administrative interface that you want to keep under wraps.

The first case is straitfoward. Assuming you've read my guide on deploying modern TLS termination with Let's Encrypt and Nginx we will just be adding two lines:

ssl_client_certificate /etc/nginx/certs/chain.cert.pem;
ssl_verify_client on;

Where chain.cert.pem is the same chain.cert.pem we created above when we "Create a Chain Certificate File". This will require the client to offer a certificate to the server before any content is shared between them.

The second case is less straitfoward. There are some antipatterns within nginx that are best avoided. When search for resources I found these discussions which made it sound pretty dire until I saw:

"it is however pretty easy to simply set ssl verification to optional and then just error unauthenticated users under certain locations while allowing them anywhere else" - jacobalberty

Which made me dig in a to figure out the accepted convention for computing on the ssl_client_verify parameter. It appears that we can use the return or rewrite without being evil. Let's assume you want to put the /ghost directive (administrative console for ghost) behind an mTLS authentication. You're location blocks would look like this:

    location '/ghost' {
        if ($ssl_client_verify != "SUCCESS") { return 403; }
        client_max_body_size 32M;
        proxy_set_header    Host $host;
        proxy_set_header    X-Real-IP $remote_addr;
        proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto $scheme;
        proxy_redirect	    off;
        proxy_pass http://127.0.0.1:2369;       
    }

    location '/' {
        client_max_body_size 32M;
        proxy_set_header    Host $host;
        proxy_set_header    X-Real-IP $remote_addr;
        proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto $scheme;
        proxy_redirect	    off;
        proxy_pass http://127.0.0.1:2369;
    }

Please, if you use this and you see that I'm perpetuating a misunderstanding... reach out! OpenSSL is a prickly beast, and applying it to PKI certaintly gives you more than enough rope to hang yourself with.

References: