ACME DNS challenges and FreeIPA
This post is part of a series of ACME client demonstrations. See also the posts about Certbot standalone HTTP and mod_md for Apache
The ACME protocol defined in RFC 8555 defines a DNS challenge for proving control of a domain name. In this post I’ll explain how the DNS challenge works and demonstrate how to use the Certbot ACME client with the FreeIPA integrated DNS service.
The DNS challenge §
To prove control of a domain name (the dns
identifier type) ACME
defines the dns-01
challenge type. It is up to ACME servers
which challenges to create for a given identifier. If a server
offers multiple challenges (e.g. http-01
and dns-01
) the
client can choose which one to attempt.
A DNS challenge object looks like:
{
"type": "dns-01",
"url": "https://example.com/acme/chall/Rg5dV14Gh1Q",
"status": "pending",
"token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
}
The token
field is a base64url-encoded high-entropy random
value. Due to the use of TLS this value should be known only to the
server and client.
The client responds to a dns-01
challenge by provisioning a DNS
TXT record containing the SHA-256 digest of the key
authorisation value, which is the concatenation of the token
value from the challenge object and the JWK Thumbprint of the
account key. For example:
_acme-challenge.www.example.org. 300 IN TXT "gfj9Xq...Rg85nM"
The client then informs the ACME server that it can validate the challenge:
POST /acme/chall/Rg5dV14Gh1Q
Host: example.com
Content-Type: application/jose+json
{
"protected": base64url({
"alg": "ES256",
"kid": "https://example.com/acme/acct/evOfKhNU60wg",
"nonce": "SS2sSl1PtspvFZ08kNtzKd",
"url": "https://example.com/acme/chall/Rg5dV14Gh1Q"
}),
"payload": base64url({}),
"signature": "Q1bURgJoEslbD1c5...3pYdSMLio57mQNN4"
}
The ACME server will query the DNS. When it sees that the expected TXT record, the challenge (and corresponding identifier authorisation) are completed.
Because DNSSEC is not widely deployed, ACME servers can mitigate against DNS-based attacks by querying DNS from mutiple vantage points. This increases attack cost and complexity.
DNS and Certbot §
Certbot provides the --preferred-challenges={dns,http}
CLI
option to specify which challenge type to prefer if the server
offers multiple challenges.
There are several DNS plugins available for using Certbot with particular DNS services. For example there are plugins for Cloudflare, Route53 and many other services. At a glance, many of them are packaged for Fedora. Each DNS plugin has different options to activate and configure it. Because we are not using any of these services I won’t go into further details here.
Certbot also provides pre and post validation hooks for the
--manual
strategy. These let the user specify scripts to carry
out challenge provisioning and cleanup steps. The command line
options are --manual-auth-hook
and --manual-cleanup-hook
.
Certbot and FreeIPA DNS §
You can use the CLI options described above to implement arbitrary
means of responding to ACME challenges. And I have done just that
for responding to the dns-01
challenge using the FreeIPA
integrated DNS service.
The FreeIPA integrated DNS is an optional component of FreeIPA. It
is implmented using the BIND DNS server and a database plugin
causing BIND to read from the FreeIPA replicated LDAP database. The
DNS service can be installed at server install time, or afterwards
via the ipa-dns-install
command. The freeipa-server-dns
(Fedora) or ipa-server-dns
(RHEL) package provides this feature.
The rest of this section assumes that the FreeIPA integrated DNS
server is installed and FreeIPA-enrolled client machines are
configured to use it.
The ipa dnsrecord-add <zone> <name> ...
command adds record(s)
to the zone. The resource types and values are given in options
like --aaaa-rec=<ip6addr>
or --txt-rec=<string>
. The
corresponding command dnsrecord-del
command has the same format.
Knowing that we can also interact with the FreeIPA server via the
ipalib
Python library, we have everything we need to implement
the Certbot hook script(s) that will use FreeIPA’s DNS to satisfy
the ACME dns-01
challenge.
Hook script §
The script is so short I will just include the whole thing here. I have broken it into chunks with commentary.
#!/usr/bin/python3
import os
from dns import resolver
from ipalib import api
from ipapython import dnsutil
Shebang, imports. Trivial.
= os.environ['CERTBOT_DOMAIN']
certbot_domain = os.environ['CERTBOT_VALIDATION']
certbot_validation if 'CERTBOT_AUTH_OUTPUT' in os.environ:
= 'dnsrecord_del'
command else:
= 'dnsrecord_add' command
Certbot provides the domain name and the authorisation string via
environment variables. In the cleanup phase it also sets the
CERTBOT_AUTH_OUTPUT
environment variable. Therefore I use this
same script for both the authorisation and cleanup phases. Because
the commands are so similar, the only thing that changes during
cleanup is the command name.
= f'_acme-challenge.{certbot_domain}'
validation_domain = dnsutil.DNSName(validation_domain).make_absolute()
fqdn = dnsutil.DNSName(resolver.zone_for_name(fqdn))
zone = fqdn.relativize(zone) name
Construct the validation domain name and find the corresponding DNS zone, i.e. the zone in which we must create the TXT record. Then we relativise the validation domain name against the zone.
='cli')
api.bootstrap(context
api.finalize()connect()
api.Backend.rpcclient.
api.Command[command](
zone,
name,=[certbot_validation],
txtrecord=60) dnsttl
Initialise the API and execute the command. Note that names of the keyword arguments are different from the corresponding CLI options.
There are some important caveats. There must be latent,
non-expired Kerberos credentials in the execution environment.
These can be in the default credential cache or specified via the
KRB5CCNAME
environment variable (e.g. to point to a keytab
file). The principal must also have permissions to add and remove
DNS records.
Demo §
As in previous ACME demos the client machine is enrolled as a
FreeIPA client and trusts the FreeIPA CA. For this demo Certbot
does not need to run as root
. But by default Certbot tries to
read and write files under /etc/letsencrypt
. I had to override
this behaviour with the following command line options:
--config-dir DIR
-
Configuration directory. (default:
/etc/letsencrypt
) --work-dir DIR
-
Working directory. (default:
/var/lib/letsencrypt
) --logs-dir LOGS_DIR
-
Logs directory. (default:
/var/log/letsencrypt
)
I defined these options in a shell array variable for use in subsequent commands. I included the ACME server configuration too:
[f31-0:~] ftweedal% CERTBOT_ARGS=(
array> --logs-dir ~/certbot/log
array> --work-dir ~/certbot/work
array> --config-dir ~/certbot/config
array> --server https://ipa-ca.ipa.local/acme/directory
array> )
Next I registered an account:
[f31-0:~] ftweedal% certbot $CERTBOT_ARGS \
register --email ftweedal@redhat.com \
--agree-tos --no-eff-email --quiet
Saving debug log to /home/ftweedal/certbot/log/letsencrypt.log
IMPORTANT NOTES:
- Your account credentials have been saved in your Certbot
configuration directory at /home/ftweedal/certbot/config. You
should make a secure backup of this folder now. This configuration
directory will also contain certificates and private keys obtained
by Certbot so making regular backups of this folder is ideal.
The --no-eff-email
option suppressed the “Would you be willing
to share your email address with the Electronic Frontier
Foundation?” prompt.
The FreeIPA hook script requires Kerberos credentials so I executed
kinit admin
. In production use a less privileged account
with permissions to add and delete DNS records.
[f31-0:~] ftweedal% kinit admin
Password for admin@IPA.LOCAL: XXXXXXXX
Now I was ready to request the certificate. Alongside executing
certbot
, in another terminal I executed DNS queries to observe
the creation and deletion of the TXT record.
[root@f31-0 ~]# certbot $CERTBOT_ARGS \
certonly --domain $(hostname) \
--key-type rsa \
--preferred-challenges dns \
--manual --manual-public-ip-logging-ok \
--manual-auth-hook /home/ftweedal/certbot-dns-ipa.py \
--manual-cleanup-hook /home/ftweedal/certbot-dns-ipa.py
Saving debug log to /home/ftweedal/certbot/log/letsencrypt.log
Plugins selected: Authenticator manual, Installer None
Obtaining a new certificate
Performing the following challenges:
dns-01 challenge for f31-0.ipa.local
Running manual-auth-hook command: /home/ftweedal/certbot-dns-ipa.py
Waiting for verification...
Cleaning up challenges
Running manual-cleanup-hook command: /home/ftweedal/certbot-dns-ipa.py
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at:
/home/ftweedal/certbot/config/live/f31-0.ipa.local/fullchain.pem
Your key file has been saved at:
/home/ftweedal/certbot/config/live/f31-0.ipa.local/privkey.pem
Your cert will expire on 2020-08-11. To obtain a new or tweaked
version of this certificate in the future, simply run certbot
again. To non-interactively renew *all* of your certificates, run
"certbot renew"
- If you like Certbot, please consider supporting our work by:
Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
Donating to EFF: https://eff.org/donate-le
The certificate was issued and the process took about 10 seconds.
In the other terminal, running dig
every couple of seconds let
me observe the TXT record that was created and then deleted:
[f31-0:~] ftweedal% dig +short TXT _acme-challenge.f31-0.ipa.local
[f31-0:~] ftweedal% dig +short TXT _acme-challenge.f31-0.ipa.local
"5qkVb3ykx8nRdJOKbKf-xDtoySFl-B2W37bBBOHGoyc"
[f31-0:~] ftweedal% dig +short TXT _acme-challenge.f31-0.ipa.local
<< no output; record is gone >>
Error handling §
To my surprise, a failure (non-zero exit status) of the
authorisation hook script does not cause Certbot to halt. For
example, after deleting my credential cache with kdestroy
and
running certbot
with the same options as above, Certbot output
an error message and the standard error output from the hook
script:
...
Running manual-auth-hook command: /home/ftweedal/certbot-dns-ipa.py
manual-auth-hook command "/home/ftweedal/certbot-dns-ipa.py"
returned error code 1
Error output from manual-auth-hook command certbot-dns-ipa.py:
Traceback (most recent call last):
File "/usr/lib/python3.7/site-packages/ipalib/rpc.py", line 647,
in get_auth_info
response = self._sec_context.step()
...
Nevertheless Certbot proceeded to indicating to the server that the challenge is ready for verification:
Waiting for verification...
< 20 seconds elapse >
It then cleaned up the challenges and ran the cleanup hook (which also failed, as expected, due to no Kerberos credentials):
Cleaning up challenges
Cleaning up challenges
Running manual-cleanup-hook command: /home/ftweedal/certbot-dns-ipa.py
manual-cleanup-hook command "/home/ftweedal/certbot-dns-ipa.py" returned error code 1
Error output from manual-cleanup-hook command certbot-dns-ipa.py:
Traceback (most recent call last):
...
Finally it output the error from the ACME service:
An unexpected error occurred:
There was a problem with a DNS query during identifier validation ::
Unable to validate DNS-01 challenge at _acme-challenge.f31-0.ipa.local
Error: DNS name not found [response code 3]
Please see the logfiles in /home/ftweedal/certbot/log for more details.
Responding to a challenge after an abnormal exit of the authorisation hook seems to infringe RFC 8555 §8.2 which states:
Clients SHOULD NOT respond to challenges until they believe that the server’s queries will succeed.
I reported this issue against the Certbot GitHub repository.
Discussion §
The certbot-dns-ipa.py
script is available in a Gist. It is
trivial so consider it public domain.
The script is an artifact of work that is partly an exploration of ACME use cases, and partly for verifying the PKI and FreeIPA ACME services. I encountered no issues on the ACME server side which was pleasing.
From the client point of view it was good to confirm that what sounded like a valid use case was indeed valid. Not only that, it was straightforward thanks to the FreeIPA Python API and the design of the DNS plugin. The success of this use case exploration leads to to a couple of related questions:
- Should we build a “proper” Certbot plugin for FreeIPA DNS?
- Should we distribute and support the manual hook script?
These questions don’t need answers today. But it is good to outline and compare the options.
From a technical standpoint these are not mutually exclusive; you
could do both. But from a usage standpoint you only really need one
or the other. A proper plugin might have better UX and
discoverability but it would be additional work (how much more I’m
not sure yet). On the other hand the hook script is pretty much
already “done”. We would just need to distribute it, e.g. install
it under /usr/libexec/ipa/
.
This post concludes my “trilogy” of ACME client use case demos. In the future I will probably explore the intersection of ACME, OpenShift and FreeIPA. If so, expect the “sequel trilogy”. But my immediate focus must be to finish the FreeIPA ACME service and get it merged upstream.