diff options
| author | Guilhem Moulin <guilhem@fripost.org> | 2016-03-02 18:19:04 +0100 | 
|---|---|---|
| committer | Guilhem Moulin <guilhem@fripost.org> | 2016-03-02 18:19:04 +0100 | 
| commit | 97369c2e6dce66881d673ba308acd069c08a8776 (patch) | |
| tree | 3946b0216071d0b2e47b805ac2a19bef685a6c38 /client | |
| parent | c4db1e4a18a13f7db04dbbd10663f0edba7d206d (diff) | |
| parent | 63633fd91bcc97217f2ac45ba602d752e8fbaafd (diff) | |
Merge branch 'master' into debian
Diffstat (limited to 'client')
| -rwxr-xr-x | client | 433 | 
1 files changed, 254 insertions, 179 deletions
| @@ -2,7 +2,7 @@  #----------------------------------------------------------------------  # Let's Encrypt ACME client -# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org> +# Copyright © 2015,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 @@ -21,98 +21,170 @@  use strict;  use warnings; +# Usage: client COMMAND CONFIG_FD SOCKET_FD [ARGUMENTS] +# +# fdopen(3) the file descriptor SOCKET_FD (corresponding to the +# listening letsencrypt-accountd socket), connect(2) to it to retrieve +# the account key's public parameters and later send data to be signed +# by the master component (using the account key). +# +# CONFIG_FD is a read-only file descriptor associated with the +# configuration file at pos 0.  (This is needed since this process +# doesn't know its name and might not have read access to it.) +# +# NOTE: one needs to chdir(2) to an appropriate ACME challenge directory +# before running this program, since challenge tokens are (on purpose) +# only written to the current directory.  If COMMAND is challenge-less, +# one should chdir(2) to the root directory "/" instead. +# +# NOTE: one should run this program as an unprivileged user:group such +# as "nobody:nogroup"; bind(2)'ing to a restricted UNIX socket (for +# instance own by another user and created with umask 0177) is not a +# problem since SOCKET_FD can be bound as root prior to the execve(2). + +my $PROTOCOL_VERSION = 1; +  use LWP::UserAgent (); -use Crypt::OpenSSL::RSA (); -use Crypt::OpenSSL::Bignum ();  use MIME::Base64 qw/encode_base64 encode_base64url/;  use JSON ();  use Digest::SHA qw/sha256 sha256_hex/; +use Config::Tiny (); +  # Clean up PATH  $ENV{PATH} = join ':', qw{/usr/bin /bin};  delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};  my $COMMAND = shift @ARGV // die; -my $PUBKEY = shift @ARGV // die; -die unless grep {$COMMAND eq $_} qw/new-reg new-cert revoke-cert/; -my $TIMEOUT = 10; +# Untaint and fdopen(3) the configuration file and listening socket +(shift @ARGV // die) =~ /\A(\d+)\z/ or die; +open my $CONFFILE, '<&=', $1 or die "fdopen $1: $!"; +(shift @ARGV // die) =~ /\A(\d+)\z/ or die; +open my $S, '+<&=', $1 or die "fdopen $1: $!"; -# Read the public key and build the JSON Web Key (RFC 7517) -my $JWK = do { -    open my $fh, '<', $PUBKEY or die "Can't open $PUBKEY: $!"; -    my $str = do { local $/ = undef; <$fh> }; -    my $pubkey = Crypt::OpenSSL::RSA->new_public_key($str) or die; -    close $fh; -    my ($n, $e) = $pubkey->get_key_parameters(); -    $_ = encode_base64url($_->to_bin()) foreach ($n, $e); +############################################################################# +# Read the protocol version and JSON Web Key (RFC 7517) from the +# letsencrypt-accountd socket +# +die "Error: Invalid client version\n" unless +    $S->getline() =~ /\A(\d+) OK(?:.*)\r\n\z/ and $1 == $PROTOCOL_VERSION; +my $JWK = JSON::->new->decode($S->getline()); -    { kty => 'RSA', n => $n, e => $e } -}; -my $JSON = JSON::->new->utf8->canonical(); # breaks hashes otherwise -my $JWK_dgst64 = encode_base64url(sha256($JSON->encode($JWK))); +# JSON keys need to be sorted lexicographically (for instance in the thumbprint) +sub json() { JSON::->new->utf8->canonical(); } + +my $JWK_thumbprint = encode_base64url(sha256(json()->encode($JWK)));  my $NONCE; -# Send an HTTP request to the ACME server -my $UA = LWP::UserAgent::->new( ssl_opts => { -    verify_hostname => 1, -    SSL_version => 'SSLv23:!TLSv1_1:!TLSv1:!SSLv3:!SSLv2', -    SSL_cipher_list => 'EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL' -}); +############################################################################# +# Parse configuration (already validated by the master) and create the +# LWP::UserAgent object +# + +my $CONFIG = do { +    my $conf = do { local $/ = undef, <$CONFFILE> }; +    close $CONFFILE or die "Can't close: $!"; +    my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n"; +    $h->{_} //= {}; +    $h->{client}->{$_} //= $h->{_}->{$_} foreach keys %{$h->{_}}; # add defaults +    $h->{client}; +}; +my $UA = do { +    my %args = %$CONFIG; +    my $verify = lc (delete $args{SSL_verify} // 'Yes') eq 'no' ? 0 : 1; +    my %ssl_opts = ( verify_hostname => $verify ); +    $ssl_opts{$_} = $args{$_} foreach grep /^SSL_/, keys %args; +    LWP::UserAgent::->new( ssl_opts => \%ssl_opts ); +} // die "Can't create LWP::UserAgent object"; + + +############################################################################# +# Send an HTTP request to the ACME server.  If $json is defined, send +# its encoding as the request content, with "application/json" as +# Content-Type. +#  sub request($$;$) {      my ($method, $uri, $json) = @_; -    print STDERR ">>> $method $uri <<<\n" if $ENV{DEBUG}; +    print STDERR "[$$] >>> $method $uri <<<\n" if $ENV{DEBUG};      my $req = HTTP::Request::->new($method => $uri) or die "Can't $method $uri";      if (defined $json) { -        $req->header('Content-Type' => 'application/json'); -        $req->content($JSON->encode($json)); +        $req->content_type('application/json'); +        $req->content(json()->encode($json));      }      my $r = $UA->request($req) or die "Can't $method $uri"; -    print STDERR ">>> ", $r->status_line, "\n", $r->headers->as_string, "\n" if $ENV{DEBUG}; -    $NONCE = $r->header('Replay-Nonce') // die; -    my $t = $r->header('Content-Type'); +    $NONCE = $r->header('Replay-Nonce'); # undef $NONCE if the header is missing +    print STDERR "[$$] >>> ", $r->status_line, "\n", $r->headers->as_string, "\n" if $ENV{DEBUG}; +    return $r; +} -    my $content = $r->decoded_content(); -    if (defined $t and $t =~ /\Aapplication\/(?:[a-z]+\+)?json\z/) { -        $content = $JSON->decode($content); -        print STDERR $JSON->pretty->encode($content), "\n" if $ENV{DEBUG}; -    } -    elsif (defined $t and $t eq 'application/pkix-cert') { -        print STDERR encode_base64($content), "\n" if $ENV{DEBUG}; -    } -    else { -        print STDERR $content, "\n" if $ENV{DEBUG}; -    } -    unless ($r->is_success) { -        my $msg = $r->status_line; -        $msg .= " (".$content->{detail}.")" if ref $content and defined $content->{detail}; -        die $msg, "\n"; +# List all 'Links' headers with the relationship $rel (RFC 5988) +sub request_link_rel($$) { +    my ($r, $rel) = @_; +    grep defined, map +        { /\A<([^>]+)>.*;\s*rel=([^;]+)/ +        ; my ($link, $rels) = ($1, $2 // '') +        ; (grep { $rel eq $_ } map { /^"(.*)"/ ? $1 : $_ } split(/\s+/, $rels)) ? $link : undef +        } +        $r->header('Link'); +} + +# The request's Status Line; if the Content-Type is +# application/problem+json, parse the decoded content as JSON and add +# the value of the 'detail' field to the Status Line. +# https://tools.ietf.org/html/draft-ietf-appsawg-http-problem +sub request_status_line($) { +    my $r = shift; +    my $msg = $r->status_line; +    if ($r->content_type() eq 'application/problem+json') { +        my $content = json()->decode($r->decoded_content()); +        print STDERR json()->pretty->encode($content), "\n" if $ENV{DEBUG}; +        $msg .= " (".$content->{detail}.")" if defined $content->{detail};      } +    return $msg; +} +# Parse and return the request's decoded content as JSON; or print the +# Status Line and fail if the request failed. +# If $dump is set, also pretty-print the decoded content. +sub request_json_decode($;$) { +    my $r = shift; +    my $dump = shift || $ENV{DEBUG}; +    die request_status_line($r), "\n" unless $r->is_success(); +    my $content = $r->decoded_content(); + +    die "Content-Type: ".$r->content_type()." is not application/json\n" +        unless $r->content_type() eq 'application/json'; +    $content = json()->decode($content); + +    print STDERR json()->pretty->encode($content), "\n" if $dump;      return $content;  } -# ACME client -# https://tools.ietf.org/html/draft-ietf-acme-acme-01 +############################################################################# +# JSON-encode the hash reference $h and send it to the ACME server $uri +# encapsulated it in a JSON Web Signature (JWS). +# https://tools.ietf.org/html/draft-ietf-acme-acme +#  sub acme($$) {      my ($uri, $h) = @_; +    # the ACME server MUST provide a Replay-Nonce header field in +    # response to a HEAD request for any valid resource +    request(HEAD => $uri) unless defined $NONCE; +      # Produce the JSON Web Signature: RFC 7515 section 5 -    my $payload = encode_base64url($JSON->encode($h)); +    my $payload = encode_base64url(json()->encode($h));      my %header = ( alg => 'RS256', jwk => $JWK ); -    my $protected = encode_base64url($JSON->encode({ %header, nonce => $NONCE })); +    my $protected = encode_base64url(json()->encode({ %header, nonce => $NONCE }));      my $data = $protected .'.'. $payload; -    print STDERR "Requesting a SHA-256 signature for ", $data, "\n" if $ENV{DEBUG}; -    STDOUT->printflush($data, "\n"); - -    # Ask for an (hex) sig -    my $sig = do { local $_ = <STDIN>; chomp; $_ }; -    $sig = encode_base64url(pack('H*', $sig)); -    print STDERR "Got SHA-256 signature ", $sig, "\n" if $ENV{DEBUG}; +    $S->printflush($data, "\r\n"); +    my $sig = $S->getline(); +    $sig =~ s/\r\n\z// or die;      # Flattened JSON Serialization, RFC 7515 section 7.2.2      request(POST => $uri, { @@ -123,147 +195,150 @@ sub acme($$) {      });  } - -# Query the root ACME directory to initialize the nonce and get the resources URIs -my %RES = %{ request(GET => "https://acme-v01.api.letsencrypt.org/directory") }; +my $SERVER_URI = $CONFIG->{server} // 'https://acme-v01.api.letsencrypt.org/'; +$SERVER_URI .= '/' unless substr($SERVER_URI, -1, 1) eq '/'; + +my %RES; +# Get the resource URI from the directory +sub acme_resource($%) { +    my $r = shift; +    # Query the root ACME directory to initialize the nonce and get the resources URIs +    %RES = %{ request_json_decode(request(GET => $SERVER_URI.'directory')) } unless %RES; +    my $uri = $RES{$r} // die "Missing ressource for \"$r\"\n"; +    acme($uri, {resource => $r, @_}); +} +############################################################################# +# new-reg AGREEMENT_URI [CONTACT ..] +#  if ($COMMAND eq 'new-reg') { +    my $agreement = shift @ARGV;      print STDERR "Requesting new registration ".(@ARGV ? ("for ".join(', ', @ARGV)) : "")."\n"; -    my $uri = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"; -    my $dgst = sha256_hex($UA->get($uri)->decoded_content()); -    die "Error: The CA's subscriber agreement (URL $uri) has changed!\n" if -        $dgst ne '33d233c8ab558ba6c8ebc370a509acdded8b80e5d587aa5d192193f35226540f'; - -    acme($RES{'new-reg'}, { -        resource => 'new-reg', -        contact => [ map {"mailto:$_"} @ARGV ], -        agreement => $uri, -    }); -    exit; -} +    my %h = (contact => \@ARGV); +    $h{agreement} = $agreement if $agreement ne ''; +    my $r = acme_resource('new-reg', %h); -if ($COMMAND eq 'revoke-cert') { -    print STDERR "Requesting revocation for\n"; -    for my $cert (@ARGV) { -        open my $fh1, '-|', qw/openssl x509 -noout -subject -serial -fingerprint -sha256/, '-in', $cert -            or die "Can't run x509(1ssl): $!"; -        my ($subject, $serial, $fingerprint) = map { s/[^=]+=\s*//; chomp; $_ } <$fh1>; -        close $fh1; - -        print STDERR "\n\tSubject:             $subject\n", -                       "\tSerial:              $serial\n", -                       "\tSHA-256 fingerprint: $fingerprint\n"; +    my ($terms) = request_link_rel($r, 'terms-of-service'); +    request_json_decode($r,1) if $r->is_success() and $ENV{DEBUG}; # pretty-print the JSON +    print STDERR request_status_line($r), "\n"; +    print STDERR "Subscriber Agreement URI: $terms\n" if defined $terms; +    print STDERR "Registration URI: ", $r->header('Location'), "\n"; +    exit ($r->is_success() ? 0 : 1); +} -        open my $fh2, '-|', qw/openssl x509 -outform DER/, '-in', $cert or die "Can't run x509(1ssl): $!"; -        my $der = do { local $/ = undef; <$fh2> }; -        close $fh2; -        acme($RES{'revoke-cert'}, { -            resource => 'revoke-cert', -            certificate => encode_base64url($der) -        }); -    } -    exit; +############################################################################# +# reg=URI AGREEMENT_URI [CONTACT ..] +# +elsif ($COMMAND =~ /\Areg=(\p{Print}+)\Z/) { +    die "Empty registration URI (use the 'new-reg' command to determine the URI)\n" if $1 eq ''; +    my $uri = $SERVER_URI.$1; +    my $agreement = shift @ARGV; + +    my %h = (resource => 'reg'); +    $h{agreement} = $agreement if $agreement ne ''; +    $h{contact} = \@ARGV if @ARGV; # don't empty the contact list +    my $r = acme($uri, \%h); + +    my ($terms) = request_link_rel($r, 'terms-of-service'); +    $r->is_success() ? request_json_decode($r,1)  # pretty-print the JSON +                     : print STDERR request_status_line($r), "\n"; +    print STDERR "Subscriber Agreement URI: $terms\n" if defined $terms; +    exit ($r->is_success() ? 0 : 1);  } -# $COMMAND eq 'new-cert' -my ($CSR, $CHALLENGE_DIR, $X509) = @ARGV; -$CHALLENGE_DIR = $CHALLENGE_DIR =~ /\A(\/\p{Print}+)\z/ ? $1 : -    die "Error: Challenge directory is not absolute: $CHALLENGE_DIR"; - -# Parse the Certificate Signing Request -# XXX use a library instead, perhaps Crypt::OpenSSL::PKCS10 -my @domains = do { -    my @req = (qw/openssl req -noout/, '-in', $CSR); - -    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)+/; -    my %domains; - -    open my $fh1, '-|', @req, '-subject' or die "Can't run req(1ssl): $!"; -    my $subject = <$fh1>; -    close $fh1; -    $domains{$1} = 1 if $subject =~ /\Asubject=.*\/CN=($RE_domain)\n\z/o; - -    open my $fh2, '-|', @req, '-text', '-reqopt', 'no_header,no_version,no_subject,no_pubkey,no_sigdump' -        or die "Can't run req(1ssl): $!"; -    while (<$fh2>) { -        /\A\s+X509v3 Subject Alternative Name:/ or next; -        my $san = <$fh2>; -        foreach (split /,/, $san) { -            chomp; -            s/\A\s*//; -            next unless s/\ADNS://; -            if (/\A$RE_domain\z/o) { -                $domains{$_} = 1; -            } -            else { -                warn "WARNING: Ignoring invalid domain $_\n"; -            } +############################################################################# +# new-cert AUTHZ [AUTHZ ..] +#   Read the CSR (in DER format) from STDIN, print the cert (in PEM format +#   to STDOUT) +# +elsif ($COMMAND eq 'new-cert') { +    die unless @ARGV; +    foreach my $domain (@ARGV) { +        print STDERR "Processing new DNS authz for $domain\n" if $ENV{DEBUG}; +        my $r = acme_resource('new-authz', identifier => {type => 'dns', value => $domain}); + +        my ($challenge) = grep {$_->{type} eq 'http-01'} +                               @{request_json_decode($r)->{challenges} // []}; +        die "Missing 'http-01' challenge in server response" unless defined $challenge; +        die "Invalid challenge token ".($challenge->{token} // '')."\n" +            unless ($challenge->{token} // '') =~ /\A[A-Za-z0-9_\-]+\z/; + +        my $keyAuthorization = $challenge->{token}.'.'.$JWK_thumbprint; + +        # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token} +        if (-e $challenge->{token}) { +            print STDERR "WARNING: File exists: $challenge->{token}\n"; +        } +        else { +            open my $fh, '>', $challenge->{token} or die "Can't open $challenge->{token}: $!"; +            $fh->print($keyAuthorization); +            $fh->close() or die "Can't close: $!";          } -        last; -    } -    close $fh2; - -    keys %domains; -}; -print STDERR "Found domain(s): ".join(", ", @domains), "\n" if $ENV{DEBUG}; - -# Process DNS Authorizations -foreach my $domain (@domains) { -    print STDERR "Processing new DNS authz for $domain\n" if $ENV{DEBUG}; -    my $challenges = acme($RES{'new-authz'}, { -        resource => 'new-authz', -        identifier => { type => 'dns', value => $domain } -    }); -    die "No challenge in server response" unless defined $challenges->{challenges}; -    my ($challenge) = grep {$_->{type} eq 'http-01'} @{$challenges->{challenges}}; -    my $keyAuthorization = $challenge->{token}.'.'.$JWK_dgst64; - -    # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token} -    my $filename = $CHALLENGE_DIR.'/'.$challenge->{token}; -    if (-e $filename) { -        warn "WARNING: File exists: $filename\n"; -    } -    else { -        open my $fh, '>', $filename or die "Can't open $filename: $!"; -        print $fh $keyAuthorization; -        close $fh; +        $r = acme($challenge->{uri}, { +            resource => 'challenge', +            keyAuthorization => $keyAuthorization +        }); +        # wait until the status become 'valid' +        for ( my $i = 0, my $status; +              $status = request_json_decode($r)->{status} // 'pending', +              $status ne 'valid'; +              $r = request('GET' => $challenge->{uri}), $i++ ) { +            die "Error: Invalid challenge for $domain (status: ".$status.")\n" if $status ne 'pending'; +            die "Timeout exceeded while waiting for challenge to pass ($domain)\n" +                if $i >= ($CONFIG->{timeout} // 10); +            sleep 1; +        }      } -    acme($challenge->{uri}, { -        resource => 'challenge', -        keyAuthorization => $keyAuthorization -    }); +    my $csr = do { local $/ = undef; <STDIN> }; +    my $r = acme_resource('new-cert', csr => encode_base64url($csr)); +    die request_status_line($r), "\n" unless $r->is_success(); +    my $uri = $r->header('Location'); +    # https://acme-v01.api.letsencrypt.org/acme/cert/$serial +    print STDERR "Certificate URI: $uri\n"; -    for (my $i=0;; $i++) { -        my $status = request('GET' => $challenge->{uri})->{status} // 'pending'; -        die "Error: Invalid challenge for $domain\n" if $status eq 'invalid'; -        last if $status eq 'valid'; -        die "Timeout exceeded while waiting for challenge to pass ($domain)\n" if $i >= $TIMEOUT; +    # wait for the cert +    for (my $i = 0; $r->decoded_content() eq ''; $r = request('GET' => $uri), $i++) { +        die request_status_line($r), "\n" unless $r->is_success(); +        die "Timeout exceeded while waiting for certificate\n" +            if $i >= ($CONFIG->{timeout} // 10);          sleep 1;      } -} - +    my $der = $r->decoded_content(); + +    # conversion DER -> PEM +    pipe my $rd, my $wd or die "Can't pipe: $!"; +    my $pid = fork // die "Can't fork: $!"; +    unless ($pid) { +        open STDIN, '<&', $rd or die "Can't dup: $!"; +        exec qw/openssl x509 -inform DER -outform PEM/ or die; +    } +    $rd->close() or die "Can't close: $!"; +    $wd->print($der); +    $wd->close() or die "Can't close: $!"; -do { -    print STDERR "Processing new CSR\n" if $ENV{DEBUG}; -    open my $fh1, '-|', qw/openssl req -outform DER/, '-in', $CSR or die "Can't run req(1ssl): $!"; -    my $req = do { local $/ = undef; <$fh1> }; -    close $fh1; +    waitpid $pid => 0; +    die $? if $? > 0; +} -    # The server also gives the cert URI in its 'Location' header in -    # https://acme-v01.api.letsencrypt.org/acme/cert/$serial -    my $x509 = acme($RES{'new-cert'}, { -        resource => 'new-cert', -        csr => encode_base64url($req) -    }); -    open my $fh2, '|-', qw/openssl x509 -inform DER/, '-out', $X509 or die "Can't run x509(1ssl): $!"; -    print $fh2 $x509; -    close $fh2; -}; +############################################################################# +# revoke-cert +#   The certificate to revoke is passed (in DER format) to STDIN; this +#   is required since the ACME client might not have read access to the +#   X.509 file +# +elsif ($COMMAND eq 'revoke-cert') { +    die if @ARGV; +    my $der = do { local $/ = undef; <STDIN> }; +    close STDIN or die "Can't close: $!"; + +    my $r = acme_resource('revoke-cert', certificate => encode_base64url($der)); +    exit 0 if $r->is_success(); +    die request_status_line($r), "\n"; +} | 
