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.