diff options
author | Guilhem Moulin <guilhem@fripost.org> | 2018-05-09 14:12:01 +0200 |
---|---|---|
committer | Guilhem Moulin <guilhem@fripost.org> | 2018-05-09 14:12:01 +0200 |
commit | 39c8e6b055981a16b7e641c5201b1d280f2c6ad0 (patch) | |
tree | 7fae6c944bf82e23ece236a482c73dcd57f02379 /client | |
parent | 5415ea0336b1c1678f8da348b26535b8ba2a7ca9 (diff) | |
parent | d3df555699c7189503de7f49c6c48d6d04b84083 (diff) |
Updated version 0.5 from 'upstream/0.5'
Diffstat (limited to 'client')
-rwxr-xr-x | client | 238 |
1 files changed, 122 insertions, 116 deletions
@@ -51,6 +51,7 @@ use Digest::SHA qw/sha256 sha256_hex/; use MIME::Base64 qw/encode_base64 encode_base64url/; use LWP::UserAgent (); +use Types::Serialiser (); use JSON (); use Config::Tiny (); @@ -75,6 +76,7 @@ open (my $S, '+<&=', $1+0) or die "fdopen $1: $!"; 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(); } @@ -103,11 +105,12 @@ my $UA = do { $ssl_opts{$_} = $args{$_} foreach grep /^SSL_/, keys %args; LWP::UserAgent::->new( ssl_opts => \%ssl_opts ); } // die "Can't create LWP::UserAgent object"; +$UA->default_header( 'Accept-Language' => 'en' ); ############################################################################# # Send an HTTP request to the ACME server. If $json is defined, send -# its encoding as the request content, with "application/json" as +# its encoding as the request content, with "application/jose+json" as # Content-Type. # sub request($$;$) { @@ -116,34 +119,22 @@ sub request($$;$) { my $req = HTTP::Request::->new($method => $uri) or die "Can't $method $uri"; if (defined $json) { - $req->content_type('application/json'); + $req->content_type('application/jose+json'); $req->content(json()->encode($json)); } my $r = $UA->request($req) or die "Can't $method $uri"; - $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}; + $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; } -# 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 +# application/problem+json (RFC 7807), parse the decoded content as JSON +# and add the value of the 'detail' field to the Status Line. sub request_status_line($) { my $r = shift; my $msg = $r->status_line; - if ($r->content_type() eq 'application/problem+json') { + if (!$r->is_success() and $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}; @@ -154,121 +145,133 @@ sub request_status_line($) { # 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($;$) { +sub request_json_decode($;$$) { my $r = shift; my $dump = shift || $ENV{DEBUG}; + my $fh = shift // \*STDERR; 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; + my $json = json()->decode($content); + print $fh (-t $fh ? (json()->pretty->encode($json)."\n") : $content) + if $dump; + return $json; } ############################################################################# # 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 +# https://tools.ietf.org/html/draft-ietf-acme-acme-12 # -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; +sub acme($@) { + my $uri = shift; + die "Missing nonce\n" unless defined $NONCE; # Produce the JSON Web Signature: RFC 7515 section 5 - my $payload = encode_base64url(json()->encode($h)); - my %header = ( alg => 'RS256', jwk => $JWK ); - my $protected = encode_base64url(json()->encode({ %header, nonce => $NONCE })); + my %header = ( alg => 'RS256', nonce => $NONCE, url => $uri ); + defined $KID ? ($header{kid} = $KID) : ($header{jwk} = $JWK); + my $payload = encode_base64url(json()->encode({ @_ })); + my $protected = encode_base64url(json()->encode(\%header)); my $data = $protected .'.'. $payload; $S->printflush($data, "\r\n"); my $sig = $S->getline(); $sig =~ s/\r\n\z// or die; + undef $NONCE; # consume the nonce # Flattened JSON Serialization, RFC 7515 section 7.2.2 request(POST => $uri, { payload => $payload, protected => $protected, - header => \%header, signature => $sig }); } -my $SERVER_URI = $CONFIG->{server} // 'https://acme-v01.api.letsencrypt.org/'; -$SERVER_URI .= '/' unless substr($SERVER_URI, -1, 1) eq '/'; +my $SERVER_URI = $CONFIG->{server} // 'https://acme-v02.api.letsencrypt.org/directory'; 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, @_}); + unless (%RES) { + # query the ACME directory to get resources URIs + %RES = %{ request_json_decode(request(GET => $SERVER_URI)) }; + # send a HEAD request to the newNonce resource to get a fresh nonce + die "Unknown resource 'newNonce'\n" unless defined $RES{newNonce}; + request(HEAD => $RES{newNonce}); + } + my $uri = $RES{$r} // die "Unknown resource '$r'\n"; + acme($uri, @_); } - -############################################################################# -# 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 %h = (contact => \@ARGV); - $h{agreement} = $agreement if $agreement ne ''; - my $r = acme_resource('new-reg', %h); - - 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); +# Set the key ID (registration URI) +sub set_kid(;$) { + my $die = shift // 1; + my $r = acme_resource('newAccount', onlyReturnExisting => Types::Serialiser::true ); + if ($r->is_success()) { + $KID = $r->header('Location'); + } elsif ($die) { + die request_status_line($r), "\n"; + } } ############################################################################# -# reg=URI AGREEMENT_URI [CONTACT ..] +# account FLAGS [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; +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; + + print STDERR "Requesting new registration ".(@ARGV ? ("for ".join(', ', @ARGV)) : "")."\n" + if $flags & 0x01; + + my $r = acme_resource('newAccount', %h); + # TODO: list account orders: https://github.com/letsencrypt/boulder/issues/3335 + + if ($r->is_success()) { + $KID = $r->header('Location'); + $r = acme($KID, %h); + request_json_decode($r, 1, \*STDOUT) + if $r->is_success() and $r->content_type() eq 'application/json'; + } + + print STDERR request_status_line($r), "\n" + if !$r->is_success() or $ENV{DEBUG}; exit ($r->is_success() ? 0 : 1); } ############################################################################# -# new-cert AUTHZ [AUTHZ ..] +# newOrder AUTHZ [AUTHZ ..] # Read the CSR (in DER format) from STDIN, print the cert (in PEM format # to STDOUT) # -elsif ($COMMAND eq 'new-cert') { +elsif ($COMMAND eq 'newOrder') { die unless @ARGV; my $timeout = $CONFIG->{timeout} // 10; - 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 $csr = do { local $/ = undef; <STDIN> }; + + set_kid(); + my @identifiers = map {{ type => 'dns', value => $_ }} @ARGV; + my $r = acme_resource('newOrder', identifiers => \@identifiers); + my $order = request_json_decode($r); + + foreach (@{$order->{authorizations}}) { + my $authz = request_json_decode(request(GET => $_)); + next unless $authz->{status} eq 'pending'; + + my $identifier = $authz->{identifier}->{value}; + my ($challenge) = grep {$_->{type} eq 'http-01'} @{$authz->{challenges} // []}; + die "Missing 'http-01' challenge in server response for '$identifier'\n" + unless defined $challenge; - 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" # ensure we don't write outside the cwd unless ($challenge->{token} // '') =~ /\A[A-Za-z0-9_\-]+\z/; @@ -285,23 +288,25 @@ elsif ($COMMAND eq 'new-cert') { die "Can't open $challenge->{token}: $!"; } - $r = acme($challenge->{uri}, { - resource => 'challenge', - keyAuthorization => $keyAuthorization - }); - # wait until the status become 'valid' - for ( my $i = 0, my $content, my $status; - $content = request_json_decode($r), - $status = $content->{status} // 'pending', + $r = acme($challenge->{url}); + + # poll until the status become 'valid' + # XXX poll the order URL instead, to get the status of all + # challenges at once + # https://github.com/letsencrypt/boulder/issues/3530 + for ( my $i = 0, my $resp, my $status; + $resp = request_json_decode($r), + $status = $resp->{status} // 'pending', $status ne 'valid'; - $r = request('GET' => $challenge->{uri})) { - if (defined (my $problem = $content->{error})) { # problem document (RFC 7807) + $r = request('GET' => $challenge->{url})) { + if (defined (my $problem = $resp->{error})) { # problem document (RFC 7807) my $msg = $problem->{status}; $msg .= " " .$problem->{title} if defined $problem->{title}; $msg .= " (".$problem->{detail}.")" if defined $problem->{detail}; die $msg, "\n"; } - die "Error: Invalid challenge for $domain (status: ".$status.")\n" if $status ne 'pending'; + die "Error: Invalid challenge for $identifier (status: ".$status.")\n" + if $status ne 'pending'; my $sleep = 1; if (defined (my $retry_after = $r->header('Retry-After'))) { @@ -310,41 +315,39 @@ elsif ($COMMAND eq 'new-cert') { } $i += $sleep; - die "Timeout exceeded while waiting for challenge to pass ($domain)\n" if $timeout > 0 and $i >= $timeout; + die "Timeout exceeded while waiting for challenges to pass ($identifier)\n" + if $timeout > 0 and $i >= $timeout; sleep $sleep; } } - 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 + $r = acme($order->{finalize}, csr => encode_base64url($csr)); + my $resp = request_json_decode($r); + + my $uri = $resp->{certificate}; print STDERR "Certificate URI: $uri\n"; - if ($r->decoded_content() eq '') { # wait for the cert - for (my $i = 0;;) { - $r = request('GET' => $uri); - die request_status_line($r), "\n" unless $r->is_success(); - last unless $r->code == 202; # Accepted - my $retry_after = $r->header('Retry-After') // 1; - print STDERR "Retrying after $retry_after seconds...\n"; - $i += $retry_after; - die "Timeout exceeded while waiting for certificate\n" if $timeout > 0 and $i >= $timeout; - sleep $retry_after; - } + # pool until the cert is available + for (my $i = 0;;) { + $r = request('GET' => $uri); + die request_status_line($r), "\n" unless $r->is_success(); + last unless $r->code == 202; # Accepted + my $retry_after = $r->header('Retry-After') // 1; + print STDERR "Retrying after $retry_after seconds...\n"; + $i += $retry_after; + die "Timeout exceeded while waiting for certificate\n" if $timeout > 0 and $i >= $timeout; + sleep $retry_after; } - my $der = $r->decoded_content(); - # conversion DER -> PEM + # keep only the leaf certificate 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; + exec qw/openssl x509 -outform PEM/ or die; } $rd->close() or die "Can't close: $!"; - $wd->print($der); + $wd->print( $r->decoded_content() ); $wd->close() or die "Can't close: $!"; waitpid $pid => 0; @@ -353,17 +356,20 @@ elsif ($COMMAND eq 'new-cert') { ############################################################################# -# revoke-cert +# 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 # X.509 file # -elsif ($COMMAND eq 'revoke-cert') { +elsif ($COMMAND eq 'revokeCert') { 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)); + # send a KID if the request is signed with the acccount key, otherwise send a JWK + set_kid(0); + + my $r = acme_resource('revokeCert', certificate => encode_base64url($der)); exit 0 if $r->is_success(); die request_status_line($r), "\n"; } |