From 29b96a13f83fcb95dac1d320cce071790fb98e0c Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 18 Dec 2015 01:40:56 +0100 Subject: =?UTF-8?q?acme-slave=20=E2=86=92=20client;=20acme-webserver=20?= =?UTF-8?q?=E2=86=92=20webserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- acme-slave | 269 --------------------------------------------------------- acme-webserver | 50 ----------- client | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ letsencrypt | 4 +- webserver | 50 +++++++++++ 5 files changed, 321 insertions(+), 321 deletions(-) delete mode 100755 acme-slave delete mode 100755 acme-webserver create mode 100755 client create mode 100755 webserver diff --git a/acme-slave b/acme-slave deleted file mode 100755 index 5cf0139..0000000 --- a/acme-slave +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/perl -T - -#---------------------------------------------------------------------- -# Let's Encrypt ACME client -# Copyright © 2015 Guilhem Moulin -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#---------------------------------------------------------------------- - -use 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 $_ = ; 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; -}; diff --git a/acme-webserver b/acme-webserver deleted file mode 100755 index e5e040d..0000000 --- a/acme-webserver +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/perl -T - -#---------------------------------------------------------------------- -# Let's Encrypt ACME client (minimal webserver for answering challenges) -# Copyright © 2015 Guilhem Moulin -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#---------------------------------------------------------------------- - -use strict; -use warnings; - -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"; - -# Consume the headers (and ignore them) -while (defined (my $h = )) { last if $h eq "\r\n" }; - -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; - } - 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'; diff --git a/client b/client new file mode 100755 index 0000000..5cf0139 --- /dev/null +++ b/client @@ -0,0 +1,269 @@ +#!/usr/bin/perl -T + +#---------------------------------------------------------------------- +# Let's Encrypt ACME client +# Copyright © 2015 Guilhem Moulin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +#---------------------------------------------------------------------- + +use 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 $_ = ; 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; +}; diff --git a/letsencrypt b/letsencrypt index 906fe48..cba4271 100755 --- a/letsencrypt +++ b/letsencrypt @@ -25,8 +25,8 @@ NAME=$(basename $0) WWW_USER=www-data WWW_GROUP=www-data -ACME_WEBSERVER=acme-webserver -ACME_CLIENT=acme-slave +ACME_WEBSERVER=/usr/lib/letsencrypt/webserver +ACME_CLIENT=/usr/lib/letsencrypt/client CAfile=/usr/share/lets-encrypt/lets-encrypt-x1-cross-signed.pem declare COMMAND ACCOUNTKEY diff --git a/webserver b/webserver new file mode 100755 index 0000000..e5e040d --- /dev/null +++ b/webserver @@ -0,0 +1,50 @@ +#!/usr/bin/perl -T + +#---------------------------------------------------------------------- +# Let's Encrypt ACME client (minimal webserver for answering challenges) +# Copyright © 2015 Guilhem Moulin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +#---------------------------------------------------------------------- + +use strict; +use warnings; + +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"; + +# Consume the headers (and ignore them) +while (defined (my $h = )) { last if $h eq "\r\n" }; + +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; + } + 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'; -- cgit v1.2.3 From 9b4e37696a8ae05650a2aec57ba294fc4785ae0d Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 18 Dec 2015 01:42:27 +0100 Subject: manpage --- letsencrypt.1 | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 letsencrypt.1 diff --git a/letsencrypt.1 b/letsencrypt.1 new file mode 100644 index 0000000..a16e165 --- /dev/null +++ b/letsencrypt.1 @@ -0,0 +1,189 @@ +.TH LETSENCRYPT "1" "DECEMBER 2015" "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 ...] + + +.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. +(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. + +.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). + +.TP +.B \-?\fR, \fB\-\-help\fR +Display a brief help and exit. + +.TP +.B \-q\fR, \fB\-\-quiet\fR +Try to be quiet. + +.TP +.B \-\-debug +Turn on debug mode. + + +.SH ACCOUNT KEY REGISTRATION +.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-reg\fR \fIACCOUNTKEY\fR [\fIEMAIL\fR ...] + +Registers the given \fIACCOUNTKEY\fR. An optional list of \fIEMAIL\fR +addresses can be given as contact information. + +.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. + + +.SH CERTIFICATE ISSUANCE +.B letsencrypt\fR [\fIOPTION\fR ...] \fBnew\-cert\fR \fIACCOUNTKEY\fR +\fB\-\-output=\fICERT\fR \fB\-\-csr=\fIFILE\fR + +.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] + +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. + +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 +.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. + +.TP +.B \-\-csr=\fIFILE +Certificate Signing Request to send (alternatively, use \fB\-\-key\fR to +generate it from the private server key). + +.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.) + +.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. + +.TP +.B \-\-hash=\fIDGST +Message digest to sign the Certificate Signing Request with. + +.TP +.B \-\-subject=\fR/\fItype0\fR=\fIvalue0\fR/\fItype1\fR=\fIvalue1\fR/\fItype2\fR=... +Subject name to use in the Certificate Signing Request. + +.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. + +.TP +.B \-\-keyusage=\fISTRING +Comma-separated list of Key Usages, see x509v3_config(5ssl). +(Default: \(lqdigitalSignature,keyEncipherment,keyCertSign\(rq.) + +.TP +.B \-\-output=\fIFILE +Where to copy the issued (signed) X.509 certificate. + +.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. + +.TP +.B \-\-notify=\fICOMMAND\fR +Command to run upon success. (This option can be repeated to run +multiple commands.) + + +.SH CERTIFICATE REVOKATION +.B letsencrypt\fR [\fIOPTION\fR ...] \fBrevoke\-cert\fR {\fIACCOUNTKEY\fR|\fISVRKEY\fR} \fIFILE\fR [\fIFILE\fR ...] + +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. + + +.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. + +.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. + + +.SH AUTHOR +Written by Guilhem Moulin +.MT guilhem@fripost.org +.ME . -- cgit v1.2.3