]> Raphaƫl G. Git Repositories - acme/blobdiff - acme.pm
Add debug and prod parameters properly
[acme] / acme.pm
diff --git a/acme.pm b/acme.pm
index dcfabf3ea5e97db38ee6484206841b02278fde00..144222c8ccbe22c014103b2f1bdb7379d728d8b5 100644 (file)
--- a/acme.pm
+++ b/acme.pm
@@ -26,9 +26,6 @@ use Net::Domain::TLD;
 use Tie::IxHash;
 use POSIX qw(EXIT_FAILURE);
 
-# Debug
-use Data::Dumper;
-
 # 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
@@ -36,35 +33,49 @@ use Data::Dumper;
 
 # 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',
-       ACCOUNT_PUB => 'account.pub',
+
+       # Server private key
        SERVER_KEY => 'server.pem',
-       REQUEST_CSR => 'request.der',
+
+       # 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_DIR => 'https://acme-v01.api.letsencrypt.org/directory',
+       ACME_PROD_DIR => 'https://acme-v01.api.letsencrypt.org/directory',
        ACME_TERMS => 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf',
 
-       VERSION => 'v0.1'
+       # Version
+       VERSION => 'v0.3'
 };
 
 # User agent object
 our $ua;
 
-# Debug
-our $_debug = 0;
-
 # Strerr backup
 our $_stderr;
 
