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