#!/bin/bash #---------------------------------------------------------------------- # Tiny Let's Encrypt ACME client # Copyright © 2015 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- 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=/usr/lib/letsencrypt/webserver ACME_CLIENT=/usr/lib/letsencrypt/client CAfile=/usr/share/lets-encrypt/lets-encrypt-x1-cross-signed.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 using options --hash, --subject, --san and --keyusage. --min-age=SECONDS Skip the issuance if the certificate specified by --output exists and its expiration date is more than SECONDS ahead. --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") --output=FILE Where to store the issued (signed) X.509 certificate --chain[=FILE] Store the server certificate along with its intermediate CA in FILE; if FILE is empty or omitted, use the file specified with --output --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=;; --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 [ "$QUIET" ] || echo OK else # 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" # add the CA chain if [ ${CHAIN+x} ]; then if [ "${CHAIN:-$SRVCRT}" = "$SRVCRT" ]; then cat "$CAfile" >>"$SRVCRT" else [ -e "$CHAIN" ] || touch "$CHAIN" cat "$SRVCRT" "$CAfile" >"$CHAIN" fi fi 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 fi