From: Lukas Schauer Date: Thu, 26 Jul 2018 02:44:29 +0000 (+0200) Subject: implemented initial support for tls-alpn-01 verification X-Git-Tag: v0.6.3~13 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=fba49ba28eb746eae127aa36e7f515beeaf0bbae;p=thirdparty%2Fdehydrated.git implemented initial support for tls-alpn-01 verification --- diff --git a/CHANGELOG b/CHANGELOG index cc2f4fd..05b40d9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,7 +6,7 @@ This file contains a log of major changes in dehydrated - OCSP refresh interval is now configurable ## Added -- ?? +- Initial support for tls-alpn-01 validation ## [0.6.2] - 2018-04-25 ## Added diff --git a/README.md b/README.md index 0817af8..a7426e3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Parameters: --config (-f) path/to/config Use specified config file --hook (-k) path/to/hook.sh Use specified script for hooks --out (-o) certs/directory Output certificates into the specified directory + --alpn alpn-certs/directory Output alpn verification certificates into the specified directory --challenge (-t) http-01|dns-01 Which challenge should be used? Currently http-01 and dns-01 are supported --algo (-a) rsa|prime256v1|secp384r1 Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 ``` diff --git a/dehydrated b/dehydrated index 9d9bca5..4897e7f 100755 --- a/dehydrated +++ b/dehydrated @@ -94,7 +94,7 @@ hookscript_bricker_hook() { # verify configuration values verify_config() { - [[ "${CHALLENGETYPE}" == "http-01" || "${CHALLENGETYPE}" == "dns-01" ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... cannot continue." + [[ "${CHALLENGETYPE}" == "http-01" || "${CHALLENGETYPE}" == "dns-01" || "${CHALLENGETYPE}" == "tls-alpn-01" ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... cannot continue." if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then _exiterr "Challenge type dns-01 needs a hook script for deployment... cannot continue." fi @@ -126,6 +126,7 @@ load_config() { CA="https://acme-v02.api.letsencrypt.org/directory" OLDCA= CERTDIR= + ALPNCERTDIR= ACCOUNTDIR= CHALLENGETYPE="http-01" CONFIG_D= @@ -256,6 +257,7 @@ load_config() { fi [[ -z "${CERTDIR}" ]] && CERTDIR="${BASEDIR}/certs" + [[ -z "${ALPNCERTDIR}" ]] && ALPNCERTDIR="${BASEDIR}/alpn-certs" [[ -z "${CHAINCACHE}" ]] && CHAINCACHE="${BASEDIR}/chains" [[ -z "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt" [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/dehydrated" @@ -266,6 +268,7 @@ load_config() { [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}" [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}" + [[ -n "${PARAM_ALPNCERTDIR:-}" ]] && ALPNCERTDIR="${PARAM_ALPNCERTDIR}" [[ -n "${PARAM_CHALLENGETYPE:-}" ]] && CHALLENGETYPE="${PARAM_CHALLENGETYPE}" [[ -n "${PARAM_KEY_ALGO:-}" ]] && KEY_ALGO="${PARAM_KEY_ALGO}" [[ -n "${PARAM_OCSP_MUST_STAPLE:-}" ]] && OCSP_MUST_STAPLE="${PARAM_OCSP_MUST_STAPLE}" @@ -321,7 +324,7 @@ init_system() { fi # Export some environment variables to be used in hook script - export WELLKNOWN BASEDIR CERTDIR CONFIG COMMAND + export WELLKNOWN BASEDIR CERTDIR ALPNCERTDIR CONFIG COMMAND # Checking for private key ... register_new_key="no" @@ -754,6 +757,10 @@ sign_csr() { # Generate DNS entry content for dns-01 validation keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" ;; + "tls-alpn-01") + keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -c -hex | awk '{print $2}')" + generate_alpn_certificate "${identifier}" "${keyauth_hook}" + ;; esac keyauths[${idx}]="${keyauth}" @@ -800,6 +807,7 @@ sign_csr() { done [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" + [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" if [[ "${reqstatus}" = "valid" ]]; then echo " + Challenge is valid!" @@ -821,6 +829,8 @@ sign_csr() { while [ ${idx} -lt ${num_pending_challenges} ]; do # Delete challenge file [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" + # Delete alpn verification certificates + [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" # Clean challenge token using non-chained hook [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && "${HOOK}" "clean_challenge" ${deploy_args[${idx}]} idx=$((idx+1)) @@ -908,6 +918,27 @@ walk_chain() { fi } +# Generate ALPN verification certificate +generate_alpn_certificate() { + local altname="${1}" + local acmevalidation="${2}" + + local alpncertdir="${ALPNCERTDIR}" + if [[ ! -e "${alpncertdir}" ]]; then + echo " + Creating new directory ${alpncertdir} ..." + mkdir -p "${alpncertdir}" || _exiterr "Unable to create directory ${alpncertdir}" + fi + + echo " + Generating ALPN certificate and key for ${1}..." + tmp_openssl_cnf="$(_mktemp)" + cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" + printf "[SAN]\nsubjectAltName=DNS:%s\n" "${altname}" >> "${tmp_openssl_cnf}" + printf "1.3.6.1.5.5.7.1.30.1=critical,DER:04:20:${acmevalidation}\n" >> "${tmp_openssl_cnf}" + SUBJ="/CN=${altname}/" + [[ "${OSTYPE:0:5}" = "MINGW" ]] && SUBJ="/${SUBJ}" + _openssl req -x509 -new -sha256 -nodes -newkey rsa:2048 -keyout "${alpncertdir}/${altname}.key.pem" -out "${alpncertdir}/${altname}.crt.pem" -subj "${SUBJ}" -extensions SAN -config "${tmp_openssl_cnf}" +} + # Create certificate for domain(s) sign_domain() { local certdir="${1}" @@ -1514,7 +1545,7 @@ command_help() { command_env() { echo "# dehydrated configuration" load_config - typeset -p CA CERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE + typeset -p CA CERTDIR ALPNCERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE } # Main method (parses script arguments and calls command_* methods) @@ -1693,6 +1724,14 @@ main() { PARAM_CERTDIR="${1}" ;; + # PARAM_Usage: --alpn alpn-certs/directory + # PARAM_Description: Output alpn verification certificates into the specified directory + --alpn) + shift 1 + check_parameters "${1:-}" + PARAM_ALPNCERTDIR="${1}" + ;; + # PARAM_Usage: --challenge (-t) http-01|dns-01 # PARAM_Description: Which challenge should be used? Currently http-01 and dns-01 are supported --challenge|-t) diff --git a/docs/examples/config b/docs/examples/config index cd24afb..0ad8684 100644 --- a/docs/examples/config +++ b/docs/examples/config @@ -31,7 +31,7 @@ # default: https://acme-v01.api.letsencrypt.org/directory #OLDCA="https://acme-v01.api.letsencrypt.org/directory" -# Which challenge should be used? Currently http-01 and dns-01 are supported +# Which challenge should be used? Currently http-01, dns-01 and tls-alpn-01 are supported #CHALLENGETYPE="http-01" # Path to a directory containing additional config files, allowing to override @@ -49,6 +49,9 @@ # Output directory for generated certificates #CERTDIR="${BASEDIR}/certs" +# Output directory for alpn verification certificates +#ALPNCERTDIR="${BASEDIR}/alpn-certs" + # Directory for account keys and registration information #ACCOUNTDIR="${BASEDIR}/accounts" diff --git a/docs/tls-alpn.md b/docs/tls-alpn.md new file mode 100644 index 0000000..8b7e90e --- /dev/null +++ b/docs/tls-alpn.md @@ -0,0 +1,106 @@ +# TLS-ALPN-01 + +With `tls-alpn-01`-type verification Let's Encrypt (or the ACME-protocol in general) is checking if you are in control of a domain by accessing +your webserver using a custom ALPN and expecting a specially crafted TLS certificate containing a verification token. +It will do that for any (sub-)domain you want to sign a certificate for. + +Dehydrated generates the required verification certificates, but the delivery is out of its scope. + +### Example nginx config + +On an nginx tcp load-balancer you can use the `ssl_preread` module to map a different port for acme-tls +requests than for e.g. HTTP/2 or HTTP/1.1 requests. + +Your config should look something like this: + +```nginx +stream { + server { + map $ssl_preread_alpn_protocols $tls_port { + ~\bacme-tls/1\b 10443; + default 443; + } + + server { + listen 443; + listen [::]:443; + proxy_pass 10.13.37.42:$tls_port; + ssl_preread on; + } + } +} +``` + +That way https requests are forwarded to port 443 on the backend server, and acme-tls/1 requests are +forwarded to port 10443. + +In the future nginx might support internal routing based on custom ALPNs, but for now you'll have to +use a custom responder for the alpn verification certificates (see below). + +### Example responder + +I hacked together a simple responder in Python, it might not be the best, but it works for me: + +```python +#!/usr/bin/env python3 + +import ssl +import socketserver +import threading +import re +import os + +ALPNDIR="/etc/dehydrated/alpn-certs" +PROXY_PROTOCOL=False + +FALLBACK_CERTIFICATE="/etc/ssl/certs/ssl-cert-snakeoil.pem" +FALLBACK_KEY="/etc/ssl/private/ssl-cert-snakeoil.key" + +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + pass + +class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): + def create_context(self, certfile, keyfile, first=False): + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.set_ciphers('ECDHE+AESGCM') + ssl_context.set_alpn_protocols(["acme-tls/1"]) + ssl_context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + if first: + ssl_context.set_servername_callback(self.load_certificate) + ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) + return ssl_context + + def load_certificate(self, sslsocket, sni_name, sslcontext): + print("Got request for %s" % sni_name) + if not re.match(r'^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][-_.a-zA-Z0-9]{0,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,13}|[a-zA-Z0-9-]{2,30}.[a-zA-Z]{2,3})$', sni_name): + return + + certfile = os.path.join(ALPNDIR, "%s.crt.pem" % sni_name) + keyfile = os.path.join(ALPNDIR, "%s.key.pem" % sni_name) + + if not os.path.exists(certfile) or not os.path.exists(keyfile): + return + + sslsocket.context = self.create_context(certfile, keyfile) + + def handle(self): + if PROXY_PROTOCOL: + buf = b"" + while b"\r\n" not in buf: + buf += self.request.recv(1) + + ssl_context = self.create_context(FALLBACK_CERTIFICATE, FALLBACK_KEY, True) + newsock = ssl_context.wrap_socket(self.request, server_side=True) + +if __name__ == "__main__": + HOST, PORT = "0.0.0.0", 10443 + + server = ThreadedTCPServer((HOST, PORT), ThreadedTCPRequestHandler, bind_and_activate=False) + server.allow_reuse_address = True + try: + server.server_bind() + server.server_activate() + server.serve_forever() + except: + server.shutdown() +```