X-Git-Url: https://git.rapsys.eu/acme/blobdiff_plain/9c3c698790057b5c32bb7daacb9269ddf4b677df..17feca20e7d0e958d43e934cdc4894a0457c2727:/Acme.pm diff --git a/Acme.pm b/Acme.pm new file mode 100644 index 0000000..db1c6d4 --- /dev/null +++ b/Acme.pm @@ -0,0 +1,760 @@ +# This file is part of Acmepl +# +# Acmepl is 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 . +# +# Copyright (C) 2016 - 2017 Raphaël Gertz + +# acme package +package acme; + +# Best practice +use strict; +use warnings; + +# Symbol export +use Exporter; +our @ISA = qw(Exporter); +our @EXPORT_OK = qw(DS CERT_DIR KEY_DIR REQUEST_CSR ACCOUNT_KEY SERVER_KEY SERVER_CRT CONFIG); + +# Load dependancies +use Carp qw(carp confess); +use Date::Parse qw(str2time); +use Digest::SHA qw(sha256_base64); +use Email::Valid; +use File::Path qw(make_path); +use File::Slurp qw(read_file write_file); +use File::Temp; # qw( :seekable ); +use IPC::System::Simple qw(capturex); +use JSON qw(from_json to_json); +use LWP; +use MIME::Base64 qw(encode_base64url encode_base64); +use Net::Domain::TLD; +use POSIX qw(EXIT_FAILURE); +use Tie::IxHash; + +# Documentation links +#XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/) +#XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt +#XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js + +# Set constants +use constant { + # Directory separator + DS => '/', + + # Directory for certificates + CERT_DIR => 'cert', + + # Directory for keys + KEY_DIR => 'key', + + # Directory for pending cache + PENDING_DIR => 'pending', + + # Request certificate file name + REQUEST_CSR => 'request.der', + + # Account key file name + ACCOUNT_KEY => 'account.pem', + + # Server private key + SERVER_KEY => 'server.pem', + + # Server public certificate + SERVER_CRT => 'server.crt', + + # rsa + KEY_TYPE => 'rsa', + + # 2048|4096 + KEY_SIZE => 4096, + + # Acme infos + ACME_CERT => 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem', + ACME_DIR => 'https://acme-staging.api.letsencrypt.org/directory', + ACME_PROD_DIR => 'https://acme-v01.api.letsencrypt.org/directory', + ACME_TERMS => 'https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf', + + # Version + VERSION => 'v0.6', + + # Config + CONFIG => '/etc/acmepl/config' +}; + +# User agent object +our $ua; + +# Strerr backup +our $_stderr; + +# JSON Web Key (JWK) +#XXX: tie to Tie::IxHash to keep a stable ordering of hash keys +#our %jwk = ( +# pubkey => undef, +# jwk => { +# alg => 'RS256', +# jwk => { +# # Exponent +# e => undef, +# # Key type +# kty => uc(KEY_TYPE), +# # Modulus +# n => undef +# } +# }, +# thumbprint => undef +#); +tie(our %jwk, 'Tie::IxHash', pubkey => undef, jwk => undef, thumbprint => undef); +tie(%{$jwk{jwk}}, 'Tie::IxHash', alg => 'RS256', jwk => undef); +#XXX: strict ordering only really needed here for thumbprint sha256 digest +tie(%{$jwk{jwk}{jwk}}, 'Tie::IxHash', e => undef, kty => uc(KEY_TYPE), n => undef); + +# Constructor +sub new { + # Extract params + my ($class, $mail, $debug, $prod, @domains) = @_; + + # Create self hash + my $self = {}; + + # Link self to package + bless($self, $class); + + # Save debug + $self->{debug} = $debug; + + # Save prod + $self->{prod} = $prod; + + # Add extra check to mail validity + #XXX: mxcheck fail if there is only a A record on the domain + my $ev = Email::Valid->new(-fqdn => 1, -tldcheck => 1, -mxcheck => 1); + + # Show error if check fail + if (! defined $ev->address($mail)) { + map { carp 'failed check: '.$_ if ($self->{debug}) } $ev->details(); + confess 'Email::Valid->address failed'; + } + + # Save mail + $self->{mail} = $mail; + + # Create resolver + my $res = new Net::DNS::Resolver(); + + # Check domains + map { + my $tld; + + # Extract tld + unless (($tld) = $_ =~ m/\.(\w+)$/) { + confess $_.'\'s tld extraction failed'; + } + + # Check if tld exists + unless(Net::Domain::TLD::tld_exists($tld)) { + confess $tld.' tld from '.$_.' don\'t exists'; + } + + # Check if we get dns answer + #XXX: only search A type because letsencrypt don't support ipv6 (AAAA) yet + unless(my $rep = $res->search($_, 'A')) { + confess 'search A record for '.$_.' failed'; + } else { + unless (scalar map { $_->type eq 'A' ? 1 : (); } $rep->answer) { + confess 'search recursively A record for '.$_.' failed'; + } + } + } @domains; + + # Save domains + @{$self->{domains}} = @domains; + + # Return class reference + return $self; +} + +# Prepare environement +sub prepare { + my ($self) = @_; + + # Create all paths + make_path(CERT_DIR, KEY_DIR, PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging'), {error => \my $err}); + if (@$err) { + map { + my ($file, $msg) = %$_; + carp ($file eq '' ? '' : $file.': ').$msg if ($self->{debug}); + } @$err; + confess 'make_path failed'; + } + + # Create user agent + $ua = LWP::UserAgent->new; + $ua->agent(__PACKAGE__.'/'.VERSION) +} + +# Drop stderr +sub _dropStdErr { + # Save stderr + open($_stderr, '>&STDERR') or die $!; + # Close it + close(STDERR) or die $!; + # Send to /dev/null + open(STDERR, '>', '/dev/null') or die $!; +} + +# Restore stderr +sub _restoreStdErr { + # Close stderr + close(STDERR); + # Open it back + open(STDERR, '>&', $_stderr) or die $!; +} + +# Generate required keys +sub genKeys { + my ($self) = @_; + + # Generate account and server key if required + map { + # Check key existence + if (! -f $_) { + # Drop stderr + _dropStdErr(); + # Generate key + #XXX: we drop stderr here because openssl can't be quiet on this command + capturex('openssl', ('genrsa', '-out', $_, KEY_SIZE)); + # Restore stderr + _restoreStdErr(); + } + } (KEY_DIR.DS.ACCOUNT_KEY, KEY_DIR.DS.SERVER_KEY); + + # Extract modulus and publicExponent jwk + #XXX: same here we tie to keep ordering + tie(%{$self->{account}}, 'Tie::IxHash', %jwk); + map { + if (/^Modulus=([0-9A-F]+)$/) { + # Extract to binary from hex and convert to base64 url + $self->{account}{jwk}{jwk}{n} = encode_base64url(pack("H*", $1) =~ s/^\0+//r); + } elsif (/^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/) { + # Extract to binary from int, trim leading zeros and convert to base64 url + chomp ($self->{account}{jwk}{jwk}{e} = encode_base64url(pack("N", $1) =~ s/^\0+//r)); + } + } capturex('openssl', ('rsa', '-text', '-in', KEY_DIR.DS.ACCOUNT_KEY, '-noout', '-modulus')); + + # Drop stderr + _dropStdErr(); + # Extract account public key + $self->{account}{pubkey} = join('', map { chomp; $_; } capturex('openssl', ('rsa', '-in', KEY_DIR.DS.ACCOUNT_KEY, '-pubout'))); + # Restore stderr + _restoreStdErr(); + + # Store thumbprint + #XXX: convert base64 to base64 url + $self->{account}{thumbprint} = (sha256_base64(to_json($self->{account}{jwk}{jwk})) =~ s/=+\z//r) =~ tr[+/][-_]r; +} + +# Generate certificate request +sub genCsr { + my ($self) = @_; + + # Openssl config template + my $oct = File::Temp->new(); + + # Load template from data + map { s/__EMAIL_ADDRESS__/$self->{mail}/; s/__COMMON_NAME__/$self->{domains}[0]/; print $oct $_; } ; + + # Close data + close(DATA); + + # Append domain names + my $i = 1; + map { print $oct 'DNS.'.$i++.' = '.$_."\n"; } @{$self->{domains}}; + + # Generate csr + capturex('openssl', ('req', '-new', '-outform', 'DER', '-key', KEY_DIR.DS.SERVER_KEY, '-config', $oct->filename, '-out', CERT_DIR.DS.REQUEST_CSR)); + + # Close oct + close($oct); +} + +# Directory call +sub directory { + my ($self) = @_; + + # Set time + my $time = time; + + # Set directory + my $dir = $self->{prod} ? ACME_PROD_DIR : ACME_DIR; + + # Create a request + my $req = HTTP::Request->new(GET => $dir.'?'.$time); + + # Get request + my $res = $ua->request($req); + + # Handle error + unless ($res->is_success) { + confess 'GET '.$dir.'?'.$time.' failed: '.$res->status_line; + } + + # Save nonce + $self->{nonce} = $res->headers->{'replay-nonce'}; + + # Merge uris in self content + %$self = (%$self, %{from_json($res->content)}); +} + +# Post request +sub _post { + my ($self, $uri, $payload) = @_; + + # Protected field + my $protected = encode_base64url(to_json({nonce => $self->{nonce}})); + + # Payload field + $payload = encode_base64url(to_json($payload)); + + # Sign temp file + my $stf = File::Temp->new(); + + # Append protect.payload to stf + print $stf $protected.'.'.$payload; + + # Close stf + close($stf); + + # Generate digest of stf + my $signature = encode_base64url(join('', capturex('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR.DS.ACCOUNT_KEY, $stf->filename))) =~ s/^\0+//r); + + # Create a request + my $req = HTTP::Request->new(POST => $uri); + + # Set new-reg request content + $req->content(to_json({ + header => $self->{account}{jwk}, + protected => $protected, + payload => $payload, + signature => $signature + })); + + # Post request + my $res = $ua->request($req); + + # Save nonce + if (defined $res->headers->{'replay-nonce'}) { + $self->{nonce} = $res->headers->{'replay-nonce'}; + } + + # Return res object + return $res; +} + +# Resolve dns and check content +#XXX: see https://community.centminmod.com/threads/looks-like-letsencrypt-dns-01-is-ready.5845/#12 for example +sub _dnsCheck { + my ($self, $domain, $token) = @_; + + # Generate signature from content + my $signature = ((sha256_base64($token.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r; + + # Fix domain + $domain = '_acme-challenge.'.$domain.'.'; + + # Create resolver + my $res = new Net::DNS::Resolver(); + + # Check if we get dns answer + unless(my $rep = $res->search($domain, 'TXT')) { + carp 'TXT record search for '.$domain.' failed' if ($self->{debug}); + return; + } else { + unless (scalar map { $_->type eq 'TXT' && $_->txtdata =~ /^$signature$/ ? 1 : (); } $rep->answer) { + carp 'TXT record recursive search for '.$domain.' failed' if ($self->{debug}); + return; + } + } + + return 1; +} + +# Get uri and check content +sub _httpCheck { + my ($self, $domain, $token) = @_; + + # Create a request + my $req = HTTP::Request->new(GET => 'http://'.$domain.'/.well-known/acme-challenge/'.$token); + + # Load config if available + my $config = undef; + if ( + #XXX: use eval to workaround a fatal in from_json + defined eval { + # Check that file exists + -f CONFIG && + # Read it + ($config = read_file(CONFIG)) && + # Decode it + ($config = from_json($config)) && + # Check defined + $config->{thumbprint} + } + ) { + # Try to write thumbprint + write_file($config->{thumbprint}, $self->{account}{thumbprint}); + } + + # Get request + my $res = $ua->request($req); + + # Handle error + unless ($res->is_success) { + carp 'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' failed: '.$res->status_line if ($self->{debug}); + return; + } + + # Handle invalid content + unless($res->content =~ /^$token.$self->{account}{thumbprint}\s*$/) { + carp 'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' content match failed: /^'.$token.'.'.$self->{account}{thumbprint}.'\s*$/ !~ '.$res->content if ($self->{debug}); + return; + } + + # Return success + return 1; +} + +# Register account +#XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3 +sub register { + my ($self) = @_; + + # Post new-reg request + #XXX: contact array may contain a tel:+33612345678 for example + my $res = $self->_post($self->{'new-reg'}, {resource => 'new-reg', contact => ['mailto:'.$self->{mail}], agreement => ACME_TERMS}); + + # Handle error + unless ($res->is_success || $res->code eq 409) { + confess 'POST '.$self->{'new-reg'}.' failed: '.$res->status_line; + } + + # Update mail informations + if ($res->code eq 409) { + # Save registration uri + $self->{'reg'} = $res->headers->{location}; + + # Post reg request + #XXX: contact array may contain a tel:+33612345678 for example + $res = $self->_post($self->{'reg'}, {resource => 'reg', contact => ['mailto:'.$self->{mail}]}); + + # Handle error + unless ($res->is_success) { + confess 'POST '.$self->{'reg'}.' failed: '.$res->status_line; + } + } +} + +# Authorize domains +sub authorize { + my ($self) = @_; + + # Create challenges hash + %{$self->{challenges}} = (); + + # Pending list + my @pending = (); + + # Create or load auth request for each domain + map { + # Init content + my $content = undef; + + # Init file + my $file = PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging').'/'.$_; + + # Load auth request content or post a new one + #TODO: add more check on cache file ??? + if ( + #XXX: use eval to workaround a fatal in from_json + ! defined eval { + # Check that file exists + -f $file && + # Read it + ($content = read_file($file)) && + # Decode it + ($content = from_json($content)) + # Check expiration + } || (str2time($content->{expires}) <= time()+3600) + ) { + # Post new-authz request + my $res = $self->_post($self->{'new-authz'}, {resource => 'new-authz', identifier => {type => 'dns', value => $_}, existing => 'accept'}); + + # Handle error + unless ($res->is_success) { + confess 'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line; + } + + # Decode content + $content = from_json($res->content); + + # Check domain + unless (defined $content->{identifier}{value} && $content->{identifier}{value} eq $_) { + confess 'domain matching '.$content->{identifier}{value}.' for '.$_.' failed: '.$res->status_line; + } + + # Check status + unless ($content->{status} eq 'valid' or $content->{status} eq 'pending') { + confess 'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line; + } + + # Write to file + write_file($file, to_json($content)); + } + + # Add challenge + %{$self->{challenges}{$_}} = ( + status => $content->{status}, + expires => $content->{expires}, + polls => [] + ); + + # Save pending data + if ($content->{status} eq 'pending') { + # Extract validation data + foreach my $challenge (@{$content->{challenges}}) { + # One test already validated this auth request + if ($self->{challenges}{$_}{status} eq 'valid') { + next; + } elsif ($challenge->{status} eq 'valid') { + $self->{challenges}{$_}{status} = $challenge->{status}; + next; + } elsif ($challenge->{status} eq 'pending') { + # Handle check + if ( + ($challenge->{type} =~ /^http-01$/ and $self->_httpCheck($_, $challenge->{token})) or + ($challenge->{type} =~ /^dns-01$/ and $self->_dnsCheck($_, $challenge->{token})) + ) { + # Post challenge request + my $res = $self->_post($challenge->{uri}, {resource => 'challenge', keyAuthorization => $challenge->{token}.'.'.$self->{account}{thumbprint}}); + + # Handle error + unless ($res->is_success) { + confess 'POST '.$challenge->{uri}.' failed: '.$res->status_line; + } + + # Extract content + my $content = from_json($res->content); + + # Save if valid + if ($content->{status} eq 'valid') { + $self->{challenges}{$_}{status} = $content->{status}; + # Check is still polling + } elsif ($content->{status} eq 'pending') { + # Add to poll list for later use + push(@{$self->{challenges}{$_}{polls}}, { + type => (split(/-/, $challenge->{type}))[0], + status => $content->{status}, + poll => $content->{uri} + }); + } + } + } + } + # Check if check is challenge still in pending and no polls + if ($self->{challenges}{$_}{status} eq 'pending' && scalar @{$self->{challenges}{$_}{polls}} == 0) { + # Loop on all remaining challenges + foreach my $challenge (@{$content->{challenges}}) { + # Display help for http-01 check + if ($challenge->{type} eq 'http-01') { + print STDERR 'Create URI http://'.$_.'/.well-known/acme-challenge/'.$challenge->{token}.' with content '.$challenge->{token}.'.'.$self->{account}{thumbprint}."\n"; + # Display help for dns-01 check + } elsif ($challenge->{type} eq 'dns-01') { + print STDERR 'Create TXT record _acme-challenge.'.$_.'. with value '.(((sha256_base64($challenge->{token}.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r)."\n"; + } + } + } + } + } @{$self->{domains}}; + + # Init max run + my $remaining = 10; + + # Poll pending + while (--$remaining >= 0 and scalar map { $_->{status} eq 'valid' ? 1 : (); } values %{$self->{challenges}}) { + # Sleep + sleep(1); + # Poll remaining pending + map { + # Init domain + my $domain = $_; + + # Poll remaining polls + map { + # Create a request + my $req = HTTP::Request->new(GET => $_->{poll}); + + # Get request + my $res = $ua->request($req); + + # Handle error + unless ($res->is_success) { + carp 'GET '.$self->{challenges}{$_}{http_challenge}.' failed: '.$res->status_line if ($self->{debug}); + } + + # Extract content + my $content = from_json($res->content); + + # Save status + if ($content->{status} ne 'pending') { + $self->{challenges}{$domain}{status} = $content->{status}; + } + } @{$self->{challenges}{$_}{polls}}; + } map { $self->{challenges}{$_}{status} eq 'pending' ? $_ : (); } keys %{$self->{challenges}}; + } + + # Load config if available + my $config = undef; + if ( + #XXX: use eval to workaround a fatal in from_json + defined eval { + # Check that file exists + -f CONFIG && + # Read it + ($config = read_file(CONFIG)) && + # Decode it + ($config = from_json($config)) && + # Check defined + $config->{thumbprint} + } + ) { + # Try to write thumbprint + write_file($config->{thumbprint}, ''); + } + + # Stop here with remaining chanllenge + if (scalar map { ! defined $_->{status} or $_->{status} ne 'valid' ? 1 : (); } values %{$self->{challenges}}) { + # Deactivate all activated domains + #XXX: not implemented by letsencrypt + #map { + # # Post deactivation request + # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'}); + # # Handle error + # unless ($res->is_success) { + # confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line; + # } + #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}}; + + # Stop here as a domain of csr list failed authorization + if ($self->{debug}) { + my @domains = map { ! defined $self->{challenges}{$_}{status} or $self->{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{challenges}}; + confess 'Fix the challenge'.(scalar @domains > 1?'s':'').' for domain'.(scalar @domains > 1?'s':'').': '.join(', ', @domains); + } else { + exit EXIT_FAILURE; + } + } +} + +# Issue certificate +sub issue { + my ($self) = @_; + + # Open csr file + open(my $fh, '<', CERT_DIR.DS.REQUEST_CSR) or die $!; + + # Load csr + my $csr = encode_base64url(join('', <$fh>) =~ s/^\0+//r); + + # Close csr file + close($fh) or die $!; + + # Post certificate request + my $res = $self->_post($self->{'new-cert'}, {resource => 'new-cert', csr => $csr}); + + # Handle error + unless ($res->is_success) { + confess 'POST '.$self->{'new-cert'}.' failed: '.$res->status_line; + } + + # Open crt file + open($fh, '>', CERT_DIR.DS.SERVER_CRT) or die $!; + + # Convert to pem + print $fh '-----BEGIN CERTIFICATE-----'."\n".encode_base64($res->content).'-----END CERTIFICATE-----'."\n"; + + # Create a request + my $req = HTTP::Request->new(GET => ACME_CERT); + + # Get request + $res = $ua->request($req); + + # Handle error + unless ($res->is_success) { + carp 'GET '.ACME_CERT.' failed: '.$res->status_line if ($self->{debug}); + } + + # Append content + print $fh $res->content; + + # Close file + close($fh) or die $!; + + # Print success + carp 'Success, pem certificate in '.CERT_DIR.DS.SERVER_CRT if ($self->{debug}); +} + +1; + +__DATA__ +# +# OpenSSL configuration file. +# This is mostly being used for generation of certificate requests. +# + +[ req ] +default_bits = 2048 +default_md = sha256 +prompt = no +distinguished_name = req_distinguished_name +# The extentions to add to the self signed cert +x509_extensions = v3_ca +# The extensions to add to a certificate request +req_extensions = v3_req + +# This sets a mask for permitted string types. There are several options. +# utf8only: only UTF8Strings (PKIX recommendation after 2004). +# WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings. +string_mask = utf8only + +[ req_distinguished_name ] +countryName = US +stateOrProvinceName = State or Province Name +localityName = Locality Name +organizationName = Organization Name +organizationalUnitName = Organizational Unit Name +commonName = __COMMON_NAME__ +emailAddress = __EMAIL_ADDRESS__ + +[ v3_req ] +basicConstraints = CA:false +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +subjectAltName = email:move +subjectAltName = @alt_names + +[ v3_ca ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = CA:true + +[alt_names]