pointers are complicated: clarify that this post is not about exploring the trade-off
[web.git] / ralf / _posts / 2017-12-26-lets-encrypt.md
1 ---
2 title: Let's Encrypt Tiny
3 categories: sysadmin
4 ---
5
6 I think all HTTP communication on the internet should be encrypted -- and thanks
7 to [Let's Encrypt](https://letsencrypt.org/), we are now much closer to this
8 goal than we were two years ago.  However, when I set up Let's Encrypt on my
9 server (which is more than a year ago by now), I was not very happy with the
10 official client: The client manages multiple certificates with different sets of
11 domains per certificate, but I found it entirely unclear which commands would
12 replace existing certificates or create a new one.  Moreover, I have some
13 special needs: I've set up DNSSEC with TLSA records containing hashes of my
14 certificates, so replacing a certificate has to also update DNS and deal with
15 the fact that DNS entries get cached.  Lucky enough, Let's Encrypt is based on
16 open standards, so I was not forced to use their client!
17
18 To make a long story short, I decided to write my own Let's Encrypt client,
19 which I describe in this post.
20
21 <!-- MORE -->
22
23 ## Let's Encrypt Tiny
24
25 The client is based on [acme-tiny](https://github.com/diafygi/acme-tiny), a
26 beautifully small Python library (<200 lines) speaking the ACME protocol.
27 That's the protocol developed by Let's Encrypt to communicate with an automated
28 CA.  I duly called my client "Let's Encrypt Tiny", and with less than 250 lines
29 I think that name is still fair.  For now, Let's Encrypt Tiny resides in my
30 [server-scripts](https://github.com/RalfJung/server-scripts) repository, and it
31 will stay there until anyone else has an interesting in using it. ;)
32
33 **Update:** Let's Encrypt Tiny now has its
34 [own repository](https://github.com/RalfJung/lets-encrypt-tiny). **/Update**
35
36 The central concept of Let's Encrypt Tiny is a "certificate line" -- a sequence
37 of certificates, possibly for different private keys, that "belong together" in
38 the sense that each is considered (by their owner) the successor of the
39 previous.  Services like apache are configured to use a particular certificate
40 line for a particular domain (well, in my case, it's the same line for all
41 domains, but one could imagine different setups).  Each certificate line has a
42 separate config file, whose most important job is to configure the set of
43 domains in the latest certificate of the line.  The key operations on a
44 certificate line are to create a *fresh* key and obtain a certificate for it,
45 and to obtain a new certificate for the *existing* key.  Let's Encrypt
46 certificates expire after 90 days, so we have to perform renewal at least that
47 often, but there is no reason to always generate a fresh private key.  One
48 reason to keep the previous key is that the TLSA record in DNS can be configured
49 to be a hash of just the key, so a renewal for an existing key can be done
50 without changing DNS.
51
52 The other important concept in Let's Encrypt Tiny is the idea of a "staging
53 key", as opposed to the "live key".  This is needed to properly support
54 DNSSEC+TLSA.  Just briefly, the idea of TLSA is to not use certificate
55 authorities to determine the correct certificate for a domain (CAs have proven
56 untrustworthy again and again, and a single failing CA undermines the security
57 of the entire system), but instead use DNS.  If DNS is secured with DNSSEC --
58 which is much more resilient against a single entity failing than the CA system
59 -- then we can just put a hash of the certificate, or a hash of the public key,
60 into the DNS, side-stepping the entire CA system.  Unfortunately, TLSA has not
61 seen widespread adoption, though a
62 [Firefox extension](https://addons.mozilla.org/en-US/firefox/addon/dnssec-validator/)
63 is available (and extensions for some other browsers as well).  Still, I like
64 this technology, so I have deployed it on my server.
65
66 However, one consequence of TLSA records is that a freshly generated key cannot
67 immediately be used (i.e., by the web server): The DNS still contains the old
68 key's data, and that data gets cached!  In Let's Encrypt Tiny, this key first
69 gets "staged".  Next, we have to update the DNS zone to contain TLSA records for
70 *both* the old and the new key.  Then we have to wait until the TTL
71 (time-to-live) of that record passes, to make sure that no caches still contain
72 *only* the old key.  Finally, we "unstage" the key so all the servers (web
73 server, jabber server, and so on) to use the new certificate and key, which are
74 now "live".
75
76 ## Configuration
77
78 Let's look at an example: Here is the configuration file for this server,
79 ralfj.de, with comments explaining the purpose of the various options:
80
81 ```
82 # The domains currently making up this certificate line:
83 domains =
84   ralfj.de
85   www.ralfj.de
86   lists.ralfj.de
87   git.ralfj.de
88   ns.ralfj.de
89   ipv4.ns.ralfj.de
90   ipv6.ns.ralfj.de
91   jabber.ralfj.de
92   conference.jabber.ralfj.de
93 # ... this list goes on.
94
95 # The size of the RSA secret key (in bits).
96 key-length = 4096
97
98 [timing]
99 # Max. age of the private key before we generate a new one.
100 max-key-age-days = 256
101 # How long a new private key is "staged" before it is used as the live key.
102 # 0 disables staging.
103 staging-hours = 0
104 # How many days before the certificate expires should be request a new one?
105 renew-cert-before-expiry-days = 15
106
107 [hooks]
108 # Script to execute after the certificate changed.
109 post-certchange = /root/letsencrypt/cert-hook
110 # Script to execute after the key changed.  Only needed when using DNSSEC+TLSA.
111 #post-keychange = /root/letsencrypt/key-hook
112
113 [acme]
114 # File storing the ACME account private key (created if missing).
115 account-key = /etc/ssl/private/letsencrypt/account.key
116 # Directory to put the ACME challenges into.  Must be mapped to
117 # /.well-known/acme-challenge on all domains listed above.  For example, in
118 # apache, put the following directive somewhere global:
119 #   Alias /.well-known/acme-challenge/ /srv/acme-challenge/
120 challenge-dir = /srv/acme-challenge/
121
122 [dirs]
123 # Directory for certificates.
124 certs = /etc/ssl/mycerts/letsencrypt
125 # Directory for private keys.
126 keys = /etc/ssl/private/letsencrypt
127 # A place to put old certificates and keys.
128 backups = /etc/ssl/old/letsencrypt
129
130 [files]
131 # Filename prefix (without extension) for the live key and certificate.
132 live = live
133 # Filename prefix (without extension) for the staging key and certificate.
134 staging = staging
135 ```
136
137 With this configuration, Let's Encrypt Tiny creates files
138 `/etc/ssl/mycerts/letsencrypt/live.crt` and
139 `/etc/ssl/private/letsencrypt/live.key`.  These files are always the "tip" of
140 the certificate line and should be configured in the various servers -- however,
141 most servers will need these files to be massaged a bit.  First of all, we also
142 need a key chain, and the intermediate CA used by Let's Encrypt actually changes
143 over time.  Moreover, some servers want certificate and key in one file, while
144 others want the certificates to be bundled with the keychain and expect the
145 private key in a separate file.  Sometimes, the Diffie-Hellman parameters are
146 also expected in the same file as the certificate -- every SSL-supporting server
147 seems to handle this slightly differently.
148
149 This is all handled by the certificate hook, which creates the various derived files:
150 ```
151 #!/bin/sh
152 cd /etc/ssl/mycerts/letsencrypt
153 export PATH="/usr/sbin/:/sbin/:$PATH"
154
155 # Determine the intermediate CA used by this certificate.  We expect the
156 # intermediate certificates to be stored in files in /etc/ssl/chains/,
157 # e.g. /etc/ssl/chains/letsencrypt-X3.crt.
158 ISSUER=$(openssl x509 -issuer -in live.crt -noout | sed 's/.*Authority \(X[0-9]\+\).*/\1/')
159 ISSUER_FILE=/etc/ssl/chains/letsencrypt-"$ISSUER".crt
160 if ! [ -f "$ISSUER_FILE" ]; then
161     echo "Cannot find certificate for issuer $(openssl x509 -issuer -in live.crt -noout)"
162     exit 1
163 fi
164
165 # Create derived files.  We expect /etc/ssl/dh2048.pem to contain the DH
166 # parameters, generated with
167 #   openssl dhparam -out /etc/ssl/dh2048.pem 2048
168 cat "$ISSUER_FILE" > live.chain # just the chain
169 cat live.crt /etc/ssl/dh2048.pem > live.crt+dh # Certificate plus DH parameters
170 cat live.crt live.chain > live.crt+chain # Certificate plus chain
171
172 # Fill in here:  the code to restart/reload all relevant services.
173 ```
174
175 With this, the apache SSL configuration looks as follows:
176
177 ```
178 # Certificate, Key, and DH parameters
179 SSLCertificateFile      /etc/ssl/mycerts/live.crt+dh
180 SSLCertificateKeyFile   /etc/ssl/private/live.key
181 SSLCertificateChainFile /etc/ssl/mycerts/live.chain
182
183 # configure SSL ciphers and protocols
184 SSLProtocol All -SSLv2 -SSLv3
185 # TODO: Once OpenSSL supports GMC with more than just AES, revisit this
186 # NOTE: The reason we support non-FS ciphers is stupid middleboxes that don't support FS
187 SSLCipherSuite 'kEECDH+AESGCM:kEDH+AESGCM:kEECDH:kEDH:AESGCM:ALL:!3DES:!EXPORT:!LOW:!MEDIUM:!aNULL:!eNULL'
188 SSLHonorCipherOrder     on      
189 ```
190
191 (My cipher suite is deliberately not the one from
192 [bettercrypto.org](https://bettercrypto.org) because I prefer to not update it
193 with every change in OpenSSL's supported ciphers.)
194
195 ## Obtaining the First Certificate
196
197 You can now run `letsencrypt-tiny -c letsencrypt.conf init` to perform the
198 initial setup.
199
200 In the future, to change the set of domains, first edit the config file and then
201 run `letsencrypt-tiny -c letsencrypt.conf -k renew`.  The `-k` tells Let's
202 Encrypt Tiny to also run the certificate hook.
203
204 ## Automation Via Cron
205
206 Let's Encrypt certificates expire after 90 days, so we want renewal to be
207 automated.  To this end, just make sure that `letsencrypt-tiny -c
208 letsencrypt.conf -k cron` gets run regularly, like once a day.  I have the
209 following in root's crontab (`sudo crontab -e`):
210
211 ```
212 32  6  *   *   *     /root/server-scripts/letsencrypt-tiny -c /root/letsencrypt/conf -k cron
213 ```
214
215 This will check the time intervals you configured above, and act accordingly.
216 If any action is taken, the script will print that information on standard
217 output; if you have email set up on your server, this means you will get an
218 email notification.
219
220 ## DNSSEC and TLSA
221
222 Everything described so far should give you a working SSL setup if you do not
223 use DNSSEC+TLSA.  If you *do* use DNSSEC+TLSA, like I do on this server, you
224 need to enable the `post-keychange` hook and have it regenerate your DNS zone,
225 and you need to increase `staging-hours`.  The zone should always contain the
226 hash of the live key, and, if a staging key exists, also the hash of the staging
227 key.
228
229 I am managing my DNS zones with
230 [zonemaker](https://www.ralfj.de/projects/zonemaker/), and wrote some Python
231 code to automatically generate TLSA records using the `tlsa` tool:
232
233 ```
234 def TLSA_from_crt(protocol, port, crtfile):
235     crtfile = "/etc/ssl/mycerts/"+crtfile
236     open(crtfile).close() # check if the file exists
237     # make sure we match on *the key only*, so that we can renew the certificate without harm
238     zone_line = subprocess.check_output(["tlsa", "--selector", str(TLSA.Selector.SubjectPublicKeyInfo), "--certificate", crtfile, "example.org"]).decode("utf-8")
239     m = re.match("^[0-9a-zA-Z_.-]+ IN TLSA ([0-9]+) ([0-9]+) ([0-9]+) ([0-9a-zA-Z]+)$", zone_line)
240     assert m is not None
241     assert int(m.group(1)) == TLSA.Usage.EndEntity
242     assert int(m.group(2)) == TLSA.Selector.SubjectPublicKeyInfo
243     return TLSA(protocol, port, TLSA.Usage.EndEntity, TLSA.Selector.SubjectPublicKeyInfo, int(m.group(3)), m.group(4))
244
245 def TLSA_for_LE(protocol = Protocol.TCP, port = 443):
246     # add both the live and (potentially) staging certificate to the letsencrypt TLSA record set
247     r = [TLSA_from_crt(protocol, port, "letsencrypt/live.crt")]
248     try:
249         r.append(TLSA_from_crt(protocol, port, "letsencrypt/staging.crt"))
250     except IOError:
251         pass
252     return r
253 ```
254
255 Now I add `TLSA_for_LE(port = 443)` to the records of my domains.  Finally, the
256 key hook just runs zonemaker and has bind reload the zone (it will automatically
257 also be resigned).  Now, whenever a staging key is created, it is automatically
258 added to my zone.  At least 25h later (I have the TTL set to 24h), the key gets
259 unstaged, and the old TLSA record is removed from the zone.
260
261 That's it!  If you have any questions, feel free to report
262 [issues at GitHub](https://github.com/RalfJung/lets-encrypt-tiny/issues).