diff options
Diffstat (limited to 'client')
-rwxr-xr-x | client | 269 |
1 files changed, 269 insertions, 0 deletions
@@ -0,0 +1,269 @@ +#!/usr/bin/perl -T + +#---------------------------------------------------------------------- +# Let's Encrypt ACME client +# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# 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 <http://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +use strict; +use warnings; + +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/; + +# 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; + + +# 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); + + { kty => 'RSA', n => $n, e => $e } +}; +my $JSON = JSON::->new->utf8->canonical(); # breaks hashes otherwise +my $JWK_dgst64 = 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' +}); +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->header('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'); + + 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"; + } + + return $content; +} + + +# ACME client +# https://tools.ietf.org/html/draft-ietf-acme-acme-01 +sub acme($$) { + my ($uri, $h) = @_; + + # Produce the JSON Web Signature: RFC 7515 section 5 + my $payload = encode_base64url($JSON->encode($h)); + my %header = ( alg => 'RS256', jwk => $JWK ); + my $protected = encode_base64url($JSON->encode({ %header, nonce => $NONCE })); + my $data = $protected .'.'. $payload; + print STDERR "Requesting a SHA-256 signature for ", $data, "\n" if $ENV{DEBUG}; + STDOUT->printflush($data, "\n"); + + # Ask for an (hex) sig + my $sig = do { local $_ = <STDIN>; chomp; $_ }; + $sig = encode_base64url(pack('H*', $sig)); + print STDERR "Got SHA-256 signature ", $sig, "\n" if $ENV{DEBUG}; + + # Flattened JSON Serialization, RFC 7515 section 7.2.2 + request(POST => $uri, { + payload => $payload, + protected => $protected, + header => \%header, + signature => $sig + }); +} + + +# 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") }; + + +if ($COMMAND eq 'new-reg') { + 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; +} + + +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"; + + 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; +} + + +# $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"; + } + } + 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; + } + + acme($challenge->{uri}, { + resource => 'challenge', + keyAuthorization => $keyAuthorization + }); + + 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; + sleep 1; + } +} + + +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; + + # 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; +}; |