diff options
Diffstat (limited to 'client')
| -rwxr-xr-x | client | 122 | 
1 files changed, 83 insertions, 39 deletions
| @@ -2,7 +2,7 @@  #----------------------------------------------------------------------  # ACME client written with process isolation and minimal privileges in mind -# Copyright © 2015-2017 Guilhem Moulin <guilhem@fripost.org> +# Copyright © 2015-2021 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 @@ -43,16 +43,17 @@ use warnings;  # 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). +our $VERSION = '0.8.0';  my $PROTOCOL_VERSION = 1; +my $NAME = 'lacme-client';  use Errno 'EEXIST';  use Fcntl qw/O_CREAT O_EXCL O_WRONLY/; -use Digest::SHA qw/sha256 sha256_hex/; +use Digest::SHA 'sha256';  use MIME::Base64 qw/encode_base64 encode_base64url/;  use Date::Parse ();  use LWP::UserAgent (); -use Types::Serialiser ();  use JSON ();  use Config::Tiny (); @@ -69,21 +70,34 @@ open (my $CONFFILE, '<&=', $1+0) or die "fdopen $1: $!";  (shift @ARGV // die) =~ /\A(\d+)\z/ or die;  open (my $S, '+<&=', $1+0) or die "fdopen $1: $!"; +# JSON keys need to be sorted lexicographically (for instance in the thumbprint) +sub json() { JSON::->new->utf8->canonical(); } +  #############################################################################  # Read the protocol version and JSON Web Key (RFC 7517) from the  # lacme-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()); -my $KID; - -# 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; +my ($JWK, $JWK_thumbprint, $ALG, $KID); +do { +    my $greeting = $S->getline(); +    die "Error: Invalid client version\n" unless defined $greeting and +        $greeting =~ /\A(\d+) OK(?: (.*))?\r\n\z/ and $1 == $PROTOCOL_VERSION; +    if (defined (my $extra = $2)) { +        my $h = eval { JSON::->new->decode($extra) }; +        if ($@ or !defined $h) { +            print STDERR "WARN: Ignoring extra greeting data from accountd \"$extra\"\n"; +        } else { +            print STDERR "Received extra greeting data from accountd: $extra\n" if $ENV{DEBUG}; +            ($JWK_thumbprint, $ALG, $KID) = @$h{qw/jwk-thumbprint alg kid/}; +        } +    } +    my $jwk_str = $S->getline() // die "ERROR: No JWK from lacme-accountd\n"; +    $JWK = JSON::->new->decode($jwk_str); +    $JWK_thumbprint //= encode_base64url(sha256(json()->encode($JWK))); # SHA-256 is hardcoded, see RFC 8555 sec. 8.1 +    $ALG //= "RS256"; +};  ############################################################################# @@ -93,7 +107,7 @@ my $NONCE;  my $CONFIG = do {      my $conf = do { local $/ = undef; <$CONFFILE> }; -    close $CONFFILE or die "Can't close: $!"; +    close $CONFFILE or die "close: $!";      my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";      $h->{_} //= {};      $h->{client}->{$_} //= $h->{_}->{$_} foreach keys %{$h->{_}}; # add defaults @@ -104,9 +118,10 @@ my $UA = do {      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 ); +    LWP::UserAgent::->new( agent => "$NAME/$VERSION", ssl_opts => \%ssl_opts );  } // die "Can't create LWP::UserAgent object";  $UA->default_header( 'Accept-Language' => 'en' ); +my $NONCE;  ############################################################################# @@ -179,39 +194,50 @@ sub request_json_decode($;$$) {  ############################################################################# -# JSON-encode the hash reference $h and send it to the ACME server $uri -# encapsulated it in a JSON Web Signature (JWS). +# JSON-encode the hash reference $payload and send it to the ACME server +# $url encapsulated it in a JSON Web Signature (JWS).  $header MUST +# contain either "jwk" (JSON Web Key) or "kid" per RFC 8555 sec. 6.2  # https://tools.ietf.org/html/rfc8555  # -sub acme($;$) { -    my ($uri, $h) = @_; -    die "Missing nonce\n" unless defined $NONCE; +sub acme2($$;$) { +    my ($url, $header, $payload) = @_;      # Produce the JSON Web Signature: RFC 7515 section 5 -    my %header = ( alg => 'RS256', nonce => $NONCE, url => $uri ); -    defined $KID ? ($header{kid} = $KID) : ($header{jwk} = $JWK); -    my $payload = defined $h ? encode_base64url(json()->encode($h)) : ""; -    my $protected = encode_base64url(json()->encode(\%header)); -    my $data = $protected .'.'. $payload; -    $S->printflush($data, "\r\n"); -    my $sig = $S->getline(); +    $header->{alg} = $ALG; +    $header->{nonce} = $NONCE // die "Missing nonce\n"; +    $header->{url} = $url; +    my $protected = encode_base64url(json()->encode($header)); +    $payload = defined $payload ? encode_base64url(json()->encode($payload)) : ""; + +    $S->printflush($protected, ".", $payload, "\r\n"); +    my $sig = $S->getline() // die "ERROR: No response from lacme-accountd\n";      $sig =~ s/\r\n\z// or die;      undef $NONCE; # consume the nonce      # Flattened JSON Serialization, RFC 7515 section 7.2.2 -    request(POST => $uri, { +    request(POST => $url, {          payload => $payload,          protected => $protected,          signature => $sig      });  } -my $SERVER_URI = $CONFIG->{server} // 'https://acme-v02.api.letsencrypt.org/directory'; +# Like above, but always use "kid" +sub acme($;$) { +    my ($url, $payload) = @_; +    die "Missing KID\n" unless defined $KID; +    acme2($url, {kid => $KID}, $payload) +} + +my $SERVER_URI = $CONFIG->{server} // '@@acmeapi_server@@';  my %RES;  # Get the resource URI from the directory  sub acme_resource($%) {      my $r = shift; +    my %payload = @_; +    my %protected; +      unless (%RES) {          # query the ACME directory to get resources URIs          %RES = %{ request_json_decode(request(GET => $SERVER_URI)) }; @@ -220,13 +246,24 @@ sub acme_resource($%) {          request(HEAD => $RES{newNonce});      }      my $uri = $RES{$r} // die "Unknown resource '$r'\n"; -    acme($uri, {@_}); + +    if ($r eq "newAccount" or ($r eq "revokeCert" and !defined $KID)) { +        # per RFC 8555 sec. 6.2 these requests MUST have a JWK +        print STDERR "WARNING: lacme-accountd supplied an empty JWK; try removing 'keyid' ", +                     "setting from lacme-accountd.conf if the ACME resource request fails.\n" +            unless %$JWK; +        return acme2($uri, {jwk => $JWK}, \%payload); +    } else { +        # per RFC 8555 sec. 6.2 all other requests MUST have a KID +        return acme($uri, \%payload); +    }  }  # Set the key ID (registration URI)  sub set_kid(;$) {      my $die = shift // 1; -    my $r = acme_resource('newAccount', onlyReturnExisting => Types::Serialiser::true ); +    return if defined $KID; # already set +    my $r = acme_resource('newAccount', onlyReturnExisting => JSON::true );      if ($r->is_success()) {          $KID = $r->header('Location');      } elsif ($die) { @@ -242,9 +279,9 @@ if ($COMMAND eq 'account') {      my $flags = shift @ARGV;      my %h = ( contact => \@ARGV ) if @ARGV; -    $h{onlyReturnExisting}   = Types::Serialiser::true unless $flags & 0x01; -    $h{termsOfServiceAgreed} = Types::Serialiser::true if     $flags & 0x02; -    $h{status}               = "deactivated"           if     $flags & 0x04; +    $h{onlyReturnExisting}   = JSON::true    unless $flags & 0x01; +    $h{termsOfServiceAgreed} = JSON::true    if     $flags & 0x02; +    $h{status}               = "deactivated" if     $flags & 0x04;      print STDERR "Requesting new registration ".(@ARGV ? ("for ".join(', ', @ARGV)) : "")."\n"          if $flags & 0x01; @@ -252,8 +289,12 @@ if ($COMMAND eq 'account') {      my $r = acme_resource('newAccount', %h);      # TODO: list account orders: https://github.com/letsencrypt/boulder/issues/3335 +    print STDERR "Terms of Service: $RES{meta}->{termsOfService}\n" +        if defined $RES{meta} and defined $RES{meta}->{termsOfService}; +      if ($r->is_success()) {          $KID = $r->header('Location'); +        print STDERR "Key ID: $KID\n";          $r = acme($KID, \%h);          request_json_decode($r, 1, \*STDOUT)              if $r->is_success() and $r->content_type() eq 'application/json'; @@ -272,7 +313,7 @@ if ($COMMAND eq 'account') {  #  elsif ($COMMAND eq 'newOrder') {      die unless @ARGV; -    my $timeout = $CONFIG->{timeout} // 10; +    my $timeout = $CONFIG->{timeout} // 30;      my $csr = do { local $/ = undef; <STDIN> };      set_kid(); @@ -299,11 +340,11 @@ elsif ($COMMAND eq 'newOrder') {          # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}          if (sysopen(my $fh, $challenge->{token}, O_CREAT|O_EXCL|O_WRONLY, 0644)) {              $fh->print($keyAuthorization); -            $fh->close() or die "Can't close: $!"; +            $fh->close() or die "close: $!";          } elsif ($! == EEXIST) {              print STDERR "WARNING: File exists: $challenge->{token}\n";          } else { -            die "Can't open $challenge->{token}: $!"; +            die "open($challenge->{token}): $!";          }          my $r = acme($challenge->{url}, {});          request_json_decode($r); @@ -368,15 +409,18 @@ elsif ($COMMAND eq 'newOrder') {  #############################################################################  # revokeCert  #   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 +#   is needed since the ACME client might not have read access to the  #   X.509 file  #  elsif ($COMMAND eq 'revokeCert') {      die if @ARGV;      my $der = do { local $/ = undef; <STDIN> }; -    close STDIN or die "Can't close: $!"; +    close STDIN or die "close: $!"; -    # send a KID if the request is signed with the acccount key, otherwise send a JWK +    # RFC 8555 sec. 6.2: send a KID if the request is signed with the +    # acccount key, otherwise send a JWK +    # We have no way to know which of the account key or certificate key +    # is used, so we try to get a KID and fallback to sending the JWK      set_kid(0);      my $r = acme_resource('revokeCert', certificate => encode_base64url($der)); | 
