ACME HTTP-01 for a Cluster of Web Servers

#acme #http #tls #web

If you have a cluster of webservers or a CDN and need to do HTTP validation but cannot predict which server will receive the ACME challenge you can hack around it with a crafted response after extracting the thumbprint from your ACME private key.

The following is an example for Caddy pulled from a deployment I once used:

(acme_standalone) {
        # ACME http-01 challenge sends a request to /.well-known/acme-challenge/TOKEN
        # and expects the server to have created a file at that location with the contents
        # being a string of TOKEN.THUMBPRINT to validate the challenge.
        #
        # We are operating in a multi-node cluster and really want to do the http-01 style
        # ACME validation, so we can use this regex trick to forge a valid response to any possible
        # challenge from any node that receives the request. This is much faster than dns-01 challenges.
        #
        # The thumbprint is derived from the account key that the ACME client has generated at first run.
        # Our account name is our email defined at the top of this config.
        # With Caddy this key file would normally be located at:
        # $THE_CADDY_DATA_DIR/acme/acme-v02.api.letsencrypt.org-directory/users/USERNAME/USERNAME.key
        #
        # The same key is used for all providers, so this is compatible with LetsEncrypt, ZeroSSL, etc.
        #
        # We are using an extension for Caddy that stores the Caddy data in an S3 bucket, so the key
        # can be found there. You will need our script to extract the thumbprint to put in the config.
        #
        # Due to the fact that we're storing the production Caddy data in S3 it is unlikely you will
        # need to generate a new thumbprint, but that is how you would retrace these steps.

        @achallenge {
                path_regexp ch ^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$
        }
        respond @achallenge "{re.ch.1}.YOUR_THUMBPRINT_HERE"
}

The script:

#!/bin/bash
# Shell script for generating thumbprint
# from LetsEncrypt account key
#
# All functions shamelessly stolen from acme.sh
# 
# You MUST use bash
#
# Details on why this exists here: https://github.com/acmesh-official/acme.sh/wiki/Stateless-Mode
# 
# Useful for making acme http-01 work on any webserver behind a load balancer that is contacted for the challenge

set -e

#Usage: multiline
_base64() {
  [ "" ] #urgly
  if [ "$1" ]; then
    #_debug3 "base64 multiline:'$1'"
    ${ACME_OPENSSL_BIN:-openssl} base64 -e
  else
    #_debug3 "base64 single line."
    ${ACME_OPENSSL_BIN:-openssl} base64 -e | tr -d '\r\n'
  fi
}

_exists() {
  cmd="$1"
  if [ -z "$cmd" ]; then
    _usage "Usage: _exists cmd"
    return 1
  fi

  if eval type type >/dev/null 2>&1; then
    eval type "$cmd" >/dev/null 2>&1
  elif command >/dev/null 2>&1; then
    command -v "$cmd" >/dev/null 2>&1
  else
    which "$cmd" >/dev/null 2>&1
  fi
  ret="$?"
  #_debug3 "$cmd exists=$ret"
  return $ret
}

#keyfile
_isRSA() {
  keyfile=$1
  if grep "BEGIN RSA PRIVATE KEY" "$keyfile" >/dev/null 2>&1 || ${ACME_OPENSSL_BIN:-openssl} rsa -in "$keyfile" -noout -text 2>&1 | grep "^publicExponent:" 2>&1 >/dev/null; then
    return 0
  fi
  return 1
}

#keyfile
_isEcc() {
  keyfile=$1
  if grep "BEGIN EC PRIVATE KEY" "$keyfile" >/dev/null 2>&1 || ${ACME_OPENSSL_BIN:-openssl} ec -in "$keyfile" -noout -text 2>/dev/null | grep "^NIST CURVE:" 2>&1 >/dev/null; then
    return 0
  fi
  return 1
}

_contains() {
  _str="$1"
  _sub="$2"
  echo "$_str" | grep -- "$_sub" >/dev/null 2>&1
}

_upper_case() {
  # shellcheck disable=SC2018,SC2019
  tr '[a-z]' '[A-Z]'
}

