Wildcard Certificates with Let’s Encrypt

Let’s Encrypt now has the possibility to create wildcard certificates which makes things much easier if you are hosting many different sites or servers with the same domain which all need SSL certificates. Here I will describe how to implement that.

I am using the Dehydrated client and BIND as my DNS server. Wildcard certificates are only supported with the DNS-01 challenge type. This means that you’ll need to be able to modify DNS TXT records for your domains.

First clone the Dehydrated client from Github:

git clone https://github.com/lukas2511/dehydrated.git
cd dehydrated

You can specify most of the configuration option via the command line. However if you want to use the Let’s encrypt staging environment first for testing (recommended!) then you need to specify the correct URL. Create a new file named “config” in the “dehydrated” directory with the following content:

# Path to certificate authority (default: https://acme-v02.api.letsencrypt.org/directory)
CA="https://acme-staging-v02.api.letsencrypt.org/directory"

You can just comment out that line later if you want to use the production environment if your tests are successful.

You also need a hook script so that the Dehydrated client is able to add the needed entries to your DNS server. Here is the script I am using which modifies my BIND configuration via the NSUPDATE command. You will need to specify the path to your keyfile which is used to get access to your BIND server. For more information how to implement access via NSUPDATE you can check this article. Save the following script as “hook.sh” in the “dehydrated” directory and make it executable with “chmod +x hook.sh”:

#!/usr/bin/env bash

# letsencrypt.sh dns-01 challenge RFC2136 hook.
# Copyright (c) 2016 Tom Laermans.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

# Load letsencrypt.sh config ($CONFIG is exported by letsencrypt.sh)
#. $CONFIG

# All the below settings can be set in the letsencrypt.sh configuration file as well.
# They will not be overwritten by the statements below if they already exist in that configuration.

# NSUPDATE - Path to nsupdate binary
[ -z "${NSUPDATE}" ] && NSUPDATE="/usr/bin/nsupdate -k /path/to/keyfile.private"

# SERVER - Master DNS server IP
[ -z "${SERVER}" ] && SERVER="dns.acme.com"

# PORT - Master DNS port (likely to be 53)
[ -z "${PORT}" ] && PORT=53

# TTL - DNS Time-To-Live of ACME TXT record
[ -z "${TTL}" ] && TTL=300

# DESTINATION - Copy files to subdirectory of DESTINATION upon successful certificate request
[ -z "${DESTINATION}" ] && DESTINATION=

# CERT_OWNER - If DESTINATION and CERT_OWNER are set, chown files to CERT_OWNER after copy
[ -z "${CERT_OWNER}" ] && CERT_OWNER=

# CERT_GROUP - If DESTINATION, CERT_OWNER and CERT_GROUP are set, chown files to CERT_GROUP after copy
[ -z "${CERT_GROUP}" ] && CERT_GROUP=

# CERT_MODE - If DESTINATION and CERT_MODE are set, chmod files to CERT_MODE after copy
[ -z "${CERT_MODE}" ] && CERT_MODE=

# CERTDIR_OWNER - If DESTINATION and CERTDIR_OWNER are set, chown files to CERTDIR_OWNER after copy
[ -z "${CERTDIR_OWNER}" ] && CERTDIR_OWNER=

# CERTDIR_GROUP - If DESTINATION, CERTDIR_OWNER and CERTDIR_GROUP are set, chown files to CERTDIR_GROUP after copy
[ -z "${CERTDIR_GROUP}" ] && CERTDIR_GROUP=

# CERTDIR_MODE - If DESTINATION and CERT_MODE are set, chmod files to CERT_MODE after copy
[ -z "${CERTDIR_MODE}" ] && CERTDIR_MODE=

# ATTEMPTS - Wait $ATTEMPTS times $SLEEP seconds for propagation to succeed, then bail out.
[ -z "${ATTEMPTS}" ] && ATTEMPTS=30

# SLEEP - Amount of seconds to sleep before retrying propagation check.
[ -z "${SLEEP}" ] && SLEEP=60

# DOMAINS_TXT - Path to the domains.txt file containing all requested certificates.
[ -z "${DOMAINS_TXT}" ] && DOMAINS_TXT="${BASEDIR}/domains.txt"

_log() {
echo >&2 " + ${@}"
}