@@ -93,7 +104,7 @@ tie(%{$jwk{jwk}{jwk}}, 'Tie::IxHash', e => undef, kty => uc(KEY_TYPE), n => unde
 # Constructor
 sub new {
        # Extract params
-       my ($class, $mail, @domains) = @_;
+       my ($class, $mail, $debug, $prod, @domains) = @_;
 
        # Create self hash
        my $self = {};
@@ -101,13 +112,19 @@ sub new {
        # 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 ($_debug) } $ev->details();
+               map { carp 'failed check: '.$_ if ($self->{debug}) } $ev->details();
                confess 'Email::Valid->address failed';
        }
 
@@ -154,11 +171,11 @@ sub prepare {
        my ($self) = @_;
 
        # Create all paths
-       make_path(CERT_DIR, KEY_DIR, PENDING_DIR.'/'.$self->{mail}, {error => \my $err});
+       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 ($_debug);
+                       carp ($file eq '' ? '' : $file.': ').$msg if ($self->{debug});
                } @$err;
                confess 'make_path failed';
        }
@@ -260,15 +277,18 @@ sub directory {
        # 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 => ACME_DIR.'?'.$time);
+       my $req = HTTP::Request->new(GET => $dir.'?'.$time);
 
        # Get request
        my $res = $ua->request($req);
 
        # Handle error
        unless ($res->is_success) {
-               confess 'GET '.ACME_DIR.'?'.$time.' failed: '.$res->status_line;
+               confess 'GET '.$dir.'?'.$time.' failed: '.$res->status_line;
        }
 
        # Save nonce
@@ -323,25 +343,53 @@ sub _post {
        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, $uri, $content) = @_;
+       my ($self, $domain, $token) = @_;
 
        # Create a request
-       my $req = HTTP::Request->new(GET => $uri);
+       my $req = HTTP::Request->new(GET => 'http://'.$domain.'/.well-known/acme-challenge/'.$token);
 
        # Get request
        my $res = $ua->request($req);
 
        # Handle error
        unless ($res->is_success) {
-               carp 'GET '.$uri.' failed: '.$res->status_line if ($_debug);
+               carp 'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' failed: '.$res->status_line if ($self->{debug});
                return;
        }
 
        # Handle invalid content
-       unless($res->content =~ /^$content\s*$/) {
-               carp 'GET '.$uri.' content match failed: /^'.$content.'\s*$/ !~ '.$res->content if ($_debug);
+       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;
        }
 
@@ -380,7 +428,6 @@ sub register {
 }
 
 # Authorize domains
-#TODO: implement combinations check one day
 sub authorize {
        my ($self) = @_;
 
@@ -390,16 +437,16 @@ sub authorize {
        # Pending list
        my @pending = ();
 
-       # Create request for each domain
+       # Create or load auth request for each domain
        map {
                # Init content
                my $content = undef;
 
                # Init file
-               my $file = PENDING_DIR.'/'.$self->{mail}.'/'.$_;
+               my $file = PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging').'/'.$_;
 
-               # Load in content domain data or post a new authz request
-               #TODO: add check on cache file ???
+               # 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 decode_json
                        ! defined eval {
@@ -440,99 +487,95 @@ sub authorize {
 
                # Add challenge
                %{$self->{challenges}{$_}} = (
-                       status => undef,
-                       expires => undef,
-                       dns_uri => undef,
-                       dns_token => undef,
-                       http_uri => undef,
-                       http_token => undef,
-                       http_challenge => undef
+                       status => $content->{status},
+                       expires => $content->{expires},
+                       polls => []
                );
 
-               # Save status
-               $self->{challenges}{$_}{status} = $content->{status};
-
                # Save pending data
                if ($content->{status} eq 'pending') {
-                       #XXX: debug
-                       print Dumper($content);
-
-                       # Exctract validation data
+                       # Extract validation data
                        foreach my $challenge (@{$content->{challenges}}) {
-                               if ($challenge->{type} eq 'http-01') {
-                                       $self->{challenges}{$_}{http_uri} = $challenge->{uri};
-                                       $self->{challenges}{$_}{http_token} = $challenge->{token};
-                               } elsif ($challenge->{type} eq 'dns-01') {
-                                       $self->{challenges}{$_}{dns_uri} = $challenge->{uri};
-                                       $self->{challenges}{$_}{dns_token} = $challenge->{token};
+                               # 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-[0-9]+$/ and $self->_httpCheck($_, $challenge->{token})) or
+                                               ($challenge->{type} =~ /^dns-[0-9]+$/ 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 = decode_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}
+                                                       });
+                                               }
+                                       # Print http help
+                                       } elsif ($challenge->{type} =~ /^http-[0-9]+$/) {
+                                               print STDERR 'Create URI http://'.$_.'/.well-known/acme-challenge/'.$challenge->{token}.' with content '.$challenge->{token}.'.'.$self->{account}{thumbprint}."\n";
+                                       # Print dns help
+                                       } elsif ($challenge->{type} =~ /^dns-[0-9]+$/) {
+                                               print STDERR 'Create TXT record _acme-challenge.'.$_.'. with value '.(((sha256_base64($challenge->{token}.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r)."\n";
+                                       }
                                }
                        }
-
-                       # Check dns challenge
-                       #XXX: disabled for now
-                       #$self->_dnsCheck('_acme-challenge.'.$_.'.', $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint});
-
-                       # Check http challenge
-#                      if ($self->_httpCheck(
-#                              # Well known uri
-#                              'http://'.$_.'/.well-known/acme-challenge/'.$self->{challenges}{$_}{http_token},
-#                              # token.thumbprint
-#                              $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}
-#                      )) {
-#                              # Post challenge request
-#                              my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'challenge', keyAuthorization => $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}});
-#
-#                              # Handle error
-#                              unless ($res->is_success) {
-#                                      confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line;
-#                              }
-#
-#                              # Extract content
-#                              my $content = decode_json($res->content);
-#
-#                              # Save status
-#                              $self->{challenges}{$_}{status} = $content->{status};
-#
-#                              # Add challenge uri to poll
-#                              #XXX: in case it is still pending
-#                              if ($content->{status} eq 'pending') {
-#                                      $self->{challenges}{$_}{http_challenge} = $content->{uri};
-#                              }
-#                      } else {
-#                              # Set failed status
-#                              $self->{challenges}{$_}{status} = 'invalid';
-#
-#                              # Display challenge to fix
-#                              print STDERR 'Makes http://'.$_.'/.well-known/acme-challenge/'.$self->{challenges}{$_}{http_token}.' return '.$self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}."\n";
-#                      }
                }
        } @{$self->{domains}};
 
-       #XXX: debug
-       exit EXIT_FAILURE;
+       # Init max run
+       my $remaining = 10;
 
        # Poll pending
-       while (scalar map { $_->{status} eq 'pending' ? 1 : (); } values %{$self->{challenges}}) {
+       while (--$remaining >= 0 and scalar map { $_->{status} eq 'valid' ? 1 : (); } values %{$self->{challenges}}) {
                # Sleep
                sleep(1);
                # Poll remaining pending
                map {
-                       # Create a request
-                       my $req = HTTP::Request->new(GET => $self->{challenges}{$_}{http_challenge});
+                       # Init domain
+                       my $domain = $_;
 
-                       # Get request
-                       my $res = $ua->request($req);
+                       # Poll remaining polls
+                       map {
+                               # Create a request
+                               my $req = HTTP::Request->new(GET => $_->{poll});
 
-                       # Handle error
-                       unless ($res->is_success) {
-                               carp 'GET '.$self->{challenges}{$_}{http_challenge}.' failed: '.$res->status_line if ($_debug);
-                       }
+                               # Get request
+                               my $res = $ua->request($req);
 
-                       # Extract content
-                       my $content = decode_json($res->content);
+                               # Handle error
+                               unless ($res->is_success) {
+                                       carp 'GET '.$self->{challenges}{$_}{http_challenge}.' failed: '.$res->status_line if ($self->{debug});
+                               }
+
+                               # Extract content
+                               my $content = decode_json($res->content);
 
-                       # Save status
-                       $self->{challenges}{$_}{status} = $content->{status};
+                               # 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}};
        } 
 
@@ -551,7 +594,7 @@ sub authorize {
                #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};
 
                # Stop here as a domain of csr list failed authorization
-               if ($_debug) {
+               if ($self->{debug}) {
                        confess 'Fix the challenges for domains: '.join(', ', map { ! defined $self->{challenges}{$_}{status} or $self->{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{challenges}});
                } else {
                        exit EXIT_FAILURE;
@@ -577,58 +620,34 @@ sub issue {
 
        # Handle error
        unless ($res->is_success) {
-               print Dumper($res);
                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";
-       #TODO: merge https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem here
-       # Close file
-       close($fh) or die $!;
 
-       # Print success
-       carp 'Success, pem certificate in '.CERT_DIR.DS.SERVER_CRT if ($_debug);
-}
-
-# Resolve dns and check content
-#XXX: this can't work without a plugin in dns to generate signature from token.thumbprint and store it in zone
-#XXX: each identifier authorisation generate a new token, it's not possible to do a yescard answer
-#XXX: the digest can be bigger than 255 TXT record limit and well known dns server will randomize TXT record order
-#
-#XXX: conclusion disabled for now
-sub _dnsCheck {
-       my ($self, $domain, $content) = @_;
-
-       # Sign temp file
-       my $stf = File::Temp->new();
-
-       # Append protect.payload to stf
-       print $stf $content;
+       # Create a request
+       my $req = HTTP::Request->new(GET => ACME_CERT);
 
-       # Close stf
-       close($stf);
+       # Get request
+       $res = $ua->request($req);
 
-       # Generate digest of stf
-       my $signature = encode_base64url(join('', capturex('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR.DS.ACCOUNT_KEY, $stf->filename))));
+       # Handle error
+       unless ($res->is_success) {
+               carp 'GET '.ACME_CERT.' failed: '.$res->status_line if ($self->{debug});
+       }
 
-       # Create resolver
-       my $res = new Net::DNS::Resolver();
+       # Append content
+       print $fh $res->content;
 
-       # Check if we get dns answer
-       unless(my $rep = $res->search($domain, 'TXT')) {
-               carp 'search TXT record for '.$domain.' failed' if ($_debug);
-               return;
-       } else {
-               unless (scalar map { $_->type eq 'TXT' && $_->txtdata =~ /^$signature$/ ? 1 : (); } $rep->answer) {
-                       carp 'search recursively TXT record for '.$_.' failed' if ($_debug);
-                       return;
-               }
-       }
+       # Close file
+       close($fh) or die $!;
 
-       return 1;
+       # Print success
+       carp 'Success, pem certificate in '.CERT_DIR.DS.SERVER_CRT if ($self->{debug});
 }
 
 1;