#!/bin/bash # Dependencies nc(1), openssl(1), socat(1) set -ue set -o pipefail PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NAME=$(basename $0) WWW_USER=www-data WWW_GROUP=www-data ACME_WEBSERVER=acme-webserver ACME_CLIENT=acme-slave declare COMMAND ACCOUNTKEY declare -l GENKEY declare RUNAS QUIET= DEBUG= declare SRVCERT= CHAIN= CSR SRVKEY declare -l HASH= declare SUBJECT=/ declare SAN= # https://security.stackexchange.com/questions/24106/which-key-usages-are-required-by-each-key-exchange-method declare KEYUSAGE='digitalSignature, keyEncipherment, keyCertSign' declare -a NOTIFY=() usage() { local msg="${1:-}" if [ "$msg" ]; then echo "$NAME: $msg" >&2 echo "Try '$NAME --help' for more information." >&2 exit 1 fi cat <<- EOF Usage: $NAME [OPTIONS] new-reg ACCOUNTKEY [EMAIL ..] or: $NAME [OPTIONS] new-cert ACCOUNTKEY --output=CERT {--csr=CSR | CSR Options } or: $NAME [OPTIONS] revoke-cert ACCOUNTKEY CERT [CERT ..] ACCOUNTKEY is the private key file of the user's account. Generic options are: --genkey[=ALGO[:BITS]] For 'new-*' commands, generate key pairs (with mode 0600) if they don't exist already. (Default: "RSA".) RSA is the only algorithm curently supported. --runas=USERNAME Username to run the ACME client as. (This user doesn't need access to any private key material.) --help, -? Display this help text and exit --quiet, -q Be quiet --debug Turn on debug mode $NAME new-reg ACCOUNTKEY [EMAIL ..] Register a new ACCOUNTKEY; an optional list of EMAIL addresses can be supplied as contact information. $NAME new-cert ACCOUNTKEY --output=CERT --csr=FILE $NAME new-cert ACCOUNTKEY --output=CERT --key=FILE [--hash=ALGO] [--subject=STRING] [--san=STRING] [--keyusage=STRING] Request a new Certificate Issuance. The Certificate Signing Request can be supplied directly, or generated from the server key. --csr=FILE Certificate Signing Request to send (alternatively, use --key to generate it) --key=FILE Server private key (use --genkey to generate it) --hash=DGST Message digest to sign the CSR with (in PEM format) --subject=STRING Subject name, formatted as "/type0=value0/type1=value1/type2=..." (default: "/") --san=STRING Comma-separated list of Subject Alternative Names formatted as "type:value" --keyusage=STRING Comma-separated list of Key Usages, see x509v3_config(5ssl) (default: "digitalSignature,keyEncipherment,keyCertSign") --chain Store not only the server certificate in the file specified with --output, but also the CA's --output=FILE Where to store the issued (signed) X.509 certificate --notify=COMMAND Command to run upon success. (This option can be repeated.) $NAME revoke-cert {ACCOUNTKEY|SVRKEY} FILE [FILE ..] Request that the given certificate(s) FILE(s) be revoked. The first argument can be either the account key file or the server's private key. EOF exit 0 } # Generate a key pair with mode 0600 genkey() { local key="$1" genkey= bits= case "$GENKEY" in rsa) genkey=genrsa;; rsa:*) genkey=genrsa; bits=${GENKEY#*:};; *) echo "Error: invalid key type ${GENKEY%%:*}" >&2; exit 1;; esac install --mode=0600 /dev/null "$key" # atomic creation with mode 0600 openssl "$genkey" $bits >"$key" } # Parse options declare -a ARGV=() while [ $# -gt 0 ]; do case "$1" in --genkey) GENKEY=RSA;; --genkey=*) GENKEY="${1#*=}";; --runas=*) RUNAS="${1#*=}";; --help|-\?) usage;; --quiet|-q) QUIET=1;; --debug) DEBUG=1;; --output=*) SRVCERT="${1#*=}";; --chain) CHAIN=1;; --csr=*) CSR="${1#*=}";; --key=*) SRVKEY="${1#*=}";; --hash=*) HASH="${1#*=}";; --subject=*) SUBJECT="${1#*=}";; --san=*) SAN="${1#*=}";; --keyusage=*) KEYUSAGE="${1#*=}";; --notify=*) NOTIFY+=( "${1#*=}" );; --) shift; ARGV+=( "$@" ); break ;; -*) usage "unknown option '${1%%=*}'";; *) if [ ${ACCOUNTKEY+x} ]; then ARGV+=( "$1" ) else [ ${COMMAND+x} ] && ACCOUNTKEY="$1" || COMMAND="$1" fi esac shift done [ "${COMMAND:-}" ] || usage "missing command" [ "$COMMAND" = 'new-reg' -o "$COMMAND" = 'new-cert' -o "$COMMAND" = 'revoke-cert' ] || usage "invalid command $COMMAND" [ ${#ARGV[@]} -eq 0 -o "$COMMAND" = 'new-reg' -o "$COMMAND" = 'revoke-cert' ] || usage "invalid argument ${ARGV[0]}" [ "${ACCOUNTKEY:-}" ] || usage "missing account key" if ! [ -f "$ACCOUNTKEY" -a -s "$ACCOUNTKEY" ]; then if [ "$COMMAND" = 'new-reg' -a "${GENKEY+x}" ]; then genkey "$ACCOUNTKEY" else echo "Error: keyfile '$ACCOUNTKEY' does not exist." >&2 [[ ! "$COMMAND" =~ ^new- ]] || echo "Use 'new-reg --genkey' to generate and register an RSA key with mode 0600." >&2 exit 1 fi fi declare -a TMPFILES=() declare CHALLENGE_DIR IPTABLES_SAVE cleanup() { set +e [ ! ${IPTABLES_SAVE+x} ] || iptables-restore -c <"$IPTABLES_SAVE" pkill -TERM -P $$ [ ${#TMPFILES[@]} -eq 0 ] || rm -${DEBUG:+v}f "${TMPFILES[@]}" [ ! ${CHALLENGE_DIR+x} ] || rm -${DEBUG:+v}rf "$CHALLENGE_DIR" } trap cleanup EXIT SIGINT # Extract the public part of the user key accountpub=$(mktemp --tmpdir XXXXXX.pub) TMPFILES+=( "$accountpub" ) openssl pkey -pubout <"$ACCOUNTKEY" >"$accountpub" chmod 0644 "$accountpub" if [ "$COMMAND" = 'revoke-cert' ]; then if [ ${#ARGV[@]} -eq 0 ]; then echo "Error: Nothing to revoke" >&2 exit 1 fi elif [ "$COMMAND" = 'new-cert' ]; then if [ ! "${SRVCERT:-}" ]; then echo "Error: Missing --output" >&2 exit 1 fi # Generate a Certificate Signing Request if need be if [ ${CSR+x} ]; then if [ -z "$CSR" -o ! -s "$CSR" ]; then echo "Error: Missing Certificate Signing Request $CSR. (One of --csr or --key must be set.)" >&2 exit 1 fi else CSR=$(mktemp --tmpdir XXXXXX.csr) config=$(mktemp --tmpdir XXXXXX.cnf) TMPFILES+=( "$CSR" "$config" ) chmod 0644 "$CSR" if [ "$HASH" ]; then case "$HASH" in md5|rmd160|sha1|sha224|sha256|sha384|sha512) HASH="-$HASH";; *) echo "Invalid digest algorithm: $HASH" >&2; exit 1;; esac fi if [ ! "${SRVKEY:-}" ] || [ ! -s "$SRVKEY" ]; then if [ "${SRVKEY:-}" -a "${GENKEY+x}" ]; then genkey "$SRVKEY" else echo "Error: Missing private server key. Use new-cert --genkey --key=... to generate" >&2 exit 1 fi fi [ ! "$DEBUG" ] || echo 'Generating Certificate Signing Request...' >&2 cat >"$config" <<- EOF [ req ] distinguished_name = req_distinguished_name req_extensions = v3_req [ req_distinguished_name ] [ v3_req ] ${SAN:+subjectAltName= $SAN} # XXX Golang errors on extensions marked critical # https://github.com/letsencrypt/boulder/issues/565 #basicConstraints = critical, CA:FALSE #keyUsage = critical${KEYUSAGE:+, $KEYUSAGE} basicConstraints = CA:FALSE keyUsage = $KEYUSAGE subjectKeyIdentifier = hash EOF openssl req -new -batch -key "$SRVKEY" $HASH -subj "$SUBJECT" -config "$config" -reqexts v3_req >"$CSR" fi [ ! "$DEBUG" ] || openssl req -noout -text <"$CSR" CHALLENGE_DIR=$(mktemp --tmpdir -d acme-challenge.XXXXXX) x509=$(mktemp --tmpdir XXXXXX.pem) TMPFILES+=( "$x509" ) [ ! "${RUNAS:-}" ] || chown "$RUNAS" "$CHALLENGE_DIR" "$x509" chgrp "$WWW_GROUP" "$CHALLENGE_DIR" chmod 0750 "$CHALLENGE_DIR" # Make sure a webserver is configured to server ACME challenges if nc -z 127.0.0.1 80; then [ ! "$DEBUG" ] || echo "Using existing webserver" >&2 ln -${DEBUG:+v}Ts "$CHALLENGE_DIR" /var/www/acme-challenge TMPFILES+=( "/var/www/acme-challenge" ) else temp=$(mktemp --tmpdir) TMPFILES+=( "$temp" ) iptables-save -c >"$temp" IPTABLES_SAVE="$temp" iptables -I INPUT -p tcp -m tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT iptables -I OUTPUT -p tcp -m tcp --sport 80 -m state --state ESTABLISHED -j ACCEPT ( [ ! "$DEBUG" ] || echo "Starting ACME webserver in $CHALLENGE_DIR" >&2 cd "$CHALLENGE_DIR" || exit 1 exec socat \ TCP-LISTEN:80,setgid="$WWW_GROUP",setuid="$WWW_USER",reuseaddr,fork,max-children=5 \ EXEC:"$ACME_WEBSERVER" )& fi ARGV=( "$CSR" "$CHALLENGE_DIR" "$x509" ) fi pipe=$(mktemp --tmpdir -u XXXXXX.fifo) mkfifo -m0600 "$pipe" TMPFILES+=( "$pipe" ) # Wait for signing requests from the ACME slave acme_client() { if [ "${RUNAS:-}" ]; then sudo -u "$RUNAS" ${DEBUG:+DEBUG="$DEBUG"} -- $ACME_CLIENT "$@" else [ ${RUNAS+x} ] || echo "WARNING: Use --runas=USER to specify a user to drop permissions to." >&2 DEBUG="$DEBUG" $ACME_CLIENT "$@" fi } acme_client "$COMMAND" "$accountpub" ${ARGV[0]+"${ARGV[@]}"} <"$pipe" | while read data; do echo -n "$data" | openssl dgst -sha256 -sign "$ACCOUNTKEY" -hex | sed 's/.*=\s*//' done >"$pipe" if [ "$COMMAND" = 'new-cert' ]; then # TODO # Verify: dump and compare public keys # Valid cert, signed by the right CA # Copy "$x509" to "$SRVCERT", possibly chained # https://crt.sh/?q=cse-fresti.cse.chalmers.se&iCAID=7395 cp "$x509" "$SRVCERT" for (( i=0; i<${#NOTIFY[@]}; i++ )); do ${NOTIFY[$i]} done fi