Creation Museum, Petersburg, KY, October 6, 2015

Let's Encrypt with acme.sh, bind, and zone-signing

A quick overview of how to get Let's Encrypt validation working using just acme.sh and bind. No terrible certbot or manual interventions needed.

Adding certificates to your sites is easy with Let’s Encrypt, but let’s be honest: their certbot utility is quite possibly the devil. It makes you type in your root password to perform updates, the documentation is not great, and it is far too much overhead for what you’re actually trying to do.

Enter acme.sh. This shell script does most of the things certbot will do for you, but in the form of a Unix shell script, under your regular user account, and with very little pain and suffering1. I won’t go into the details of installing acme.sh. The README.md file does an excellent job there.

Once acme.sh is installed the fun starts. Of course you don’t want to manually validate each domain. Much more baller is to have acme.sh take care of it for you. Fortunately, it does DNS validation. If you use a third party DNS provider (which you probably shouldn’t for security reasons) it’s as easy as specifying the API. Using bind 9 it takes a little bit more work.

The documentation for acme.sh is fairly complete, but it’s the chmod 777 version of what you probably want to be doing. The instructions are also possibly incompatible with existing (local) update policies.

If you don’t already have bind 9 set up to do your zone signing for you, you should probably do that, especially if you’re going to use DNS to verify ownership of things. There is a handy auto-dnssec option in bind 9 that does exactly what you think it does. There is a walkthrough available if you have yet to set that up.

Note that the walkthrough defines an update key, but you can also use the “update-policy local” feature in bind to automatically generate a key named local-ddns:

When BIND encounters an update-policy local; statement it generates a TSIG key (with the algorithm HMAC-SHA256) and a private key file only in /var/run/named/session.key (location may be modified using the session-keyfile statement) and with a key-name of “local-ddns”. (Note: The use of the HMAC-SHA256 algorithm means that DHCP cannot use this key.) This session key is also used by nsupdate when the -l argument is supplied.

With that set up your zone definition will look something like this:

zone "melkfl.es" {
    type master;
    auto-dnssec maintain;
    key-directory "/etc/bind/keys/melkfl.es";
    inline-signing yes;

    update-policy local;

    file "/etc/bind/master/db.melkfl.es";
    allow-transfer { ***SECONDARY*** };
    also-notify { ***SECONDARY*** };
};

Getting from here to where you can do updates using acme.sh is pretty much a breeze. First, you need to generate a key for acme.sh to use (full disclosure, most of what follows is copy-pasted from the acme.sh manual:

b=$(dnssec-keygen -a hmac-sha512 -b 512 -n USER -K /tmp foo)
cat > /etc/bind/keys/acme-update.key <<EOF
key "acme-update" {
    algorithm hmac-sha512;
    secret "$(awk '/^Key/{print $2}' /tmp/$b.private)";
};
EOF
rm -f /tmp/$b.{private,key}
chown bind: /etc/bind/keys/acme-update.key
chmod 400 /etc/bind/keys/acme-update.key

You should have an /etc/bind/keys/acme-update.key file that looks like this:

key "acme-update" {
    algorithm hmac-sha512;
    secret "/ovlKkQ3C/OhpZJymleDNfBLIFT+A8aQiequ7UaUNIVwru393a5kqFqRD2aPYCe3/WPlR+YWF6IdJtyuGK7HcQ==";
};

Make sure the permissions are correctly set. Anyone who can read this file will be able to update your zone. After that’s done, you can update your zone defintion to allow updates using this key. Of course, you don’t want the key to have access to all your shit, so only allow updates for the relevant (sub)domainnames (i.e. names that live on the web server that will be running acme.sh):

include "/etc/bind/keys/acme-update.key";
zone {
    ...
    update-policy {
        grant "acme-update" name _acme-challenge.melkfl.es TXT;
        grant "acme-update" name _acme-challenge.www.melkfl.es TXT;
    };
};

But … you have to now expand the update-policy local to its component policy grant, because you can’t have both a policy list and a “local” definition. Remember the key name was local-ddns so the policy becomes:

update-policy {
	grant "acme-update" name _acme-challenge.melkfl.es TXT;
	grant "acme-update" name _acme-challenge.www.melkfl.es TXT;
	grant local-ddns zonesub any;
};

Now the key we just generated can update the TXT record _acme-challenge.melkfl.es, and the generated session key can still sign your zonefiles.

With bind setup you can start issuing certificates with acme.sh. On first run you can set the key in your environment (make sure to prefix the following commands with a space so they don’t end up in your history file):

 export NSUPDATE_SERVER="ns.melkfl.es"
 export NSUPDATE_KEY="/etc/bind/keys/acme-update.key"
acme.sh --issue --dns dns_nsupdate -d melkfl.es -d www.melkfl.es -k ec-384

After the first run this will be saved in your account.conf. acme.sh –install will have set a cron job to renew your certificates, so you’re all good to go.

When you add an extra webserver, simply generate a new key include it in your configuration. This way you can have many keys on many servers, without risk of all your domains getting hijacked if a key leaks.

An added benefit is that from now on, all you need to validate a domain is a shell (for acme.sh), crond, and nsupdate (for bind). This means you can issue and renew certificates even from embedded systems like a Ubiquiti Edgerouter.

  1. Of course, it is still software, so some pain and suffering is expected, nay, required.