The goals of this article are to explain:

  1. The big picture of how certificates work
  2. How to setup HTTPS locally for your Rails application, in development mode

Overview

When you request a webpage over HTTPS, your browser receives the server certificate of that website and makes sure:

  • the certificate is not outdated
  • the certificate covers the requested domain (a certificate that covers *.google.com cannot be used to access twitter.com)
  • the certificate is signed by a trusted certificate authority

What is an SSL certificate?

An SSL certificate is a file that identifies an entity (a person, an organization, ...). This file contains the following information:

  • The domain name that the certificate was issued for
  • Which person, organization, or device it was issued to
  • Which certificate authority issued it
  • The certificate authority's digital signature
  • Associated subdomains
  • Issue date of the certificate
  • Expiration date of the certificate
  • The public key (the private key is kept secret)

Source: https://www.cloudflare.com/fr-fr/learning/ssl/what-is-an-ssl-certificate/

There are several formats for SSL certificates. The most commonly used is X.509.

Chain of trust

In theory

- Bob trusts Alice
- Alice trusts Elon
- Therefore Bob trusts Elon

Or:

- You trust Mozilla/Firefox
- Mozilla/Firefox trusts Let's Encrypt
- Therefore you trust Let's Encrypt
- Let's Encrypt trusts https://younes.codes
- Therefore you trust https://younes.codes

In practice

The chain of trust can be seen as trees with three different types of nodes/certificates:

  • Root certificates: they are the top nodes. They are issued by big, globally trusted companies also called Certificate Authorities (CA).
  • Server certificates: they are at the end of the chain of trust (leaf nodes) and serve an end-user purpose if you will (for instance making a website accessible through HTTPS)
  • Intermediate certificates: they are the linking nodes between root certificates down to server certificates. There are usually several intermediate certificates between a root certificate and a server certificate.

Chain of trust illustration

All web browsers come with a built-in list of root certificates that are chosen by the editor of each browser. Installing and using a browser (ex: Firefox) on your computer means you are trusting the editor (ex: Mozilla) of this web browser, ** therefore you are trusting the CAs that are trusted by that editor** as well as all certificates that are signed by those CAs.

