Modern TLS with Nginx and LetsEncrypt

With all of the nasties we are seeing about snarfing up data, there has been a concerted effort for people to get encryption in place. For the web, it has never been easier to get these things sorted because there have been significant efforts recently to reduce the barrier. Firstly the letsencrypt project broke up the cabal of certificate authorities by providing a recognized authority that could issue certificates to verified domain operators without a transaction cost. Secondly, the letsencrypt project and the EFF collaborated on certbot to provide a fully featured utility for requesting, issuing, and, updating certificates. And, thirdly, the openssl project has been getting a lot more external attention due to recent vulnerabilities being reported in a much more trendy fashion.

With all of that, its still a challenge to incant the right set of parameters in your nginx configuration. There are a variety of resources available. When I first began I was using cipherli.st as my sole reference. This was due to the explanation provided by the original author for each parameter. However, this list appears not to be curated lately, with several issues in the repository and no commits for many months. Selfishly I want to come up with my list of resources, as well as a curated rational for each parameter.

This will be a curated list of how I am deploying TLS via nginx on my the systems I touch, it will be updated as I continue deployments

My deployments are all delightfully ArchLinux these days, with a curated minimum path to server deployment documented here. The most referenced documentation is currently with Mozilla and is accompanied with both a configuration generator and scanning tool.

This is currently written for nginx 1.13.5 and openssl 1.1.0f, 1.1.1 has hit and TLS1.3 is out… I’m waiting to see some discussion in this ticket.

Here it is in block form:

    ssl_certificate         /etc/letsencrypt/live/domain.name/fullchain.pem;
    ssl_certificate_key     /etc/letsencrypt/live/domain.name/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/domain.name/chain.pem;

    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:64m;
    ssl_session_tickets off;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';

    add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload";
    ssl_stapling on;
    ssl_stapling_verify on;

    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy no-referrer-when-downgrade;
    add_header Content-Security-Policy "default-src https:";

    resolver 1.1.1.1 1.0.0.1;
    resolver_timeout 5s;

List of decisions about what was added, or chosen to not be added:

why not tlsv1.3

It’s not turned on in Firefox-Nightly yet. Cloudflare also has a good write up about tlsv1.3 deployment as well. This is largely moot now that OpenSSL 1.1.1 has hit and TLS1.3 is out… I’m waiting to see some discussion in this ticket.

Why add several headers?

This resource is awesome and has several suggestions:

  • The X-Content-Type-Options header stops a browser from trying to MIME-sniff the content type and forces it to stick with the declared content-type.
  • The X-Frame-Options header tells the browser whether you want to allow your site to be framed or not, we choose deny as cipherlist recommends.
  • The X-XSS-Protection header sets the configuration for the cross-site scripting filter built into most browsers.
  • The Referrer-Policy header configures how much information is sent to a site about the origin the user came from.
  • The Content-Security-Policy header defines approved sources of content that the browser may load. It can be an effective countermeasure to Cross Site Scripting (XSS) attacks. These policies are hard to write and I currently just build from the default of all resources must be loaded over https.

Why is that use an internet resolver?

This report of vulnerabilities related to using allowing someone to send spoofed DNS replies to poison the resolver cache, causing nginx to proxy http requests to an arbitrary upstream server chosen by the attacker. I’ve only seen this singular report, with many many resources suggesting using internet resolver. I did see this ticket opened and closed without resolution, so it’s something I’m wary of but am still following the ‘swarm’ so to speak.

Why not Use ECDSA with Letsencrypt via certbot?

It is actually possible to use ECDSA with certbot at this time via the --csr parameter, but by using a custom CSR you disable many of certbot’s features, specifically auto-renewal. As the letsencrypt certificates are expiring every 59.9 days, auto-renewal is a critical feature for maintainability.

Let’s Encrypt will sign ECDSA certificates (P-256 or P-384, maybe P-521 soon) but the intermediates and roots are RSA. There may be support for ECDSA signing, I’ve been watching this pull request.

Why not specify ssl_dhparams?

Another trendy vulnerability is logjam which preys upon built-in insecure DH parameter groups. The advisory suggests that each deployment should generate a strong DH group. However as we’re using all ECDH ciphers we don’t ever allow the handshake to use DHE, thus our instance never looks for the ssl_dhparam file as DHE is never used.

Why not specify ssl_ecdh_curve?

Why not include ssl_ecdh_curve like what is mentioned in cipherli.st. A combination of these two comments that ultimately say the specification in config of the ssl_ecdh_curve constrains the server’s certificate, where OpenSSL must be able to choose the ECDHE curve based on the intersection of the server and client supported curves list. At the time of this writing by not specifying you are able to achieve x25519, secp256r1, secp521r1, secp384r1 by letting nginx and OpenSSL negotiate on their own.

Why not use HPKP?

This would manifest as add_header Public-Key-Pins, which you can find examples online for doing with LetsEncrypt, but turns out its pretty scary.

Originally I had stumbled across this guide and opted to use his method, but upon review found this post by a significantly reputable community member and decided that it wasn’t currently worth the risk. There is interest in the community for these features, so I’ll keep my eyes out.

What other resources did you use in your evaluation?

Special thanks to the #letsencrypt channel on freenode, several people responded to my inquiries over the last couple months on this topic. Specifically Peng who is an incredibly valuable resource and responds to everyone in kind.

I’ll also include a template for a basic nginx server directive that I use as a crib:

server {
    listen 80;
    server_name domain.name;

    access_log /var/log/nginx/domain.name.access.log;
    error_log /var/log/nginx/domain.name.error.log;

    location '/.well-known/acme-challenge' {
        root /srv/http/letsencrypt;
        default_type "text/plain";
        try_files $uri =404;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}
server {
    listen 443 ssl http2;
    server_name  domain.name;

    access_log /var/log/nginx/domain.name.access.log;
    error_log /var/log/nginx/domain.name.error.log;

    ssl_certificate         /etc/letsencrypt/live/domain.name/fullchain.pem;
    ssl_certificate_key     /etc/letsencrypt/live/domain.name/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/domain.name/chain.pem;

    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:64m;
    ssl_session_tickets off;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';

    add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload";
    ssl_stapling on;
    ssl_stapling_verify on;

    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy no-referrer-when-downgrade;
    add_header Content-Security-Policy "default-src https:";

    resolver 1.1.1.1 1.0.0.1;
    resolver_timeout 5s;

    root /srv/http/domain.name;
    index index.html;

    location ~ /\.git {
      deny all;
    }
}