aboutsummaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rwxr-xr-xclient122
1 files changed, 83 insertions, 39 deletions
diff --git a/client b/client
index bacd4d6..fdef865 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 <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));