X-Git-Url: https://git.rapsys.eu/acme/blobdiff_plain/2143e273c43cb7ef1b311f725f3f80f3fa67728b..834ba74c4170b5fe54d148ba9f0a197c08460f5a:/acme.pm diff --git a/acme.pm b/acme.pm index 144222c..db1c6d4 100644 --- a/acme.pm +++ b/acme.pm @@ -1,3 +1,20 @@ +# 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; @@ -8,23 +25,23 @@ 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 DateTime; 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(encode_json decode_json); +use JSON qw(from_json to_json); use LWP; use MIME::Base64 qw(encode_base64url encode_base64); use Net::Domain::TLD; -use Tie::IxHash; 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/) @@ -67,10 +84,13 @@ use constant { 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.0.1-July-27-2015.pdf', + ACME_TERMS => 'https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf', # Version - VERSION => 'v0.3' + VERSION => 'v0.6', + + # Config + CONFIG => '/etc/acmepl/config' }; # User agent object @@ -243,7 +263,7 @@ sub genKeys { # Store thumbprint #XXX: convert base64 to base64 url - $self->{account}{thumbprint} = (sha256_base64(encode_json($self->{account}{jwk}{jwk})) =~ s/=+\z//r) =~ tr[+/][-_]r; + $self->{account}{thumbprint} = (sha256_base64(to_json($self->{account}{jwk}{jwk})) =~ s/=+\z//r) =~ tr[+/][-_]r; } # Generate certificate request @@ -295,7 +315,7 @@ sub directory { $self->{nonce} = $res->headers->{'replay-nonce'}; # Merge uris in self content - %$self = (%$self, %{decode_json($res->content)}); + %$self = (%$self, %{from_json($res->content)}); } # Post request @@ -303,10 +323,10 @@ sub _post { my ($self, $uri, $payload) = @_; # Protected field - my $protected = encode_base64url(encode_json({nonce => $self->{nonce}})); + my $protected = encode_base64url(to_json({nonce => $self->{nonce}})); # Payload field - $payload = encode_base64url(encode_json($payload)); + $payload = encode_base64url(to_json($payload)); # Sign temp file my $stf = File::Temp->new(); @@ -324,7 +344,7 @@ sub _post { my $req = HTTP::Request->new(POST => $uri); # Set new-reg request content - $req->content(encode_json({ + $req->content(to_json({ header => $self->{account}{jwk}, protected => $protected, payload => $payload, @@ -378,6 +398,25 @@ sub _httpCheck { # 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); @@ -448,17 +487,16 @@ sub authorize { # 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 + #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 = decode_json($content)) && - # Check expiration - (DateTime->from_epoch(epoch => str2time($content->{expires})) >= DateTime->now()->add(hours => 1)) - } + ($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'}); @@ -469,7 +507,7 @@ sub authorize { } # Decode content - $content = decode_json($res->content); + $content = from_json($res->content); # Check domain unless (defined $content->{identifier}{value} && $content->{identifier}{value} eq $_) { @@ -482,7 +520,7 @@ sub authorize { } # Write to file - write_file($file, encode_json($content)); + write_file($file, to_json($content)); } # Add challenge @@ -505,8 +543,8 @@ sub authorize { } 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})) + ($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}}); @@ -517,7 +555,7 @@ sub authorize { } # Extract content - my $content = decode_json($res->content); + my $content = from_json($res->content); # Save if valid if ($content->{status} eq 'valid') { @@ -531,11 +569,18 @@ sub authorize { poll => $content->{uri} }); } - # Print http help - } elsif ($challenge->{type} =~ /^http-[0-9]+$/) { + } + } + } + # 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"; - # Print dns help - } elsif ($challenge->{type} =~ /^dns-[0-9]+$/) { + # 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"; } } @@ -569,7 +614,7 @@ sub authorize { } # Extract content - my $content = decode_json($res->content); + my $content = from_json($res->content); # Save status if ($content->{status} ne 'pending') { @@ -579,6 +624,25 @@ sub authorize { } 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 @@ -588,14 +652,14 @@ sub authorize { # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'}); # # Handle error # unless ($res->is_success) { - # print Dumper($res); # 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}) { - confess 'Fix the challenges for domains: '.join(', ', map { ! defined $self->{challenges}{$_}{status} or $self->{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{challenges}}); + 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; }