_h2b() {
  if _exists xxd; then
    if _contains "$(xxd --help 2>&1)" "assumes -c30"; then
      if xxd -r -p -c 9999 2>/dev/null; then
        return
      fi
    else
      if xxd -r -p 2>/dev/null; then
        return
      fi
    fi
  fi

  hex=$(cat)
  ic=""
  jc=""
  #_debug2 _URGLY_PRINTF "$_URGLY_PRINTF"
  if [ -z "$_URGLY_PRINTF" ]; then
    if [ "$_ESCAPE_XARGS" ] && _exists xargs; then
      #_debug2 "xargs"
      echo "$hex" | _upper_case | sed 's/\([0-9A-F]\{2\}\)/\\\\\\x\1/g' | xargs printf
    else
      for h in $(echo "$hex" | _upper_case | sed 's/\([0-9A-F]\{2\}\)/ \1/g'); do
        if [ -z "$h" ]; then
          break
        fi
        printf "\x$h%s"
      done
    fi
  else
    for c in $(echo "$hex" | _upper_case | sed 's/\([0-9A-F]\)/ \1/g'); do
      if [ -z "$ic" ]; then
        ic=$c
        continue
      fi
      jc=$c
      ic="$(_h_char_2_dec "$ic")"
      jc="$(_h_char_2_dec "$jc")"
      printf '\'"$(printf "%o" "$(_math "$ic" \* 16 + $jc)")""%s"
      ic=""
      jc=""
    done
  fi

}

_url_replace() {
  tr '/+' '_-' | tr -d '= '
}

#a + b
_math() {
  _m_opts="$@"
  printf "%s" "$(($_m_opts))"
}

#keylength or isEcc flag (empty str => not ecc)
_isEccKey() {
  _length="$1"

  if [ -z "$_length" ]; then
    return 1
  fi

  [ "$_length" != "1024" ] &&
    [ "$_length" != "2048" ] &&
    [ "$_length" != "3072" ] &&
    [ "$_length" != "4096" ] &&
    [ "$_length" != "8192" ]
}

#Usage: hashalg  [outputhex]
#Output Base64-encoded digest
_digest() {
  alg="$1"
  if [ -z "$alg" ]; then
    #_usage "Usage: _digest hashalg"
    return 1
  fi

  outputhex="$2"

  if [ "$alg" = "sha256" ] || [ "$alg" = "sha1" ] || [ "$alg" = "md5" ]; then
    if [ "$outputhex" ]; then
      ${ACME_OPENSSL_BIN:-openssl} dgst -"$alg" -hex | cut -d = -f 2 | tr -d ' '
    else
      ${ACME_OPENSSL_BIN:-openssl} dgst -"$alg" -binary | _base64
    fi
  else
    #_err "$alg is not supported yet"
    return 1
  fi

}

__calc_account_thumbprint() {
  tp=$(printf "%s" "$jwk" | tr -d ' ' | _digest "sha256" | _url_replace)
  echo $tp
}

