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)); |