The basic HTTP protocol sends requests and responses in plain text, which anyone in the middle can read. This is problematic if we ever want to send sensitive information, such as email addresses, passwords, or authenticated session tokens. Thankfully we can encrypt all communication between a web client and server by using Transport Layer Security (TLS). This is what your browser uses when you request a URL that start with https://
.
How TLS Works
TLS uses a combination of symmetric and asymmetric encryption. If you don't remember what those terms mean, here's a brief refresher:
- Symmetric encryption uses one key to both encrypt and decrypt. That "key" is actually a large cryptographically-random array of bits (currently 128 or 256 bits). Symmetric algorithms are quite fast and memory-efficient on today's hardware, but intractable to crack in a reasonable amount of time if you don't know the key.
- Asymmetric encryption uses a pair of mathematically-related keys, one that must be kept private and secure (the private key) and one that can be shared freely with the public (the public key). You can encrypt with either, but you can only decrypt with the other. That is, if you encrypt with the public key, you can only decrypt using the associated private key, and vice-versa. If Bob wants to send Alice an encrypted message, Bob encrypts the message using Alice's public key (which is freely shared), and then only Alice can decrypt it, because only Alice has the private key (which must be kept private and secure). Asymmetric encryption is much slower and memory-intensive than symmetric, so it's typically used for only small amounts of data.
Symmetric encryption is much more efficient, but it poses a significant problem: how do you share the symmetric key with the recipient across an insecure network without it being intercepted? If an attacker-in-the-middle is able to intercept the symmetric key, the attacker could decrypt all the traffic being exchanged.
This is where asymmetric encryption can help. We can do the following:
- use asymmetric encryption to share a secret between the client and server that only the server can read;
- use that secret to generate a new symmetric key;
- and then switch to symmetric encryption for the rest of the conversation.
Here's a graphical representation of the flow, which comes from CloudFlare's excellent article, Keyless SSL: The Nitty Gritty Teachnical Details
source: CloudFlare, Keyless SSL: The Nitty Gritty Teachnical Details
The client gets the server's public key from the server's certificate (which I will explain in more detail in the next section). After some negotiation regarding which cipher suite to use, the client then generates a "pre-master secret" and encrypts it using the server's public key. Since the server is the only agent that possesses the associated private key, only the server can read this pre-master secret. The client sends that encrypted pre-master secret to the server, the server decrypts it, and then both use the pre-master secret to calculate a new "shared secret," which will be the symmetric encryption key they use for the rest of the conversation.
The algorithm for generating the shared secret is known as Diffie-Hellman. It uses some cool math so that both the client and server can arrive at the same shared secret without ever sending that secret across the wire. This adds some forward-secrecy: if an attacker recorded the handshake traffic, and was somehow able to decrypt it later (e.g., manages to steal the server's private key), the attacker still couldn't read the symmetrically-encrypted conversation, as the shared secret was never transmitted.
Digital Certificates
As I noted above, the client gets the server's public key from the server's certificate. If you want to sound cool and in-the-know, you refer to this as a "cert." The cert is just a file that is digitally-signed by a trusted organization known as a Certificate Authority (CA). The file contains information about the web site (most importantly, the domain name), as well as the server's public key.
As you might remember, the recipient of a digitally-signed document can verify that the document hasn't been modified since it was signed. The "signature" is actually an asymmetrically-encrypted hash of the document at the moment of signing. The signer hashes the document, encrypts the hash with the signer's private key, and adds the encrypted hash to the document. Any receiver can then decrypt the signature using the signer's public key, rehash the document, and compare the two hashes. If the hashes are different, the recipient knows the document was either modified, or signed by someone else.
The digital signature lets your browser verify that the cert hasn't been modified since it was signed, but since anyone can generate signing keys and a digital signature, how does your browser know that it should trust the signer? Couldn't someone just generate a cert claiming their server is facebook.com and sign the cert themselves? How does your browser distinguish between that and the legitimate facebook.com?
The answer is that your computer's operating system ships with a set of "root certs," which are certs for the CAs your computer trusts implicitly. Your computer will also trust any cert signed by one of those root CAs, as the trusted CA is vouching for the legitimacy of the other cert holder. CAs can also sign certs with special signing permissions for other CAs, who can then sign certs for web sites. This creates a hierarchical chain of trust: if the client follows the chain of signatures backwards and eventually reaches one of the trusted roots, the certificate will be trusted.
After the client validates that the cert hasn't been modified and that it was signed by a trusted CA, the client will then verify that the domain name in the cert matches the domain name the client thinks it's talking to. If an attacker-in-the-middle intercepted your handshake with facebook.com and returned a valid cert for a domain the attacker controlled, your browser would still reject it, as the domain name in the cert won't match the domain it thinks it's talking to.
Obtaining Certificates from Let's Encrypt
Given all of that, it should now be clear that supporting HTTPS requires three things:
- A domain name pointing to your server's IP address;
- A certificate, signed by a CA, containing your server's domain name and public key, which your server freely shares with all clients;
- An associated private key that is kept on the server and never revealed.
Obtaining the first involves registering a domain name. Obtaining the second and third can be obtained for free using Let's Encrypt.
Register and Host a Domain Name
You can obtain a domain name from any domain registrar. My current favorite is Hover, though Namecheap is also popular, and currently offers free domain names for students.
Once you have your domain name, you can host that domain name with DigitalOcean, pointing it at one of your running droplets. You can also define new sub-domains that point to different droplets: this enables you to have example.com
point to a droplet that hosts your web client, while api.example.com
points to a different droplet running your API server. If you ever need to scale out, you can point api.example.com
to a load balancer instead that distributes the requests amongst a group of droplets, each running a copy of your API server.
Run the Let's Encrypt Command on Ubuntu 16.04
After you register and host your domain name, you need to ssh
in to your droplets and run the letsencrypt
command. This command will start a web server listening on ports 80 and 443, so you need to open those ports on the firewall. To do that, use these commands while connected to your droplet via ssh
:
sudo ufw allow 80
sudo ufw allow 443
After the ports are open, you can run the letsencrypt
command. DigitalOcean used to include this command in the "Docker x.x.x-ce on Ubuntu 16.04" One-Click app image, but they seem to have removed it in more recent versions. You can verify whether it's already installed by trying to execute letsencrypt -h
. If you get an error saying that the command is not found or is not yet installed, you can install it easily using this command:
sudo apt update && apt install -y letsencrypt
Then run the command like this, replacing your-host-name.com
with the server's host name (e.g., example.com
or api.example.com
):
sudo letsencrypt certonly --standalone -d your-host-name.com
The command will prompt you for an email address (for expiry notifications) and to accept their terms of service. After you do that, it will start its own web server and communicate with the central Let's Encrypt servers. It will ensure that the domain name you passed as the -d
flag is pointing to the current server, after which it writes your server's cert and private key to the following files (replace your-host-name.com
with your server's host name):
/etc/letsencrypt/live/your-host-name.com/fullchain.pem
: your server's certificate, along with all the other intermediate and root CA certs./etc/letsencrypt/live/your-host-name.com/privkey.pem
: your server's private key.
Note that these files are actually symbolic links to files in the /etc/letsencrypt/archive
directory. This will matter when we start trying to read them using a Docker mapped volume. More details in the sections that follow.
Pro Tip: if you want to script the letsencrypt command, you can agree to the terms of service and supply your email address via command-line flags. Use this command, replacing
your-email-address
with your email address:sudo letsencrypt certonly --standalone -n # non-interative mode --agree-tos # agree to TOS --email your-email-address # provide email address -d your-host-name.com # host name
Run Let's Encrypt on Amazon Linux 2
If you are using an Amazon Linux 2 instance on AWS, the Let's Encrypt utility is named certbot
instead. Installing this utility requires a few more commands than on Ubuntu 16.04, but it's fairly straightforward. Connect to your VM using ssh
and then execute these commands:
# download the extra packages list for enterprise linux 7
sudo wget -r --no-parent -A 'epel-release-*.rpm' http://dl.fedoraproject.org/pub/epel/7/x86_64/Packages/e/
# install the extra packages list
sudo rpm -Uvh dl.fedoraproject.org/pub/epel/7/x86_64/Packages/e/epel-release-*.rpm
# enable the extra packages list
sudo yum-config-manager --enable epel*
# install the certbot package
sudo yum install -y certbot
Before you run the certbot
command, ensure that your VM's security group is configured to allow incoming connections on ports 80 and 443. Then run the certbot
command in standalone mode, providing your server's host name (e.g., example.com
or api.example.com
) in the -d
flag:
sudo certbot certonly --standalone -d your-host-name.com
The command will prompt you for an email address (for expiry notifications) and to accept their terms of service. After you do that, it will start its own web server and communicate with the central Let's Encrypt servers. It will ensure that the domain name you passed as the -d
flag is pointing to the current server, after which it writes your server's cert and private key to the following files (replace your-host-name.com
with your server's host name):
/etc/letsencrypt/live/your-host-name.com/fullchain.pem
: your server's certificate, along with all the other intermediate and root CA certs./etc/letsencrypt/live/your-host-name.com/privkey.pem
: your server's private key.
Note that these files are actually symbolic links to files in the /etc/letsencrypt/archive
directory. This will matter when we start trying to read them using a Docker mapped volume. More details in the sections that follow.
Pro Tip: if you want to script the certbot command, you can agree to the terms of service and supply your email address via command-line flags. Use this command, replacing
your-email-address
with your email address:sudo certbot certonly --standalone -n # non-interative mode --agree-tos # agree to TOS --email your-email-address # provide email address -d your-host-name.com # host name
Renewing Certs
Let's Encrypt certs are valid for only 90 days at a time. This is one of the ways in which they limit the risks associated with a completely automated certificate authority. Thankfully, they make it very easy to renew these certificates. To renew, stop any web server that is listening on port 443, and then run this command:
letsencrypt renew
On Amazon Linux 2, use certbot renew
instead.
It should only take a few seconds, after which it should print a message confirming the renewal. If you're using Docker to run your web server, you can limit your downtime with a simple bash script like this:
# temporarily stop your Docker container
docker stop container-id-or-name
# renew your cert (use 'certbot renew' on Amazon linux)
letsencrypt renew
# restart the stopped container
docker start container-id-or-name
The docker stop
command will temporarily stop your container, but it won't completely remove it. You can then restart it again using the docker start
command. Note that docker start
doesn't require all the switches you use with the docker run
command: those settings are still preserved in the stopped container instance, so all you need is docker start
to restart it.
To completely automate this, schedule a cron job that runs a script like the one above every 89 days.
Supporting HTTPS in Go
Go servers can use the cert and key generated by Let's Encrypt to support HTTPS connections. Instead of using http.ListenAndServe()
use http.ListenAndServeTLS()
, like so:
func main() {
addr := os.Getenv("ADDR")
if len(addr) == 0 {
addr = ":443"
}
//get the TLS key and cert paths from environment variables
//this allows us to use a self-signed cert/key during development
//and the Let's Encrypt cert/key in production
tlsKeyPath := os.Getenv("TLSKEY")
tlsCertPath := os.Getenv("TLSCERT")
mux := http.NewServeMux()
//...and handlers
//start the server
fmt.Printf("listening on %s...\n", addr)
log.Fatal(http.ListenAndServeTLS(addr, tlsCertPath, tlsKeyPath, mux))
}
With this code, your Go server will only support HTTPS requests, not HTTP requests. This is actually a good idea, as the API server will be called only by client-side code, and not by end-users typing URLs in the browser. Client-side code can ensure that the API URLs start with https://
, and this ensures that all communication is always encrypted.
If you run this Go server within a Docker container, you must give the container access to the directory containing the Let's Encrypt cert and key. Remember that Docker containers are entirely isolated from the host operating system, including its file system, so you must mount the let's encrypt directory into a mounted volume within the container. You also need to set these new TLSCERT
and TLSKEY
environment variables as you run the container. The command would look like this (replace your-host-name.com
with your host name):
export TLSCERT=/etc/letsencrypt/live/your-host-name.com/fullchain.pem
export TLSKEY=/etc/letsencrypt/live/your-host-name.com/privkey.pem
docker run -d \ #run as detached process
--name 344gateway \ #name for container instance
-p 443:443 \ #publish port 443
-v /etc/letsencrypt:/etc/letsencrypt:ro \ #mount /etc/letsencrypt as /etc/letsencrypt in the container, read-only
-e TLSCERT=$TLSCERT \ #forward TLSCERT env var into container
-e TLSKEY=$TLSKEY \ #forward TLSKEY env var into container
your-dockerhub-name/your-container-name #name of container image
Note that we are mounting the /etc/letsencrypt
directory as opposed to the /etc/letsencrypt/live/your-host-name.com/
directory, because the files in that latter directory are just symlinks to files in the /etc/letsencrypt/archive/
directory. If we mount the more specific sub-directory, your Docker container won't be able to follow the symlinks, and thus won't be able to load the files.
Supporting HTTPS in NGINX
If you are using NGINX to serve a static web site or a web application client, you can configure NGINX to use your new certificate and key for HTTPS connections. You can also configure it to automatically redirect HTTP requests to HTTPS. And to be extra-secure, you can enable HTTP Strict Transport Security (HSTS), which tells the browser to always use HTTPS when talking to your site, even if the user types in an HTTP URL.
The NGINX Docker container keeps its default configuration file at /etc/nginx/conf.d/default.conf
. You can get a copy of this file by executing these commands on your development machine:
docker run -d --name tmp-nginx nginx
docker cp tmp-nginx:/etc/nginx/conf.d/default.conf default.conf
docker rm -f tmp-nginx
This spins-up a new NGINX container, copies the /etc/nginx/conf.d/default.conf
inside the container to the current directory on your machine, and stops/removes the container.
Open the default.conf
file and notice that it contains one server {}
configuration block for HTTP. You need to modify it so that it looks like this, replacing your-host-name.com
with your host name:
server {
listen 80;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
server_name your-host-name.com; #REPLACE `your-host-name.com` with your host name!
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/your-host-name.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-host-name.com/privkey.pem;
# ...rest of default configuration...
}
This configuration tells NGINX to support HTTP connections on port 80, but automatically redirect those requests to the equivalent HTTPS URL. It also tells NGINX to support HTTPS connections on port 443, and tells NGINX where to find your certificate and private key.
For more details on NGINX configuration files, see their Beginner's Guide.
After you modify the default.conf
add this line to your Dockerfile
to replace the default.conf
as you build your web client docker container image:
ADD default.conf /etc/nginx/conf.d/default.conf
When you start the container you built, NGINX will use this modified configuration file. But as noted in the section above, your Docker container won't be able to read your certificate and private key files, as Docker containers are completely isolated and unable to read files on the host system. To allow your Docker container to read those files, you need to mount the /etc/letsencrypt
directory on your host machine as a volume in your Docker container:
docker run -d \ #run as detached process
--name 344client \ #name for container instance
-p 80:80 -p 443:443 \ #publish ports 80 and 443
-v /etc/letsencrypt:/etc/letsencrypt:ro \ #mount /etc/letsencrypt as /etc/letsencrypt in the container, read-only
your-dockerhub-name/your-container-name #name of container image
Self-Signed Certs
Your production servers will need real certs signed by a CA like Let's Encrypt, but on your development machine, one typically uses self-signed certs. These are valid but untrusted certs, as they are not signed by a CA your computer trusts. They are the equivalent of a Passport written in crayon, signed by Elmo. Or to use a more recent cultural reference, they are about as valid as McLovin's fake ID.
To create a self-signed cert and key, use this command:
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -subj "/CN=localhost" -keyout privkey.pem -out fullchain.pem
WINDOWS USERS: If you execute this command GitBash and it generates the error
Subject does not start with '/'
, change your subject parameter to be-sub "//CN=localhost"
. This occurs because GitBash tries to convert parameters it thinks are file paths into Windows-style paths for programs that weren't built to run under GitBash (which includes OpenSSL). Adding a second/
will override this behavior, resulting in the value you see in the original command. Or simply execute this command from a regular Windows command shell, and not GitBash. For more details see this StackOverflow answer for detailed explanation.
This will create two files in the current directory with the same names as the cert and key files generated by Let's Encrypt. You can use these files during development. Your browser will not trust the cert, and will show a nasty warning message when you make your first request to your development server, but you can tell the browser to make an exception and use the cert anyway.
Although the private key generated with this self-signed cert won't really be useful to an attacker, it's good practice to never add private TLS keys to your repo. They are like passwords and should be treated as such. Use your .gitignore
file to ignore the cert and private key files. If you use a lab machine or move between machines, use the command above to quickly regenerate a new self-signed cert and key.