diff options
Diffstat (limited to 'letsencrypt')
| -rwxr-xr-x | letsencrypt | 955 | 
1 files changed, 679 insertions, 276 deletions
| diff --git a/letsencrypt b/letsencrypt index 60695eb..23659d5 100755 --- a/letsencrypt +++ b/letsencrypt @@ -1,8 +1,8 @@ -#!/bin/bash +#!/usr/bin/perl -T  #---------------------------------------------------------------------- -# Tiny Let's Encrypt ACME client -# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org> +# Let's Encrypt ACME client +# Copyright © 2016 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 @@ -18,284 +18,687 @@  # 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-tiny/webserver -ACME_CLIENT=/usr/lib/letsencrypt-tiny/client -CAfile=/usr/share/letsencrypt-tiny/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' or consult the manpage 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 ..] -		Consult the manpage for more information. -	EOF -    exit 0 +use strict; +use warnings; + +our $VERSION = '0.0.1'; +my $NAME = 'letsencrypt'; + +use Errno qw/EADDRINUSE EINTR/; +use Fcntl qw/F_GETFD F_SETFD FD_CLOEXEC SEEK_SET/; +use File::Temp (); +use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/; +use List::Util 'first'; +use POSIX (); +use Socket qw/PF_INET PF_INET6 PF_UNIX INADDR_ANY IN6ADDR_ANY +              SOCK_STREAM SOL_SOCKET SO_REUSEADDR SHUT_RDWR/; + +use Config::Tiny (); +use Net::SSLeay (); + +# Clean up PATH +$ENV{PATH} = join ':', qw{/usr/bin /bin}; +delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; + +my ($COMMAND, %OPTS, $CONFFILE, $CONFIG, @CLEANUP); +$SIG{$_} = sub() { exit 1 } foreach qw/INT TERM/; # run the END block upon SIGINT/SIGTERM + + +############################################################################# +# Parse and validate configuration +# +sub usage(;$$) { +    my $rv = shift // 0; +    if ($rv) { +        my $msg = shift; +        print STDERR $msg."\n" if defined $msg; +        print STDERR "Try '$NAME --help' or consult the manpage for more information.\n"; +    } +    else { +        print STDERR "Usage: $NAME [--config=FILE] [--socket=PATH] [OPTIONS] COMMAND [ARGUMENT ..]\n" +                    ."Consult the manpage for more information.\n"; +    } +    exit $rv; +} +usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s socket=s agreement-uri=s debug help|h/); +usage(0) if $OPTS{help}; + +$COMMAND = shift(@ARGV) // usage(1, "Missing command"); +$COMMAND = $COMMAND =~ /\A(new-reg|reg=\p{Print}*|new-cert|revoke-cert)\z/ ? $1 +         : usage(1, "Invalid command: $COMMAND"); # validate and untaint $COMMAND +@ARGV = map { /\A(\p{Print}*)\z/ ? $1 : die } @ARGV; # untaint @ARGV + +do { +    my $conffile = $OPTS{config} // first { -f $_ } +        ( "./$NAME.conf" +        , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME.conf" +        , "/etc/letsencrypt-tiny/$NAME.conf" +        ); +    die "Error: Can't find configuration file\n" unless defined $conffile; +    print STDERR "Using configuration file: $conffile\n" if $OPTS{debug}; +    open $CONFFILE, '<', $conffile or die "Can't open $conffile: $!\n"; +    my $conf = do { local $/ = undef, <$CONFFILE> }; +    # don't close $CONFFILE so we can pass it to the client + +    my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n"; +    my $defaults = delete $h->{_} // {}; +    my %valid = ( +        client => { +            socket  => (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.letsencrypt" : undef), +            user    => 'nobody', +            group   => 'nogroup', +            command => '/usr/lib/letsencrypt-tiny/client', +            # the rest is for the ACME client +            map {$_ => undef} qw/server timeout SSL_verify SSL_version SSL_cipher_list/ +        }, +        webserver => { +            listen                => ':80', +            'challenge-directory' => '/var/www/acme-challenge', +            user                  => 'www-data', +            group                 => 'www-data', +            command               => '/usr/lib/letsencrypt-tiny/webserver', +            iptables              => 'Yes' + +        } +    ); +    foreach my $s (keys %valid) { +        my $h = delete $h->{$s} // {}; +        my %v = map { $_ => delete $h->{$_} // $valid{$s}->{$_} } keys %{$valid{$s}}; +        die "Unknown option(s) in [$s]: ".join(', ', keys %$h)."\n" if %$h; +        $h->{$_} //= $defaults->{$_} foreach keys %$defaults; +        $CONFIG->{$s} = \%v; +    } +    die "Invalid section(s): ".join(', ', keys %$h)."\n" if %$h; +    $CONFIG->{_} = $defaults; +}; + +# Regular expressions for domain validation +my $RE_LABEL  = qr/[0-9a-z](?:[0-9a-z\x2D]{0,61}[0-9a-z])?/aai; +my $RE_DOMAIN = qr/$RE_LABEL(?:\.$RE_LABEL)+/; + + +############################################################################# +# Generate a Certificate Signing Request (in DER format) +# +sub gen_csr(%) { +    my %args = @_; +    return unless defined $args{'certificate-key'} and defined $args{subject}; +    return if defined $args{hash} and !grep { $args{hash} eq $_ } qw/md5 rmd160 sha1 sha224 sha256 sha384 sha512/; + +    my $config = File::Temp::->new(SUFFIX => '.conf', TMPDIR => 1) // die; +    $config->print( +        "[ req ]\n", +        "distinguished_name = req_distinguished_name\n", +        "req_extensions     = v3_req\n", + +        "[ req_distinguished_name ]\n", + +        "[ v3_req ]\n", +        # XXX Golang errors on extensions marked critical +        # https://github.com/letsencrypt/boulder/issues/565 +        #"basicConstraints     = critical, CA:FALSE\n", +        "basicConstraints     = CA:FALSE\n", +        "subjectKeyIdentifier = hash\n" +    ); +    #$config->print("keyUsage = critical, $args{keyUsage}\n")   if defined $args{keyUsage}; +    $config->print("keyUsage = $args{keyUsage}\n")             if defined $args{keyUsage}; +    $config->print("subjectAltName = $args{subjectAltName}\n") if defined $args{subjectAltName}; +    $config->close() or die "Can't close: $!"; + +    my @args = (qw/-new -batch -key/, $args{'certificate-key'}); +    push @args, "-$args{hash}" if defined $args{hash}; +    push @args, '-subj', $args{subject}, '-config', $config->filename(), qw/-reqexts v3_req/; + +    open my $fh, '-|', qw/openssl req -outform DER/, @args or die "fork: $!"; +    my $csr = do { local $/ = undef; <$fh> }; +    close $fh or $! ? die "Can't close: $!" : return; + +    if ($OPTS{debug}) { +        # print out the CSR in text form +        pipe my $rd, my $wd or die "pipe: $!"; +        my $pid = fork // die "fork: $!"; +        unless ($pid) { +            open STDIN,  '<&', $rd      or die "Can't dup: $!"; +            open STDOUT, '>&', \*STDERR or die "Can't dup: $!"; +            exec qw/openssl req -noout -text -inform DER/ or die; +        } +        $rd->close() or die "Can't close: $!"; +        $wd->print($csr); +        $wd->close() or die "Can't close: $!"; + +        waitpid $pid => 0; +        die $? if $? > 0; +    } + +    return $csr; +} + + +############################################################################# +# Get the timestamp of the given cert's expiration date. +# Internally the expiration date is stored as a RFC3339 string (such as +# yyyy-mm-ddThh:mm:ssZ); we convert it to a timestamp manually. +# +sub x509_enddate($) { +    my $filename = shift; +    my ($bio, $x509, $time, $dt); + +    $bio  = Net::SSLeay::BIO_new_file($filename, 'r'); +    $x509 = Net::SSLeay::PEM_read_bio_X509($bio)        if defined $bio; +    $time = Net::SSLeay::X509_get_notAfter($x509)       if defined $x509; +    $dt   = Net::SSLeay::P_ASN1_TIME_get_isotime($time) if defined $time; + +    my $t; +    if (defined $dt and $dt =~ s/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})//) { +        # RFC3339 datetime strings; assume epoch is on January 1 of $epoch_year +        my ($y, $m, $d, $h, $min, $s) = ($1, $2, $3, $4, $5, $6); +        my (undef,undef,undef,undef,undef,$epoch_year,undef,undef,undef) = gmtime(0); +        $t = 0; +        foreach (($epoch_year+1900) .. $y-1) { +            $t += 365*86400; +            $t += 86400 if ($_ % 4 == 0 and $_ % 100 != 0) or ($_ % 400 == 0); # leap +        } + +        if ($m > 1) { +            my @m = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31); +            $m[1]++ if ($y % 4 == 0 and $y % 100 != 0) or ($y % 400 == 0); # leap +            $t += 86400*$m[$_] for (0 .. $m-2); +        } + +        $t += 86400*($d-1); +        $t += $s + 60*($min + 60*$h); + +        $dt =~ s/\A\.(\d{1,9})\d*//; # ignore nanosecs + +        if ($dt =~ /\A([+-])(\d{2}):(\d{2})\z/) { +            my $tz = 60*($3 + 60*$2); +            $t = $1 eq '-' ? ($t+$tz) : ($t-$tz); +        } +    } + +    Net::SSLeay::X509_free($x509) if defined $x509; +    Net::SSLeay::BIO_free($bio)   if defined $bio; +    return $t; +} + + +############################################################################# +# Drop privileges and chdir afterwards +# +sub drop_privileges($$$) { +    my ($user, $group, $dir) = @_; + +    # set effective and real gid; also set the list of supplementary gids to that single gid +    if ($group ne '') { +        my $gid = getgrnam($group) // die "Can't getgrnam($group): $!"; +        $) = "$gid $gid"; +        die "Can't setgroups: $!" if $@; +        POSIX::setgid($gid) or die "Can't setgid: $!"; +        die "Couldn't setgid/setguid" unless $( eq "$gid $gid" and $) eq "$gid $gid"; # safety check +    } + +    # set effective and real uid +    if ($user ne '') { +        my $uid = getpwnam($user) // die "Can't getpwnam($user): $!"; +        POSIX::setuid($uid) or die "Can't setuid: $!"; +        die "Couldn't setuid/seteuid" unless $< == $uid and $> == $uid; # safety check +    } + +    chdir $dir or die "Can't chdir to $dir: $!"; +} + + +############################################################################# +# Ensure the FD_CLOEXEC bit is $set on $fd +# +sub set_FD_CLOEXEC($$) { +    my ($fd, $set) = @_; +    my $flags = fcntl($fd, F_GETFD, 0) or die "Can't fcntl F_GETFD: $!"; +    my $flags2 = $set ? ($flags | FD_CLOEXEC) : ($flags & ~FD_CLOEXEC); +    return if $flags == $flags2; +    fcntl($fd, F_SETFD, $flags2) or die "Can't fcntl F_SETFD: $!"; +} + + +############################################################################# +# Try to spawn a webserver to serve ACME challenges, and return the +# temporary challenge directory. +# If a webserver is already listening, symlink the 'challenge-directory' +# configuration option to the temporary challenge directory. +# Otherwise, bind(2) a socket, pass its fileno to the webserver +# component, and optionally install iptables rules. +# +sub spawn_webserver() { +    # create a temporary directory; give write access to the ACME client +    # and read access to the webserver +    my $tmpdir = File::Temp::->newdir(CLEANUP => 1, TMPDIR => 1) // die; +    chmod 0755, $tmpdir or die "Can't chmod: $!"; +    if ((my $username = $CONFIG->{client}->{user}) ne '') { +        my $uid = getpwnam($username) // die "Can't getgrnam($username): $!"; +        chown($uid, -1, $tmpdir) or die "Can't chown: $!"; +    } + +    my $conf = $CONFIG->{webserver}; +    my ($fam, $addr, $port) = (PF_INET, $conf->{listen}, 80); +    $port = $1 if $addr =~ s/:(\d+)$//; +    $addr = Socket::inet_ntop(PF_INET, INADDR_ANY) if $addr eq ''; +    $fam = PF_INET6 if $addr =~ s/^\[(.+)\]$/$1/; + +    my $proto = getprotobyname("tcp") // die; +    socket(my $srv, $fam, SOCK_STREAM, $proto) or die "socket: $!"; +    setsockopt($srv, SOL_SOCKET, SO_REUSEADDR, pack("l", 1)) or die "setsockopt: $!"; +    $addr = Socket::inet_pton($fam, $addr) // die "Invalid address $conf->{listen}\n"; +    my $sockaddr = $fam == PF_INET  ? Socket::pack_sockaddr_in($port,  $addr) +                 : $fam == PF_INET6 ? Socket::pack_sockaddr_in6($port, $addr) +                 : die; + +    # try to bind aginst the specified address:port +    bind($srv, $sockaddr) or do { +        die "Can't bind to $conf->{listen}: $!" if $! != EADDRINUSE; +        print STDERR "[$$] Using existing webserver on $conf->{listen}\n" if $OPTS{debug}; +        my $dir = $conf->{'challenge-directory'}; +        symlink $tmpdir, $dir or die "Can't symlink $dir -> $tmpdir: $!"; +        push @CLEANUP, sub() { +            print STDERR "Unlinking $dir\n" if $OPTS{debug}; +            unlink $dir or warn "Warning: Can't unlink $dir: $!"; +        }; +        return $tmpdir; +    }; +    listen($srv, 5) or die "listen: $!"; + +    # spawn the webserver component +    my $pid = fork() // "fork: $!"; +    unless ($pid) { +        drop_privileges($conf->{user}, $conf->{group}, $tmpdir); +        set_FD_CLOEXEC($srv, 0); +        $ENV{DEBUG} = $OPTS{debug}; +        # use execve(2) rather than a Perl pseudo-process to ensure that +        # the child doesn't have access to the parent's memory +        exec $conf->{command}, fileno($srv) or die; +    } + +    print STDERR "[$$] Forking ACME webserver, child PID $pid\n" if $OPTS{debug}; +    set_FD_CLOEXEC($srv, 1); +    push @CLEANUP, sub() { +        print STDERR "[$$] Shutting down ACME webserver\n" if $OPTS{debug}; +        shutdown($srv, SHUT_RDWR) or warn "shutdown: $!"; +        kill 15 => $pid; +        waitpid $pid => 0; +    }; + +    return $tmpdir if lc ($conf->{iptables} // 'Yes') eq 'no'; + +    # install iptables +    my $iptables_bin = $fam == PF_INET ? 'iptables' : $fam == PF_INET6 ? 'ip6tables' : die; +    my $iptables_tmp = File::Temp::->new(TMPDIR => 1) // die; +    set_FD_CLOEXEC($iptables_tmp, 1); + +    my $pid2 = fork() // die "fork: $!"; +    unless ($pid2) { +        open STDIN,  '<',  '/dev/null'   or die "Can't open /dev/null: $!"; +        open STDOUT, '>&', $iptables_tmp or die "Can't dup: $!"; +        $| = 1; # turn off buffering for STDOUT +        exec "/sbin/$iptables_bin-save", "-c" or die; +    } +    waitpid $pid2 => 0; +    die "Error: /sbin/$iptables_bin-save exited with value ".($? >> 8) if $? > 0; + +    # seek back to the begining, as we'll restore directly from the +    # handle and not from the file.  XXX if there was a way in Perl to +    # use open(2) with the O_TMPFILE flag we would use that to avoid +    # creating a file to start with +    seek($iptables_tmp, SEEK_SET, 0) or die "Can't seek: $!"; + +    push @CLEANUP, sub() { +        print STDERR "[$$] Restoring iptables\n" if $OPTS{debug}; +        my $pid2 = fork() // die "fork: $!"; +        unless ($pid2) { +            open STDIN, '<&', $iptables_tmp or die "Can't dup: $!"; +            open STDOUT, '>', '/dev/null'   or die "Can't open /dev/null: $!"; +            exec "/sbin/$iptables_bin-restore", "-c" or die; +        } +        waitpid $pid2 => 0; +        warn "Warning: /sbin/$iptables_bin-restore exited with value ".($? >> 8) if $? > 0; +    }; + +    # it's safe to install the new iptables to open $port now that the +    # restore hook is in place +    my $mask = $fam == PF_INET  ? ($addr eq INADDR_ANY  ? '0' : '32') +             : $fam == PF_INET6 ? ($addr eq IN6ADDR_ANY ? '0' : '128') +             : die; +    my $dest = Socket::inet_ntop($fam, $addr) .'/'. $mask; + +    system ("/sbin/$iptables_bin", qw/-I INPUT  -p tcp -m tcp -m state/, +            '-d', $dest, '--dport', $port, +            '--state', 'NEW,ESTABLISHED', '-j', 'ACCEPT') == 0 or die; +    system ("/sbin/$iptables_bin", qw/-I OUTPUT -p tcp -m tcp -m state/, +            '-s', $dest, '--sport', $port, +            '--state',     'ESTABLISHED', '-j', 'ACCEPT') == 0 or die; + +    return $tmpdir; +} + + +############################################################################# +# Spawn the client component, and wait for it to return. +# If $args->{in} is defined, the data is written to the client's STDIN. +# If $args->{out} is defined, its value is set to client's STDOUT data. +# +sub acme_client($@) { +    my $args = shift; +    my @args = @_; + +    my @stat; +    my $conf = $CONFIG->{client}; +    my $sockname = $OPTS{socket} // $conf->{socket} // die "Missing socket option\n"; +    $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : die "Invalid socket name\n"; # untaint $sockname + +    # ensure we're the only user with write access to the parent dir +    my $dirname = $sockname =~ s/[^\/]+$//r; +    @stat = stat($dirname) or die "Can't stat $dirname: $!"; +    die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0; + +    # ensure we're the only user with read/write access to the socket +    @stat = stat($sockname) or die "Can't stat $sockname: $!  (Is letsencrypt-accountd running?)\n"; +    die "Error: insecure permissions on $sockname\n" if ($stat[2] & 0066) != 0; + +    # connect(2) to the socket +    socket(my $client, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!"; +    my $sockaddr = Socket::sockaddr_un($sockname) // die "Invalid address $sockname\n"; +    until (connect($client, $sockaddr)) { +        next if $! == EINTR; # try again if connect(2) was interrupted by a signal +        die "connect: $!"; +    } + +    my @fileno = map { fileno($_) =~ /^(\d+)$/ ? $1 : die } ($CONFFILE, $client); # untaint fileno +    spawn({%$args{qw/in out/}, child => sub() { +        drop_privileges($conf->{user}, $conf->{group}, $args->{chdir} // '/'); +        set_FD_CLOEXEC($_, 0) foreach ($CONFFILE, $client); +        seek($CONFFILE, SEEK_SET, 0) or die "Can't seek: $!"; +        $ENV{DEBUG} = $OPTS{debug}; +    }}, $conf->{command}, $COMMAND, @fileno, @args); +} + +sub spawn($@) { +    my $args = shift; +    my @exec = @_; + +    # create communication pipes if needed +    my ($in_rd, $in_wd, $out_rd, $out_wd); +    if (defined $args->{in}) { +        pipe $in_rd, $in_wd or die "pipe: $!"; +    } +    if (defined $args->{out}) { +        pipe $out_rd, $out_wd or die "pipe: $!"; +    } + +    my $pid = fork() // "fork: $!"; +    unless ($pid) { +        # child +        $args->{child}->() if defined $args->{child}; +        if (defined $args->{in}) { +            close $in_wd or die "Can't close: $!"; +            open STDIN, '<&', $in_rd or die "Can't dup: $!"; +        } else { +            open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!"; +        } +        if (defined $args->{out}) { +            close $out_rd or die "Can't close: $!"; +            open STDOUT, '>&', $out_wd or die "Can't dup: $!"; +        } else { +            open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!"; +        } +        # use execve(2) rather than a Perl pseudo-process to ensure that +        # the child doesn't have access to the parent's memory +        exec { $exec[0] } @exec or die; +    } +    push @CLEANUP, sub() { +        kill 15 => $pid; +        waitpid $pid => 0; +    }; + +    # parent +    print STDERR "[$$] Forking $exec[0], child PID $pid\n" if $OPTS{debug}; +    if (defined $args->{in}) { +        $in_rd->close() or die "Can't close: $!"; +        $in_wd->print($args->{in}); +        $in_wd->close() or die "Can't close: $!"; +    } +    if (defined $args->{out}) { +        $out_wd->close() or die "Can't close: $!"; +        ${$args->{out}} = do { local $/ = undef; $out_rd->getline() }; +        $out_rd->close() or die "Can't close: $!"; +    } +    waitpid $pid => 0; +    pop @CLEANUP; +    undef ${$args->{out}} if defined $args->{out} and $? > 0; +    return $? > 255 ? ($? >> 8) : $? > 0 ? 1 : 0; +} + + +############################################################################# +# Install the certificate +# +sub install_cert($$@) { +    my $filename = shift; +    my $x509 = shift; + +    open my $fh, '>', $filename or die "Can't open $filename: $!"; +    print $fh $x509; +    foreach (@_) { # append the chain +        open my $fh2, '<', $_ or die "Can't open $_: $!"; +        my $ca = do { local $/ = undef; $fh2->getline() }; +        print $fh $ca; +        close $fh2 or die "Can't close: $!"; +    } +    close $fh or die "Can't close: $!";  } -# 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" + +############################################################################# +# new-reg [--agreement-uri=URI] [CONTACT ..] +# reg=URI [--agreement-uri=URI] [CONTACT ..] +# +if ($COMMAND eq 'new-reg' or $COMMAND =~ /^reg=/) { +    die "Invalid registration URI (use the 'new-reg' command to determine the URI)\n" +        if $COMMAND eq 'reg='; +    $OPTS{'agreement-uri'} = $OPTS{'agreement-uri'} =~ /\A(\p{Print}+)\z/ ? $1 +                           : die "Invalid value for --agreement-uri\n" +        if defined $OPTS{'agreement-uri'}; + +    unshift @ARGV, ($OPTS{'agreement-uri'} // ''); +    exit acme_client({}, @ARGV); +} + + +############################################################################# +# new-cert [SECTION ..] +#   TODO: renewal without the account key, see +#         https://github.com/letsencrypt/acme-spec/pull/168 +# +elsif ($COMMAND eq 'new-cert') { +    my $conf; +    do { +        my $conffile = $OPTS{'config-certs'} // $CONFIG->{_}->{'config-certs'} // first { -f $_ } +            ( "./$NAME-certs.conf" +            , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME-certs.conf" +            , "/etc/letsencrypt-tiny/$NAME-certs.conf" +            ); +        die "Error: Can't find certificate configuration file\n" unless defined $conffile; +        my $h = Config::Tiny::->read($conffile) or die Config::Tiny::->errstr()."\n"; +        my $defaults = delete $h->{_} // {}; +        my @valid = qw/certificate certificate-chain certificate-key min-days CAfile +                       hash keyUsage subject subjectAltName chown chmod notify/; +        foreach my $s (keys %$h) { +            $conf->{$s} = { map { $_ => delete $h->{$s}->{$_} } @valid }; +            die "Unknown option(s) in [$s]: ".join(', ', keys %{$h->{$s}})."\n" if %{$h->{$s}}; +            $conf->{$s}->{$_} //= $defaults->{$_} foreach keys %$defaults; +        } +    }; + +    my $challenge_dir; +    my $rv = 0; +    foreach my $s (@ARGV ? @ARGV : keys %$conf) { +        my $conf = $conf->{$s} // do { +            print STDERR "Warning: No such section $s, skipping\n"; +            $rv = 1; +            next; +        }; + +        my $certtype = first { defined $conf->{$_} } qw/certificate certificate-chain/; +        unless (defined $certtype) { +            print STDERR "[$s] Warning: Missing 'certificate' and 'certificate-chain', skipping\n"; +            $rv = 1; +            next; +        } + +        # skip certificates that expire at least $conf->{'min-days'} days in the future +        if (-f $conf->{$certtype} and defined (my $t = x509_enddate($conf->{$certtype}))) { +            my $d = $conf->{'min-days'} // 10; +            if ($d > 0 and $t - time > $d*86400) { +                my $d = POSIX::strftime('%Y-%m-%d %H:%M:%S UTC', gmtime($t)); +                print STDERR "[$s] Valid until $d, skipping\n"; +                next; +            } +        } + +        # generate the CSR +        my $csr = gen_csr(%$conf{qw/certificate-key subject subjectAltName keyUsage hash/}) // do { +            print STDERR "[$s] Warning: Couldn't generate CSR, skipping\n"; +            $rv = 1; +            next; +        }; + +        # spawn the webserver if not done already +        $challenge_dir //= spawn_webserver(); + +        # list all authorization domains to request +        my @authz; +        push @authz, $1 if defined $conf->{subject} =~ /\A.*\/CN=($RE_DOMAIN)\z/o; +        if (defined $conf->{subjectAltName}) { +            foreach my $d (split /,/, $conf->{subjectAltName}) { +                next unless $d =~ s/\A\s*DNS://; +                if ($d =~ /\A$RE_DOMAIN\z/o) { +                    push @authz, $d unless grep {$_ eq $d} @authz; +                } else { +                    print STDERR "[$s] Warning: Ignoring invalid domain $d\n"; +                } +            } +        } + +        my ($x509, $csr_pubkey, $x509_pubkey); +        print STDERR "[$s] Will request authorization for: ".join(", ", @authz), "\n" if $OPTS{debug}; +        if (acme_client({chdir => $challenge_dir, in => $csr, out => \$x509}, @authz)) { +            print STDERR "[$s] Error: Couldn't issue X.509 certificate!\n"; +            $rv = 1; +            next; +        } + +        # extract pubkeys from CSR and cert, and ensure they match +        spawn({in => $csr,  out => \$csr_pubkey }, qw/openssl req  -inform DER -noout -pubkey/); +        spawn({in => $x509, out => \$x509_pubkey}, qw/openssl x509 -inform PEM -noout -pubkey/); +        unless (defined $x509_pubkey and defined $csr_pubkey and $x509_pubkey eq $csr_pubkey) { +            print STDERR "[$s] Error: Received bogus X.509 certificate from ACME server!\n"; +            $rv = 1; +            next; +        }; + +        # verify certificate validity against the CA +        $conf->{CAfile} //= '/usr/share/letsencrypt-tiny/lets-encrypt-x1-cross-signed.pem'; +        if ($conf->{CAfile} ne '' and spawn({in => $x509}, 'openssl', 'verify', '-CAfile', $conf->{CAfile}, +                                                                      qw/-purpose sslserver -x509_strict/)) { +            print STDERR "[$s] Error: Received invalid X.509 certificate from ACME server!\n"; +            $rv = 1; +            next; +        } + +        # install certificate +        if (defined $conf->{'certificate'}) { +            print STDERR "Installing X.509 certificate $conf->{'certificate'}\n"; +            install_cert($conf->{'certificate'}, $x509); +        } +        if (defined $conf->{'certificate-chain'}) { +            print STDERR "Installing X.509 certificate chain $conf->{'certificate-chain'}\n"; +            install_cert($conf->{'certificate-chain'}, $x509, $conf->{CAfile}); +        } + +        if (defined $conf->{chown}) { +            my ($user, $group) = split /:/, $conf->{chown}, 2; +            my $uid = getpwnam($user) // die "Can't getpwnam($user): $!"; +            my $gid = defined $group ? (getgrnam($group) // die "Can't getgrnam($group): $!") : -1; +            foreach (grep defined, @$conf{qw/certificate certificate-chain/}) { +                chown($uid, $gid, $_) or die "Can't chown: $!"; +            } +        } +        if (defined $conf->{chmod}) { +            my $mode = oct($conf->{chmod}) // die; +            foreach (grep defined, @$conf{qw/certificate certificate-chain/}) { +                chmod($mode, $_) or die "Can't chown: $!"; +            } +        } + +        my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump/; +        open my $fh, '|-', qw/openssl x509 -noout -fingerprint -sha256 -text -certopt/, @certopts +            or die "fork: $!"; +        print $fh $x509; +        close $fh or die $! ? +            "Can't close: $!" : +            "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; + +        if (defined $conf->{notify}) { +            print STDERR "Running notification command `$conf->{notify}`\n"; +            if (system($conf->{notify}) != 0) { +                print STDERR "Warning: notification command exited with value ".($? >> 8)."\n"; +                $rv = 1; +            } +        } +    } +    undef $challenge_dir; +    exit $rv;  } -# 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" +############################################################################# +# revoke-cert FILE [FILE ..] +# +elsif ($COMMAND eq 'revoke-cert') { +    die "Nothing to revoke\n" unless @ARGV; +    my $rv = 0; +    foreach my $filename (@ARGV) { +        print STDERR "Revoking $filename\n"; + +        # conversion PEM -> DER +        open my $fh, '-|', qw/openssl x509 -outform DER -in/, $filename or die "fork: $!"; +        my $der = do { local $/ = undef; <$fh> }; +        close $fh or die $! ? +            "Can't close: $!" : +            "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; + +        my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump no_extensions/; +        open my $fh2, '|-', qw/openssl x509 -inform DER -noout -fingerprint -sha256 -text -certopt/, @certopts +            or die "fork: $!"; +        print $fh2 $der; +        close $fh2 or die $! ? +            "Can't close: $!" : +            "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; + +        if (acme_client({in => $der})) { +            print STDERR "Warning: Couldn't revoke $filename\n"; +            $rv = 1; +        } +    } +    exit $rv;  } -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 + + +############################################################################# +# +else { +    die "Unknown command $COMMAND"  } -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 + +END { +    local $?; +    $_->() foreach reverse @CLEANUP; +} | 