#keyfile
_calcjwk() {
  keyfile="$1"
  if [ -z "$keyfile" ]; then
    #_usage "Usage: _calcjwk keyfile"
    return 1
  fi

  if [ "$JWK_HEADER" ] && [ "$__CACHED_JWK_KEY_FILE" = "$keyfile" ]; then
    #_debug2 "Use cached jwk for file: $__CACHED_JWK_KEY_FILE"
    return 0
  fi

  if _isRSA "$keyfile"; then
    #_debug "RSA key"
    pub_exp=$(${ACME_OPENSSL_BIN:-openssl} rsa -in "$keyfile" -noout -text | grep "^publicExponent:" | cut -d '(' -f 2 | cut -d 'x' -f 2 | cut -d ')' -f 1)
    if [ "${#pub_exp}" = "5" ]; then
      pub_exp=0$pub_exp
    fi
    #_debug3 pub_exp "$pub_exp"

    e=$(echo "$pub_exp" | _h2b | _base64)
    #_debug3 e "$e"

    modulus=$(${ACME_OPENSSL_BIN:-openssl} rsa -in "$keyfile" -modulus -noout | cut -d '=' -f 2)
    #_debug3 modulus "$modulus"
    n="$(printf "%s" "$modulus" | _h2b | _base64 | _url_replace)"
    #_debug3 n "$n"

    jwk='{"e": "'$e'", "kty": "RSA", "n": "'$n'"}'
    #_debug3 jwk "$jwk"

    JWK_HEADER='{"alg": "RS256", "jwk": '$jwk'}'
    JWK_HEADERPLACE_PART1='{"nonce": "'
    JWK_HEADERPLACE_PART2='", "alg": "RS256"'
  elif _isEcc "$keyfile"; then
    #_debug "EC key"
    crv="$(${ACME_OPENSSL_BIN:-openssl} ec -in "$keyfile" -noout -text 2>/dev/null | grep "^NIST CURVE:" | cut -d ":" -f 2 | tr -d " \r\n")"
    #_debug3 crv "$crv"
    __ECC_KEY_LEN=$(echo "$crv" | cut -d "-" -f 2)
    if [ "$__ECC_KEY_LEN" = "521" ]; then
      __ECC_KEY_LEN=512
    fi
    #_debug3 __ECC_KEY_LEN "$__ECC_KEY_LEN"
    if [ -z "$crv" ]; then
      #_debug "Let's try ASN1 OID"
      crv_oid="$(${ACME_OPENSSL_BIN:-openssl} ec -in "$keyfile" -noout -text 2>/dev/null | grep "^ASN1 OID:" | cut -d ":" -f 2 | tr -d " \r\n")"
      #_debug3 crv_oid "$crv_oid"
      case "${crv_oid}" in
      "prime256v1")
        crv="P-256"
        __ECC_KEY_LEN=256
        ;;
      "secp384r1")
        crv="P-384"
        __ECC_KEY_LEN=384
        ;;
      "secp521r1")
        crv="P-521"
        __ECC_KEY_LEN=512
        ;;
      *)
        #_err "ECC oid : $crv_oid"
        return 1
        ;;
      esac
      #_debug3 crv "$crv"
    fi

    pubi="$(${ACME_OPENSSL_BIN:-openssl} ec -in "$keyfile" -noout -text 2>/dev/null | grep -n pub: | cut -d : -f 1)"
    pubi=$(_math "$pubi" + 1)
    #_debug3 pubi "$pubi"

    pubj="$(${ACME_OPENSSL_BIN:-openssl} ec -in "$keyfile" -noout -text 2>/dev/null | grep -n "ASN1 OID:" | cut -d : -f 1)"
    pubj=$(_math "$pubj" - 1)
    #_debug3 pubj "$pubj"

    pubtext="$(${ACME_OPENSSL_BIN:-openssl} ec -in "$keyfile" -noout -text 2>/dev/null | sed -n "$pubi,${pubj}p" | tr -d " \n\r")"
    #_debug3 pubtext "$pubtext"

    xlen="$(printf "%s" "$pubtext" | tr -d ':' | wc -c)"
    xlen=$(_math "$xlen" / 4)
    #_debug3 xlen "$xlen"

    xend=$(_math "$xlen" + 1)
    x="$(printf "%s" "$pubtext" | cut -d : -f 2-"$xend")"
    #_debug3 x "$x"

    x64="$(printf "%s" "$x" | tr -d : | _h2b | _base64 | _url_replace)"
    #_debug3 x64 "$x64"

    xend=$(_math "$xend" + 1)
    y="$(printf "%s" "$pubtext" | cut -d : -f "$xend"-2048)"
    #_debug3 y "$y"

    y64="$(printf "%s" "$y" | tr -d : | _h2b | _base64 | _url_replace)"
    #_debug3 y64 "$y64"

    jwk='{"crv": "'$crv'", "kty": "EC", "x": "'$x64'", "y": "'$y64'"}'
    #_debug3 jwk "$jwk"

    JWK_HEADER='{"alg": "ES'$__ECC_KEY_LEN'", "jwk": '$jwk'}'
    JWK_HEADERPLACE_PART1='{"nonce": "'
    JWK_HEADERPLACE_PART2='", "alg": "ES'$__ECC_KEY_LEN'"'
  else
    echo "Only RSA or EC key is supported. keyfile=$keyfile"
    #_debug2 "$(cat "$keyfile")"
    return 1
  fi

  #_debug3 JWK_HEADER "$JWK_HEADER"
  #__CACHED_JWK_KEY_FILE="$keyfile"
}

_calcjwk $1

__calc_account_thumbprint

This will give you what you need to respond correctly to ACME HTTP-01 challenges. You can apply this same method to Nginx, Varnish, Apache, etc. It will work fine as long as you can capture the request to /.well-known/acme-challenge/* and reuse it in your response with your thumbprint appended.