aboutsummaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2018-04-26 20:29:44 +0200
committerGuilhem Moulin <guilhem@fripost.org>2018-04-27 01:43:03 +0200
commitd1bc3ad109a3000bda8a7876673ff9a0281e8c7b (patch)
treeb5202fae0c6b580b7760e7b6ea66647c2da4da8d /client
parent5ea132288e4f83fa24ebf3f61b503e440aaccad5 (diff)
Use ACME v2 endpoints
https://tools.ietf.org/html/draft-ietf-acme-acme-12
Diffstat (limited to 'client')
-rwxr-xr-xclient238
1 files changed, 122 insertions, 116 deletions
diff --git a/client b/client
index e9fb8f8..838b184 100755
--- a/client
+++ b/client
@@ -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";
}