aboutsummaryrefslogtreecommitdiffstats
path: root/letsencrypt
blob: cba42711a6541ed445f8585651f70bb636c54b07 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
#!/bin/bash

#----------------------------------------------------------------------
# Tiny Let's Encrypt ACME client
# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
#
# 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 <http://www.gnu.org/licenses/>.
#----------------------------------------------------------------------

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