_checkdns() {
local ATTEMPT="${1}" DOMAIN="${2}" TOKEN_VALUE="${3}"
if [ $ATTEMPT -gt $ATTEMPTS ];
then
_log "Propagation check failed after ${ATTEMPTS} attempts. Bailing out!"
exit 2
fi

_log "Checking for dns propagation via Google's recursor... (${ATTEMPT}/${ATTEMPTS})"

# host -t txt _acme-challenge.${DOMAIN} 8.8.8.8 | grep ${TOKEN_VALUE} >/dev/null 2>&1
host -t txt _acme-challenge.${DOMAIN} 8.8.8.8 | grep -- ${TOKEN_VALUE} >/dev/null 2>&1 
if [ "$?" -eq 0 ];
then
host -t txt _acme-challenge.${DOMAIN} 192.174.68.104 | grep -- ${TOKEN_VALUE} >/dev/null 2>&1 
if [ "$?" -eq 0 ];
then
host -t txt _acme-challenge.${DOMAIN} 176.97.158.104 | grep -- ${TOKEN_VALUE} >/dev/null 2>&1 
if [ "$?" -eq 0 ];
then
_log "Propagation success!"
return
fi
fi 
else

_log "Waiting ${SLEEP}s..."
sleep ${SLEEP}
_checkdns $((ATTEMPT+1)) ${DOMAIN} ${TOKEN_VALUE}
fi
}

deploy_challenge() {
local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

# This hook is called once for every domain that needs to be
# validated, including any alternative names you may have listed.
#
# Parameters:
# - DOMAIN
# The domain name (CN or subject alternative name) being
# validated.
# - TOKEN_FILENAME
# The name of the file containing the token to be served for HTTP
# validation. Should be served by your web server as
# /.well-known/acme-challenge/${TOKEN_FILENAME}.
# - TOKEN_VALUE
# The token value that needs to be served for validation. For DNS
# validation, this is what you want to put in the _acme-challenge
# TXT record. For HTTP validation it is the value that is expected
# be found in the $TOKEN_FILENAME file.

_log "Adding ACME challenge record via RFC2136 update to ${SERVER}..."
printf "server %s %s\nupdate add _acme-challenge.%s. %d in TXT \"%s\"\n\n" "${SERVER}" "${PORT}" "${DOMAIN}" "${TTL}" "${TOKEN_VALUE}" | $NSUPDATE > /dev/null 2>&1
if [ "$?" -ne 0 ];
then
_log "Failure reported by nsupdate. Bailing out!"
exit 2
fi

# Allow at least a little time to propagate to slaves before asking Google
sleep 5

_checkdns 1 ${DOMAIN} ${TOKEN_VALUE}
}

clean_challenge() {
local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

# This hook is called after attempting to validate each domain,
# whether or not validation was successful. Here you can delete
# files or DNS records that are no longer needed.
#
# The parameters are the same as for deploy_challenge.

_log "Removing ACME challenge record via RFC2136 update to ${SERVER}..."
printf "server %s %s\nupdate delete _acme-challenge.%s. %d in TXT \"%s\"\n\n" "${SERVER}" "${PORT}" "${DOMAIN}" "${TTL}" "${TOKEN_VALUE}" | $NSUPDATE > /dev/null 2>&1
if [ "$?" -ne 0 ];
then
_log "Failure reported by nsupdate. Bailing out!"
exit 2
fi
}

deploy_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"

# This hook is called once for each certificate that has been
# produced. Here you might, for instance, copy your new certificates
# to service-specific locations and reload the service.
#
# Parameters:
# - DOMAIN
# The primary domain name, i.e. the certificate common
# name (CN).
# - KEYFILE
# The path of the file containing the private key.
# - CERTFILE
# The path of the file containing the signed certificate.
# - FULLCHAINFILE
# The path of the file containing the full certificate chain.
# - CHAINFILE
# The path of the file containing the intermediate certificate(s).
# - TIMESTAMP
# Timestamp when the specified certificate was created.

# Simple example: Copy file to nginx config
# cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl
# systemctl reload nginx

# If destination is set, copy/chown/chmod certificate files
if [ "$DESTINATION" != "" ];
then
_log "Copying certificate files to destination repository"

mkdir -p ${DESTINATION}/${DOMAIN}
if [ "$CERTDIR_MODE" != "" ];
then
chmod ${CERTDIR_MODE} ${DESTINATION}/${DOMAIN}
fi

if [ "$CERTDIR_OWNER" != "" ];
then
chown ${CERTDIR_OWNER}:${CERTDIR_GROUP} ${DESTINATION}/${DOMAIN}
fi

