#!/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
CAfile=/usr/share/lets-encrypt/letsencryptauthorityx1.pem

declare COMMAND ACCOUNTKEY
declare -l GENKEY
declare RUNAS QUIET= DEBUG=

declare SRVCRT= CHAIN= CSR SRVKEY
declare -i MIN_AGE=0
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
		    --min-age=SECONDS Don't do anything if the certificate specified by --output exists and its expiration
		                      is more than SECONDS ahead.
		    --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=*) SRVCRT="${1#*=}";;
        --min-age=*) MIN_AGE="${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 [ ! "${SRVCRT:-}" ]; then
        echo "Error: Missing --output" >&2
        exit 1
    fi
    if [ -s "$SRVCRT" ] && [ $MIN_AGE -gt 0 ] && \
         exp=$(openssl x509 -noout -enddate <"$SRVCRT" 2>/dev/null) && \
         [ $(( $(date -d "${exp#*=}" +%s) - $(date +%s))) -gt $MIN_AGE ]; then
        [ ! "$DEBUG" ] || echo "Expiration date ($(date -d"${exp#*=}")) is too far away, come back later." >&2
        exit 0
    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
    # https://crt.sh/?q=mail.fripost.org&iCAID=7395
    # https://crt.sh/?spkisha1=$sha1

    # Ensure the cert's pubkey matches that of the CSR, and that it's signed by the intended CA
    if [ ! -s "$x509" ] ||
         ! diff <(openssl req  -in "$CSR"  -pubkey -noout) \
                <(openssl x509 -in "$x509" -pubkey -noout) >/dev/null ||
         ! openssl verify -CAfile "$CAfile" -purpose sslserver -x509_strict <"$x509" >/dev/null; then
        echo "Error: Got an invalid X.509 certificate from the ACME server!" >&2
        exit 1
    fi

    # if it doesn't exist, create the file with mode 0644 minus the process's umask(2)
    [ -e "$SRVCRT" ] || touch "$SRVCRT"
    cat "$x509" >"$SRVCRT"
    [ ! "$DEBUG" ] || openssl x509 -noout -text <"$SRVCRT"

    if [ ! "$QUIET" ]; then
        echo "X.509 certificate $SRVCRT has been updated or renewed"
        echo
        openssl x509 -noout \
            -text -certopt no_header,no_version,no_pubkey,no_sigdump \
            -fingerprint -sha256 <"$SRVCRT"
    fi

    for (( i=0; i<${#NOTIFY[@]}; i++ )); do
        ${NOTIFY[$i]}
    done

else
    [ "$QUIET" ] || echo OK
fi