diff options
Diffstat (limited to 'client')
-rwxr-xr-x | client | 433 |
1 files changed, 254 insertions, 179 deletions
@@ -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"; +} |