if [ "$CERTDIR_MODE" != "" ];
then
chmod ${CERTDIR_MODE} ${DESTINATION}/${DOMAIN}
fi

for FILE in ${KEYFILE} ${CERTFILE} ${CHAINFILE}
do
FILENAME=$(basename $FILE)
cp ${FILE} ${DESTINATION}/${DOMAIN}

if [ "$CERT_OWNER" != "" ];
then
chown ${CERT_OWNER}:${CERT_GROUP} ${DESTINATION}/${DOMAIN}/${FILENAME}
fi

if [ "$CERT_MODE" != "" ];
then
chmod ${CERT_MODE} ${DESTINATION}/${DOMAIN}/${FILENAME}
fi
done
fi

# Add DOMAIN to domains.txt if not already there
grep ^$HOST\$ ${DOMAINS_TXT} > /dev/null 2>&1
if [ "$?" -ne 0 ];
then
echo ${DOMAIN} >> ${DOMAINS_TXT}
fi
}

unchanged_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"

# This hook is called once for each certificate that is still
# valid and therefore wasn't reissued.
#
# Parameters:
# - DOMAIN
# The primary domain name, i.e. the certificate common
# name (CN).
# - KEYFILE
# The path of the file containing the private key.
# - CERTFILE
# The path of the file containing the signed certificate.
# - FULLCHAINFILE
# The path of the file containing the full certificate chain.
# - CHAINFILE
# The path of the file containing the intermediate certificate(s).


# NOOP
}

invalid_challenge() {
# This hook is called at the beginning of a dehydrated command 
local DOMAIN="${1}" RESPONSE="${2}"

# This hook is called if the challenge response has failed, so domain
# owners can be aware and act accordingly.
#
# Parameters:
# - DOMAIN
# The primary domain name, i.e. the certificate common
# name (CN).
# - RESPONSE
# The response that the verification server returned

# Simple example: Send mail to root
# printf "Subject: Validation of ${DOMAIN} failed!\n\nOh noez!" | sendmail root
:
}

request_failure() {
local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}"

# This hook is called when an HTTP request fails (e.g., when the ACME
# server is busy, returns an error, etc). It will be called upon any
# response code that does not start with '2'. Useful to alert admins
# about problems with requests.
#
# Parameters:
# - STATUSCODE
# The HTML status code that originated the error.
# - REASON
# The specified reason for the error.
# - REQTYPE
# The kind of request that was made (GET, POST...)

# Simple example: Send mail to root
# printf "Subject: HTTP request failed failed!\n\nA http request failed with status ${STATUSCODE}!" | sendmail root
}

generate_csr() {
local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}"

# This hook is called before any certificate signing operation takes place.
# It can be used to generate or fetch a certificate signing request with external
# tools.
# The output should be just the cerificate signing request formatted as PEM.
#
# Parameters:
# - DOMAIN
# The primary domain as specified in domains.txt. This does not need to
# match with the domains in the CSR, it's basically just the directory name.
# - CERTDIR
# Certificate output directory for this particular certificate. Can be used
# for storing additional files.
# - ALTNAMES
# All domain names for the current certificate as specified in domains.txt.
# Again, this doesn't need to match with the CSR, it's just there for convenience.

# Simple example: Look for pre-generated CSRs
# if [ -e "${CERTDIR}/pre-generated.csr" ]; then
# cat "${CERTDIR}/pre-generated.csr"
# fi
}


startup_hook() {
# This hook is called at the beginning of a dehydrated command

:
}

exit_hook() {
# This hook is called at the end of a dehydrated command and can be used
# to do some final (cleanup or other) tasks.

:
}


HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then
"$HANDLER" "$@"
fi

In line 25 you need to specify the path to your key file which you created for dynamic BIND updates. And in line 29 you need to specify the IP or host name of your BIND DNS server.

Now you need first to register an account with Let’s Encrypt. This is needed only one time (for both the staging and the production environment of Let’s Encrypt). You do that with the following command:

./dehydrated --register --accept-terms

As a result you should see something like that:

# INFO: Using main config file /root/dehydrated/config
+ Generating account key...
+ Registering account key with ACME server...
+ Done!

In the “accounts” directory you will now find a sub-directory with your registration key and information.

Now you are ready to create your first wildcard certificate. Run the following command (change the “acme.com” domain below to your domain for which you want to create a certificate and the path “/root/dehydrated” to the path where you cloned the Dehydrated client):

