#!/bin/sh -e #- #- Certosis is a simple ACME v2 client for managing TLS certificates # # Install: # curl -JO https://certosis.com # chmod +x certosis.sh # ./certosis.sh #- #- First you have to configure your domain and then can create a certificate. #- #- Commands: #- domain [credentials..] #- cert [names|check] #- update #- help [command] #- exit #- #- See 'help ' to read more about a specific command. #- #- #- Examples: #- > domain example.org dns manual #- > domain example.com dns cloudflare 'XXX..' #- > cert example names 'a.com,b.com,*.b.com' #- > cert example order #- > check #- #ca- #ca- Some CA directory urls: #ca- LetsEncrypt Test: https://acme-staging-v02.api.letsencrypt.org/directory #ca- LetsEncrypt Live: https://acme-v02.api.letsencrypt.org/directory #ca- Google Test: https://dv.acme-v02.test-api.pki.goog/directory #ca- Google Live: https://dv.acme-v02.api.pki.goog/directory #ca- ZeroSSL Live: https://acme.zerossl.com/v2/DV90 #ca- #eab- #eab- Request External Account Binding (EAB) #eab- #eab- For Google, open cloud shell in web https://console.cloud.google.com/welcome?cloudshell=true #eab- and run 'gcloud publicca external-account-keys create'. #eab- On failure read the guide: https://cloud.google.com/certificate-manager/docs/public-ca-tutorial #eab- #eab- For ZeroSSL run: curl --data 'email=your@email.com' https://api.zerossl.com/acme/eab-credentials-email #eab- #domain- #domain- Before creating a cert, domain must be configured. #domain- : "${CERTOSIS_CONFIG:="./certosis.conf"} ${CERTOSIS_LOG:="./${0%.*}-$(date '+%Y-%m-%d').log"}" usage() { sed -n "/^#$1- \?/s,,,p" "$0" >&2 } log() { printf '%s\n' "$1" >&2 printf '%s [%s] %s -- %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$$" "${SUDO_USER-$USER}" "$1" >>"$CERTOSIS_LOG" [ $# -lt 2 ] || usage "$2" } die() { printf "\n" >&2 # shellcheck disable=SC2145 # Intentionally prepend 'ERROR: ' only to the first argument log "ERROR: $@" exit 1 } conf() { sed -n "/^$(printf %s "$1" | sed 's/[][\\.^$*]/\\&/g') *= */$2" "$CERTOSIS_CONFIG" } conf_get() { conf "$1" '!b;s,,,p;q' } conf_has() { [ -n "$(conf_get "$1")" ] } conf_set() { REST=$(conf "$1" '!p') printf '%s = %s\n%s' "$1" "$2" "$REST" | sort >"$CERTOSIS_CONFIG" } conf_find() { sed -n "/^$1 *=/!b;s, *=.*,,p" "$CERTOSIS_CONFIG" } conf_ask() { conf_has "$1" || { printf "\n$2: " >&2 read -r R && [ -n "$R" ] && conf_set "$1" "$R" } } b64url() { openssl base64 | tr '/+' '_-' | tr -d '=\n' } b64dec() { printf "%s%$(((4-${#1}%4)%4))s\n" "$1" "" | tr '_ -' '/=+' | openssl base64 -d } sha256b64() { printf %s "$1" | openssl sha256 -binary | b64url } hexToBase64() { # shellcheck disable=SC2046 # Intentionally split printf %b "$(printf '\\%03o' $(sed 's/../0x& /g'))" | b64url } # Usage: json [file] [section-matcher] json() { tr -d '\011\n ' <"${2:-_dir}" | sed 's/{/\n{/g' | sed -n "/${3-.}/p" | sed -En 's/.*"'"$1"'":("[^"]+"|\[[^]]+\]|[[:alnum:]]*).*/\1/p' | sed 's/","/\n/g;s/[]["]//g' } sign() { # shellcheck disable=SC2046 # Intentionally split printf %64s $(printf %s.%s "$1" "$2" | openssl $3 | openssl asn1parse -inform der | cut -d: -f4) | tr ' ' 0 | hexToBase64 } req() { for TRY in $(seq 1 30); do sleep $((2*(TRY-1))) [ -n "$2" ] && { [ -n "$NONCE" ] || req "$(json newNonce)" || die 'Cannot get Nonce' PROTECTED=$(printf '{"alg":"ES256",%s,"nonce":"%s","url":"%s"}' "${3-"$KID"}" "$NONCE" "$1" | b64url) PAYLOAD=$(printf %s "$2" | b64url) SIG=$(sign "$PROTECTED" "$PAYLOAD" "sha256 -sign _key") DATA='{"protected":"'"$PROTECTED"'","payload":"'"$PAYLOAD"'","signature":"'"$SIG"'"}' RES=$(curl -si -H 'Content-Type: application/jose+json' -d "$DATA" "$1" | sed 's/[[:space:]]*$//g') } || RES=$(curl -si "$1" | sed 's/[[:space:]]*$//g') LOC=$(printf %s "$RES" | sed -En 's/location: *(.+)/\1/pi') NONCE=$(printf %s "$RES" | sed -En 's/replay-nonce: *(.+)/\1/pi') SLEEP=$(printf %s "$RES" | sed -En 's/retry-after: *(.+)/\1/pi') LINE=$(printf %s "$RES 500" | head -n1) [ "${LINE##* }" -lt 300 ] && { printf '%s\n' "$RES" return 0 } printf "Attempt %s\nREQ: %s\nRES: %s\n" "$TRY $1" "$DATA" "$RES" >&2 done return 1 } expand_key() { b64dec "$(conf_get "$1.key")" | openssl ec -inform DER 2>/dev/null >"$2" || { log "Creating KEY '$1'" openssl ecparam -genkey -name prime256v1 -noout >"$2" conf_set "$1.key" "$(openssl ec -in "$2" -no_public -conv_form compressed -outform DER 2>/dev/null | b64url)" } } get_kid() { [ -n "$KID" ] && return req "$CA" >_dir || die "Cannot get CA directory '$CA'" conf_ask ca.terms "CA terms of service: $(json termsOfService)\nAccept? (type YES)" # Expand compressed account.key or create a new one expand_key account _key conf_has account.kid || { log 'Registering account' REG_URL=$(json newAccount) || die "No newAccount URL" EMAIL=$(conf_get account.email) && EMAIL=',"contact":["mailto:'"$EMAIL"'"]' openssl ec -in _key -pubout -outform DER 2>/dev/null >_pub # DER public key: 30 [total-len] 30 [id-len] 06 [oid-len] 06 [curve-len] 03 [xy-len] 00 04 JWK='{"crv":"P-256","kty":"EC","x":"'$(tail -c64 _pub | head -c32 | b64url)'","y":"'$(tail -c32 _pub | b64url)'"}' conf_set account.jwk "$JWK" conf_set account.thumb "$(sha256b64 "$JWK")" EAB="" [ "$(json externalAccountRequired)" = "true" ] && { log 'External Account Required!' eab conf_ask ca.kid "Enter EAB key ID" conf_ask ca.mac "Enter EAB HMAC" PROTECTED=$(printf '{"alg":"HS256","kid":"%s","url":"%s"}' "$(conf_get ca.kid)" "$(json newAccount)" | b64url) PAYLOAD=$(printf %s "$JWK" | b64url) HEX=$(b64dec "$(conf_get ca.mac)" | xxd -ps -c 200) SIG=$(printf %s.%s "$PROTECTED" "$PAYLOAD" | openssl mac -digest sha256 -macopt "hexkey:$HEX" -binary HMAC | b64url) EAB=',"externalAccountBinding": {"protected":"'$PROTECTED'","payload":"'$PAYLOAD'","signature":"'$SIG'"}' } req "$(json newAccount)" '{"termsOfServiceAgreed":true'"${EMAIL}${EAB}}" '"jwk":'"$JWK" || die 'Account registration failed' conf_set account.kid "$LOC" } KID='"kid":"'$(conf_get account.kid)'"' } copy_cert() { NAME=$1 shift cp "$1" "$2" } get_domain() { DOM=$1 while ! conf_has "domain.$DOM.$2"; do [ "$DOM" = "${DOM#*.}" ] && die "No domain for '$1'" domain DOM=${DOM#*.} done } check_domain() { get_domain "$1" dns || get_domain "$1" http } # Usage: challenge challenge_add() { set -- "$(json value _auth)" "$(json token _auth '"type":"dns-01"').$(conf_get account.thumb)" "$(json url _auth '"type":"dns-01"')" RR="_acme-challenge.$1" printf "Add DNS record %s TXT=%s\nDone? " "$RR" "$(sha256b64 "$2")" read -r _ # Trigger validation req "$3" "{}" } # Usage: order order() { get_kid FILE=$1 # shellcheck disable=SC2046 # Intentionally split set -- $(conf_get "cert.$FILE.names" | tr ',' ' ') log "Order cert: '$FILE' Names: $*" [ $# -gt 0 ] || die "No names configured" for NAME; do check_domain "$NAME"; done NAMES=$(printf '{"type":"dns","value":"%s"},' "$@") req "$(json newOrder)" '{"identifiers":['"${NAMES%?}"']}' >_order || die 'Creating order failed' ORDER_URL=$LOC #ORDER_URL=$(sed -En 's/location: *(.+)/\1/pi' _order) for AUTH in $(json authorizations _order); do req "$AUTH" >_auth || die 'Reading token failed' [ "$(json status _auth '"challenges"')" = "pending" ] && challenge_add done expand_key "cert.$FILE" "$FILE.key" while req "$ORDER_URL" >_order; do case "$(json status _order)" in processing) sleep "${SLEEP:-1}" ;; ready) log "Send CSR" SAN=$(printf 'DNS:%s,' "$@") CSR=$(openssl req -new -sha256 -key "$FILE.key" -subj '/' -addext "subjectAltName=${SAN%?}" -outform DER | b64url) req "$(json finalize _order)" '{"csr":"'"$CSR"'"}' >/dev/null || die 'Sending CSR failed' ;; valid) log "Download CRT '$FILE.crt'" req "$(json certificate _order)" | sed '1,/^$/d' >"$FILE.crt" conf_set "cert.$FILE.enddate" "$(openssl x509 -noout -enddate -in "$FILE.crt" | cut -d= -f2)" return ;; *) break ;; esac done die 'Order failed' } export LC_ALL=C umask 077 # Check dependencies for cmd in cp curl cut date head openssl sed sleep sort tail tr; do [ -x "$(command -v "$cmd")" ] || die "Command '$cmd' not found" done # Touch config file if not writable [ -w "$CERTOSIS_CONFIG" ] || :>"$CERTOSIS_CONFIG" || die "Failed to create config file '$CERTOSIS_CONFIG'" CA=$(conf_get ca.dir) || { log 'No CA configured' ca printf "Enter CA directory url: " read -r CA && conf_set ca.dir "$CA" conf_ask account.email "Enter account e-mail address (optional)" } log "Run: $*" case "$1.$3" in cert.order) order "$2" ;; ca.dir|domain.dns|domain.http|cert.names|cert.key_path|cert.crt_path) K="$1.$2.$3" shift 3 conf_set "$K" "$*" ;; check.*) conf_find "cert\..*\.enddate" usage "$2";; help.*) usage "$2";; ?*) log "Invalid command '$*'" "" ;; esac # #