From 8c70ba081e9892217510b6b01f0402482161ef84 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 12 Feb 2021 22:11:01 +0100 Subject: Raise client timeout from 10 to 30s. --- client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index bacd4d6..c5788dd 100755 --- a/client +++ b/client @@ -272,7 +272,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; }; set_kid(); -- cgit v1.2.3 From b54d248515357297d84a01cf45a42a6787c21240 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 12 Feb 2021 22:06:43 +0100 Subject: Replace Types::Serialiser::true with JSON::true. This removes the dependency on Types::Serialiser. --- client | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) (limited to 'client') diff --git a/client b/client index c5788dd..c1066c1 100755 --- a/client +++ b/client @@ -52,7 +52,6 @@ use MIME::Base64 qw/encode_base64 encode_base64url/; use Date::Parse (); use LWP::UserAgent (); -use Types::Serialiser (); use JSON (); use Config::Tiny (); @@ -226,7 +225,7 @@ sub acme_resource($%) { # Set the key ID (registration URI) sub set_kid(;$) { my $die = shift // 1; - my $r = acme_resource('newAccount', onlyReturnExisting => Types::Serialiser::true ); + my $r = acme_resource('newAccount', onlyReturnExisting => JSON::true ); if ($r->is_success()) { $KID = $r->header('Location'); } elsif ($die) { @@ -242,9 +241,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; -- cgit v1.2.3 From 49d90dbaf471931f85f9e97bc57ddc0fde1f2fb7 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 12 Feb 2021 22:26:33 +0100 Subject: client: fail immediately when the accountd is unreachable. --- client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index c1066c1..b722d59 100755 --- a/client +++ b/client @@ -193,7 +193,7 @@ sub acme($;$) { my $protected = encode_base64url(json()->encode(\%header)); my $data = $protected .'.'. $payload; $S->printflush($data, "\r\n"); - my $sig = $S->getline(); + my $sig = $S->getline() // die "ERROR: No response from lacme-accountd\n"; $sig =~ s/\r\n\z// or die; undef $NONCE; # consume the nonce -- cgit v1.2.3 From f3e28985165e9ff30907d5da45a4a0bc8c0ccf31 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 14 Feb 2021 17:02:31 +0100 Subject: Bump copyright years. --- client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index b722d59..0556450 100755 --- a/client +++ b/client @@ -2,7 +2,7 @@ #---------------------------------------------------------------------- # ACME client written with process isolation and minimal privileges in mind -# Copyright © 2015-2017 Guilhem Moulin +# Copyright © 2015-2021 Guilhem Moulin # # 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 -- cgit v1.2.3 From 35ba3f6919fbd4a724383169d16f9eec43a27989 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Tue, 16 Feb 2021 01:06:07 +0100 Subject: typofix --- client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index 0556450..e457df1 100755 --- a/client +++ b/client @@ -367,7 +367,7 @@ 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') { -- cgit v1.2.3 From c75bc6c37840b8fc2c57424d24c06a0bfe399de6 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Tue, 16 Feb 2021 01:06:01 +0100 Subject: client: use "lacme-client/$VERSION" as User-Agent header. --- client | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index e457df1..bcdf6cd 100755 --- a/client +++ b/client @@ -43,7 +43,9 @@ 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.3'; my $PROTOCOL_VERSION = 1; +my $NAME = 'lacme-client'; use Errno 'EEXIST'; use Fcntl qw/O_CREAT O_EXCL O_WRONLY/; @@ -103,7 +105,7 @@ 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' ); -- cgit v1.2.3 From 0ca64b6236f8fe767181214a97d8428d473b8e32 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 17 Feb 2021 11:36:49 +0100 Subject: client: avoid "Use of uninitialized value in pattern match (m//)" perl warnings. When the accountd socket can't be reached. --- client | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'client') diff --git a/client b/client index bcdf6cd..e29d2a0 100755 --- a/client +++ b/client @@ -75,8 +75,11 @@ open (my $S, '+<&=', $1+0) or die "fdopen $1: $!"; # 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; +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; +}; my $JWK = JSON::->new->decode($S->getline()); my $KID; -- cgit v1.2.3 From 3a5c3f0596398d64bb34498f40becbcd32ffa5de Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 17 Feb 2021 11:42:18 +0100 Subject: Consolidate error messages for consistency. --- client | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'client') diff --git a/client b/client index e29d2a0..a5490f8 100755 --- a/client +++ b/client @@ -97,7 +97,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 @@ -303,11 +303,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); @@ -378,7 +378,7 @@ elsif ($COMMAND eq 'newOrder') { elsif ($COMMAND eq 'revokeCert') { die if @ARGV; my $der = do { local $/ = undef; }; - 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 set_kid(0); -- cgit v1.2.3 From 626c0418b3d8c3747a7be8e2620d7c85a8c2c613 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 02:55:46 +0100 Subject: Make the ACME API server URL configurable at build time. --- client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index a5490f8..e62541c 100755 --- a/client +++ b/client @@ -210,7 +210,7 @@ sub acme($;$) { }); } -my $SERVER_URI = $CONFIG->{server} // 'https://acme-v02.api.letsencrypt.org/directory'; +my $SERVER_URI = $CONFIG->{server} // '@@acmeapi_server@@'; my %RES; # Get the resource URI from the directory -- cgit v1.2.3 From ba6addf54cef0b1536dc87c42a41b4dc207ac884 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 14:27:50 +0100 Subject: accountd: Pass JWA and JWK thumbprint via extended greeting data. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing the JWA to the ACME client is required if we want to support account keys other than RSA. As of 0.7 both lacme-accountd(1) and lacme(8) hardcode “RS256” (SHA256withRSA per RFC 7518 sec. A.1). Passing the JWK thumbprint is handy as it gives more flexibility if RFC 8555 sec. 8.1 were to be updated with another digest algorithm (it's currently hardcoded to SHA-256). A single lacme-account(1) instance might be used to sign requests from many clients, and it's easier to upgrade a single ‘lacme-accountd’ than many ‘lacme’. Moreover, in some restricted environments lacme-accountd might hide the JWK from the client to prevent ‘newAccount’ requests (such as contact updates); passing its thumbprint is enough for ‘newOrder’ requests. --- client | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) (limited to 'client') diff --git a/client b/client index e62541c..7a63259 100755 --- a/client +++ b/client @@ -49,7 +49,7 @@ 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 (); @@ -70,24 +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 # + +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; + $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) = @$h{qw/jwk-thumbprint alg/}; + } + } + 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"; }; -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; ############################################################################# @@ -111,6 +121,7 @@ my $UA = do { 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; ############################################################################# @@ -192,7 +203,7 @@ sub acme($;$) { die "Missing nonce\n" unless defined $NONCE; # Produce the JSON Web Signature: RFC 7515 section 5 - my %header = ( alg => 'RS256', nonce => $NONCE, url => $uri ); + my %header = ( alg => $ALG, 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)); -- cgit v1.2.3 From 9898b1877ce2973bbc336921969bd7f16d3698fa Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 18:49:14 +0100 Subject: lacme-accountd(1): new setting 'keyid'. This saves a round trip and provides a safeguard against malicious clients. --- client | 56 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 15 deletions(-) (limited to 'client') diff --git a/client b/client index 7a63259..d7874b7 100755 --- a/client +++ b/client @@ -90,7 +90,7 @@ do { 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) = @$h{qw/jwk-thumbprint alg/}; + ($JWK_thumbprint, $ALG, $KID) = @$h{qw/jwk-thumbprint alg kid/}; } } my $jwk_str = $S->getline() // die "ERROR: No JWK from lacme-accountd\n"; @@ -194,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 => $ALG, 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"); + $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 }); } +# 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)) }; @@ -235,12 +246,23 @@ 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; + return if defined $KID; # already set my $r = acme_resource('newAccount', onlyReturnExisting => JSON::true ); if ($r->is_success()) { $KID = $r->header('Location'); @@ -269,6 +291,7 @@ if ($COMMAND eq 'account') { 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'; @@ -391,7 +414,10 @@ elsif ($COMMAND eq 'revokeCert') { my $der = do { local $/ = undef; }; 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)); -- cgit v1.2.3 From ed85b6a6740028ce9ce821975a534f696eabd8ed Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Mon, 22 Feb 2021 01:04:58 +0100 Subject: client: Print Terms of Service URL for 'account' command. --- client | 3 +++ 1 file changed, 3 insertions(+) (limited to 'client') diff --git a/client b/client index d7874b7..0bcf8cd 100755 --- a/client +++ b/client @@ -289,6 +289,9 @@ 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"; -- cgit v1.2.3 From 3eba02ef820a393bd5781be9f8fcda1611ae7c3d Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Mon, 22 Feb 2021 03:19:57 +0100 Subject: Prepare new release v0.8.0. --- client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client b/client index 0bcf8cd..fdef865 100755 --- a/client +++ b/client @@ -43,7 +43,7 @@ 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.3'; +our $VERSION = '0.8.0'; my $PROTOCOL_VERSION = 1; my $NAME = 'lacme-client'; -- cgit v1.2.3