#!/usr/bin/perl -T #---------------------------------------------------------------------- # ACME client written with process isolation and minimal privileges in mind # Copyright © 2015-2021 Guilhem Moulin # # 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 # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- use v5.14.2; use strict; use warnings; # Usage: client COMMAND CONFIG_FD SOCKET_FD [ARGUMENTS] # # fdopen(3) the file descriptor SOCKET_FD (corresponding to the # listening lacme-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). 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 'sha256'; use MIME::Base64 qw/encode_base64 encode_base64url/; use Date::Parse (); use LWP::UserAgent (); use JSON (); 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; # Untaint and fdopen(3) the configuration file and listening socket (shift @ARGV // die) =~ /\A(\d+)\z/ or die; 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 # 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"; }; ############################################################################# # 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 "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( agent => "$NAME/$VERSION", ssl_opts => \%ssl_opts ); } // die "Can't create LWP::UserAgent object"; $UA->default_header( 'Accept-Language' => 'en' ); my $NONCE; ############################################################################# # Send an HTTP request to the ACME server. If $json is defined, send # its encoding as the request content, with "application/jose+json" as # Content-Type. # sub request($$;$) { my ($method, $uri, $json) = @_; 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->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}; return $r; } # The request's Status Line; if the Content-Type is # 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->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}; } return $msg; } # The request's Retry-After header (RFC 7231 sec. 7.1.3), converted to # waiting time in seconds. sub request_retry_after($) { my $r = shift; my $v = $r->header('Retry-After'); if (defined $v and $v !~ /\A\d+\z/) { $v = Date::Parse::str2time($v); if (defined $v) { $v = $v - time; undef $v if $v <= 0; } } return $v; } # 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}; 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'; my $json = json()->decode($content); print $fh (-t $fh ? (json()->pretty->encode($json)."\n") : $content) if $dump; return $json; } ############################################################################# # 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 acme2($$;$) { my ($url, $header, $payload) = @_; # Produce the JSON Web Signature: RFC 7515 section 5 $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 => $url, { payload => $payload, protected => $protected, signature => $sig }); } # 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)) }; # 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"; 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; return if defined $KID; # already set my $r = acme_resource('newAccount', onlyReturnExisting => JSON::true ); if ($r->is_success()) { $KID = $r->header('Location'); } elsif ($die) { die request_status_line($r), "\n"; } } ############################################################################# # account FLAGS [CONTACT ..] # if ($COMMAND eq 'account') { my $flags = shift @ARGV; my %h = ( contact => \@ARGV ) if @ARGV; $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; 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'; } print STDERR request_status_line($r), "\n" if !$r->is_success() or $ENV{DEBUG}; exit ($r->is_success() ? 0 : 1); } ############################################################################# # newOrder AUTHZ [AUTHZ ..] # Read the CSR (in DER format) from STDIN, print the cert (in PEM format # to STDOUT) # elsif ($COMMAND eq 'newOrder') { die unless @ARGV; my $timeout = $CONFIG->{timeout} // 30; my $csr = do { local $/ = undef; }; set_kid(); my @identifiers = map {{ type => 'dns', value => $_ }} @ARGV; my $r = acme_resource('newOrder', identifiers => \@identifiers); my $order = request_json_decode($r); my $orderurl = $r->header('Location'); foreach (@{$order->{authorizations}}) { my $authz = request_json_decode(acme($_)); 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; die "Invalid challenge token ".($challenge->{token} // '')."\n" # ensure we don't write outside the cwd 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 (sysopen(my $fh, $challenge->{token}, O_CREAT|O_EXCL|O_WRONLY, 0644)) { $fh->print($keyAuthorization); $fh->close() or die "close: $!"; } elsif ($! == EEXIST) { print STDERR "WARNING: File exists: $challenge->{token}\n"; } else { die "open($challenge->{token}): $!"; } my $r = acme($challenge->{url}, {}); request_json_decode($r); } # poll the order URL (to get the status of all challenges at once) # until the status become 'valid' my $orderstr = join(', ', map {uc($_->{type}) .":". $_->{value}} @identifiers); my $certuri; for (my $i = 0;;) { my $r = acme($orderurl); my $resp = request_json_decode($r); 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"; } my $status = $resp->{status}; if (!defined $status or $status eq "invalid") { die "Error: Invalid order $orderstr\n"; } elsif ($status eq "ready") { my $r = acme($order->{finalize}, {csr => encode_base64url($csr)}); my $resp = request_json_decode($r); $certuri = $resp->{certificate}; last; } elsif ($status eq "valid") { $certuri = $resp->{certificate} // die "Error: Missing \"certificate\" field in \"valid\" order\n"; last; } elsif ($status ne "pending" and $status ne "processing") { warn "Unknown order status: $status\n"; } my $retry_after = request_retry_after($r) // 1; print STDERR "Retrying after $retry_after seconds...\n"; $i += $retry_after; die "Timeout exceeded while waiting for challenges to pass ($orderstr)\n" if $timeout > 0 and $i >= $timeout; sleep $retry_after; } # poll until the cert is available print STDERR "Certificate URI: $certuri\n"; for (my $i = 0;;) { $r = acme($certuri); die request_status_line($r), "\n" unless $r->is_success(); last unless $r->code == 202; # Accepted my $retry_after = request_retry_after($r) // 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; } print $r->decoded_content(); } ############################################################################# # revokeCert # The certificate to revoke is passed (in DER format) to STDIN; this # 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; }; close STDIN or die "close: $!"; # 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)); exit 0 if $r->is_success(); die request_status_line($r), "\n"; }