LetsEncrypt DNS verification using a local BIND instance

I’ve been looking into Let’s Encrypt DNS verification for a while. Not only because you’re able to obtain wildcard certificates through this method, freeing you from the necessity to obtain an individual certificate for every single one of your subdomains: It also allows you to get a certificate for stuff running on your LAN, provided you’re running it on a subdomain that belongs to you. The problem is though, how do you enable Certbot to automate the DNS server update, without putting a credential in place that would allow full access to all your domains? And what to do if you’re running a server for a domain that doesn’t even belong to you: How can the owner delegate permissions for the verification TXT records to you, without having to give you full access to all their domains? Today I stumbled across a solution: Delegate the _acme-challenge subdomain to a local BIND instance and have Certbot update that. Here’s how.

So, the basic idea as outlined above (and stolen from this guy) is to delegate the _acme-challenge name into a subzone on a local BIND. This is easily achieved by adding an NS record to the zone, like so:

_acme-challenge.example.com. 300 IN  NS      tiamat.example.com.

tiamat is the host on which I want to run Certbot and BIND. Thus, bind needs to be set up and it needs to know the zone.

Bind setup

(This part is also mostly outlined in the official certbot documentation, but with a few Debian-specific things missing.)

First of all, apt-get install bind9 and go to the /etc/named directory. There we’ll have to generate a key that is used to secure the update process:

dnssec-keygen -a HMAC-SHA512 -b 512 -n HOST certbot.

This will generate two files: Kcertbot.+165+39568.key and Kcertbot.+165+39568.private, which appear to make up a public/private key pair? Not sure. Anyway, the .key file has a certbot. IN KEY record which we are interested in, because that key will show up in the other configs that we’re about to create.

I put the zone configuration into named.conf.certbot:

key "certbot." {
        algorithm hmac-sha512;
        secret "here goes the secret from the .key file";
};

zone "_acme-challenge.example.com" {
        type master;
        file "/var/lib/bind/db.example.com";
        allow-query { any; };
        update-policy {
                grant certbot. name _acme-challenge.example.com. txt;
        };
};

I placed the db file in /var/lib/bind because Bind will want to create a Journal file and update the Zone file as it goes, which is forbidden by Apparmor if files are placed in /etc/bind.

Here’s the Zonefile I started with:

$ORIGIN .
$TTL 300        ; 5 minutes
_acme-challenge.example.com IN SOA tiamat.example.com. email-address.example.com. (
                                2020050806 ; serial
                                10800      ; refresh (3 hours)
                                3600       ; retry (1 hour)
                                604800     ; expire (1 week)
                                86400      ; minimum (1 day)
                                )
                        NS      tiamat.example.com.
$TTL 60         ; 1 minute
                        TXT     "127.0.0.1"

Be sure to include the file in /etc/bind/named.conf:

include "/etc/bind/named.conf.certbot";

Validate the configs:

# named-checkzone _acme-challenge.example.com. /var/lib/bind/db.example.com 
zone _acme-challenge.example.com/IN: loaded serial 2020050806
OK
# named-checkconf
#

Now restart bind and you should be good to go.

Verifying bind

When requesting a certificate, Certbot will try to figure out which Zone it needs to update by guessing a few names and querying their SOA record from the DNS server. If you run into an error such as this one, this means that your DNS server does not deliver the SOA record correctly:

Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/certbot/auth_handler.py", line 75, in handle_authorizations
    resp = self._solve_challenges(aauthzrs)
  File "/usr/lib/python3/dist-packages/certbot/auth_handler.py", line 139, in _solve_challenges
    resp = self.auth.perform(all_achalls)
  File "/usr/lib/python3/dist-packages/certbot/plugins/dns_common.py", line 57, in perform
    self._perform(domain, validation_domain_name, validation)
  File "/usr/lib/python3/dist-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 76, in _perform
    self._get_rfc2136_client().add_txt_record(validation_name, validation, self.ttl)
  File "/usr/lib/python3/dist-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 112, in add_txt_record
    domain = self._find_domain(record_name)
  File "/usr/lib/python3/dist-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 190, in _find_domain
    .format(record_name, domain_name_guesses))
certbot.errors.PluginError: Unable to determine base domain for _acme-challenge.example.com using names: ['_acme-challenge.example.com', 'example.com', 'com'].

You can verify this by running dig @127.0.0.1 _acme-challenge.example.com SOA. If it comes back without an ANSWER SECTION, your DNS config is botched and you need to get it to work first. Once it works locally, also be sure to check it remotely, because there might be a firewall rule necessary to allow incoming DNS traffic.

Certbot configuration

Certbot needs a configuration file to tell it how to reach the DNS server and how to authenticate to it. Since there does not seem to be a default location for that file, I placed it at /etc/bind/certbot-credentials.ini:

# Target DNS server
dns_rfc2136_server = 127.0.0.1
# Target DNS port
dns_rfc2136_port = 53
# TSIG key name
dns_rfc2136_name = certbot.
# TSIG key secret
dns_rfc2136_secret = here goes the secret from the .key file
# TSIG key algorithm
dns_rfc2136_algorithm = HMAC-SHA512

Be sure to install the dns-rfc2136 Plugin:

apt-get install python3-certbot-dns-rfc2136

With any luck, you should now be able to request a certificate using:

certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/bind/certbot-credentials.ini -d 'example.com' -d '*.example.com'

Congratulations!

Obtaining a Cert for a LAN

This method also works for obtaining a certificate for a host that sits in a LAN and is not reachable by the outside world, as long as it can talk to your DNS server. To try this, delegate the _acme-challenge name for a subdomain such as _acme-challenge.lan.example.com, and then try to obtain certs for lan.example.com and *.lan.example.com. The rest of lan.example.com does not have to be visible to the outside world.