]> git.ipfire.org Git - thirdparty/dehydrated.git/commitdiff
implemented initial support for tls-alpn-01 verification
authorLukas Schauer <lukas@schauer.so>
Thu, 26 Jul 2018 02:44:29 +0000 (04:44 +0200)
committerLukas Schauer <lukas@schauer.so>
Thu, 26 Jul 2018 02:44:29 +0000 (04:44 +0200)
CHANGELOG
README.md
dehydrated
docs/examples/config
docs/tls-alpn.md [new file with mode: 0644]

index cc2f4fd8ebfb62c391abec7e5957f182f84ab54d..05b40d912fec7444775bd071a7f02a0548892c16 100644 (file)
--- 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
index 0817af841b9e75d05f849e098aefd0e53a63abfe..a7426e38a0650744f7a0a3a1e0860d9f4c97289b 100644 (file)
--- 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
 ```
index 9d9bca5bab0f0f0639fec0f86f5c8ff51518d22e..4897e7fb3a7f08fbe177453dcb7aef844cffd41e 100755 (executable)
@@ -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)
index cd24afb69003ae06992efe6974d67e38be415ec6..0ad86845bf505647024aa6e73010710a7cd1fffc 100644 (file)
@@ -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 (file)
index 0000000..8b7e90e
--- /dev/null
@@ -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()
+```