/root/dehydrated/dehydrated -c -d "*.acme.com acme.com" --alias _wildcard.acme.com -k /root/dehydrated/hook.sh -t dns-01

Please note that you also should also include your domain without the “*.” as SAN (Subject Alternative Name) into the certificate, otherwise you will not be able to use “https://acme.com” with your certificate!

You should see an output like the following:

# INFO: Using main config file /root/dehydrated/config
Processing *.acme.com
+ Signing domains...
+ Generating private key...
+ Generating signing request...
+ Requesting new certificate order from CA...
+ Received 1 authorizations URLs from the CA
+ Handling authorization for acme.com
+ 1 pending challenge(s)
+ Deploying challenge tokens...
+ Adding ACME challenge record via RFC2136 update to dns.acme.com...
+ Checking for dns propagation via Google's recursor... (1/30)
+ Waiting 60s...
+ Checking for dns propagation via Google's recursor... (2/30)
+ Waiting 60s...
+ Checking for dns propagation via Google's recursor... (3/30)
+ Waiting 60s...
+ Checking for dns propagation via Google's recursor... (4/30)
+ Waiting 60s...
+ Checking for dns propagation via Google's recursor... (5/30)
+ Propagation success!
+ Responding to challenge for acme.com authorization...
+ Removing ACME challenge record via RFC2136 update to dns.acme.com...
+ Challenge is valid!
+ Requesting certificate...
+ Checking certificate...
+ Done!
+ Creating fullchain.pem..

The number of tries for the DNS propagation depends on how fast the change to your BIND server will be propagated to the outside world. The kook script checks three different external DNS servers and all of them needs to respond to the DNS challenge correctly.

As a result you now should have the certificate files in the directory “certs/_wildcard.acme.com”:

cert.pem: Contains your certificate

privkey.pem: Contains the private key of your certificate

chain.pem: Contains the upper certificate in the certificate chain

fullchain.pem: Contains the full certificate chain from the root certificate down to level to your certificate

You can then use the certificate e.g. in your Apache web server by including the following lines in your SSL section:

SSLCertificateFile "/path/to/your/certificate/files/_wildcard.acme.com/cert.pem"
SSLCertificateKeyFile "/path/to/your/certificate/files/_wildcard.acme.com/privkey.pem"
SSLCertificateChainFile "/path/to/your/certificate/files/_wildcard.acme.com/chain.pem"

In the “config” file you can specify where the Dehydrated client will store the generated certificates. Just add the following lines to your “config” file:

# Output directory for generated certificates
CERTDIR="/path/to/your/certificate/files"

The certificate is valid for three months. So you need to run that task on a regular basis. I am running it every day. As long as the certificate is valid at least 30 days, nothing will happen. Otherwise a new certificate will be generated (and you you need to restart your application e.g. Apache, in order to activate it). The time frame of 30 days can be changed by adding the following lines to “config”:

# Minimum days before expiration to automatically renew certificate (default: 30)
RENEW_DAYS="30"

There are some more options in the config file. You will find a documented sample file under “docs/examples/config” in the “dehydrated” directory.

Sometime you will need to have the certificate in the PKCS12 format. You can use the following OpenSSL command to create such a file:

openssl pkcs12 -export -inkey privkey.pem -in cert.pem -name *.acme.com -out _wildcard.acme.com.p12 -password pass:<your_cert_password>

If you are also using the DANE protcol, then you can generate the necessary DNS entry (for 3 1 1) with the following command:

printf '_443._tcp.www.acme.com. IN TLSA 3 1 1 %s\n' $(openssl x509 -in cert.pem -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | hexdump -ve '/1 "%02x"')

You will get then something like that:

_443._tcp.www.acme.com. IN TLSA 3 1 1 78ac2ccae6045c8005851bd53ea2b667840406be5145114f0c2fd7d659397853

As you do not want to change the TLSA records every time a certificate renewal takes place, you need to configure Dehydrated to re-use the private key while issuing a new certificate by adding these lines to the “config” file:

# Regenerate private keys instead of just signing new certificates on renewal (default: yes)
PRIVATE_KEY_RENEW="no"

Keep in mind that it is still recommended to change that key from time to time for security reasons. However I would say it is not needed to do that every three months (one time a year should be ok as well).

By the way: You can use the same procedure from above to create non-wildcard domains for just one site/server. Although in this case you might also use the “HTTP-01” challenge mechanism which does also work without access to your DNS server.

 

Wildcard Certificates with Let’s Encrypt