When you visit a website, your browser receives the server certificate and goes up the certification tree checking the signatures of all certificates looking for a trusted root certificate. If found (aka if the root certificate is in the browser's built-in list that we talked about earlier), then it displays the green padlock and allows you to navigate the website. If not, it warns you that the server certificate is not to be trusted and suggests you do not go any further.

What is the technical difference between root certificates and other certificates?

A root certificate is self-signed, intermediate and server certificates are not.

How does the signing of certificates work?

Let's say I want a server certificate for the domain younes.codes that I own. The CA who will sign my certificate is Let's Encrypt.

  1. I create a Certificate Signing Request (CSR) for the domain younes.codes
  2. I send the CSR to the CA who is supposed to sign it (Let's Encrypt) and comply with their identity/ownership verification procedure (see below what it looks like for Let's Encrypt)
  3. Given I successfully complied with the CA's requirements, they sign the CSR with their CA's private key. Signing it means they create a signed server certificate out of it. The CA then sends me back the signed server certificate.
  4. I can now use my server certificate to make my website accessible over HTTPS.

Let's Encrypt ownership verification procedure: In the case of Let's Encrypt, there's no identity check, just a domain ownership check. They send you a random string and ask you to add it as a TXT entry to the DNS of the domain you want a server certificate for. They then check whether or not you were able to actually add it. Having the right credentials to do so means you're its owner/administrator, which means you have the right to request a certificate for it.

Enough theory, let's get down to practice

A big thank you to Jeffrey Walton for his stackoverflow answer that was of great help! The following is greatly inspired by his contribution.

Create your own CA

As we saw earlier, it comes down to creating a self-signed X.509 certificate.

Create a configuration file called openssl-ca.cnf with the following content. If you know what you're going, you can obviously change the configuration to meet your specific needs:

HOME            = .
RANDFILE        = $ENV::HOME/.rnd

####################################################################
[ ca ]
default_ca    = CA_default      # The default ca section

[ CA_default ]

default_days     = 1000         # How long to certify for
default_crl_days = 30           # How long before next CRL
default_md       = sha256       # Use public key default MD
preserve         = no           # Keep passed DN ordering

x509_extensions = ca_extensions # The extensions to add to the cert

email_in_dn     = no            # Don't concat the email in the DN
copy_extensions = copy          # Required to copy SANs from CSR to cert

base_dir      = .
certificate   = $base_dir/cacert.pem   # The CA certifcate
private_key   = $base_dir/cakey.pem    # The CA private key
new_certs_dir = $base_dir              # Location for new certs after signing
database      = $base_dir/index.txt    # Database index file
serial        = $base_dir/serial.txt   # The current serial number

unique_subject = no  # Set to 'no' to allow creation of
                     # several certificates with same subject.

####################################################################
[ req ]
default_bits       = 4096
default_keyfile    = cakey.pem
distinguished_name = ca_distinguished_name
x509_extensions    = ca_extensions
string_mask        = utf8only

####################################################################
[ ca_distinguished_name ]
countryName         = Country Name (2 letter code)
countryName_default = US

stateOrProvinceName         = State or Province Name (full name)
stateOrProvinceName_default = Maryland

localityName                = Locality Name (eg, city)
localityName_default        = Baltimore

organizationName            = Organization Name (eg, company)
organizationName_default    = Test CA, Limited

organizationalUnitName         = Organizational Unit (eg, division)
organizationalUnitName_default = Server Research Department

commonName         = Common Name (e.g. server FQDN or YOUR name)
commonName_default = Test CA

emailAddress         = Email Address
emailAddress_default = test@example.com

####################################################################
[ ca_extensions ]

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

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

####################################################################
[ signing_req ]
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints       = CA:FALSE
keyUsage               = digitalSignature, keyEncipherment

Now execute the following command and when prompted, you can accept the default values that actually come from the config file we just created.

Beware -nodes stands for No DES, which means OpenSSL will not encrypt the generated private key of your newly created CA. This is not secure and only serves the purpose of not complexifying this article.

$ openssl req -x509 -config openssl-ca.cnf -newkey rsa:4096 -sha256 -nodes -out cacert.pem -outform PEM

This command created two files:

  • cacert.pem: root certificate. This file identifies your CA. We will need to import it to your browser's trusted root certificates store before it has any value.
  • cakey.pem : private key of that root certificate. This file will be used to sign other certificates that will become trusted by your CA. The same way you should never share a personal password, this file should never be shared with anyone because anyone who has it can pretend to be you (basic asymmetric cryptography knowledge).

Import your private CA to the root store of your browser

Please note that it would also be possible to import it at the operating system level. I strongly advise against this because it considerably increases security risks.

Here's how you would import it in Firefox:

  • Menu: Edit > Preferences > Privacy & Security.
  • Find the Certificates section and click on View certificates. Click on the Authorities tab and click the Import... button to select the cacert.pem file we just created. If asked for trust settings, I suggest you select This certificate can identify websites only.

That's it.

Create your server certificate

Choose a domain

First, let's decide what domain we want to cover with the new SSL certificate. Personally, I chose dev.localhost for two reasons:

  1. It makes use of localhost which points to 127.0.0.1 without needing to edit /etc/hosts
  2. The dev. part adds meaning compared to just bare localhost

That being said, you really can call it anything, just make sure /etc/hosts reflects your choice if need be.

Avoid domains you don't own...

I strongly suggest you do not use lvh.me, vcap.me or anything like these.
Like many people, I used to use lvh.me and used to believe that it was some kind of built-in alias for localhost. It's not. It's an actual domain that is owned by someone out there. It's just that they decided to make it point to 127.0.0.1, but there's no guaranty it will remain as such. Also, you need to make that external DNS request before your local setup works.

$ whois lvh.me
Domain Name: LVH.ME
Registrar: GoDaddy.com, LLC
Registrant Organization: Domains By Proxy, LLC
Registrant State/Province: Arizona
Registrant Country: US
Name Server: NS35.DOMAINCONTROL.COM
Name Server: NS36.DOMAINCONTROL.COM
[...]

$ dig +noall +answer lvh.me
lvh.me.			1800	IN	A	127.0.0.1

I strongly suggest you stick to a domain name you own. For me, dev.localhost seems perfect.

Create the SSL certificate for that domain

One cannot directly create a server certificate. You first create a Certificate Signature Request (CSR) and hand it to the CA. It's the CA who will generate a server certificate out of it.

Create a CSR for your server certificate (and its corresponding private key)

Let's now create another configuration file called openssl-server.cnf with the following content:

HOME            = .
RANDFILE        = $ENV::HOME/.rnd

####################################################################
[ req ]
default_bits       = 2048
default_keyfile    = serverkey.pem
distinguished_name = server_distinguished_name
req_extensions     = server_req_extensions
string_mask        = utf8only

####################################################################
[ server_distinguished_name ]
countryName         = Country Name (2 letter code)
countryName_default = US

stateOrProvinceName         = State or Province Name (full name)
stateOrProvinceName_default = MD

localityName         = Locality Name (eg, city)
localityName_default = Baltimore

organizationName            = Organization Name (eg, company)
organizationName_default    = Test Server, Limited

commonName           = Common Name (e.g. server FQDN or YOUR name)
commonName_default   = Test Server

emailAddress         = Email Address
emailAddress_default = test@example.com

####################################################################
[ server_req_extensions ]

subjectKeyIdentifier = hash
basicConstraints     = CA:FALSE
keyUsage             = digitalSignature, keyEncipherment
subjectAltName       = @alternate_names
nsComment            = "OpenSSL Generated Certificate"

####################################################################
[ alternate_names ]

DNS.1  = localhost
DNS.2  = dev.localhost

# IPv4 localhost
IP.1     = 127.0.0.1

# IPv6 localhost
IP.2     = ::1

Make sure the [ alternate_names ] section reflects your domain choice.

Then execute the following command:

$ openssl req -config openssl-server.cnf -newkey rsa:2048 -sha256 -nodes -out servercert.csr -outform PEM

With this command, you have created these two files:

  • servercert.csr: The certificate signature request that will be signed by the CA and which will then produce a server certificate.
  • serverkey.pem : The private key of the server certificate to come.

Sign your server's CSR with your CA private key

We're almost done. At this point, you need to create two files and run the next command

$ touch index.txt
$ echo '01' > serial.txt
$ openssl ca -config openssl-ca.cnf -policy signing_policy -extensions signing_req -out servercert.pem -infiles servercert.csr

Tadaa!, You now have your signed server certificate in servercert.pem.

Make your Rails app accessible via HTTPS

Okay so we created a CA:

  • cacert.pem: Root certificate, already added to your browser.
  • cakey.pem: Private key of the CA

And we have a server certificate:

  • servercert.pem: Server certificate (valid only in the browser that trusts our CA)
  • serverkey.pem: Private key of the server certificate

We now have to decide which HTTP server is going to handle the SSL part: puma or any other (nginx, apache, ...). Since there are many tutorials about setting up TLS/SSL for nginx, let's pick puma which, by the way, will simplify things for the Ruby on Rails developer that you are. Again, we're talking development environment, not production!

So the goal here is to make Puma accept TLS/SSL connections on the port of your choice (I chose 9292). For it to work, we will need to provide the path to both server certificate (servercert.pem) and server private key (serverkey.pem).

I wrote the following snippet with two approaches:

  1. The hardcoded way: copy the two required files in Rails.root.join('config', 'ssl'). Easier for everyday use but you must .gitignore this directory.
  2. The ENV way: tell puma the port of your choice and the paths to the required files using PORT, SSL_SERVER_KEY_PATH and SSL_SERVER_CERT_PATH environment variables.

Put this at the end of config/puma.rb:

if ENV.fetch("RAILS_ENV", "development") == "development"
  ssl_bind(
    '127.0.0.1',
    ENV.fetch('PORT', '9292'),
    {
      key: ENV.fetch("SSL_SERVER_KEY_PATH", 'config/ssl/serverkey.pem'),
      cert: ENV.fetch("SSL_SERVER_CERT_PATH", 'config/ssl/servercert.pem'),
      verify_mode: 'none'
    }
  )
end

You can now run your rails app (bundle exec rails server) and access it through:

  • http://localhost:3000
  • https://localhost:9292
  • http://dev.localhost:3000
  • https://dev.localhost:9292

One last thing to keep in mind: if you ever set config.force_ssl = true and toggle it back to false later, you'll need to clear your browser cache before being able to access your app on HTTP again.

That's it for this topic.

Thank you for reading!
Younes SERRAJ