1 # This file is part of Acmepl
3 # Acmepl is is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation, either version 3 of the License, or
6 # (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 # Copyright (C) 2016 - 2017 Raphaël Gertz <acme@rapsys.eu>
26 use filetest
qw(access);
30 our @ISA = qw(Exporter);
31 our @EXPORT_OK = qw(VERSION);
34 use Carp
qw(carp confess);
35 use Date
::Parse
qw(str2time);
37 use Digest
::SHA
qw(sha256_base64);
39 use File
::Copy
qw(copy);
40 use File
::Path
qw(make_path);
41 use File
::Slurp
qw(read_file write_file);
42 use File
::stat qw(stat);
43 use File
::Temp
; # qw( :seekable );
44 use IPC
::System
::Simple
qw(capturex);
45 use JSON
qw(from_json to_json);
47 use MIME
::Base64
qw(encode_base64url encode_base64);
49 use POSIX
qw(EXIT_FAILURE);
53 #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/)
54 #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt
55 #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js
59 # Request certificate file name
60 REQUEST_CSR
=> 'request.der',
69 ACME_CERT
=> 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem',
70 ACME_DIR
=> 'https://acme-staging.api.letsencrypt.org/directory',
71 ACME_PROD_DIR
=> 'https://acme-v01.api.letsencrypt.org/directory',
84 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
93 # kty => uc(KEY_TYPE),
100 tie
(our %jwk, 'Tie::IxHash', pubkey
=> undef, jwk
=> undef, thumbprint
=> undef);
101 tie
(%{$jwk{jwk
}}, 'Tie::IxHash', alg
=> 'RS256', jwk
=> undef);
102 #XXX: strict ordering only really needed here for thumbprint sha256 digest
103 tie
(%{$jwk{jwk
}{jwk
}}, 'Tie::IxHash', e
=> undef, kty
=> uc(KEY_TYPE
), n
=> undef);
108 my ($class, $debug, $domain, $config) = @_;
113 # Link self to package
114 bless($self, $class);
117 $self->{debug
} = $debug;
120 $self->{domain
} = $domain;
123 $self->{config
} = $config;
126 @{$self->{domains
}} = ($domain->{domain
}, @{$domain->{domains
}});
128 # Add extra check to mail validity
129 #XXX: mxcheck fail if there is only a A record on the domain
130 my $ev = Email
::Valid-
>new(-fqdn
=> 1, -tldcheck
=> 1, -mxcheck
=> 1);
132 # Show error if check fail
133 if (! defined $ev->address($self->{domain
}{mail
})) {
134 map { carp
'failed check: '.$_ if ($self->{debug
}) } $ev->details();
135 confess
'Email::Valid->address failed';
139 $self->{mail
} = $self->{domain
}{mail
};
142 my $res = new Net
::DNS
::Resolver
();
149 unless (($tld) = $_ =~ m/\.(\w+)$/) {
150 confess
$_.'\'s tld extraction failed';
153 # Check if tld exists
154 unless(Net
::Domain
::TLD
::tld_exists
($tld)) {
155 confess
$tld.' tld from '.$_.' don\'t exists';
158 # Check if we get dns answer
159 #XXX: only search A type because letsencrypt don't support ipv6 (AAAA) yet
160 unless(my $rep = $res->search($_, 'A')) {
161 confess
'search A record for '.$_.' failed';
163 unless (scalar map { $_->type eq 'A' ? 1 : (); } $rep->answer) {
164 confess
'search recursively A record for '.$_.' failed';
167 } @{$self->{domains
}};
169 # Return class reference
173 # Prepare environement
177 # Extract cert directory and filename
178 my ($certFile, $certDir) = File
::Spec-
>splitpath($self->{domain
}{cert
});
180 # Extract key directory and filename
181 my ($keyFile, $keyDir) = File
::Spec-
>splitpath($self->{domain
}{key
});
183 # Extract account directory and filename
184 my ($accountFile, $accountDir) = File
::Spec-
>splitpath($self->{domain
}{account
});
188 make_path
($certDir, $keyDir, $accountDir, $self->{config
}{pending
}.'/'.$self->{mail
}.'.'.($self->{domain
}{prod
} ? 'prod' : 'staging'), {error
=> \
my $err});
191 my ($file, $msg) = %$_;
192 carp
($file eq '' ? '' : $file.': ').$msg if ($self->{debug
});
194 confess
'make_path failed';
199 $ua = LWP
::UserAgent-
>new;
200 $ua->agent(__PACKAGE__
.'/'.VERSION
);
202 # Check that certificate is writable
203 unless (-w
$certDir || -w
$self->{domain
}{cert
}) {
204 confess
('Directory '.$certDir.' or file '.$self->{domain
}{cert
}.' must be writable: '.$!);
207 # Check that key is writable
208 unless (-r
$self->{domain
}{key
} || -w
$keyDir) {
209 confess
('File '.$self->{domain
}{key
}.' must be readable or directory '.$keyDir.' must be writable: '.$!);
212 # Check that account is writable
213 unless (-r
$self->{domain
}{account
} || -w
$accountDir) {
214 confess
('File '.$self->{domain
}{account
}.' must be readable or directory '.$accountDir.' must be writable: '.$!);
217 # Backup old certificate if possible
218 if (-w
$certDir && -f
$self->{domain
}{cert
}) {
219 my ($dt, $suffix) = undef;
221 # Extract datetime suffix
222 $suffix = ($dt = DateTime-
>from_epoch(epoch
=> stat($self->{domain
}{cert
})->mtime))->ymd('').$dt->hms('');
224 # Rename old certificate
225 unless(copy
($self->{domain
}{cert
}, $self->{domain
}{cert
}.'.'.$suffix)) {
226 carp
('Copy '.$self->{domain
}{cert
}.' to '.$self->{domain
}{cert
}.'.'.$suffix.' failed: '.$!);
234 open($_stderr, '>&STDERR') or die $!;
236 close(STDERR
) or die $!;
238 open(STDERR
, '>', '/dev/null') or die $!;
246 open(STDERR
, '>&', $_stderr) or die $!;
249 # Generate required keys
253 # Generate account and server key if required
255 # Check key existence
260 #XXX: we drop stderr here because openssl can't be quiet on this command
261 capturex
('openssl', ('genrsa', '-out', $_, KEY_SIZE
));
265 } ($self->{domain
}{account
}, $self->{domain
}{key
});
267 # Extract modulus and publicExponent jwk
268 #XXX: same here we tie to keep ordering
269 tie
(%{$self->{account
}}, 'Tie::IxHash', %jwk);
271 if (/^Modulus=([0-9A-F]+)$/) {
272 # Extract to binary from hex and convert to base64 url
273 $self->{account
}{jwk
}{jwk
}{n
} = encode_base64url
(pack("H*", $1) =~ s/^\0+//r);
274 } elsif (/^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/) {
275 # Extract to binary from int, trim leading zeros and convert to base64 url
276 chomp ($self->{account
}{jwk
}{jwk
}{e
} = encode_base64url
(pack("N", $1) =~ s/^\0+//r));
278 } capturex
('openssl', ('rsa', '-text', '-in', $self->{domain
}{account
}, '-noout', '-modulus'));
282 # Extract account public key
283 $self->{account
}{pubkey
} = join('', map { chomp; $_; } capturex
('openssl', ('rsa', '-in', $self->{domain
}{account
}, '-pubout')));
288 #XXX: convert base64 to base64 url
289 $self->{account
}{thumbprint
} = (sha256_base64
(to_json
($self->{account
}{jwk
}{jwk
})) =~ s/=+\z//r) =~ tr
[+/][-_
]r
;
292 # Generate certificate request
296 # Openssl config template
297 my $oct = File
::Temp-
>new();
299 # Save data start position
302 # Load template from data
303 map { s/__EMAIL_ADDRESS__/$self->{mail}/; s/__COMMON_NAME__/$self->{domains}[0]/; print $oct $_; } <DATA
>;
308 # Append domain names
310 map { print $oct 'DNS.'.$i++.' = '.$_."\n"; } @{$self->{domains
}};
313 capturex
('openssl', ('req', '-new', '-outform', 'DER', '-key', $self->{domain
}{key
}, '-config', $oct->filename, '-out', $self->{config
}{pending
}.'/'.$self->{mail
}.'.'.($self->{domain
}{prod
} ? 'prod' : 'staging').'/'.REQUEST_CSR
));
327 my $dir = $self->{domain
}{prod
} ? ACME_PROD_DIR
: ACME_DIR
;
330 my $req = HTTP
::Request-
>new(GET
=> $dir.'?'.$time);
333 my $res = $ua->request($req);
336 unless ($res->is_success) {
337 confess
'GET '.$dir.'?'.$time.' failed: '.$res->status_line;
341 $self->{nonce
} = $res->headers->{'replay-nonce'};
343 # Merge uris in self content
344 %$self = (%$self, %{from_json
($res->content)});
349 my ($self, $uri, $payload) = @_;
352 my $protected = encode_base64url
(to_json
({nonce
=> $self->{nonce
}}));
355 $payload = encode_base64url
(to_json
($payload));
358 my $stf = File
::Temp-
>new();
360 # Append protect.payload to stf
361 print $stf $protected.'.'.$payload;
366 # Generate digest of stf
367 my $signature = encode_base64url
(join('', capturex
('openssl', ('dgst', '-sha256', '-binary', '-sign', $self->{domain
}{account
}, $stf->filename))) =~ s/^\0+//r);
370 my $req = HTTP
::Request-
>new(POST
=> $uri);
372 # Set new-reg request content
373 $req->content(to_json
({
374 header
=> $self->{account
}{jwk
},
375 protected
=> $protected,
377 signature
=> $signature
381 my $res = $ua->request($req);
384 if (defined $res->headers->{'replay-nonce'}) {
385 $self->{nonce
} = $res->headers->{'replay-nonce'};
392 # Resolve dns and check content
393 #XXX: see https://community.centminmod.com/threads/looks-like-letsencrypt-dns-01-is-ready.5845/#12 for example
395 my ($self, $domain, $token) = @_;
397 # Generate signature from content
398 my $signature = ((sha256_base64
($token.'.'.$self->{account
}{thumbprint
})) =~ s/=+\z//r) =~ tr
[+/][-_
]r
;
401 $domain = '_acme-challenge.'.$domain.'.';
404 my $res = new Net
::DNS
::Resolver
();
406 # Check if we get dns answer
407 unless(my $rep = $res->search($domain, 'TXT')) {
408 carp
'TXT record search for '.$domain.' failed' if ($self->{debug
});
411 unless (scalar map { $_->type eq 'TXT' && $_->txtdata =~ /^$signature$/ ? 1 : (); } $rep->answer) {
412 carp
'TXT record recursive search for '.$domain.' failed' if ($self->{debug
});
420 # Get uri and check content
422 my ($self, $domain, $token) = @_;
425 my $req = HTTP
::Request-
>new(GET
=> 'http://'.$domain.'/.well-known/acme-challenge/'.$token);
427 # Check if thumbprint is writeable
428 if (-w
$self->{config
}{thumbprint
}) {
429 # Try to write thumbprint
430 write_file
($self->{config
}{thumbprint
}, $self->{account
}{thumbprint
});
434 my $res = $ua->request($req);
437 unless ($res->is_success) {
438 carp
'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' failed: '.$res->status_line if ($self->{debug
});
442 # Handle invalid content
443 unless($res->content =~ /^$token.$self->{account}{thumbprint}\s*$/) {
444 carp
'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' content match failed: /^'.$token.'.'.$self->{account
}{thumbprint
}.'\s*$/ !~ '.$res->content if ($self->{debug
});
453 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3
457 # Post new-reg request
458 #XXX: contact array may contain a tel:+33612345678 for example
459 my $res = $self->_post($self->{'new-reg'}, {resource
=> 'new-reg', contact
=> ['mailto:'.$self->{mail
}], agreement
=> $self->{term
}});
462 unless ($res->is_success || $res->code eq 409) {
463 confess
'POST '.$self->{'new-reg'}.' failed: '.$res->status_line;
466 # Update mail informations
467 if ($res->code eq 409) {
468 # Save registration uri
469 $self->{'reg'} = $res->headers->{location
};
472 #XXX: contact array may contain a tel:+33612345678 for example
473 $res = $self->_post($self->{'reg'}, {resource
=> 'reg', contact
=> ['mailto:'.$self->{mail
}]});
476 unless ($res->is_success) {
477 confess
'POST '.$self->{'reg'}.' failed: '.$res->status_line;
486 # Create challenges hash
487 %{$self->{challenges
}} = ();
492 # Create or load auth request for each domain
498 my $file = $self->{config
}{pending
}.'/'.$self->{mail
}.'.'.($self->{domain
}{prod
} ? 'prod' : 'staging').'/'.$_;
500 # Load auth request content or post a new one
501 #TODO: add more check on cache file ???
503 #XXX: use eval to workaround a fatal in from_json
505 # Check that file exists
508 ($content = read_file
($file)) &&
510 ($content = from_json
($content))
512 } || (str2time
($content->{expires
}) <= time()+3600)
514 # Post new-authz request
515 my $res = $self->_post($self->{'new-authz'}, {resource
=> 'new-authz', identifier
=> {type
=> 'dns', value
=> $_}, existing
=> 'accept'});
518 unless ($res->is_success) {
519 confess
'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line;
523 $content = from_json
($res->content);
526 unless (defined $content->{identifier
}{value
} && $content->{identifier
}{value
} eq $_) {
527 confess
'domain matching '.$content->{identifier
}{value
}.' for '.$_.' failed: '.$res->status_line;
531 unless ($content->{status
} eq 'valid' or $content->{status
} eq 'pending') {
532 confess
'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line;
536 write_file
($file, to_json
($content));
540 %{$self->{challenges
}{$_}} = (
541 status
=> $content->{status
},
542 expires
=> $content->{expires
},
547 if ($content->{status
} eq 'pending') {
548 # Extract validation data
549 foreach my $challenge (@{$content->{challenges
}}) {
550 # One test already validated this auth request
551 if ($self->{challenges
}{$_}{status
} eq 'valid') {
553 } elsif ($challenge->{status
} eq 'valid') {
554 $self->{challenges
}{$_}{status
} = $challenge->{status
};
556 } elsif ($challenge->{status
} eq 'pending') {
559 ($challenge->{type
} =~ /^http-01$/ and $self->_httpCheck($_, $challenge->{token
})) or
560 ($challenge->{type
} =~ /^dns-01$/ and $self->_dnsCheck($_, $challenge->{token
}))
562 # Post challenge request
563 my $res = $self->_post($challenge->{uri
}, {resource
=> 'challenge', keyAuthorization
=> $challenge->{token
}.'.'.$self->{account
}{thumbprint
}});
566 unless ($res->is_success) {
567 confess
'POST '.$challenge->{uri
}.' failed: '.$res->status_line;
571 my $content = from_json
($res->content);
574 if ($content->{status
} eq 'valid') {
575 $self->{challenges
}{$_}{status
} = $content->{status
};
576 # Check is still polling
577 } elsif ($content->{status
} eq 'pending') {
578 # Add to poll list for later use
579 push(@{$self->{challenges
}{$_}{polls
}}, {
580 type
=> (split(/-/, $challenge->{type
}))[0],
581 status
=> $content->{status
},
582 poll
=> $content->{uri
}
588 # Check if check is challenge still in pending and no polls
589 if ($self->{challenges
}{$_}{status
} eq 'pending' && scalar @{$self->{challenges
}{$_}{polls
}} == 0) {
590 # Loop on all remaining challenges
591 foreach my $challenge (@{$content->{challenges
}}) {
592 # Display help for http-01 check
593 if ($challenge->{type
} eq 'http-01') {
594 print STDERR
'Create URI http://'.$_.'/.well-known/acme-challenge/'.$challenge->{token
}.' with content '.$challenge->{token
}.'.'.$self->{account
}{thumbprint
}."\n";
595 # Display help for dns-01 check
596 } elsif ($challenge->{type
} eq 'dns-01') {
597 print STDERR
'Create TXT record _acme-challenge.'.$_.'. with value '.(((sha256_base64
($challenge->{token
}.'.'.$self->{account
}{thumbprint
})) =~ s/=+\z//r) =~ tr
[+/][-_
]r
)."\n";
602 } @{$self->{domains
}};
608 while (--$remaining >= 0 and scalar map { $_->{status
} eq 'pending' ? 1 : (); } values %{$self->{challenges
}}) {
611 # Poll remaining pending
616 # Poll remaining polls
619 my $req = HTTP
::Request-
>new(GET
=> $_->{poll
});
622 my $res = $ua->request($req);
625 unless ($res->is_success) {
626 carp
'GET '.$self->{challenges
}{$_}{http_challenge
}.' failed: '.$res->status_line if ($self->{debug
});
630 my $content = from_json
($res->content);
633 if ($content->{status
} ne 'pending') {
634 $self->{challenges
}{$domain}{status
} = $content->{status
};
636 } @{$self->{challenges
}{$_}{polls
}};
637 } map { $self->{challenges
}{$_}{status
} eq 'pending' ? $_ : (); } keys %{$self->{challenges
}};
640 # Check if thumbprint is writeable
641 if (-w
$self->{config
}{thumbprint
}) {
642 # Try to write thumbprint
643 write_file
($self->{config
}{thumbprint
}, '');
646 # Stop here with remaining chanllenge
647 if (scalar map { ! defined $_->{status
} or $_->{status
} ne 'valid' ? 1 : (); } values %{$self->{challenges
}}) {
648 # Deactivate all activated domains
649 #XXX: not implemented by letsencrypt
651 # # Post deactivation request
652 # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'});
654 # unless ($res->is_success) {
655 # confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line;
657 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};
659 # Stop here as a domain of csr list failed authorization
660 if ($self->{debug
}) {
661 my @domains = map { ! defined $self->{challenges
}{$_}{status
} or $self->{challenges
}{$_}{status
} ne 'valid' ? $_ : (); } keys %{$self->{challenges
}};
662 confess
'Fix the challenge'.(scalar @domains > 1?'s':'').' for domain'.(scalar @domains > 1?'s':'').': '.join(', ', @domains);
674 open(my $fh, '<', $self->{config
}{pending
}.'/'.$self->{mail
}.'.'.($self->{domain
}{prod
} ? 'prod' : 'staging').'/'.REQUEST_CSR
) or die $!;
677 my $csr = encode_base64url
(join('', <$fh>) =~ s/^\0+//r);
680 close($fh) or die $!;
682 # Post certificate request
683 my $res = $self->_post($self->{'new-cert'}, {resource
=> 'new-cert', csr
=> $csr});
686 unless ($res->is_success) {
687 confess
'POST '.$self->{'new-cert'}.' failed: '.$res->status_line;
691 open($fh, '>', $self->{domain
}{cert
}) or die $!;
694 print $fh '-----BEGIN CERTIFICATE-----'."\n".encode_base64
($res->content).'-----END CERTIFICATE-----'."\n";
697 my $req = HTTP
::Request-
>new(GET
=> ACME_CERT
);
700 $res = $ua->request($req);
703 unless ($res->is_success) {
704 carp
'GET '.ACME_CERT
.' failed: '.$res->status_line if ($self->{debug
});
708 print $fh $res->content;
711 close($fh) or die $!;
714 carp
'Success, pem certificate in '.$self->{domain
}{cert
} if ($self->{debug
});
721 # OpenSSL configuration file.
722 # This is mostly being used for generation of certificate requests.
729 distinguished_name
= req_distinguished_name
730 # The extentions to add to the self signed cert
731 x509_extensions
= v3_ca
732 # The extensions to add to a certificate request
733 req_extensions
= v3_req
735 # This sets a mask for permitted string types. There are several options.
736 # utf8only: only UTF8Strings (PKIX recommendation after 2004).
737 # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings.
738 string_mask
= utf8only
740 [ req_distinguished_name
]
742 stateOrProvinceName
= State
or Province Name
743 localityName
= Locality Name
744 organizationName
= Organization Name
745 organizationalUnitName
= Organizational Unit Name
746 commonName
= __COMMON_NAME__
747 emailAddress
= __EMAIL_ADDRESS__
750 basicConstraints
= CA
:false
751 keyUsage
= nonRepudiation
, digitalSignature
, keyEncipherment
752 subjectAltName
= email
:move
753 subjectAltName
= @alt_names
756 subjectKeyIdentifier
= hash
757 authorityKeyIdentifier
= keyid
:always
,issuer
758 basicConstraints
= CA
:true