Wild wild certs

TLS certificates, not so long ago still a gold mine for shady “trust” resellers, have become a commodity, since Let’s encrypt made it their mission to provide everyone with domain-validated certificates to secure their connections. There’s a plethora of useful tools to keep your certs up-to-date. I for my part favour the bash-only dehydrated and even wrote a tiny guide how to get it running on FreeBSD using privilege separation (i.e. not as root).

Now all was well and I deployed this everywhere until I stumbled over a project where users could create their own team-sites on demand, having their own subdomain. But because acme – the protocol behind Let’s encrypt – did not support wild card certs, I would (at least in theory) have created a different cert for each of these domains, or at least have their sub domain added to the SubjAltName list of the web server’s cert. Which is what I grudgingly did for a while: Identify the most active team sites and manually add them to the domain name list for my LE certs.

You can imagine how glad I was when I heard that acme v2 added support for wild card certs. Until I noticed that they require a new authentication method over DNS to provide them. What the hell were they smoking? In order to renew your wild card cert every 90 days, you would need to allow unattended updates to your name server from the server taking care of the renewals. To understand, what a stupid idea the is, look no further than at the proliferation of plugins of varying software quality now scripting DNS service updates for dehydrated.

In every remotely sane setup you would separate the services so that your web server wouldn’t need to know about its name server, let alone have access and credentials to update it. With the old acme http-01 protocol, the instance handling nearly every aspect of TLS anyway – in my case nginx – would be the only service involved, allowing for some neat separation: Only the .well-known directory would need to have a letsencrypt specific ownership.

A much less intrusive solution would have been to just require a static TXT record in your zone, stating that wild card certs are okay in principle (i.e. _acme.example.com TXT "wild card enable") and then have the acme server use the old http-01 mechanism to fetch challenge tokens from one or multiple random sub domains. This should be more than enough to proof that you control wild card dns.

However now we’re stuck with the less than optimal solution and I still needed a wild card cert. Since I do not directly control the name server, I needed to ask for a delegation. That meant that I would actually have to run a name server for this zone. On my web server. Great!

After shopping around for a while I’ve found tinydns, the authoritative name server from the djbdns bundle, to still be the least complicated server around. It sports its own very simple DNS record description language (that’s important, if you want to update and create it from simple shell scripts), is really, REALLY tiny and has an excellent security and performance track record. It basically pre-compiles every conceivable DNS response, stores it in an efficient constant single file database which can be atomically replaced while the server is running, meaning I didn’t have to meddle with permission to send signals to reload the zones.

Unfortunately, Dan Bernstein, tinydns’ author really likes you to run services the way he considers most convenient, using the daemon tools package sporting a steep learning curve. Since tinydns comes as a simple stand alone server, I just wrote a FreeBSD rc-script to start it as the FreeBSD gods have intended services to be started. Just place this file as /usr/local/etc/rc.d/tinydns, set execute (+x) permissions and enable it in rc.conf.

#!/bin/sh

#
# PROVIDES: tinydns
# REQUIRE: DAEMON cleanvar
# BEFORE: LOGIN
# KEYWORD: shutdown
#
# Add the following to /etc/rc.conf to enable this service:
#
# tinydns_enable="YES"
#
# tinydns_ip
# tinydns_root

. /etc/rc.subr

name=tinydns
rcvar=tinydns_enable
command=/usr/local/bin/tinydns

start_cmd=tinydns_start
pidfile=/var/run/tinydns

load_rc_config $name

: ${tinydns_enable:=no}
: ${tinydns_ip:=0.0.0.0}
: ${tinydns_root:=/etc/tinydns/root}

tinydns_start() {
    IP=${tinydns_ip} ROOT=${tinydns_root} UID=bind GID=bind ${command} >/dev/null 2>/dev/null &
    echo $! > ${pidfile}
}

run_rc_command "$1"

I placed my compiled zone file in /etc/tinydns/root/data.cdb, so the paths just work. My uncompiled zone file (i.e. source at /etc/tinydns/root/data) looks like this:

Zservice.example.com.:ns.service.example.com.:erdgeist@ccc.de.::86400:7200:604800:43200:43200
&service.example.com.::ns.service.example.com.:43200

+ns.service.example.com.:10.1.1.1:43200

@service.example.com.::mail.service.example.com.:50:43200
@*.service.example.com::mail.service.example.com.:50:43200

+service.example.com.:10.1.1.2:43200
+*.service.example.com.:10.1.1.2:43200

For AAAA records you might pre-compile an answer here or use Fefes tindydns IPv6 patches for a simpler syntax.

Once the file is there, you compile it using tinydns-data (within the /etc/tinydns/root/ directory. Yay!). Best is to also give it to your letsencrypt user right now.

cd /etc/tinydns/root
tinydns-data > data.cdb
chown -R letsencrypt /etc/tinydns/root

Finally, you need to add the actual code to your hooks. I just modified the file hook.sh in /usr/local/etc/dehydrated/ to read in the deploy_challenge() { function

printf "\'_acme-challenge.%s:%s:120\n" ${DOMAIN} ${TOKEN_VALUE} >> /etc/tinydns/root/data
cd /etc/tinydns/root/
tinydns-data > /etc/tinydns/root/data.cdb

and for later cleanup in the clean_challenge() { function I added

sed -E -i '' '/_acme-challenge/d' /etc/tinydns/root/data
cd /etc/tinydns/root/
tinydns-data > /etc/tinydns/root/data.cdb

After all is set and done, you need to change the default scheme to dns-01 in your dehydrated config (which is kind of silly, because all other domains on that nginx host require http-01 auth, but maybe there’ll be a patch to dehydrated to support multiple challenge types in the same config). I just changed the config line for CHALLENGETYPE line to read CHALLENGETYPE="dns-01" in my /usr/local/etc/dehydrated/config. I also enabled the HOOK=/usr/local/etc/dehydrated/hook.sh line.

Then you can just run

su letsencrypt
dehydrated -c

and enjoy your wild card certs.