From 2e332833c4f1cf069262ffdcae5f66ca8b818808 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 2 Mar 2016 07:28:36 +0100 Subject: Refactoring to use the account key manager. --- README | 85 ++-- client | 433 +++++++++++-------- config/letsencrypt-certs.conf | 56 +++ config/letsencrypt.conf | 86 ++++ letsencrypt | 957 ++++++++++++++++++++++++++++++------------ letsencrypt.1 | 413 ++++++++++++------ webserver | 100 ++++- 7 files changed, 1501 insertions(+), 629 deletions(-) create mode 100644 config/letsencrypt-certs.conf create mode 100644 config/letsencrypt.conf diff --git a/README b/README index 4de2a20..69a1859 100644 --- a/README +++ b/README @@ -1,32 +1,55 @@ +Requesting new Certificate Issuance with the ACME protocol generally +works as follows: + + 1. Generate a Certificate Signing Request. This requires access to + the private part of the server key. + 2. Issue an issuance request against the ACME server. + 3. Answer the ACME Identifier Validation Challenges. The challenge + type "http-01" requires a webserver to listen on port 80 for each + address for which an authorization request is issued; if there is + no running webserver, root privileges are required to bind against + port 80 and to install firewall rules to temporarily open the port. + 4. Install the certificate (after verification) and restart the + service. This usually requires root access as well. + +Steps 1,3,4 need to be run on the host for which an authorization +request is issued. However the the issuance itself (step 2) could be +done from another machine. Furthermore, each ACME command (step 2), as +well as the key authorization token in step 3, need to be signed using +an account key. The account key can be stored on another machine, or +even on a smartcard. + +_______________________________________________________________________ + letsencrypt is a tiny ACME client written with process isolation and -minimal privileges in mind. It is divided into three components: - -1. The "master" process, which runs as root and is the only component - with access to the private key material (both account and server - keys). It is only used to fork the other components (after dropping - privileges), and to sign ACME requests (JSON Web Signatures); for - certificate issuance ("new-cert" command), it is also used to - generate the Certificate Signing Request, then to verify the validity - of the issued certificate, and optionally to reload or restart - services using "--notify". - -2. The actual ACME client, which runs as the user specified with - "--runas" (or root if the option is omitted). It builds ACME - requests and dialogues with the remote ACME server. All requests - need to be signed with the account key, but this process doesn't need - direct access to any private key material: instead, it write the data - to be signed to a pipe shared with the master process, which in turns - replies with its SHA-256 signature. - -3. An optional webserver, which is spawned by the master process (when - nothing is listening on localhost:80); socat(1) is used to listen on - port 80 and to change the user (owner) and group of the process to - "www-data:www-data". (The only challenge type currently supported by - letsencrypt-tiny is "http-01", hence a webserver is required.) Some - iptables rules are automatically added to open port 80, and removed - afterwards. The web server only processes GET requests under the - "/.well-known/acme-challenge" URI. - If a webserver is already listening on port 80, it needs to be - configured to serve these URIs (for each virtual-hosts requiring - authorization) as static files under the - "/var/www/acme-challenge" root directory, which must not exist. +minimal privileges in mind. It is divided into four components, each +with its own executable: + + * A process to manage the account key and issue SHA-256 signatures + needed for each ACME command. (This process binds to a UNIX-domain + socket to reply to signature requests from the ACME client.) One + can use the UNIX-domain socket forwarding facility of OpenSSH 6.7 + and later to run this process on a different host. + + * A "master" process, which runs as root and is the only component + with access to the private key material of the server keys. It is + used to fork the ACME client (and optionally the ACME webserver) + after dropping root privileges. For certificate issuances, + it also generates Certificate Signing Requests, then verifies the + validity of the issued certificate, and optionally reloads or + restarts services when the notify option is set. + + * An actual ACME client, which builds ACME commands and dialogues with + the remote ACME server. Since ACME commands need to be signed with + the account key, the "master" process passes the UNIX-domain socket + of the account key manager to the ACME client: data signatures are + requested by writing the data to be signed to the socket. + + * For certificate issuances, an optional webserver, which is spawned + by the "master" process when no service is listening on the HTTP + port. (The only challenge type currently supported is "http-01", + which requires a webserver to answer challenges.) That webserver + only processes GET and HEAD requests under the + "/.well-known/acme-challenge/" URI. By default some iptables(1) + rules are automatically installed to open the HTTP port, and removed + afterwards. 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 +# Copyright © 2015,2016 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 @@ -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 $_ = ; 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; }; + 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; }; + 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"; +} diff --git a/config/letsencrypt-certs.conf b/config/letsencrypt-certs.conf new file mode 100644 index 0000000..5613ef6 --- /dev/null +++ b/config/letsencrypt-certs.conf @@ -0,0 +1,56 @@ +# Each non-default section denotes a separate certificate issuance. +# Options in the default section apply to each sections. + +# Message digest to sign the Certificate Signing Request with. +#hash = sha512 + +# Comma-separated list of Key Usages, see x509v3_config(5ssl). +#keyUsage = digitalSignature, keyEncipherment + +#[www] + +# Where to store the issued certificate (in PEM format). +#certificate = /etc/nginx/ssl/srv.pem + +# Where to store the issued certificate, concatenated with the content +# of the file specified specified with the CAfile option (in PEM format). +#certificate-chain = /etc/nginx/ssl/srv.chain.pem + +# Path the service's private key. This option is required. +#certificate-key = /etc/nginx/ssl/srv.key + +# For an existing certificate, the minimum number of days before its +# expiration date the section is considered for re-issuance. +#min-days = 10 + +# Path to the issuer's certificate. This is used for certificate-chain +# and to verify the validity of each issued certificate. Specifying an +# empty value skip certificate validation. +#CAfile = /usr/share/letsencrypt-tiny/lets-encrypt-x1-cross-signed.pem + +# Subject field of the Certificate Signing Request. This option is +# required. +#subject = /CN=example.org + +# Comma-separated list of Subject Alternative Names. +#subjectAltName = DNS:example.org,DNS:www.example.org + +# username[:groupname] to chown the issued certificate and +# certificate-chain with. +#chown = root:root + +# octal mode to chmod the issued certificate and certificate-chain with. +#chmod = 0644 + +# Command to pass the the system's command shell ("/bin/sh -c") after +# successful installation of the certificate and/or certificate-chain. +#notify = /bin/systemctl restart nginx + + +#[smtp] +#certificate-key = /etc/postfix/ssl/srv.key +#certificate-chain = /etc/postfix/ssl/srv.pem +#subject = /CN=smtp.example.org +#notify = /bin/systemctl restart postfix + +; vim:ft=dosini diff --git a/config/letsencrypt.conf b/config/letsencrypt.conf new file mode 100644 index 0000000..1502020 --- /dev/null +++ b/config/letsencrypt.conf @@ -0,0 +1,86 @@ +# For certificate issuance (new-cert command), specify the certificate +# configuration file to use +# +#config-certs = config/letsencrypt-certs.conf + +[client] +# The value of "socket" specifies the letsencrypt-accountd(1) +# UNIX-domain socket to connect to for signature requests from the ACME +# client. letsencrypt aborts if the socket is readable or writable by +# other users, or if its parent directory is writable by other users. +# Default: "$XDG_RUNTIME_DIR/S.letsencrypt" if the XDG_RUNTIME_DIR +# environment variable is set. +# +#socket = /run/user/1000/S.letsencrypt + +# username to drop privileges to (setting both effective and real uid). +# Preserve root privileges if the value is empty (not recommended). +# Default: "nobody". +# +#user = letsencrypt + +# groupname to drop privileges to (setting both effective and real gid, +# and also setting the list of supplementary gids to that single group). +# Preserve root privileges if the value is empty (not recommended). +# +#group = nogroup + +# Path to the ACME client executable. +#command = /usr/lib/letsencrypt-tiny/client + +# Root URI of the ACME server. NOTE: Use the staging server for testing +# as it has relaxed ratelimit. +# +#server = https://acme-v01.api.letsencrypt.org/ +#server = https://acme-staging.api.letsencrypt.org/ + +# Timeout in seconds after which the client stops polling the ACME +# server and considers the request failed. +# +#timeout = 10 + +# Whether to verify the server certificate chain. +#SSL_verify = yes + +# Specify the version of the SSL protocol used to transmit data. +#SSL_version = SSLv23:!TLSv1_1:!TLSv1:!SSLv3:!SSLv2 + +# Specify the cipher list for the connection. +#SSL_cipher_list = EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL + + +[webserver] + +# Specify the local address to listen on, in the form ADDRESS[:PORT]. +# +#listen = 0.0.0.0:80 +#listen = [::]:80 + +# If a webserver is already running, specify a non-existent directory +# under which the webserver is configured to serve GET requests for +# challenge files under "/.well-known/acme-challenge/" (for each virtual +# hosts requiring authorization) as static files. +# +#challenge-directory = /var/www/acme-challenge + +# username to drop privileges to (setting both effective and real uid). +# Preserve root privileges if the value is empty (not recommended). +# +#user = www-data + +# groupname to drop privileges to (setting both effective and real gid, +# and also setting the list of supplementary gids to that single group). +# Preserve root privileges if the value is empty (not recommended). +# +#user = www-data + +# Path to the ACME webserver executable. +#command = /usr/lib/letsencrypt-tiny/webserver + +# Whether to automatically install iptables(1) rules to open the +# ADDRESS[:PORT] specified with listen. Theses rules are automatically +# removed once letsencrypt exits. +# +#iptables = Yes + +; vim:ft=dosini diff --git a/letsencrypt b/letsencrypt index b6235cf..23659d5 100755 --- a/letsencrypt +++ b/letsencrypt @@ -1,8 +1,8 @@ -#!/bin/bash +#!/usr/bin/perl -T #---------------------------------------------------------------------- -# Tiny Let's Encrypt ACME client -# Copyright © 2015 Guilhem Moulin +# Let's Encrypt ACME client +# Copyright © 2016 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 @@ -18,286 +18,687 @@ # along with this program. If not, see . #---------------------------------------------------------------------- -set -ue -set -o pipefail -PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -NAME=$(basename $0) - -WWW_USER=www-data -ACME_WEBSERVER=/usr/lib/letsencrypt-tiny/webserver -ACME_CLIENT=/usr/lib/letsencrypt-tiny/client -CAfile=/usr/share/letsencrypt-tiny/lets-encrypt-x1-cross-signed.pem - -declare COMMAND ACCOUNTKEY -declare -l GENKEY -declare RUNAS QUIET= DEBUG= - -declare SRVCRT= CHAIN CSR SRVKEY -declare -i MIN_AGE=0 -declare -l HASH= -declare SUBJECT=/ -declare SAN= -# https://security.stackexchange.com/questions/24106/which-key-usages-are-required-by-each-key-exchange-method -declare KEYUSAGE='digitalSignature, keyEncipherment, keyCertSign' -declare -a NOTIFY=() - -usage() { - local msg="${1:-}" - if [ "$msg" ]; then - echo "$NAME: $msg" >&2 - echo "Try '$NAME --help' or consult the manpage for more information." >&2 - exit 1 - fi - cat <<- EOF - Usage: $NAME [OPTIONS] new-reg ACCOUNTKEY [EMAIL ..] - or: $NAME [OPTIONS] new-cert ACCOUNTKEY --output=CERT {--csr=CSR | CSR Options } - or: $NAME [OPTIONS] revoke-cert ACCOUNTKEY CERT [CERT ..] - Consult the manpage for more information. - EOF - exit 0 +use strict; +use warnings; + +our $VERSION = '0.0.1'; +my $NAME = 'letsencrypt'; + +use Errno qw/EADDRINUSE EINTR/; +use Fcntl qw/F_GETFD F_SETFD FD_CLOEXEC SEEK_SET/; +use File::Temp (); +use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/; +use List::Util 'first'; +use POSIX (); +use Socket qw/PF_INET PF_INET6 PF_UNIX INADDR_ANY IN6ADDR_ANY + SOCK_STREAM SOL_SOCKET SO_REUSEADDR SHUT_RDWR/; + +use Config::Tiny (); +use Net::SSLeay (); + +# Clean up PATH +$ENV{PATH} = join ':', qw{/usr/bin /bin}; +delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; + +my ($COMMAND, %OPTS, $CONFFILE, $CONFIG, @CLEANUP); +$SIG{$_} = sub() { exit 1 } foreach qw/INT TERM/; # run the END block upon SIGINT/SIGTERM + + +############################################################################# +# Parse and validate configuration +# +sub usage(;$$) { + my $rv = shift // 0; + if ($rv) { + my $msg = shift; + print STDERR $msg."\n" if defined $msg; + print STDERR "Try '$NAME --help' or consult the manpage for more information.\n"; + } + else { + print STDERR "Usage: $NAME [--config=FILE] [--socket=PATH] [OPTIONS] COMMAND [ARGUMENT ..]\n" + ."Consult the manpage for more information.\n"; + } + exit $rv; +} +usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s socket=s agreement-uri=s debug help|h/); +usage(0) if $OPTS{help}; + +$COMMAND = shift(@ARGV) // usage(1, "Missing command"); +$COMMAND = $COMMAND =~ /\A(new-reg|reg=\p{Print}*|new-cert|revoke-cert)\z/ ? $1 + : usage(1, "Invalid command: $COMMAND"); # validate and untaint $COMMAND +@ARGV = map { /\A(\p{Print}*)\z/ ? $1 : die } @ARGV; # untaint @ARGV + +do { + my $conffile = $OPTS{config} // first { -f $_ } + ( "./$NAME.conf" + , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME.conf" + , "/etc/letsencrypt-tiny/$NAME.conf" + ); + die "Error: Can't find configuration file\n" unless defined $conffile; + print STDERR "Using configuration file: $conffile\n" if $OPTS{debug}; + open $CONFFILE, '<', $conffile or die "Can't open $conffile: $!\n"; + my $conf = do { local $/ = undef, <$CONFFILE> }; + # don't close $CONFFILE so we can pass it to the client + + my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n"; + my $defaults = delete $h->{_} // {}; + my %valid = ( + client => { + socket => (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.letsencrypt" : undef), + user => 'nobody', + group => 'nogroup', + command => '/usr/lib/letsencrypt-tiny/client', + # the rest is for the ACME client + map {$_ => undef} qw/server timeout SSL_verify SSL_version SSL_cipher_list/ + }, + webserver => { + listen => ':80', + 'challenge-directory' => '/var/www/acme-challenge', + user => 'www-data', + group => 'www-data', + command => '/usr/lib/letsencrypt-tiny/webserver', + iptables => 'Yes' + + } + ); + foreach my $s (keys %valid) { + my $h = delete $h->{$s} // {}; + my %v = map { $_ => delete $h->{$_} // $valid{$s}->{$_} } keys %{$valid{$s}}; + die "Unknown option(s) in [$s]: ".join(', ', keys %$h)."\n" if %$h; + $h->{$_} //= $defaults->{$_} foreach keys %$defaults; + $CONFIG->{$s} = \%v; + } + die "Invalid section(s): ".join(', ', keys %$h)."\n" if %$h; + $CONFIG->{_} = $defaults; +}; + +# Regular expressions for domain validation +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)+/; + + +############################################################################# +# Generate a Certificate Signing Request (in DER format) +# +sub gen_csr(%) { + my %args = @_; + return unless defined $args{'certificate-key'} and defined $args{subject}; + return if defined $args{hash} and !grep { $args{hash} eq $_ } qw/md5 rmd160 sha1 sha224 sha256 sha384 sha512/; + + my $config = File::Temp::->new(SUFFIX => '.conf', TMPDIR => 1) // die; + $config->print( + "[ req ]\n", + "distinguished_name = req_distinguished_name\n", + "req_extensions = v3_req\n", + + "[ req_distinguished_name ]\n", + + "[ v3_req ]\n", + # XXX Golang errors on extensions marked critical + # https://github.com/letsencrypt/boulder/issues/565 + #"basicConstraints = critical, CA:FALSE\n", + "basicConstraints = CA:FALSE\n", + "subjectKeyIdentifier = hash\n" + ); + #$config->print("keyUsage = critical, $args{keyUsage}\n") if defined $args{keyUsage}; + $config->print("keyUsage = $args{keyUsage}\n") if defined $args{keyUsage}; + $config->print("subjectAltName = $args{subjectAltName}\n") if defined $args{subjectAltName}; + $config->close() or die "Can't close: $!"; + + my @args = (qw/-new -batch -key/, $args{'certificate-key'}); + push @args, "-$args{hash}" if defined $args{hash}; + push @args, '-subj', $args{subject}, '-config', $config->filename(), qw/-reqexts v3_req/; + + open my $fh, '-|', qw/openssl req -outform DER/, @args or die "fork: $!"; + my $csr = do { local $/ = undef; <$fh> }; + close $fh or $! ? die "Can't close: $!" : return; + + if ($OPTS{debug}) { + # print out the CSR in text form + pipe my $rd, my $wd or die "pipe: $!"; + my $pid = fork // die "fork: $!"; + unless ($pid) { + open STDIN, '<&', $rd or die "Can't dup: $!"; + open STDOUT, '>&', \*STDERR or die "Can't dup: $!"; + exec qw/openssl req -noout -text -inform DER/ or die; + } + $rd->close() or die "Can't close: $!"; + $wd->print($csr); + $wd->close() or die "Can't close: $!"; + + waitpid $pid => 0; + die $? if $? > 0; + } + + return $csr; +} + + +############################################################################# +# Get the timestamp of the given cert's expiration date. +# Internally the expiration date is stored as a RFC3339 string (such as +# yyyy-mm-ddThh:mm:ssZ); we convert it to a timestamp manually. +# +sub x509_enddate($) { + my $filename = shift; + my ($bio, $x509, $time, $dt); + + $bio = Net::SSLeay::BIO_new_file($filename, 'r'); + $x509 = Net::SSLeay::PEM_read_bio_X509($bio) if defined $bio; + $time = Net::SSLeay::X509_get_notAfter($x509) if defined $x509; + $dt = Net::SSLeay::P_ASN1_TIME_get_isotime($time) if defined $time; + + my $t; + if (defined $dt and $dt =~ s/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})//) { + # RFC3339 datetime strings; assume epoch is on January 1 of $epoch_year + my ($y, $m, $d, $h, $min, $s) = ($1, $2, $3, $4, $5, $6); + my (undef,undef,undef,undef,undef,$epoch_year,undef,undef,undef) = gmtime(0); + $t = 0; + foreach (($epoch_year+1900) .. $y-1) { + $t += 365*86400; + $t += 86400 if ($_ % 4 == 0 and $_ % 100 != 0) or ($_ % 400 == 0); # leap + } + + if ($m > 1) { + my @m = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31); + $m[1]++ if ($y % 4 == 0 and $y % 100 != 0) or ($y % 400 == 0); # leap + $t += 86400*$m[$_] for (0 .. $m-2); + } + + $t += 86400*($d-1); + $t += $s + 60*($min + 60*$h); + + $dt =~ s/\A\.(\d{1,9})\d*//; # ignore nanosecs + + if ($dt =~ /\A([+-])(\d{2}):(\d{2})\z/) { + my $tz = 60*($3 + 60*$2); + $t = $1 eq '-' ? ($t+$tz) : ($t-$tz); + } + } + + Net::SSLeay::X509_free($x509) if defined $x509; + Net::SSLeay::BIO_free($bio) if defined $bio; + return $t; +} + + +############################################################################# +# Drop privileges and chdir afterwards +# +sub drop_privileges($$$) { + my ($user, $group, $dir) = @_; + + # set effective and real gid; also set the list of supplementary gids to that single gid + if ($group ne '') { + my $gid = getgrnam($group) // die "Can't getgrnam($group): $!"; + $) = "$gid $gid"; + die "Can't setgroups: $!" if $@; + POSIX::setgid($gid) or die "Can't setgid: $!"; + die "Couldn't setgid/setguid" unless $( eq "$gid $gid" and $) eq "$gid $gid"; # safety check + } + + # set effective and real uid + if ($user ne '') { + my $uid = getpwnam($user) // die "Can't getpwnam($user): $!"; + POSIX::setuid($uid) or die "Can't setuid: $!"; + die "Couldn't setuid/seteuid" unless $< == $uid and $> == $uid; # safety check + } + + chdir $dir or die "Can't chdir to $dir: $!"; +} + + +############################################################################# +# Ensure the FD_CLOEXEC bit is $set on $fd +# +sub set_FD_CLOEXEC($$) { + my ($fd, $set) = @_; + my $flags = fcntl($fd, F_GETFD, 0) or die "Can't fcntl F_GETFD: $!"; + my $flags2 = $set ? ($flags | FD_CLOEXEC) : ($flags & ~FD_CLOEXEC); + return if $flags == $flags2; + fcntl($fd, F_SETFD, $flags2) or die "Can't fcntl F_SETFD: $!"; +} + + +############################################################################# +# Try to spawn a webserver to serve ACME challenges, and return the +# temporary challenge directory. +# If a webserver is already listening, symlink the 'challenge-directory' +# configuration option to the temporary challenge directory. +# Otherwise, bind(2) a socket, pass its fileno to the webserver +# component, and optionally install iptables rules. +# +sub spawn_webserver() { + # create a temporary directory; give write access to the ACME client + # and read access to the webserver + my $tmpdir = File::Temp::->newdir(CLEANUP => 1, TMPDIR => 1) // die; + chmod 0755, $tmpdir or die "Can't chmod: $!"; + if ((my $username = $CONFIG->{client}->{user}) ne '') { + my $uid = getpwnam($username) // die "Can't getgrnam($username): $!"; + chown($uid, -1, $tmpdir) or die "Can't chown: $!"; + } + + my $conf = $CONFIG->{webserver}; + my ($fam, $addr, $port) = (PF_INET, $conf->{listen}, 80); + $port = $1 if $addr =~ s/:(\d+)$//; + $addr = Socket::inet_ntop(PF_INET, INADDR_ANY) if $addr eq ''; + $fam = PF_INET6 if $addr =~ s/^\[(.+)\]$/$1/; + + my $proto = getprotobyname("tcp") // die; + socket(my $srv, $fam, SOCK_STREAM, $proto) or die "socket: $!"; + setsockopt($srv, SOL_SOCKET, SO_REUSEADDR, pack("l", 1)) or die "setsockopt: $!"; + $addr = Socket::inet_pton($fam, $addr) // die "Invalid address $conf->{listen}\n"; + my $sockaddr = $fam == PF_INET ? Socket::pack_sockaddr_in($port, $addr) + : $fam == PF_INET6 ? Socket::pack_sockaddr_in6($port, $addr) + : die; + + # try to bind aginst the specified address:port + bind($srv, $sockaddr) or do { + die "Can't bind to $conf->{listen}: $!" if $! != EADDRINUSE; + print STDERR "[$$] Using existing webserver on $conf->{listen}\n" if $OPTS{debug}; + my $dir = $conf->{'challenge-directory'}; + symlink $tmpdir, $dir or die "Can't symlink $dir -> $tmpdir: $!"; + push @CLEANUP, sub() { + print STDERR "Unlinking $dir\n" if $OPTS{debug}; + unlink $dir or warn "Warning: Can't unlink $dir: $!"; + }; + return $tmpdir; + }; + listen($srv, 5) or die "listen: $!"; + + # spawn the webserver component + my $pid = fork() // "fork: $!"; + unless ($pid) { + drop_privileges($conf->{user}, $conf->{group}, $tmpdir); + set_FD_CLOEXEC($srv, 0); + $ENV{DEBUG} = $OPTS{debug}; + # use execve(2) rather than a Perl pseudo-process to ensure that + # the child doesn't have access to the parent's memory + exec $conf->{command}, fileno($srv) or die; + } + + print STDERR "[$$] Forking ACME webserver, child PID $pid\n" if $OPTS{debug}; + set_FD_CLOEXEC($srv, 1); + push @CLEANUP, sub() { + print STDERR "[$$] Shutting down ACME webserver\n" if $OPTS{debug}; + shutdown($srv, SHUT_RDWR) or warn "shutdown: $!"; + kill 15 => $pid; + waitpid $pid => 0; + }; + + return $tmpdir if lc ($conf->{iptables} // 'Yes') eq 'no'; + + # install iptables + my $iptables_bin = $fam == PF_INET ? 'iptables' : $fam == PF_INET6 ? 'ip6tables' : die; + my $iptables_tmp = File::Temp::->new(TMPDIR => 1) // die; + set_FD_CLOEXEC($iptables_tmp, 1); + + my $pid2 = fork() // die "fork: $!"; + unless ($pid2) { + open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!"; + open STDOUT, '>&', $iptables_tmp or die "Can't dup: $!"; + $| = 1; # turn off buffering for STDOUT + exec "/sbin/$iptables_bin-save", "-c" or die; + } + waitpid $pid2 => 0; + die "Error: /sbin/$iptables_bin-save exited with value ".($? >> 8) if $? > 0; + + # seek back to the begining, as we'll restore directly from the + # handle and not from the file. XXX if there was a way in Perl to + # use open(2) with the O_TMPFILE flag we would use that to avoid + # creating a file to start with + seek($iptables_tmp, SEEK_SET, 0) or die "Can't seek: $!"; + + push @CLEANUP, sub() { + print STDERR "[$$] Restoring iptables\n" if $OPTS{debug}; + my $pid2 = fork() // die "fork: $!"; + unless ($pid2) { + open STDIN, '<&', $iptables_tmp or die "Can't dup: $!"; + open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!"; + exec "/sbin/$iptables_bin-restore", "-c" or die; + } + waitpid $pid2 => 0; + warn "Warning: /sbin/$iptables_bin-restore exited with value ".($? >> 8) if $? > 0; + }; + + # it's safe to install the new iptables to open $port now that the + # restore hook is in place + my $mask = $fam == PF_INET ? ($addr eq INADDR_ANY ? '0' : '32') + : $fam == PF_INET6 ? ($addr eq IN6ADDR_ANY ? '0' : '128') + : die; + my $dest = Socket::inet_ntop($fam, $addr) .'/'. $mask; + + system ("/sbin/$iptables_bin", qw/-I INPUT -p tcp -m tcp -m state/, + '-d', $dest, '--dport', $port, + '--state', 'NEW,ESTABLISHED', '-j', 'ACCEPT') == 0 or die; + system ("/sbin/$iptables_bin", qw/-I OUTPUT -p tcp -m tcp -m state/, + '-s', $dest, '--sport', $port, + '--state', 'ESTABLISHED', '-j', 'ACCEPT') == 0 or die; + + return $tmpdir; +} + + +############################################################################# +# Spawn the client component, and wait for it to return. +# If $args->{in} is defined, the data is written to the client's STDIN. +# If $args->{out} is defined, its value is set to client's STDOUT data. +# +sub acme_client($@) { + my $args = shift; + my @args = @_; + + my @stat; + my $conf = $CONFIG->{client}; + my $sockname = $OPTS{socket} // $conf->{socket} // die "Missing socket option\n"; + $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : die "Invalid socket name\n"; # untaint $sockname + + # ensure we're the only user with write access to the parent dir + my $dirname = $sockname =~ s/[^\/]+$//r; + @stat = stat($dirname) or die "Can't stat $dirname: $!"; + die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0; + + # ensure we're the only user with read/write access to the socket + @stat = stat($sockname) or die "Can't stat $sockname: $! (Is letsencrypt-accountd running?)\n"; + die "Error: insecure permissions on $sockname\n" if ($stat[2] & 0066) != 0; + + # connect(2) to the socket + socket(my $client, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!"; + my $sockaddr = Socket::sockaddr_un($sockname) // die "Invalid address $sockname\n"; + until (connect($client, $sockaddr)) { + next if $! == EINTR; # try again if connect(2) was interrupted by a signal + die "connect: $!"; + } + + my @fileno = map { fileno($_) =~ /^(\d+)$/ ? $1 : die } ($CONFFILE, $client); # untaint fileno + spawn({%$args{qw/in out/}, child => sub() { + drop_privileges($conf->{user}, $conf->{group}, $args->{chdir} // '/'); + set_FD_CLOEXEC($_, 0) foreach ($CONFFILE, $client); + seek($CONFFILE, SEEK_SET, 0) or die "Can't seek: $!"; + $ENV{DEBUG} = $OPTS{debug}; + }}, $conf->{command}, $COMMAND, @fileno, @args); +} + +sub spawn($@) { + my $args = shift; + my @exec = @_; + + # create communication pipes if needed + my ($in_rd, $in_wd, $out_rd, $out_wd); + if (defined $args->{in}) { + pipe $in_rd, $in_wd or die "pipe: $!"; + } + if (defined $args->{out}) { + pipe $out_rd, $out_wd or die "pipe: $!"; + } + + my $pid = fork() // "fork: $!"; + unless ($pid) { + # child + $args->{child}->() if defined $args->{child}; + if (defined $args->{in}) { + close $in_wd or die "Can't close: $!"; + open STDIN, '<&', $in_rd or die "Can't dup: $!"; + } else { + open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!"; + } + if (defined $args->{out}) { + close $out_rd or die "Can't close: $!"; + open STDOUT, '>&', $out_wd or die "Can't dup: $!"; + } else { + open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!"; + } + # use execve(2) rather than a Perl pseudo-process to ensure that + # the child doesn't have access to the parent's memory + exec { $exec[0] } @exec or die; + } + push @CLEANUP, sub() { + kill 15 => $pid; + waitpid $pid => 0; + }; + + # parent + print STDERR "[$$] Forking $exec[0], child PID $pid\n" if $OPTS{debug}; + if (defined $args->{in}) { + $in_rd->close() or die "Can't close: $!"; + $in_wd->print($args->{in}); + $in_wd->close() or die "Can't close: $!"; + } + if (defined $args->{out}) { + $out_wd->close() or die "Can't close: $!"; + ${$args->{out}} = do { local $/ = undef; $out_rd->getline() }; + $out_rd->close() or die "Can't close: $!"; + } + waitpid $pid => 0; + pop @CLEANUP; + undef ${$args->{out}} if defined $args->{out} and $? > 0; + return $? > 255 ? ($? >> 8) : $? > 0 ? 1 : 0; +} + + +############################################################################# +# Install the certificate +# +sub install_cert($$@) { + my $filename = shift; + my $x509 = shift; + + open my $fh, '>', $filename or die "Can't open $filename: $!"; + print $fh $x509; + foreach (@_) { # append the chain + open my $fh2, '<', $_ or die "Can't open $_: $!"; + my $ca = do { local $/ = undef; $fh2->getline() }; + print $fh $ca; + close $fh2 or die "Can't close: $!"; + } + close $fh or die "Can't close: $!"; } -# Generate a key pair with mode 0600 -genkey() { - local key="$1" genkey= bits= - case "$GENKEY" in - rsa) genkey=genrsa;; - rsa:*) genkey=genrsa; bits=${GENKEY#*:};; - *) echo "Error: invalid key type ${GENKEY%%:*}" >&2; exit 1;; - esac - install --mode=0600 /dev/null "$key" # atomic creation with mode 0600 - openssl "$genkey" $bits >"$key" + +############################################################################# +# new-reg [--agreement-uri=URI] [CONTACT ..] +# reg=URI [--agreement-uri=URI] [CONTACT ..] +# +if ($COMMAND eq 'new-reg' or $COMMAND =~ /^reg=/) { + die "Invalid registration URI (use the 'new-reg' command to determine the URI)\n" + if $COMMAND eq 'reg='; + $OPTS{'agreement-uri'} = $OPTS{'agreement-uri'} =~ /\A(\p{Print}+)\z/ ? $1 + : die "Invalid value for --agreement-uri\n" + if defined $OPTS{'agreement-uri'}; + + unshift @ARGV, ($OPTS{'agreement-uri'} // ''); + exit acme_client({}, @ARGV); +} + + +############################################################################# +# new-cert [SECTION ..] +# TODO: renewal without the account key, see +# https://github.com/letsencrypt/acme-spec/pull/168 +# +elsif ($COMMAND eq 'new-cert') { + my $conf; + do { + my $conffile = $OPTS{'config-certs'} // $CONFIG->{_}->{'config-certs'} // first { -f $_ } + ( "./$NAME-certs.conf" + , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME-certs.conf" + , "/etc/letsencrypt-tiny/$NAME-certs.conf" + ); + die "Error: Can't find certificate configuration file\n" unless defined $conffile; + my $h = Config::Tiny::->read($conffile) or die Config::Tiny::->errstr()."\n"; + my $defaults = delete $h->{_} // {}; + my @valid = qw/certificate certificate-chain certificate-key min-days CAfile + hash keyUsage subject subjectAltName chown chmod notify/; + foreach my $s (keys %$h) { + $conf->{$s} = { map { $_ => delete $h->{$s}->{$_} } @valid }; + die "Unknown option(s) in [$s]: ".join(', ', keys %{$h->{$s}})."\n" if %{$h->{$s}}; + $conf->{$s}->{$_} //= $defaults->{$_} foreach keys %$defaults; + } + }; + + my $challenge_dir; + my $rv = 0; + foreach my $s (@ARGV ? @ARGV : keys %$conf) { + my $conf = $conf->{$s} // do { + print STDERR "Warning: No such section $s, skipping\n"; + $rv = 1; + next; + }; + + my $certtype = first { defined $conf->{$_} } qw/certificate certificate-chain/; + unless (defined $certtype) { + print STDERR "[$s] Warning: Missing 'certificate' and 'certificate-chain', skipping\n"; + $rv = 1; + next; + } + + # skip certificates that expire at least $conf->{'min-days'} days in the future + if (-f $conf->{$certtype} and defined (my $t = x509_enddate($conf->{$certtype}))) { + my $d = $conf->{'min-days'} // 10; + if ($d > 0 and $t - time > $d*86400) { + my $d = POSIX::strftime('%Y-%m-%d %H:%M:%S UTC', gmtime($t)); + print STDERR "[$s] Valid until $d, skipping\n"; + next; + } + } + + # generate the CSR + my $csr = gen_csr(%$conf{qw/certificate-key subject subjectAltName keyUsage hash/}) // do { + print STDERR "[$s] Warning: Couldn't generate CSR, skipping\n"; + $rv = 1; + next; + }; + + # spawn the webserver if not done already + $challenge_dir //= spawn_webserver(); + + # list all authorization domains to request + my @authz; + push @authz, $1 if defined $conf->{subject} =~ /\A.*\/CN=($RE_DOMAIN)\z/o; + if (defined $conf->{subjectAltName}) { + foreach my $d (split /,/, $conf->{subjectAltName}) { + next unless $d =~ s/\A\s*DNS://; + if ($d =~ /\A$RE_DOMAIN\z/o) { + push @authz, $d unless grep {$_ eq $d} @authz; + } else { + print STDERR "[$s] Warning: Ignoring invalid domain $d\n"; + } + } + } + + my ($x509, $csr_pubkey, $x509_pubkey); + print STDERR "[$s] Will request authorization for: ".join(", ", @authz), "\n" if $OPTS{debug}; + if (acme_client({chdir => $challenge_dir, in => $csr, out => \$x509}, @authz)) { + print STDERR "[$s] Error: Couldn't issue X.509 certificate!\n"; + $rv = 1; + next; + } + + # extract pubkeys from CSR and cert, and ensure they match + spawn({in => $csr, out => \$csr_pubkey }, qw/openssl req -inform DER -noout -pubkey/); + spawn({in => $x509, out => \$x509_pubkey}, qw/openssl x509 -inform PEM -noout -pubkey/); + unless (defined $x509_pubkey and defined $csr_pubkey and $x509_pubkey eq $csr_pubkey) { + print STDERR "[$s] Error: Received bogus X.509 certificate from ACME server!\n"; + $rv = 1; + next; + }; + + # verify certificate validity against the CA + $conf->{CAfile} //= '/usr/share/letsencrypt-tiny/lets-encrypt-x1-cross-signed.pem'; + if ($conf->{CAfile} ne '' and spawn({in => $x509}, 'openssl', 'verify', '-CAfile', $conf->{CAfile}, + qw/-purpose sslserver -x509_strict/)) { + print STDERR "[$s] Error: Received invalid X.509 certificate from ACME server!\n"; + $rv = 1; + next; + } + + # install certificate + if (defined $conf->{'certificate'}) { + print STDERR "Installing X.509 certificate $conf->{'certificate'}\n"; + install_cert($conf->{'certificate'}, $x509); + } + if (defined $conf->{'certificate-chain'}) { + print STDERR "Installing X.509 certificate chain $conf->{'certificate-chain'}\n"; + install_cert($conf->{'certificate-chain'}, $x509, $conf->{CAfile}); + } + + if (defined $conf->{chown}) { + my ($user, $group) = split /:/, $conf->{chown}, 2; + my $uid = getpwnam($user) // die "Can't getpwnam($user): $!"; + my $gid = defined $group ? (getgrnam($group) // die "Can't getgrnam($group): $!") : -1; + foreach (grep defined, @$conf{qw/certificate certificate-chain/}) { + chown($uid, $gid, $_) or die "Can't chown: $!"; + } + } + if (defined $conf->{chmod}) { + my $mode = oct($conf->{chmod}) // die; + foreach (grep defined, @$conf{qw/certificate certificate-chain/}) { + chmod($mode, $_) or die "Can't chown: $!"; + } + } + + my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump/; + open my $fh, '|-', qw/openssl x509 -noout -fingerprint -sha256 -text -certopt/, @certopts + or die "fork: $!"; + print $fh $x509; + close $fh or die $! ? + "Can't close: $!" : + "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; + + if (defined $conf->{notify}) { + print STDERR "Running notification command `$conf->{notify}`\n"; + if (system($conf->{notify}) != 0) { + print STDERR "Warning: notification command exited with value ".($? >> 8)."\n"; + $rv = 1; + } + } + } + undef $challenge_dir; + exit $rv; } -# Parse options -declare -a ARGV=() -while [ $# -gt 0 ]; do - case "$1" in - --genkey) GENKEY=RSA;; - --genkey=*) GENKEY="${1#*=}";; - --runas=*) RUNAS="${1#*=}";; - --help|-\?) usage;; - --quiet|-q) QUIET=1;; - --debug) DEBUG=1;; - - --output=*) SRVCRT="${1#*=}";; - --min-age=*) MIN_AGE="${1#*=}";; - --chain) CHAIN=;; - --chain=*) CHAIN="${1#*=}";; - --csr=*) CSR="${1#*=}";; - --key=*) SRVKEY="${1#*=}";; - --hash=*) HASH="${1#*=}";; - --subject=*) SUBJECT="${1#*=}";; - --san=*) SAN="${1#*=}";; - --keyusage=*) KEYUSAGE="${1#*=}";; - --notify=*) NOTIFY+=( "${1#*=}" );; - - --) shift; ARGV+=( "$@" ); break ;; - -*) usage "unknown option '${1%%=*}'";; - *) if [ ${ACCOUNTKEY+x} ]; then - ARGV+=( "$1" ) - else - [ ${COMMAND+x} ] && ACCOUNTKEY="$1" || COMMAND="$1" - fi - esac - shift -done - -[ "${COMMAND:-}" ] || usage "missing command" -[ "$COMMAND" = 'new-reg' -o "$COMMAND" = 'new-cert' -o "$COMMAND" = 'revoke-cert' ] || - usage "invalid command $COMMAND" -[ ${#ARGV[@]} -eq 0 -o "$COMMAND" = 'new-reg' -o "$COMMAND" = 'revoke-cert' ] || - usage "invalid argument ${ARGV[0]}" - -[ "${ACCOUNTKEY:-}" ] || usage "missing account key" -if ! [ -f "$ACCOUNTKEY" -a -s "$ACCOUNTKEY" ]; then - if [ "$COMMAND" = 'new-reg' -a "${GENKEY+x}" ]; then - genkey "$ACCOUNTKEY" - else - echo "Error: keyfile '$ACCOUNTKEY' does not exist." >&2 - [[ ! "$COMMAND" =~ ^new- ]] || echo "Use 'new-reg --genkey' to generate and register an RSA key with mode 0600." >&2 - exit 1 - fi -fi - - -declare -a TMPFILES=() -declare CHALLENGE_DIR IPTABLES_SAVE -cleanup() { - set +e - [ ! ${IPTABLES_SAVE+x} ] || iptables-restore -c <"$IPTABLES_SAVE" - pkill -TERM -P $$ - [ ${#TMPFILES[@]} -eq 0 ] || rm -${DEBUG:+v}f "${TMPFILES[@]}" - [ ! ${CHALLENGE_DIR+x} ] || rm -${DEBUG:+v}rf "$CHALLENGE_DIR" +############################################################################# +# revoke-cert FILE [FILE ..] +# +elsif ($COMMAND eq 'revoke-cert') { + die "Nothing to revoke\n" unless @ARGV; + my $rv = 0; + foreach my $filename (@ARGV) { + print STDERR "Revoking $filename\n"; + + # conversion PEM -> DER + open my $fh, '-|', qw/openssl x509 -outform DER -in/, $filename or die "fork: $!"; + my $der = do { local $/ = undef; <$fh> }; + close $fh or die $! ? + "Can't close: $!" : + "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; + + my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump no_extensions/; + open my $fh2, '|-', qw/openssl x509 -inform DER -noout -fingerprint -sha256 -text -certopt/, @certopts + or die "fork: $!"; + print $fh2 $der; + close $fh2 or die $! ? + "Can't close: $!" : + "Error: x509(1ssl) exited with value ".($? >> 8)."\n"; + + if (acme_client({in => $der})) { + print STDERR "Warning: Couldn't revoke $filename\n"; + $rv = 1; + } + } + exit $rv; } -trap cleanup EXIT SIGINT - - -# Extract the public part of the user key -accountpub=$(mktemp --tmpdir XXXXXX.pub) -TMPFILES+=( "$accountpub" ) -openssl pkey -pubout <"$ACCOUNTKEY" >"$accountpub" -chmod 0644 "$accountpub" - - -if [ "$COMMAND" = 'revoke-cert' ]; then - if [ ${#ARGV[@]} -eq 0 ]; then - echo "Error: Nothing to revoke" >&2 - exit 1 - fi -elif [ "$COMMAND" = 'new-cert' ]; then - if [ ! "${SRVCRT:-}" ]; then - echo "Error: Missing --output" >&2 - exit 1 - fi - if [ -s "$SRVCRT" ] && [ $MIN_AGE -gt 0 ] && \ - exp=$(openssl x509 -noout -enddate <"$SRVCRT" 2>/dev/null) && \ - [ $(( $(date -d "${exp#*=}" +%s) - $(date +%s))) -gt $MIN_AGE ]; then - [ ! "$DEBUG" ] || echo "Expiration date ($(date -d"${exp#*=}")) is too far away, come back later." >&2 - exit 0 - fi - - # Generate a Certificate Signing Request if need be - if [ ${CSR+x} ]; then - if [ -z "$CSR" -o ! -s "$CSR" ]; then - echo "Error: Missing Certificate Signing Request $CSR. (One of --csr or --key must be set.)" >&2 - exit 1 - fi - else - CSR=$(mktemp --tmpdir XXXXXX.csr) - config=$(mktemp --tmpdir XXXXXX.cnf) - TMPFILES+=( "$CSR" "$config" ) - chmod 0644 "$CSR" - - if [ "$HASH" ]; then - case "$HASH" in - md5|rmd160|sha1|sha224|sha256|sha384|sha512) HASH="-$HASH";; - *) echo "Invalid digest algorithm: $HASH" >&2; exit 1;; - esac - fi - if [ ! "${SRVKEY:-}" ] || [ ! -s "$SRVKEY" ]; then - if [ "${SRVKEY:-}" -a "${GENKEY+x}" ]; then - genkey "$SRVKEY" - else - echo "Error: Missing private server key. Use new-cert --genkey --key=... to generate" >&2 - exit 1 - fi - fi - - [ ! "$DEBUG" ] || echo 'Generating Certificate Signing Request...' >&2 - cat >"$config" <<- EOF - [ req ] - distinguished_name = req_distinguished_name - req_extensions = v3_req - - [ req_distinguished_name ] - - [ v3_req ] - ${SAN:+subjectAltName= $SAN} - # XXX Golang errors on extensions marked critical - # https://github.com/letsencrypt/boulder/issues/565 - #basicConstraints = critical, CA:FALSE - #keyUsage = critical${KEYUSAGE:+, $KEYUSAGE} - basicConstraints = CA:FALSE - keyUsage = $KEYUSAGE - subjectKeyIdentifier = hash - EOF - openssl req -new -batch -key "$SRVKEY" $HASH -subj "$SUBJECT" -config "$config" -reqexts v3_req >"$CSR" - fi - [ ! "$DEBUG" ] || openssl req -noout -text <"$CSR" - - CHALLENGE_DIR=$(mktemp --tmpdir -d acme-challenge.XXXXXX) - x509=$(mktemp --tmpdir XXXXXX.pem) - TMPFILES+=( "$x509" ) - - [ ! "${RUNAS:-}" ] || chown "$RUNAS" "$CHALLENGE_DIR" "$x509" - chgrp "$(id -g -- "$WWW_USER")" "$CHALLENGE_DIR" - chmod 0750 "$CHALLENGE_DIR" - - # Make sure a webserver is configured to server ACME challenges - if nc -z 127.0.0.1 80; then - [ ! "$DEBUG" ] || echo "Using existing webserver" >&2 - ln -${DEBUG:+v}Ts "$CHALLENGE_DIR" /var/www/acme-challenge - TMPFILES+=( "/var/www/acme-challenge" ) - else - temp=$(mktemp --tmpdir) - TMPFILES+=( "$temp" ) - iptables-save -c >"$temp" - IPTABLES_SAVE="$temp" - iptables -I INPUT -p tcp -m tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT - iptables -I OUTPUT -p tcp -m tcp --sport 80 -m state --state ESTABLISHED -j ACCEPT - ( - [ ! "$DEBUG" ] || echo "Starting ACME webserver in $CHALLENGE_DIR" >&2 - cd "$CHALLENGE_DIR" || exit 1 - # use the "su" otion rather than "setuid/setgid" since while setgid - # changes the primary group of the process, it doesn't drop other - # group related privileges - exec socat \ - TCP-LISTEN:80,su="$WWW_USER",reuseaddr,fork,max-children=5 \ - EXEC:"$ACME_WEBSERVER" - )& - fi - - ARGV=( "$CSR" "$CHALLENGE_DIR" "$x509" ) -fi - - -pipe=$(mktemp --tmpdir -u XXXXXX.fifo) -mkfifo -m0600 "$pipe" -TMPFILES+=( "$pipe" ) - -# Wait for signing requests from the ACME slave -acme_client() { - if [ "${RUNAS:-}" ]; then - sudo -u "$RUNAS" ${DEBUG:+DEBUG="$DEBUG"} -- $ACME_CLIENT "$@" - else - [ ${RUNAS+x} ] || echo "WARNING: Use --runas=USER to specify a user to drop permissions to." >&2 - DEBUG="$DEBUG" $ACME_CLIENT "$@" - fi + + +############################################################################# +# +else { + die "Unknown command $COMMAND" } -acme_client "$COMMAND" "$accountpub" ${ARGV[0]+"${ARGV[@]}"} <"$pipe" | -while read data; do - echo -n "$data" | openssl dgst -sha256 -sign "$ACCOUNTKEY" -hex | sed 's/.*=\s*//' -done >"$pipe" - -if [ "$COMMAND" != 'new-cert' ]; then - [ "$QUIET" ] || echo OK -else - # Ensure the cert's pubkey matches that of the CSR, and that it's signed by the intended CA - if [ ! -s "$x509" ] || - ! diff <(openssl req -in "$CSR" -pubkey -noout) \ - <(openssl x509 -in "$x509" -pubkey -noout) >/dev/null || - ! openssl verify -CAfile "$CAfile" -purpose sslserver -x509_strict <"$x509" >/dev/null; then - echo "Error: Got an invalid X.509 certificate from the ACME server!" >&2 - exit 1 - fi - - # if it doesn't exist, create the file with mode 0644 minus the process's umask(2) - [ -e "$SRVCRT" ] || touch "$SRVCRT" - cat "$x509" >"$SRVCRT" - [ ! "$DEBUG" ] || openssl x509 -noout -text <"$SRVCRT" - - # add the CA chain - if [ ${CHAIN+x} ]; then - if [ "${CHAIN:-$SRVCRT}" = "$SRVCRT" ]; then - cat "$CAfile" >>"$SRVCRT" - else - [ -e "$CHAIN" ] || touch "$CHAIN" - cat "$SRVCRT" "$CAfile" >"$CHAIN" - fi - fi - - if [ ! "$QUIET" ]; then - echo "X.509 certificate $SRVCRT has been updated or renewed" - echo - openssl x509 -noout \ - -text -certopt no_header,no_version,no_pubkey,no_sigdump \ - -fingerprint -sha256 <"$SRVCRT" - fi - - for (( i=0; i<${#NOTIFY[@]}; i++ )); do - ${NOTIFY[$i]} - done -fi + +END { + local $?; + $_->() foreach reverse @CLEANUP; +} diff --git a/letsencrypt.1 b/letsencrypt.1 index a16e165..2175bd2 100644 --- a/letsencrypt.1 +++ b/letsencrypt.1 @@ -1,187 +1,362 @@ -.TH LETSENCRYPT "1" "DECEMBER 2015" "Tiny Let's Encrypt ACME client" "User Commands" +.TH LETSENCRYPT "1" "MARCH 2016" "Tiny Let's Encrypt ACME client" "User Commands" .SH NAME letsencrypt \- Tiny Let's Encrypt ACME client .SH SYNOPSIS -.B letsencrypt\fR [\fIOPTION\fR ...] \fICOMMAND\fR \fIACCOUNTKEY\fR [\fIARGUMENT\fR ...] +.B letsencrypt\fR [\fB\-\-config=\fIFILENAME\fR] +[\fB\-\-socket=\fIPATH\fR] [\fIOPTION\fR ...] \fICOMMAND\fR +[\fIARGUMENT\fR ...] .SH DESCRIPTION .PP .B letsencrypt\fR is a tiny ACME client written with process isolation and minimal privileges in mind. -It is divided into three components: - -.nr step 1 1 -.IP \n[step]. 8 -The \(lqmaster\(rq process, which runs as root and is the only component -with access to the private key material (both account and server keys). -It is only used to fork the other components (after dropping -privileges), and to sign ACME requests (JSON Web Signatures); for -certificate issuance (\fBnew-cert\fR command), it is also used to -generate the Certificate Signing Request, then to verify the validity of -the issued certificate, and optionally to reload or restart services -using \fB--notify\fR. - -.IP \n[step]. 8 -The actual ACME client, which runs as the user specified with -\fB--runas\fR (or root if the option is omitted). It builds ACME -requests and dialogues with the remote ACME server. All requests need -to be signed with the account key, but this process doesn't need direct -access to any private key material: instead, it write the data to be -signed to a pipe shared with the master process, which in turns replies -with its SHA-256 signature. - -.IP \n+[step]. -An optional webserver, which is spawned by the master process (when -nothing is listening on localhost:80); \fBsocat\fR(1) is used to listen -on port 80 and to change the user (owner) and group of the process to -\(lqwww-data:www-data\(rq. +It is divided into four components, each with its own executable: + +.IP \[bu] 4 +A \fIletsencrypt\-accountd\fR(1) process to manage the account key and +issue SHA\-256 signatures needed for each ACME command. +(This process binds to a UNIX\-domain socket to reply to signature +requests from the ACME client.) +One can use the UNIX\-domain socket forwarding facility of OpenSSH 6.7 +and later to run \fIletsencrypt\-accountd\fR(1) and \fBletsencrypt\fR on +different hosts. + +.IP \[bu] 4 +A \(lqmaster\(rq \fBletsencrypt\fR process, which runs as root and is +the only component with access to the private key material of the server +keys. +It is used to fork the ACME client (and optionally the ACME webserver) +after dropping root privileges. +For certificate issuances (\fBnew\-cert\fR command), it also generates +Certificate Signing Requests, then verifies the validity of the issued +certificate, and optionally reloads or restarts services when the +\fInotify\fR option is set. + +.IP \[bu] 4 +An actual ACME client (specified with the \fIcommand\fR option of the +\(lq[client]\(rq section of the configuration file), which builds ACME +commands and dialogues with the remote ACME server. +Since ACME commands need to be signed with the account key, the +\(lqmaster\(rq \fBletsencrypt\fR process passes the +\fIletsencrypt\-accountd\fR(1) UNIX\-domain socket to the ACME client: +data signatures are requested by writing the data to be signed to the +socket. + +.IP \[bu] 4 +For certificate issuances (\fBnew\-cert\fR command), an optional +webserver (specified with the \fIcommand\fR option of the +\(lq[webserver]\(rq section of the configuration file), which is spawned +by the \(lqmaster\(rq \fBletsencrypt\fR process when no service is +listening on the HTTP port. (The only challenge type currently supported by \fBletsencrypt\fR is -\(lqhttp-01\(rq, hence a webserver is required.) -Some iptables rules are automatically added to open port 80, and -removed afterwards. -The web server only processes GET requests under the -\(lq/.well-known/acme-challenge\(rq URI. -If a webserver is already listening on port 80, it needs to be -configured to serve these URIs (for each virtual-hosts requiring -authorization) as static files under the \(lq/var/www/acme-challenge\(rq -root directory, which must not exist. +\(lqhttp\-01\(rq, which requires a webserver to answer challenges.) +That webserver only processes GET and HEAD requests under the +\(lq/.well\-known/acme\-challenge/\(rq URI. +By default some \fIiptables\fR(1) rules are automatically installed to +open the HTTP port, and removed afterwards. + +.SH COMMANDS +.TP +.B letsencrypt \fR[\fB\-\-agreement\-uri=\fIURI\fR]\fB \fBnew\-reg \fR[\fICONTACT\fR ...] +Register the account key managed by \fIletsencrypt\-accountd\fR(1). A +list of \fICONTACT\fR information (such as \(lqmaito:\(rq +URIs) can be specified in order for the server to contact the client for +issues related to this registration (such as notifications about +server\-initiated revocations). + +\fB\-\-agreement\-uri=\fR can be used to specify a \fIURI\fR referring +to a subscriber agreement or terms of service provided by the server; +adding this options indicates the client's agreement with the referenced +terms. Note that the server might require the client to agree to +subscriber agreement before performing any further actions. + +If the account key is already registered, \fBletsencrypt\fR prints the +URI of the existing registration and aborts. + +.TP +.B letsencrypt \fR[\fB\-\-agreement\-uri=\fIURI\fR]\fB \fBreg=\fIURI\fR \fR[\fICONTACT\fR ...] + +Dump or edit the registration \fIURI\fR (relative to the ACME server URI, +which is specified with the \fIserver\fR option of the \(lq[client]\(rq +section of the configuration file). + +When specified, the list of \fICONTACT\fR information and the agreement +\fIURI\fR are sent to the server to replace the existing values. + +.TP +.B letsencrypt \fR[\fB\-\-config\-certs=\fIFILE\fR]\fB \fBnew\-cert \fR[\fISECTION\fR ...] + +Read the certificate configuration \fIFILE\fR (see the \fBCERTIFICATE +CONFIGURATION FILE\fR section below for the configuration options), and +request new Certificate Issuance for each of its sections (or the given +list of \fISECTION\fRs). + +.TP +.B letsencrypt \fBrevoke\-cert \fIFILE\fR [\fIFILE\fR ...] + +Request that the given certificate(s) \fIFILE\fR(s) be revoked. For +this command, \fIletsencrypt\-accountd\fR(1) can be pointed to either +the account key or the server's private key. + .SH GENERIC OPTIONS .TP -.B \-\-runas=\fIusername\fR -Username to run the ACME client as. This user does not need access to -any private key material. The ACME client runs as root when this option -is omited (not recommended). +.B \-\-config=\fIfilename\fR +Use \fIfilename\fR as configuration file. See the \fBCONFIGURATION +FILE\fR section below for the configuration options. .TP -.B \-?\fR, \fB\-\-help\fR -Display a brief help and exit. +.B \-\-socket=\fIpath\fR +Use \fIpath\fR as the \fIletsencrypt\-accountd\fR(1) UNIX\-domain socket +to connect to for signature requests from the ACME client. +\fBletsencrypt\fR aborts if \fIpath\fR is readable or writable by +other users, or if its parent directory is writable by other users. +This overrides the \fIsocket\fR option of the \(lq[client]\(rq section +of the configuration file. .TP -.B \-q\fR, \fB\-\-quiet\fR -Try to be quiet. +.B \-?\fR, \fB\-\-help\fR +Display a brief help and exit. .TP .B \-\-debug Turn on debug mode. -.SH ACCOUNT KEY REGISTRATION -.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-reg\fR \fIACCOUNTKEY\fR [\fIEMAIL\fR ...] +.SH CONFIGURATION FILE +If \fB\-\-config=\fR is not given, \fBletsencrypt\fR uses the first +existing configuration file among +\fI./letsencrypt.conf\fR, +\fI$XDG_CONFIG_HOME/letsencrypt\-tiny/letsencrypt.conf\fR (or +\fI~/.config/letsencrypt\-tiny/letsencrypt.conf\fR if the +XDG_CONFIG_HOME environment variable is not set), and +\fI/etc/letsencrypt\-tiny/letsencrypt.conf\fR. +Valid options are: -Registers the given \fIACCOUNTKEY\fR. An optional list of \fIEMAIL\fR -addresses can be given as contact information. +.TP +Default section +.RS +.TP +.I config\-certs +For certificate issuances (\fBnew\-cert\fR command), specify the +certificate configuration file to use (see the \fBCERTIFICATE +CONFIGURATION FILE\fR section below for the configuration options). +.RE .TP -.B \-\-genkey\fR[\fB=\fIALGO\fR[:\fIBITS\fR]] -Automatically generate the \fIACCOUNTKEY\fR (with mode 0600) if it does -not exist, using a \fIBITS\fR\-long \fIALGO\fR key. -The \fBopenssl\fR(1) default length is used when \fIBITS\fR is omited. -The default \fIALGO\fRrithm is \(lqRSA\(rq, which is also the only one -currently supported. +\(lq[client]\(rq section +This section is used for configuring the ACME client (which takes care +of ACME commands and dialogues with the remote ACME server). +.RS +.TP +.I socket +See \fB\-\-socket=\fR. +Default: \(lq$XDG_RUNTIME_DIR/S.letsencrypt\(rq if the XDG_RUNTIME_DIR +environment variable is set. -.SH CERTIFICATE ISSUANCE -.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-cert\fR \fIACCOUNTKEY\fR -\fB\-\-output=\fICERT\fR \fB\-\-csr=\fIFILE\fR +.TP +.I user +The username to drop privileges to (setting both effective and real +uid). +Preserve root privileges if the value is empty (not recommended). +Default: \(lqnobody\(rq. -.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-cert\fR \fIACCOUNTKEY\fR -\fB\-\-output=\fICERT\fR \fB\-\-key=\fIFILE\fR [\fB\-\-hash=\fIALGO\fR] -[\fB\-\-subject=\fISTRING\fR] [\fB\-\-san=\fISTRING\fR] -[\fB\-\-keyusage=\fISTRING\fR] +.TP +.I group +The groupname to drop privileges to (setting both effective and real +gid, and also setting the list of supplementary gids to that single +group). Preserve root privileges if the value is empty (not +recommended). +Default: \(lqnogroup\(rq. -Request a new Certificate Issuance using the given \fIACCOUNTKEY\fR. -The Certificate Signing Request can be supplied directly using -\fB\-\-csr\fR, or generated from the server key (\fB\-\-key\fR) using -options \fB\-\-hash\fR, \fB\-\-subject\fR, \fB\-\-san\fR and \fB\-\-keyusage\fR. +.TP +.I command +Path to the ACME client executable. +Default: \(lq/usr/lib/letsencrypt\-tiny/client\(rq. -The issued X.509 certificate is then validated, and upon success is -placed (in PEM format) into the file specified with \fB\-\-output\fR; the -optional \fB\-\-chain\fR option can be used to append the issuer -certificate as well. +.TP +.I server +Root URI of the ACME server. +Default: \(lqhttps://acme\-v01.api.letsencrypt.org/\(rq. .TP -.B \-\-min-age=\fISECONDS -Skip the issuance if the certificate specified by \fB\-\-output\fR exists -and its expiration date is more than \fISECONDS\fR ahead. +.I timeout +Timeout in seconds after which the client stops polling the ACME server +and considers the request failed. +Default: \(lq10\(rq. .TP -.B \-\-csr=\fIFILE -Certificate Signing Request to send (alternatively, use \fB\-\-key\fR to -generate it from the private server key). +.I SSL_verify +Whether to verify the server certificate chain. +Default: \(lqYes\(rq. .TP -.B \-\-key=\fIFILE -Server private key used to generate the Certificate Signing Request when -\fB\-\-csr\fR is omitted. (Use \fB\-\-genkey\fR to generate it -automatically.) +.I SSL_version +Specify the version of the SSL protocol used to transmit data. .TP -.B \-\-genkey\fR[\fB=\fIALGO\fR[:\fIBITS\fR]] -Automatically generate the server private key (with mode 0600) if it -does not exist, using a \fIBITS\fR\-long \fIALGO\fR key. -The \fBopenssl\fR(1) default length is used when \fIBITS\fR is omited. -The default \fIALGO\fRrithm is \(lqRSA\(rq, which is also the only one -currently supported. +.I SSL_cipher_list +Specify the cipher list for the connection. +.RE .TP -.B \-\-hash=\fIDGST -Message digest to sign the Certificate Signing Request with. +\(lq[webserver]\(rq section +This section is used for configuring the ACME webserver. +.RS .TP -.B \-\-subject=\fR/\fItype0\fR=\fIvalue0\fR/\fItype1\fR=\fIvalue1\fR/\fItype2\fR=... -Subject name to use in the Certificate Signing Request. +.I listen +Specify the local address to listen on, in the form +\fIADDRESS\fR[:\fIPORT\fR]. +If \fIADDRESS\fR is enclosed with brackets \(oq[\(cq/\(oq]\(cq then it +denotes an IPv6; an empty \fIADDRESS\fR means \(oq0.0.0.0\(cq. +Default: \(lq:80\(rq. .TP -.B \-\-san=\fItype0\fR:\fIvalue1\fR,\fItype1\fR:\fIvalue1\fR,\fItype2\fR:... -Comma-separated list of Subject Alternative Names. The only \fItype\fR -currently supported is \(lqDNS\(rq, to specify an alternative domain -name. +.I challenge\-directory +If a webserver is already running, specify a non\-existent directory +under which the webserver is configured to serve GET requests for +challenge files under \(lq/.well\-known/acme\-challenge/\(rq (for each +virtual hosts requiring authorization) as static files. +Default: \(lq/var/www/acme\-challenge\(rq. .TP -.B \-\-keyusage=\fISTRING -Comma-separated list of Key Usages, see x509v3_config(5ssl). -(Default: \(lqdigitalSignature,keyEncipherment,keyCertSign\(rq.) +.I user +The username to drop privileges to (setting both effective and real +uid). +Preserve root privileges if the value is empty (not recommended). +Default: \(lqwww\-data\(rq. .TP -.B \-\-output=\fIFILE -Where to copy the issued (signed) X.509 certificate. +.I group +The groupname to drop privileges to (setting both effective and real +gid, and also setting the list of supplementary gids to that single +group). Preserve root privileges if the value is empty (not +recommended). +Default: \(lqwww\-data\(rq. .TP -.B \-\-chain=\fR[\fIFILE\fR] -Store the server certificate along with its intermediate CA in -\fIFILE\fR. If \fIFILE\fR is empty or omitted, use the file specified -with \fB\-\-output\fR instead. +.I command +Path to the ACME webserver executable. +Default: \(lq/usr/lib/letsencrypt\-tiny/webserver\(rq. .TP -.B \-\-notify=\fICOMMAND\fR -Command to run upon success. (This option can be repeated to run -multiple commands.) +.I iptables +Whether to automatically install \fIiptables\fR(1) rules to open the +\fIADDRESS\fR[:\fIPORT\fR] specified with \fIlisten\fR. +Theses rules are automatically removed once \fBletsencrypt\fR exits. +Default: \(lqYes\(rq. +.RE + + +.SH CERTIFICATE CONFIGURATION FILE +For certificate issuances (\fBnew\-cert\fR command), a separate file is +used to configure paths to the certificate and key, as well as the +subject, subjectAltName, etc. to generate Certificate Signing Requests. +If \fB\-\-config\-certs=\fR is not given, and if the +\fIconfig\-certs\fR configuration option is absent, +then \fBletsencrypt\fR uses the first existing configuration file among +\fI./letsencrypt\-certs.conf\fR, +\fI$XDG_CONFIG_HOME/letsencrypt\-tiny/letsencrypt\-certs.conf\fR (or +\fI~/.config/letsencrypt\-tiny/letsencrypt\-certs.conf\fR if the +XDG_CONFIG_HOME environment variable is not set), and +\fI/etc/letsencrypt\-tiny/letsencrypt\-certs.conf\fR. +Each section denotes a separate certificate issuance. +Valid options are: +.TP +.I certificate +Where to store the issued certificate (in PEM format). +At least one of \fIcertificate\fR or \fIcertificate\-chain\fR is +required. + +.TP +.I certificate\-chain +Where to store the issued certificate, concatenated with the content of +the file specified specified with the \fICAfile\fR option (in PEM +format). +At least one of \fIcertificate\fR or \fIcertificate\-chain\fR is +required. -.SH CERTIFICATE REVOKATION -.B letsencrypt\fR [\fIOPTION\fR ...] \fBrevoke\-cert\fR {\fIACCOUNTKEY\fR|\fISVRKEY\fR} \fIFILE\fR [\fIFILE\fR ...] +.TP +.I certificate\-key +Path the service's private key. This option is required. The following +command can be used to generate a new 4096\-bits RSA key in PEM format +with mode 0600: -Request that the given certificate(s) \fIFILE\fR(s) be revoked. The -first argument after the command name can be either the account key file -or the private part of the certificate(s) to revoke. +.nf + openssl genrsa 4096 | install -m0600 /dev/stdin /path/to/priv.key +.fi +.TP +.I min\-days +For an existing certificate, the minimum number of days before its +expiration date the section is considered for re\-issuance. +Default: \(lq10\(rq. -.SH EXAMPLES .TP -letsencrypt \-\-runas=letsencrypt new\-reg \-\-genkey=RSA:4096 /etc/ssl/letsencrypt.key admin@fripost.org -Register a new account key \(lq/etc/ssl/letsencrypt.key\(rq using -\(lqadmin@fripost.org\(rq as contact information. A 4096-bits long RSA -key is generated (with mode 0600) if the key file does not exists. +.I CAfile +Path to the issuer's certificate. This is used for +\fIcertificate\-chain\fR and to verify the validity of each issued +certificate. +Specifying an empty value skip certificate validation. +Default: \(lq/usr/share/letsencrypt\-tiny/lets\-encrypt\-x1\-cross\-signed.pem\(rq. + +.TP +.I hash +Message digest to sign the Certificate Signing Request with. + +.TP +.I keyUsage +Comma\-separated list of Key Usages, see \fIx509v3_config\fR(5ssl). + +.TP +.I subject +Subject field of the Certificate Signing Request, in the form +\fR/\fItype0\fR=\fIvalue0\fR/\fItype1\fR=\fIvalue1\fR/\fItype2\fR=... +This option is required. + +.TP +.I subjectAltName +Comma\-separated list of Subject Alternative Names, in the form +\fItype0\fR:\fIvalue1\fR,\fItype1\fR:\fIvalue1\fR,\fItype2\fR:... +The only \fItype\fR currently supported is \(lqDNS\(rq, to specify an +alternative domain name. + +.TP +.I chown +An optional \fIusername\fR[:\fIgroupname\fR] to chown the issued +\fIcertificate\fR and \fIcertificate\-chain\fR with. + +.TP +.I chmod +An optional octal mode to chmod the issued \fIcertificate\fR and +\fIcertificate\-chain\fR with. .TP -letsencrypt \-\-runas=letsencrypt new\-cert /etc/ssl/letsencrypt.key \-\-output=/etc/nginx/ssl/www.fripost.org.pem \-\-chain \-\-key=/etc/nginx/ssl/www.fripost.org.key \-\-hash=SHA512 \-\-subject=/O=Fripost/CN=fripost.org \-\-san=DNS:fripost.org,DNS:www.fripost.org,DNS:wiki.fripost.org \-\-min-age=432000 \-\-notify='systemctl restart nginx' -Request issuance of a new (chained) certificate, but only if it doesn't exist or expires in less than 5 days; restart nginx upon success. +.I notify +Command to pass the the system's command shell (\(lq/bin/sh \-c\(rq) +after successful installation of the \fIcertificate\fR and/or +\fIcertificate\-chain\fR. + + +.SH EXAMPLES + +.nf + ~$ sudo letsencrypt new-reg mailto:noreply@example.com + ~$ sudo letsencrypt reg=/acme/reg/137760 --agreement-uri=https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf + ~$ sudo letsencrypt new-cert + ~$ sudo letsencrypt revoke-cert /path/to/server/certificate.pem +.fi + +.SH SEE ALSO +\fBletsencrypt\-accountd\fR(1) .SH AUTHOR Written by Guilhem Moulin diff --git a/webserver b/webserver index e5e040d..0fe2979 100755 --- a/webserver +++ b/webserver @@ -1,8 +1,8 @@ #!/usr/bin/perl -T #---------------------------------------------------------------------- -# Let's Encrypt ACME client (minimal webserver for answering challenges) -# Copyright © 2015 Guilhem Moulin +# Let's Encrypt ACME client (webserver component) +# Copyright © 2015,2016 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 @@ -21,30 +21,86 @@ use strict; use warnings; +# Usage: webserver FD +# +# fdopen(3) the file descriptor FD (corresponding to a listening +# socket), then keep accept(2)'ing connections and consider each +# connection as the verification of an ACME challenge, i.e., serve the +# requested challenge token. +# +# NOTE: one needs to chdir(2) to an appropriate ACME challenge directory +# before running this program, since challenge tokens are (on purpose) +# only looked for in the current directory. +# +# NOTE: one should run this program as an unprivileged user:group such +# as "www-data:www-data"; bind(2)'ing to a privileged port such as 80 is +# not a problem since FD can be bound as root prior to the execve(2). + +use Errno 'EINTR'; +use Socket qw/AF_INET AF_INET6/; + +# Untaint and fdopen(3) the listening socket +# TODO: we could even take multiple file descriptors and select(2) +# between them; this could be useful to listen on two sockets, one for +# INET and one for INET6 +(shift @ARGV // die) =~ /\A(\d+)\z/ or die; +open my $S, '+<&=', $1 or die "fdopen $1: $!"; my $ROOT = '/.well-known/acme-challenge'; -$_ = // exit; -my $proto = s/ HTTP\/(1\.[01])\r\n\z// ? $1 : die "Error: Bad request\n"; -my $method = s/\A(GET|HEAD) // ? $1 : die "Error: Bad request\n"; +close STDIN or die "close: $!"; +close STDOUT or die "close: $!"; + +sub info($$$) { + my ($sockaddr, $msg, $req) = @_; + $req =~ s/\r\n\z// if defined $req; + + # get a string representation of the peer's address + my $fam = Socket::sockaddr_family($sockaddr); + my (undef, $ip) = + $fam == AF_INET ? Socket::unpack_sockaddr_in($sockaddr) : + $fam == AF_INET6 ? Socket::unpack_sockaddr_in6($sockaddr) : + die; + my $addr = Socket::inet_ntop($fam, $ip); -# Consume the headers (and ignore them) -while (defined (my $h = )) { last if $h eq "\r\n" }; + print STDERR $msg." from [$addr]".(defined $req ? ": $req" : "")."\n"; +} + +while (1) { + my $sockaddr = accept(my $conn, $S) or do { + next if $! == EINTR; # try again if accept(2) was interrupted by a signal + die "accept: $!"; + }; + my $req = $conn->getline(); + info($sockaddr, "[$$] Incoming connection", $req) if $ENV{DEBUG}; + next unless defined $req; -my ($status_line, $content_type, $content); -if (/\A\Q$ROOT\E\/([A-Za-z0-9_\-]+)\z/ and -f $1) { - if (open my $fh, '<', $1) { - ($status_line, $content_type) = ('200 OK', 'application/jose+json'); - $content = do { local $/ = undef; <$fh> }; - close $fh; + if ($req !~ s/\A(GET|HEAD) (.*) HTTP\/(1\.[01])\r\n\z/$2/) { + info($sockaddr, "Error: Bad request", $req); + next; } - else { - $status_line = '403 Forbidden'; + my ($method, $proto) = ($1, $3); + + # Consume (and ignore) the headers + while (defined (my $h = $conn->getline())) { last if $h eq "\r\n" }; + + my ($status_line, $content_type, $content); + if ($req =~ /\A\Q$ROOT\E\/([A-Za-z0-9_\-]+)\z/ and -f $1) { + if (open my $fh, '<', $1) { # only open files in the cwd + ($status_line, $content_type) = ('200 OK', 'application/jose+json'); + $content = do { local $/ = undef; $fh->getline() }; + $fh->close() or die "close: $!"; + } + else { + $status_line = '403 Forbidden'; + } } -} -print "HTTP/$proto ", ($status_line // '404 Not Found'), "\r\n"; -print "Content-Type: $content_type\r\n" if defined $content_type; -print "Content-Length: ".length($content)."\r\n" if defined $content; -print "Connection: close\r\n"; -print "\r\n"; -print $content if defined $content and $method eq 'GET'; + $conn->print( "HTTP/$proto ", ($status_line // '404 Not Found'), "\r\n" ); + $conn->print( "Content-Type: $content_type\r\n" ) if defined $content_type; + $conn->print( "Content-Length: ".length($content)."\r\n" ) if defined $content; + $conn->print( "Connection: close\r\n" ); + $conn->print( "\r\n" ); + $conn->print( $content ) if defined $content and $method eq 'GET'; + + $conn->close() or die "close: $!"; +} -- cgit v1.2.3