aboutsummaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rwxr-xr-xclient433
1 files changed, 254 insertions, 179 deletions
diff --git a/client b/client
index 5cf0139..70150ca 100755
--- a/client
+++ b/client
@@ -2,7 +2,7 @@
#----------------------------------------------------------------------
# Let's Encrypt ACME client
-# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
+# Copyright © 2015,2016 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
@@ -21,98 +21,170 @@
use strict;
use warnings;
+# Usage: client COMMAND CONFIG_FD SOCKET_FD [ARGUMENTS]
+#
+# fdopen(3) the file descriptor SOCKET_FD (corresponding to the
+# listening letsencrypt-accountd socket), connect(2) to it to retrieve
+# the account key's public parameters and later send data to be signed
+# by the master component (using the account key).
+#
+# CONFIG_FD is a read-only file descriptor associated with the
+# configuration file at pos 0. (This is needed since this process
+# doesn't know its name and might not have read access to it.)
+#
+# NOTE: one needs to chdir(2) to an appropriate ACME challenge directory
+# before running this program, since challenge tokens are (on purpose)
+# only written to the current directory. If COMMAND is challenge-less,
+# one should chdir(2) to the root directory "/" instead.
+#
+# NOTE: one should run this program as an unprivileged user:group such
+# as "nobody:nogroup"; bind(2)'ing to a restricted UNIX socket (for
+# 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).
+
+my $PROTOCOL_VERSION = 1;
+
use LWP::UserAgent ();
-use Crypt::OpenSSL::RSA ();
-use Crypt::OpenSSL::Bignum ();
use MIME::Base64 qw/encode_base64 encode_base64url/;
use JSON ();
use Digest::SHA qw/sha256 sha256_hex/;
+use Config::Tiny ();
+
# Clean up PATH
$ENV{PATH} = join ':', qw{/usr/bin /bin};
delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};
my $COMMAND = shift @ARGV // die;
-my $PUBKEY = shift @ARGV // die;
-die unless grep {$COMMAND eq $_} qw/new-reg new-cert revoke-cert/;
-my $TIMEOUT = 10;
+# Untaint and fdopen(3) the configuration file and listening socket
+(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
+open my $CONFFILE, '<&=', $1 or die "fdopen $1: $!";
+(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
+open my $S, '+<&=', $1 or die "fdopen $1: $!";
-# Read the public key and build the JSON Web Key (RFC 7517)
-my $JWK = do {
- open my $fh, '<', $PUBKEY or die "Can't open $PUBKEY: $!";
- my $str = do { local $/ = undef; <$fh> };
- my $pubkey = Crypt::OpenSSL::RSA->new_public_key($str) or die;
- close $fh;
- my ($n, $e) = $pubkey->get_key_parameters();
- $_ = encode_base64url($_->to_bin()) foreach ($n, $e);
+#############################################################################
+# Read the protocol version and JSON Web Key (RFC 7517) from the
+# letsencrypt-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());
- { kty => 'RSA', n => $n, e => $e }
-};
-my $JSON = JSON::->new->utf8->canonical(); # breaks hashes otherwise
-my $JWK_dgst64 = encode_base64url(sha256($JSON->encode($JWK)));
+# 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;
-# Send an HTTP request to the ACME server
-my $UA = LWP::UserAgent::->new( ssl_opts => {
- verify_hostname => 1,
- SSL_version => 'SSLv23:!TLSv1_1:!TLSv1:!SSLv3:!SSLv2',
- SSL_cipher_list => 'EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL'
-});
+#############################################################################
+# Parse configuration (already validated by the master) and create the
+# LWP::UserAgent object
+#
+
+my $CONFIG = do {
+ my $conf = do { local $/ = undef, <$CONFFILE> };
+ close $CONFFILE or die "Can't close: $!";
+ my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";
+ $h->{_} //= {};
+ $h->{client}->{$_} //= $h->{_}->{$_} foreach keys %{$h->{_}}; # add defaults
+ $h->{client};
+};
+my $UA = do {
+ my %args = %$CONFIG;
+ 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 );
+} // die "Can't create LWP::UserAgent object";
+
+
+#############################################################################
+# Send an HTTP request to the ACME server. If $json is defined, send
+# its encoding as the request content, with "application/json" as
+# Content-Type.
+#
sub request($$;$) {
my ($method, $uri, $json) = @_;
- print STDERR ">>> $method $uri <<<\n" if $ENV{DEBUG};
+ print STDERR "[$$] >>> $method $uri <<<\n" if $ENV{DEBUG};
my $req = HTTP::Request::->new($method => $uri) or die "Can't $method $uri";
if (defined $json) {
- $req->header('Content-Type' => 'application/json');
- $req->content($JSON->encode($json));
+ $req->content_type('application/json');
+ $req->content(json()->encode($json));
}
my $r = $UA->request($req) or die "Can't $method $uri";
- print STDERR ">>> ", $r->status_line, "\n", $r->headers->as_string, "\n" if $ENV{DEBUG};
- $NONCE = $r->header('Replay-Nonce') // die;
- my $t = $r->header('Content-Type');
+ $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;
+}
- my $content = $r->decoded_content();
- if (defined $t and $t =~ /\Aapplication\/(?:[a-z]+\+)?json\z/) {
- $content = $JSON->decode($content);
- print STDERR $JSON->pretty->encode($content), "\n" if $ENV{DEBUG};
- }
- elsif (defined $t and $t eq 'application/pkix-cert') {
- print STDERR encode_base64($content), "\n" if $ENV{DEBUG};
- }
- else {
- print STDERR $content, "\n" if $ENV{DEBUG};
- }
- unless ($r->is_success) {
- my $msg = $r->status_line;
- $msg .= " (".$content->{detail}.")" if ref $content and defined $content->{detail};
- die $msg, "\n";
+# 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
+sub request_status_line($) {
+ my $r = shift;
+ my $msg = $r->status_line;
+ if ($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};
}
+ return $msg;
+}
+# 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($;$) {
+ my $r = shift;
+ my $dump = shift || $ENV{DEBUG};
+ 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;
}
-# ACME client
-# https://tools.ietf.org/html/draft-ietf-acme-acme-01
+#############################################################################
+# 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
+#
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;
+
# Produce the JSON Web Signature: RFC 7515 section 5
- my $payload = encode_base64url($JSON->encode($h));
+ my $payload = encode_base64url(json()->encode($h));
my %header = ( alg => 'RS256', jwk => $JWK );
- my $protected = encode_base64url($JSON->encode({ %header, nonce => $NONCE }));
+ my $protected = encode_base64url(json()->encode({ %header, nonce => $NONCE }));
my $data = $protected .'.'. $payload;
- print STDERR "Requesting a SHA-256 signature for ", $data, "\n" if $ENV{DEBUG};
- STDOUT->printflush($data, "\n");
-
- # Ask for an (hex) sig
- my $sig = do { local $_ = <STDIN>; chomp; $_ };
- $sig = encode_base64url(pack('H*', $sig));
- print STDERR "Got SHA-256 signature ", $sig, "\n" if $ENV{DEBUG};
+ $S->printflush($data, "\r\n");
+ my $sig = $S->getline();
+ $sig =~ s/\r\n\z// or die;
# Flattened JSON Serialization, RFC 7515 section 7.2.2
request(POST => $uri, {
@@ -123,147 +195,150 @@ sub acme($$) {
});
}
-
-# Query the root ACME directory to initialize the nonce and get the resources URIs
-my %RES = %{ request(GET => "https://acme-v01.api.letsencrypt.org/directory") };
+my $SERVER_URI = $CONFIG->{server} // 'https://acme-v01.api.letsencrypt.org/';
+$SERVER_URI .= '/' unless substr($SERVER_URI, -1, 1) eq '/';
+
+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, @_});
+}
+#############################################################################
+# 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 $uri = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf";
- my $dgst = sha256_hex($UA->get($uri)->decoded_content());
- die "Error: The CA's subscriber agreement (URL $uri) has changed!\n" if
- $dgst ne '33d233c8ab558ba6c8ebc370a509acdded8b80e5d587aa5d192193f35226540f';
-
- acme($RES{'new-reg'}, {
- resource => 'new-reg',
- contact => [ map {"mailto:$_"} @ARGV ],
- agreement => $uri,
- });
- exit;
-}
+ my %h = (contact => \@ARGV);
+ $h{agreement} = $agreement if $agreement ne '';
+ my $r = acme_resource('new-reg', %h);
-if ($COMMAND eq 'revoke-cert') {
- print STDERR "Requesting revocation for\n";
- for my $cert (@ARGV) {
- open my $fh1, '-|', qw/openssl x509 -noout -subject -serial -fingerprint -sha256/, '-in', $cert
- or die "Can't run x509(1ssl): $!";
- my ($subject, $serial, $fingerprint) = map { s/[^=]+=\s*//; chomp; $_ } <$fh1>;
- close $fh1;
-
- print STDERR "\n\tSubject: $subject\n",
- "\tSerial: $serial\n",
- "\tSHA-256 fingerprint: $fingerprint\n";
+ 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);
+}
- open my $fh2, '-|', qw/openssl x509 -outform DER/, '-in', $cert or die "Can't run x509(1ssl): $!";
- my $der = do { local $/ = undef; <$fh2> };
- close $fh2;
- acme($RES{'revoke-cert'}, {
- resource => 'revoke-cert',
- certificate => encode_base64url($der)
- });
- }
- exit;
+#############################################################################
+# reg=URI AGREEMENT_URI [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;
+ exit ($r->is_success() ? 0 : 1);
}
-# $COMMAND eq 'new-cert'
-my ($CSR, $CHALLENGE_DIR, $X509) = @ARGV;
-$CHALLENGE_DIR = $CHALLENGE_DIR =~ /\A(\/\p{Print}+)\z/ ? $1 :
- die "Error: Challenge directory is not absolute: $CHALLENGE_DIR";
-
-# Parse the Certificate Signing Request
-# XXX use a library instead, perhaps Crypt::OpenSSL::PKCS10
-my @domains = do {
- my @req = (qw/openssl req -noout/, '-in', $CSR);
-
- my $RE_label = qr/[0-9a-z](?:[0-9a-z\x2D]{0,61}[0-9a-z])?/aai;
- my $RE_domain = qr/$RE_label(?:\.$RE_label)+/;
- my %domains;
-
- open my $fh1, '-|', @req, '-subject' or die "Can't run req(1ssl): $!";
- my $subject = <$fh1>;
- close $fh1;
- $domains{$1} = 1 if $subject =~ /\Asubject=.*\/CN=($RE_domain)\n\z/o;
-
- open my $fh2, '-|', @req, '-text', '-reqopt', 'no_header,no_version,no_subject,no_pubkey,no_sigdump'
- or die "Can't run req(1ssl): $!";
- while (<$fh2>) {
- /\A\s+X509v3 Subject Alternative Name:/ or next;
- my $san = <$fh2>;
- foreach (split /,/, $san) {
- chomp;
- s/\A\s*//;
- next unless s/\ADNS://;
- if (/\A$RE_domain\z/o) {
- $domains{$_} = 1;
- }
- else {
- warn "WARNING: Ignoring invalid domain $_\n";
- }
+#############################################################################
+# new-cert AUTHZ [AUTHZ ..]
+# Read the CSR (in DER format) from STDIN, print the cert (in PEM format
+# to STDOUT)
+#
+elsif ($COMMAND eq 'new-cert') {
+ die unless @ARGV;
+ 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 ($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"
+ unless ($challenge->{token} // '') =~ /\A[A-Za-z0-9_\-]+\z/;
+
+ my $keyAuthorization = $challenge->{token}.'.'.$JWK_thumbprint;
+
+ # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}
+ if (-e $challenge->{token}) {
+ print STDERR "WARNING: File exists: $challenge->{token}\n";
+ }
+ else {
+ open my $fh, '>', $challenge->{token} or die "Can't open $challenge->{token}: $!";
+ $fh->print($keyAuthorization);
+ $fh->close() or die "Can't close: $!";
}
- last;
- }
- close $fh2;
-
- keys %domains;
-};
-print STDERR "Found domain(s): ".join(", ", @domains), "\n" if $ENV{DEBUG};
-
-# Process DNS Authorizations
-foreach my $domain (@domains) {
- print STDERR "Processing new DNS authz for $domain\n" if $ENV{DEBUG};
- my $challenges = acme($RES{'new-authz'}, {
- resource => 'new-authz',
- identifier => { type => 'dns', value => $domain }
- });
- die "No challenge in server response" unless defined $challenges->{challenges};
- my ($challenge) = grep {$_->{type} eq 'http-01'} @{$challenges->{challenges}};
- my $keyAuthorization = $challenge->{token}.'.'.$JWK_dgst64;
-
- # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}
- my $filename = $CHALLENGE_DIR.'/'.$challenge->{token};
- if (-e $filename) {
- warn "WARNING: File exists: $filename\n";
- }
- else {
- open my $fh, '>', $filename or die "Can't open $filename: $!";
- print $fh $keyAuthorization;
- close $fh;
+ $r = acme($challenge->{uri}, {
+ resource => 'challenge',
+ keyAuthorization => $keyAuthorization
+ });
+ # wait until the status become 'valid'
+ for ( my $i = 0, my $status;
+ $status = request_json_decode($r)->{status} // 'pending',
+ $status ne 'valid';
+ $r = request('GET' => $challenge->{uri}), $i++ ) {
+ die "Error: Invalid challenge for $domain (status: ".$status.")\n" if $status ne 'pending';
+ die "Timeout exceeded while waiting for challenge to pass ($domain)\n"
+ if $i >= ($CONFIG->{timeout} // 10);
+ sleep 1;
+ }
}
- acme($challenge->{uri}, {
- resource => 'challenge',
- keyAuthorization => $keyAuthorization
- });
+ 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
+ print STDERR "Certificate URI: $uri\n";
- for (my $i=0;; $i++) {
- my $status = request('GET' => $challenge->{uri})->{status} // 'pending';
- die "Error: Invalid challenge for $domain\n" if $status eq 'invalid';
- last if $status eq 'valid';
- die "Timeout exceeded while waiting for challenge to pass ($domain)\n" if $i >= $TIMEOUT;
+ # wait for the cert
+ for (my $i = 0; $r->decoded_content() eq ''; $r = request('GET' => $uri), $i++) {
+ die request_status_line($r), "\n" unless $r->is_success();
+ die "Timeout exceeded while waiting for certificate\n"
+ if $i >= ($CONFIG->{timeout} // 10);
sleep 1;
}
-}
-
+ my $der = $r->decoded_content();
+
+ # conversion DER -> PEM
+ 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;
+ }
+ $rd->close() or die "Can't close: $!";
+ $wd->print($der);
+ $wd->close() or die "Can't close: $!";
-do {
- print STDERR "Processing new CSR\n" if $ENV{DEBUG};
- open my $fh1, '-|', qw/openssl req -outform DER/, '-in', $CSR or die "Can't run req(1ssl): $!";
- my $req = do { local $/ = undef; <$fh1> };
- close $fh1;
+ waitpid $pid => 0;
+ die $? if $? > 0;
+}
- # The server also gives the cert URI in its 'Location' header in
- # https://acme-v01.api.letsencrypt.org/acme/cert/$serial
- my $x509 = acme($RES{'new-cert'}, {
- resource => 'new-cert',
- csr => encode_base64url($req)
- });
- open my $fh2, '|-', qw/openssl x509 -inform DER/, '-out', $X509 or die "Can't run x509(1ssl): $!";
- print $fh2 $x509;
- close $fh2;
-};
+#############################################################################
+# revoke-cert
+# 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') {
+ 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));
+ exit 0 if $r->is_success();
+ die request_status_line($r), "\n";
+}