]> Raphaël G. Git Repositories - acme/blob - Acme.pm
Add bug report link
[acme] / Acme.pm
1 # This file is part of Acmepl
2 #
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.
7 #
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.
12 #
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/>.
15 #
16 # Copyright (C) 2016 - 2017 Raphaël Gertz <acme@rapsys.eu>
17
18 # Acme package
19 package Acme;
20
21 # Best practice
22 use strict;
23 use warnings;
24
25 # Add acl support to file tests
26 use filetest qw(access);
27
28 # Symbol export
29 use Exporter;
30 our @ISA = qw(Exporter);
31 our @EXPORT_OK = qw(ACCOUNT CONFIG MAIL PENDING TERM THUMBPRINT VERSION);
32
33 # Load dependancies
34 use Carp qw(carp confess);
35 use Data::Validate::IP qw(is_public_ip is_public_ipv6);
36 use Date::Parse qw(str2time);
37 use DateTime;
38 use Digest::SHA qw(sha256_base64);
39 use Email::Valid;
40 use File::Copy qw(copy);
41 use File::Path qw(make_path);
42 use File::Slurp qw(read_file write_file);
43 use File::Spec qw(splitpath);
44 use File::stat qw(stat);
45 use File::Temp; # qw( :seekable );
46 use IPC::System::Simple qw(capturex);
47 use JSON qw(from_json to_json);
48 use LWP;
49 use MIME::Base64 qw(encode_base64url encode_base64);
50 use Net::DNS qw();
51 use Net::Domain::TLD qw(tld_exists);
52 use POSIX qw(EXIT_FAILURE);
53 use Tie::IxHash;
54
55 # Load debug
56 #use Data::Dumper;
57
58 # Documentation links
59 #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/)
60 #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt
61 #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js
62 #XXX: see https://www.rfc-editor.org/rfc/rfc8555.html
63
64 # Bug report links
65 #XXX: see https://community.letsencrypt.org/t/shortlived-certificate-stuck-as-processing/241006/14
66
67 # Todo list
68 #TODO: try to drop retry code in _post, asynch answer may obsolete it
69 #TODO: cleanup challenge verification code ?
70 #TODO: verify that shortlived certificates get renewed in time
71 #TODO: try to drop mail address from newAccount, unused by letsencrypt now ?
72
73 # Set constants
74 use constant {
75 # Config infos
76 ACCOUNT => '/etc/acme/account.pem',
77 CONFIG => '/etc/acme/config',
78 PENDING => '/tmp/acme',
79 THUMBPRINT => '/etc/acme/thumbprint',
80 TERM => 'https://letsencrypt.org/documents/LE-SA-v1.5-February-24-2025.pdf',
81 MAIL => 'webmaster',
82
83 # Certificate info
84 CSR_SUFFIX => '.der',
85
86 # Redhat infos
87 RH_CERTS => '/etc/pki/tls/certs',
88 RH_PRIVATE => '/etc/pki/tls/private',
89 RH_SUFFIX => '.pem',
90
91 # Debian infos
92 DEB_CERTS => '/etc/ssl/certs',
93 DEB_PRIVATE => '/etc/ssl/private',
94 DEB_CERTS_SUFFIX => '.crt',
95 DEB_PRIVATE_SUFFIX => '.key',
96
97 # Dns infos
98 DNS_PREFIX => '_acme-challenge.',
99 DNS_SUFFIX => '.',
100
101 # Key infos
102 KEY_TYPE => 'rsa',
103 KEY_SIZE => 4096,
104
105 # Acme infos
106 #ACME_CERT => 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem',
107 ACME_DIR => 'https://acme-staging-v02.api.letsencrypt.org/directory',
108 ACME_PROD_DIR => 'https://acme-v02.api.letsencrypt.org/directory',
109
110 # Version
111 VERSION => '2.0.4',
112
113 # Timeout
114 TIMEOUT => 300
115 };
116
117 # User agent object
118 our $ua;
119
120 # Strerr backup
121 our $_stderr;
122
123 # Retry count
124 our $retry;
125
126 # JSON Web Key (JWK)
127 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
128 #our %jwk = (
129 # pubkey => undef,
130 # jwk => {
131 # alg => 'RS256',
132 # jwk => {
133 # # Exponent
134 # e => undef,
135 # # Key type
136 # kty => uc(KEY_TYPE),
137 # # Modulus
138 # n => undef
139 # }
140 # },
141 # thumbprint => undef
142 #);
143 tie(our %jwk, 'Tie::IxHash', pubkey => undef, jwk => undef, thumbprint => undef);
144 tie(%{$jwk{jwk}}, 'Tie::IxHash', alg => 'RS256', jwk => undef);
145 #XXX: strict ordering only really needed here for thumbprint sha256 digest
146 tie(%{$jwk{jwk}{jwk}}, 'Tie::IxHash', e => undef, kty => uc(KEY_TYPE), n => undef);
147
148 # Constructor
149 sub new {
150 # Extract params
151 my ($class, $verbose, $domain, $config) = @_;
152
153 # Create self hash
154 my $self = {};
155
156 # Link self to package
157 bless($self, $class);
158
159 # Save retry
160 $self->{retry} = 0;
161
162 # Save verbose
163 $self->{verbose} = $verbose;
164
165 # Save domain
166 $self->{domain} = $domain;
167
168 # Save config
169 $self->{config} = $config;
170
171 # Save domains
172 my @domains = ($domain->{domain}, @{$domain->{domains}});
173
174 # Show error if check fail
175 unless (defined $self->{domain}{mail}) {
176 confess('Missing mail');
177 }
178
179 # Transform mail in an array
180 unless (ref($self->{domain}{mail}) eq 'ARRAY') {
181 $self->{domain}{mail} = [ $self->{domain}{mail} ];
182 }
183
184 # Add extra check to mail validity
185 #XXX: mxcheck fail if there is only a A record on the domain
186 my $ev = Email::Valid->new(-fqdn => 1, -tldcheck => 1, -mxcheck => 1);
187
188 # Loop on each mail
189 map {
190 # Checke address
191 if (! defined $ev->address($_)) {
192 map { carp 'failed check: '.$_ if ($self->{verbose}) } $ev->details();
193 confess('Validate '.$_.' mail address failed');
194 }
195 } @{$self->{domain}{mail}};
196
197 # Check domains
198 map {
199 my $tld;
200
201 # With non-numeric tld
202 if (!is_public_ip($_)) {
203 # Extract tld
204 unless (($tld) = $_ =~ m/\.(\w+)$/) {
205 confess('Extract '.$_.' tld failed');
206 }
207
208 # Check if tld exists
209 unless(Net::Domain::TLD::tld_exists($tld)) {
210 confess('Extracted '.$_.' tld '.$tld.' do not exists');
211 }
212
213 # Search a record
214 my $a = Net::DNS::Resolver->new->search($_, 'A', 'IN');
215
216 # Search aaaa record
217 my $aaaa = Net::DNS::Resolver->new->search($_, 'AAAA', 'IN');
218
219 # Trigger error for unresolvable domain
220 unless (
221 # Check if either has a A or AAAA record
222 scalar map {
223 ($_->type eq 'A' or $_->type eq 'AAAA') ? 1 : ();
224 }
225 # Merge both answer
226 (
227 (defined $a and defined $a->answer) ? $a->answer : (),
228 (defined $aaaa and defined $aaaa->answer) ? $aaaa->answer : ()
229 )
230 ) {
231 confess('Resolve '.$_.' to an A or AAAA record failed');
232 }
233 }
234 } @domains;
235
236 # Return class reference
237 return $self;
238 }
239
240 # Prepare environement
241 sub prepare {
242 my ($self) = @_;
243
244 # Extract cert directory and filename
245 my ($certFile, $certDir) = File::Spec->splitpath($self->{domain}{cert});
246
247 # Extract key directory and filename
248 my ($keyFile, $keyDir) = File::Spec->splitpath($self->{domain}{key});
249
250 # Extract account directory and filename
251 my ($accountFile, $accountDir) = File::Spec->splitpath($self->{domain}{account});
252
253 # Create all paths
254 {
255 make_path($certDir, $keyDir, $accountDir, $self->{config}{pending}, {error => \my $err});
256 if (@$err) {
257 map {
258 my ($file, $msg) = %{$_};
259 carp 'Mkdir '.($file ? $file.' ' : '').'failed: '.$msg if ($self->{verbose});
260 } @$err;
261 confess('Make path failed');
262 }
263 }
264
265 # Create user agent
266 $ua = LWP::UserAgent->new;
267 $ua->agent(__PACKAGE__.'/'.VERSION);
268
269 # Check that certificate is writable
270 unless (-w $certDir || -w $self->{domain}{cert}) {
271 confess('Directory '.$certDir.' or file '.$self->{domain}{cert}.' must be writable: '.$!);
272 }
273
274 # Check that key is readable or parent directory is writable
275 unless (-r $self->{domain}{key} || -w $keyDir) {
276 confess('File '.$self->{domain}{key}.' must be readable or directory '.$keyDir.' must be writable: '.$!);
277 }
278
279 # Check that account key is readable or parent directory is writable
280 unless (-r $self->{domain}{account} || -w $accountDir) {
281 confess('File '.$self->{domain}{account}.' must be readable or directory '.$accountDir.' must be writable: '.$!);
282 }
283
284 # Backup old certificate if possible
285 if (-w $certDir && -f $self->{domain}{cert}) {
286 my ($dt, $suffix) = undef;
287
288 # Extract datetime suffix
289 $suffix = ($dt = DateTime->from_epoch(epoch => stat($self->{domain}{cert})->mtime))->ymd('').$dt->hms('');
290
291 # Rename old certificate
292 unless(copy($self->{domain}{cert}, $self->{domain}{cert}.'.'.$suffix)) {
293 carp('Copy '.$self->{domain}{cert}.' to '.$self->{domain}{cert}.'.'.$suffix.' failed: '.$!);
294 }
295 }
296 }
297
298 # Drop stderr
299 sub _dropStdErr {
300 # Save stderr
301 open($_stderr, '>&STDERR') or die $!;
302 # Close it
303 close(STDERR) or die $!;
304 # Send to /dev/null
305 open(STDERR, '>', '/dev/null') or die $!;
306 }
307
308 # Restore stderr
309 sub _restoreStdErr {
310 # Close stderr
311 close(STDERR);
312 # Open it back
313 open(STDERR, '>&', $_stderr) or die $!;
314 }
315
316 # Generate required keys
317 sub genKeys {
318 my ($self) = @_;
319
320 # Generate account and server key if required
321 map {
322 # Check key existence
323 if (! -f $_) {
324 # Drop stderr
325 _dropStdErr();
326 # Generate key
327 #XXX: we drop stderr here because openssl can't be quiet on this command
328 capturex('openssl', ('genrsa', '-out', $_, KEY_SIZE));
329 # Restore stderr
330 _restoreStdErr();
331 }
332 } ($self->{domain}{account}, $self->{domain}{key});
333
334 # Extract modulus and publicExponent jwk
335 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
336 tie(%{$self->{account}}, 'Tie::IxHash', %jwk);
337 map {
338 if (/^Modulus=([0-9A-F]+)$/) {
339 # Extract to binary from hex and convert to base64 url
340 $self->{account}{jwk}{jwk}{n} = encode_base64url(pack("H*", $1) =~ s/^\0+//r);
341 } elsif (/^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/) {
342 # Extract to binary from int, trim leading zeros and convert to base64 url
343 chomp ($self->{account}{jwk}{jwk}{e} = encode_base64url(pack("N", $1) =~ s/^\0+//r));
344 }
345 } capturex('openssl', ('rsa', '-text', '-in', $self->{domain}{account}, '-noout', '-modulus'));
346
347 # Drop stderr
348 _dropStdErr();
349 # Extract account public key
350 $self->{account}{pubkey} = join('', map { chomp; $_; } capturex('openssl', ('rsa', '-in', $self->{domain}{account}, '-pubout')));
351 # Restore stderr
352 _restoreStdErr();
353
354 # Store thumbprint
355 #XXX: convert base64 to base64 url
356 $self->{account}{thumbprint} = (sha256_base64(to_json($self->{account}{jwk}{jwk})) =~ s/=+\z//r) =~ tr[+/][-_]r;
357 }
358
359 # Directory call
360 sub directory {
361 my ($self) = @_;
362
363 # Set time
364 my $time = time;
365
366 # Set directory
367 my $dir = $self->{domain}{prod} ? ACME_PROD_DIR : ACME_DIR;
368
369 # Create a request
370 my $req = HTTP::Request->new(GET => $dir.'?'.$time);
371
372 # Get request
373 my $res = $ua->request($req);
374
375 # Handle error
376 unless ($res->is_success) {
377 confess('GET '.$dir.'?'.$time.' failed: '.$res->status_line);
378 }
379
380 # Init content
381 my %content;
382
383 # Extract content
384 unless (%content = %{from_json($res->content)}) {
385 confess('GET '.$dir.'?'.$time.' from_json failed: '.$res->status_line);
386 }
387
388 # Merge uris in self content
389 $self->{req}{dir} = $dir;
390 $self->{req}{keyChange} = $content{keyChange};
391 $self->{req}{newNonce} = $content{newNonce};
392 $self->{req}{newAccount} = $content{newAccount};
393 $self->{req}{revokeCert} = $content{revokeCert};
394 $self->{req}{newOrder} = $content{newOrder};
395
396 # Check term
397 unless ($self->{config}{term} eq $content{meta}{termsOfService}) {
398 confess('GET '.$dir.'?'.$time.' term: '.$content{meta}{termsOfService}.' differ from config: '.$self->{config}{term});
399 }
400 }
401
402 # Nonce call
403 sub nonce {
404 my ($self) = @_;
405
406 # Set time
407 my $time = time;
408
409 # Create a request
410 my $req = HTTP::Request->new(HEAD => $self->{req}{newNonce}.'?'.$time);
411
412 # Get request
413 my $res = $ua->request($req);
414
415 # Handle error
416 unless ($res->is_success) {
417 confess('HEAD '.$self->{req}{newNonce}.'?'.$time.' failed: '.$res->status_line);
418 }
419
420 # Save nonce
421 $self->{req}{nonce} = $res->headers->{'replay-nonce'};
422 }
423
424 # Post request
425 sub _post {
426 my ($self, $uri, $payload) = @_;
427
428 # Init protected
429 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
430 #XXX: strict ordering only really needed here for thumbprint sha256 digest
431 tie(my %protected, 'Tie::IxHash', alg => $self->{account}{jwk}{alg}, jwk => $self->{account}{jwk}{jwk}, nonce => $self->{req}{nonce}, url => $uri);
432
433 # We have a kid
434 if (defined($self->{req}{kid})) {
435 # Replace jwk entry with it
436 #XXX: when kid is available all request with jwk are rejected by the api
437 %protected = (alg => $self->{account}{jwk}{alg}, kid => $self->{req}{kid}, nonce => $self->{req}{nonce}, url => $uri);
438 }
439
440 # Encode protected
441 my $protected = encode_base64url(to_json(\%protected));
442
443 # Encode payload
444 $payload = encode_base64url(to_json($payload)) unless ($payload eq '');
445
446 # Sign temp file
447 my $stf = File::Temp->new();
448
449 # Append protect.payload to stf
450 print $stf $protected.'.'.$payload;
451
452 # Close stf
453 close($stf);
454
455 # Generate digest of stf
456 my $signature = encode_base64url(join('', capturex('openssl', ('dgst', '-sha256', '-binary', '-sign', $self->{domain}{account}, $stf->filename))) =~ s/^\0+//r);
457
458 # Create a request
459 my $req = HTTP::Request->new(POST => $uri);
460
461 # Set request header
462 $req->header('Content-Type' => 'application/jose+json');
463
464 # Set new-reg request content
465 $req->content(to_json({
466 protected => $protected,
467 payload => $payload,
468 signature => $signature
469 }));
470
471 # Post request
472 my $res = $ua->request($req);
473
474 # Save nonce
475 if (defined $res->headers->{'replay-nonce'}) {
476 $self->{req}{nonce} = $res->headers->{'replay-nonce'};
477 }
478
479 # Handle error
480 #TODO: see if we may drop retry section with asynch answer which should fix the problem ?
481 #TODO: https://community.letsencrypt.org/t/shortlived-certificate-stuck-as-processing/241006/9
482 unless ($res->is_success and $self->{retry} <= 3) {
483 # Display error
484 confess('POST '.$uri.' failed: '.$res->status_line.':'.$res->content) if ($self->{verbose});
485
486 # Increment retry
487 $self->{retry}++;
488
489 # Sleep
490 sleep(1);
491
492 # Next try
493 $res = $self->_post($uri, $payload);
494 }
495
496 # Reset retry
497 $self->{retry} = 0;
498
499 # Return res object
500 return $res;
501 }
502
503 # Resolve dns and check content
504 #XXX: see https://community.centminmod.com/threads/looks-like-letsencrypt-dns-01-is-ready.5845/#12 for example
505 sub _dnsCheck {
506 my ($self, $domain, $token) = @_;
507
508 # Generate signature from content
509 my $signature = ((sha256_base64($token.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r;
510
511 # Search txt record
512 my $txt = Net::DNS::Resolver->new->search(DNS_PREFIX.$domain.DNS_SUFFIX, 'TXT', 'IN');
513
514 # Check that we have a txt record
515 unless (defined $txt and defined $txt->answer and scalar map { $_->type eq 'TXT' ? 1 : (); } $txt->answer) {
516 carp 'Resolve '.DNS_PREFIX.$domain.DNS_SUFFIX.' to a TXT record failed' if ($self->{verbose});
517 return;
518 }
519
520 # Check that txt record data match signature
521 unless (scalar map { ($_->type eq 'TXT' and $_->txtdata eq $signature) ? 1 : (); } $txt->answer) {
522 # Check verbose
523 if ($self->{verbose}) {
524 # Loop on each answer
525 map {
526 # Check if we have a TXT record with different value
527 if ($_->type eq 'TXT' and $_->txtdata ne $signature) {
528 carp 'Resolved '.DNS_PREFIX.$domain.DNS_SUFFIX.' with "'.$_->txtdata.'" instead of "'.$signature.'"';
529 }
530 } $txt->answer;
531 }
532 return;
533 }
534
535 # Return success
536 return 1;
537 }
538
539 # Get uri and check content
540 sub _httpCheck {
541 my ($self, $domain, $token) = @_;
542
543 # Set uri
544 my $uri = 'http://'.(is_public_ipv6($domain)?'['.$domain.']':$domain).'/.well-known/acme-challenge/'.$token;
545
546 # Create a request
547 my $req = HTTP::Request->new(GET => $uri);
548
549 # Check if thumbprint is writeable
550 if (-w $self->{config}{thumbprint}) {
551 # Try to write thumbprint
552 write_file($self->{config}{thumbprint}, $self->{account}{thumbprint});
553 }
554
555 # Get request
556 my $res = $ua->request($req);
557
558 # Handle error
559 unless ($res->is_success) {
560 carp 'Fetch '.$uri.' failed: '.$res->status_line if ($self->{verbose});
561 return;
562 }
563
564 # Handle invalid content
565 unless($res->content =~ /^$token.$self->{account}{thumbprint}\s*$/) {
566 carp 'Fetched '.$uri.' with "'.$res->content.'" instead of "'.$token.'.'.$self->{account}{thumbprint}.'"' if ($self->{verbose});
567 return;
568 }
569
570 # Return success
571 return 1;
572 }
573
574 # Register account
575 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3
576 sub account {
577 my ($self) = @_;
578
579 # Init pending directory
580 $self->{req}{pending} = $self->{config}{pending}.'/'.encode_base64url($self->{req}{dir}).'/'.encode_base64url(join(',', @{$self->{domain}{mail}}));
581
582 # Create pending directory
583 {
584 make_path($self->{req}{pending}, {error => \my $err});
585 if (@$err) {
586 map {
587 my ($file, $msg) = %{$_};
588 carp 'Mkdir '.($file ? $file.' ' : '').'failed: '.$msg if ($self->{verbose});
589 } @$err;
590 confess('Make path failed');
591 }
592 }
593
594 # Init file
595 #XXX: we use this file to store the fetched account
596 my $file = $self->{req}{pending}.'/'.(((sha256_base64(join(',', @{$self->{domain}{mail}}))) =~ s/=+\z//r) =~ tr[+/][-_]r);
597
598 # Init content
599 my $content = undef;
600
601 # Load account content or post a new one
602 if (
603 #XXX: use eval to workaround a fatal in from_json
604 ! defined eval {
605 # Check that file exists
606 -f $file &&
607 # Read it
608 ($content = read_file($file)) &&
609 # Decode it
610 ($content = from_json($content))
611 }
612 ) {
613 # Init tied payload
614 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
615 tie(my %payload, 'Tie::IxHash', termsOfServiceAgreed => JSON::true, contact => []);
616
617 # Loop on mails
618 map {
619 # Append mail to payload
620 $payload{contact}[scalar @{$payload{contact}}] = 'mailto:'.$_;
621 } @{$self->{domain}{mail}};
622
623 # Post newAccount request
624 # TODO: change contact field in config to contain directly the array [mailto:example@example.com,...] ???
625 #XXX: contact array may contain a tel:+33612345678 for example (supported ???)
626 my $res = $self->_post($self->{req}{'newAccount'}, \%payload);
627
628 # Handle error
629 unless ($res->is_success) {
630 confess('POST '.$self->{req}{'newAccount'}.' failed: '.$res->status_line)
631 }
632
633 # Store kid from header location
634 $content = {
635 'kid' => $res->headers->{location},
636 };
637
638 # Write to file
639 write_file($file, to_json($content));
640 }
641
642 # Set kid from content
643 $self->{req}{kid} = $content->{kid};
644
645 }
646
647 # Authorize domains
648 sub order {
649 my ($self) = @_;
650
651 # Init file
652 #XXX: we use this file to store the requested domains on our side
653 #XXX: see bug https://github.com/letsencrypt/boulder/issues/3335 and https://community.letsencrypt.org/t/acmev2-orders-list/51662
654 my $file = $self->{req}{pending}.'/'.(((sha256_base64(join(',', ($self->{domain}{domain}, @{$self->{domain}{domains}})))) =~ s/=+\z//r) =~ tr[+/][-_]r);
655
656 # Init content
657 my $content = undef;
658
659 # Load account content or post a new one
660 if (
661 #XXX: use eval to workaround a fatal in from_json
662 ! defined eval {
663 # Check that file exists
664 -f $file &&
665 # Read it
666 ($content = read_file($file)) &&
667 # Decode it
668 ($content = from_json($content))
669 # Check expiration
670 } || (str2time($content->{expires}) <= time()+3600)
671 ) {
672 # Init tied payload
673 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
674 #XXX: https://www.perlmonks.org/?node_id=1215976
675 #XXX: optional notBefore, notAfter, see https://ietf-wg-acme.github.io/acme/draft-ietf-acme-acme.html#applying-for-certificate-issuance
676 tie(my %payload, 'Tie::IxHash', 'profile' => 'classic', identifiers => []);
677
678 # Loop on domains
679 map {
680 # With public ip
681 if (is_public_ip($_)) {
682 # Set shortlived profile
683 $payload{profile} = 'shortlived';
684
685 # Tie in a stable hash and append to identifiers array
686 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
687 tie(%{$payload{identifiers}[scalar @{$payload{identifiers}}]}, 'Tie::IxHash', type => 'ip', value => $_);
688 # With fqdn
689 } else {
690 # Tie in a stable hash and append to identifiers array
691 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
692 tie(%{$payload{identifiers}[scalar @{$payload{identifiers}}]}, 'Tie::IxHash', type => 'dns', value => $_);
693 }
694 } ($self->{domain}{domain}, @{$self->{domain}{domains}});
695
696 # Post new order request
697 my $res = $self->_post($self->{req}{'newOrder'}, \%payload);
698
699 # Handle error
700 unless ($res->is_success) {
701 confess('POST '.$self->{req}{'newOrder'}.' failed: '.$res->status_line);
702 }
703
704 # Handle error
705 unless ($res->content) {
706 confess('POST '.$self->{req}{'newOrder'}.' empty content: '.$res->status_line);
707 }
708
709 # Handle error
710 unless ($res->headers->{location}) {
711 confess('POST '.$self->{req}{'newOrder'}.' missing location: '.$res->status_line);
712 }
713
714 # Extract content
715 $content = from_json($res->content);
716
717 # Check status
718 unless ($content->{status} eq 'ready' or $content->{status} eq 'pending') {
719 confess('POST '.$self->{req}{'newOrder'}.' invalid status: '.$content->{status}.': '.$res->status_line);
720 }
721
722 # Store location
723 # XXX: used with async response
724 $content->{location} = $res->headers->{location};
725
726 # Store retry after
727 $content->{retryafter} = (defined $res->headers->{'retry-after'} and $res->headers->{'retry-after'}) ? $res->headers->{'retry-after'} : 1;
728
729 # Write to file
730 write_file($file, to_json($content));
731 }
732
733 # Save the authorizations
734 $self->{req}{authorizations} = [ keys %{{ map { $_ => undef } @{$content->{authorizations}} }} ];
735
736 # Create challenges hash
737 %{$self->{req}{challenges}} = ();
738
739 # Save finalize uri
740 $self->{req}{finalize} = $content->{finalize};
741
742 # Save location
743 $self->{req}{location} = $content->{location};
744
745 # Save retry after
746 $self->{req}{retryafter} = $content->{retryafter};
747
748 # Save status
749 $self->{req}{status} = $content->{status};
750
751 # Extract authorizations
752 map {
753 # Init uri
754 my $uri = $_;
755
756 # Init content
757 my $content = undef;
758
759 # Init file
760 #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>
761 my $authFile = $self->{req}{pending}.'/'.encode_base64url($uri);
762
763 # Load auth request content or post a new one
764 #TODO: add more check on cache file ???
765 if (
766 #XXX: use eval to workaround a fatal in from_json
767 ! defined eval {
768 # Check that file exists
769 -f $authFile &&
770 # Read it
771 ($content = read_file($authFile)) &&
772 # Decode it
773 ($content = from_json($content))
774 # Check expiration
775 } || (str2time($content->{expires}) <= time()+3600)
776 ) {
777 # Post new-authz request
778 my $res = $self->_post($uri, '');
779
780 # Handle error
781 unless ($res->is_success) {
782 confess('POST '.$uri.' failed: '.$res->status_line);
783 }
784
785 # Decode content
786 $content = from_json($res->content);
787
788 # Check identifier
789 unless (
790 defined $content->{identifier} and
791 defined $content->{identifier}{type} and
792 defined $content->{identifier}{value}
793 ) {
794 confess('POST '.$uri.' missing identifier: '.$res->status_line);
795 } else {
796 unless (
797 (
798 $content->{identifier}{type} eq 'dns' or
799 $content->{identifier}{type} eq 'ip'
800 ) and
801 $content->{identifier}{value}
802 ) {
803 confess('POST '.$uri.' invalid identifier: '.$res->status_line);
804 }
805 }
806
807 # Check status
808 unless ($content->{status} eq 'valid' or $content->{status} eq 'pending') {
809 confess('POST '.$uri.' for '.$content->{identifier}{value}.' failed: '.$res->status_line);
810 }
811
812 # Write to file
813 write_file($authFile, to_json($content));
814 }
815
816 # Add challenge
817 %{$self->{req}{challenges}{$content->{identifier}{value}}} = (
818 status => $content->{status},
819 expires => $content->{expires},
820 challenges => {},
821 polls => {}
822 );
823
824 # Extract challenges
825 map {
826 # Save if valid
827 if ($_->{status} eq 'valid') {
828 $self->{req}{challenges}{$content->{identifier}{value}}{status} = $_->{status};
829 # Check is still polling
830 } elsif ($content->{status} eq 'pending') {
831 # Add to challenges list for later use
832 $self->{req}{challenges}{$content->{identifier}{value}}{challenges}{$_->{type}} = {
833 status => $_->{status},
834 token => $_->{token},
835 url => $_->{url}
836 };
837 }
838 } @{$content->{challenges}};
839
840 # Set identifier
841 my $identifier = $content->{identifier}{value};
842
843 # Save pending data
844 if ($self->{req}{challenges}{$identifier}{status} eq 'pending') {
845 # Check challenges
846 map {
847 # One test already validated this auth request
848 unless($self->{req}{challenges}{$identifier}{status} eq 'valid') {
849 # One challenge validated
850 if ($self->{req}{challenges}{$identifier}{challenges}{$_}{status} eq 'valid') {
851 $self->{req}{challenges}{$identifier}{status} = $self->{req}{challenges}{$identifier}{challenges}{$_}{status};
852 # This challenge is to be validated
853 } elsif ($self->{req}{challenges}{$identifier}{challenges}{$_}{status} eq 'pending') {
854 #TODO: implement tls-alpn-01 challenge someday if possible
855 if (
856 ($_ eq 'http-01' and $self->_httpCheck($identifier, $self->{req}{challenges}{$identifier}{challenges}{$_}{token})) or
857 ($_ eq 'dns-01' and $self->_dnsCheck($identifier, $self->{req}{challenges}{$identifier}{challenges}{$_}{token}))
858 ) {
859 # Init file
860 #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>
861 my $authFile = $self->{req}{pending}.'/'.encode_base64url($self->{req}{challenges}{$identifier}{challenges}{$_}{url});
862
863 # Reset content
864 $content = undef;
865
866 # Load auth request content or post a new one
867 #TODO: add more check on cache file ???
868 if (
869 #XXX: use eval to workaround a fatal in from_json
870 ! defined eval {
871 # Check that file exists
872 -f $authFile &&
873 # Read it
874 ($content = read_file($authFile)) &&
875 # Decode it
876 ($content = from_json($content))
877 #TODO: Check file modification time ? There is no expires field in json answer
878 }# || (str2time($content->{expires}) <= time()+3600)
879 ) {
880 # Post challenge request
881 my $res = $self->_post(
882 $self->{req}{challenges}{$identifier}{challenges}{$_}{url},
883 {keyAuthorization => $self->{req}{challenges}{$identifier}{challenges}{$_}{token}.'.'.$self->{account}{thumbprint}}
884 );
885
886 # Handle error
887 unless ($res->is_success) {
888 confess('POST '.$self->{req}{challenges}{$identifier}{challenges}{$_}{url}.' failed: '.$res->status_line);
889 }
890
891 # Extract content
892 $content = from_json($res->content);
893
894 # Write to file
895 write_file($authFile, to_json($content));
896 }
897
898 # Save if valid
899 if ($content->{status} eq 'valid') {
900 $self->{req}{challenges}{$identifier}{status} = $content->{status};
901 # Check is still polling
902 } elsif ($content->{status} eq 'pending') {
903 # Add to poll list for later use
904 $self->{req}{challenges}{$identifier}{polls}{$content->{type}} = 1;
905 }
906 }
907 }
908 }
909 } keys %{$self->{req}{challenges}{$identifier}{challenges}};
910
911 # Check if check is challenge still in pending and no polls
912 if ($self->{req}{challenges}{$identifier}{status} eq 'pending' && scalar keys %{$self->{req}{challenges}{$identifier}{polls}} == 0) {
913 # Loop on all remaining challenges
914 map {
915 #TODO: implement tls-alpn-01 challenge someday if possible
916 # Display help for http-01 check
917 if ($_ eq 'http-01') {
918 print STDERR 'Require URI http://'.$identifier.'/.well-known/acme-challenge/'.$self->{req}{challenges}{$identifier}{challenges}{$_}{token}.' with "'.$self->{req}{challenges}{$identifier}{challenges}{$_}{token}.'.'.$self->{account}{thumbprint}.'"'."\n";
919 # Display help for dns-01 check
920 } elsif ($_ eq 'dns-01') {
921 print STDERR 'Require TXT record _acme-challenge.'.$identifier.'. with "'.(((sha256_base64($self->{req}{challenges}{$identifier}{challenges}{$_}{token}.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r).'"'."\n";
922 }
923 } keys %{$self->{req}{challenges}{$identifier}{challenges}};
924 }
925 }
926 } @{$self->{req}{authorizations}};
927
928 # Init max run
929 my $remaining = TIMEOUT;
930
931 # Poll pending
932 while (--$remaining >= 0 and scalar map { ($_->{status} eq 'pending' and scalar keys %{$_->{polls}}) ? 1 : (); } values %{$self->{req}{challenges}}) {
933 # Sleep
934 sleep(1);
935
936 # Poll remaining pending
937 map {
938 # Init identifier
939 my $identifier = $_;
940
941 # Poll remaining polls
942 map {
943 # Init file
944 #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>
945 my $authFile = $self->{req}{pending}.'/'.encode_base64url($self->{req}{challenges}{$identifier}{challenges}{$_}{url});
946
947 # Post challenge request
948 #XXX: no cache here we force update
949 my $res = $self->_post(
950 $self->{req}{challenges}{$identifier}{challenges}{$_}{url},
951 {keyAuthorization => $self->{req}{challenges}{$identifier}{challenges}{$_}{token}.'.'.$self->{account}{thumbprint}}
952 );
953
954 # Handle error
955 unless ($res->is_success) {
956 confess('POST '.$self->{req}{challenges}{$identifier}{challenges}{$_}{url}.' failed: '.$res->status_line);
957 }
958
959 # Extract content
960 $content = from_json($res->content);
961
962 # Write to file
963 write_file($authFile, to_json($content));
964
965 # Save status
966 if ($content->{status} ne 'pending') {
967 $self->{req}{challenges}{$identifier}{status} = $content->{status};
968 }
969 } keys %{$self->{req}{challenges}{$identifier}{polls}};
970 } map { $self->{req}{challenges}{$_}{status} eq 'pending' ? $_ : (); } keys %{$self->{req}{challenges}};
971 }
972
973 # Check if thumbprint is writeable
974 if (-w $self->{config}{thumbprint}) {
975 # Try to write thumbprint
976 write_file($self->{config}{thumbprint}, '');
977 }
978
979 # Stop here with remaining challenge
980 if (scalar map { $_->{status} ne 'valid' ? 1 : (); } values %{$self->{req}{challenges}}) {
981 #TODO: Deactivate all activated domains ?
982 #XXX: see if implemented by letsencrypt ACMEv2
983 #map {
984 # # Post deactivation request
985 # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'});
986 # # Handle error
987 # unless ($res->is_success) {
988 # confess('POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line);
989 # }
990 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};
991
992 # Stop here as a domain of csr list failed authorization
993 if ($self->{verbose}) {
994 my @domains = map { $self->{req}{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{req}{challenges}};
995 #my @domains = map { ! defined $self->{challenges}{$_}{status} or $self->{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{challenges}};
996 carp 'Fix challenge'.(scalar @domains > 1?'s':'').' for: '.join(', ', @domains);
997 }
998 exit EXIT_FAILURE;
999 }
1000
1001 # With pending
1002 if ($self->{req}{status} eq 'pending') {
1003 # Init max run
1004 $remaining = TIMEOUT;
1005
1006 # Iterate until processing order becomes ready
1007 while (--$remaining >= 0 and $self->{req}{status} eq 'pending') {
1008 # Sleep
1009 sleep($self->{req}{retryafter});
1010
1011 # Refresh content
1012 my $res = $self->_post($self->{req}{location}, '');
1013
1014 # Handle error
1015 unless ($res->is_success) {
1016 confess('POST '.$self->{req}{location}.' failed: '.$res->status_line);
1017 }
1018
1019 # Handle error
1020 unless ($res->content) {
1021 confess('POST '.$self->{req}{location}.' empty content: '.$res->status_line);
1022 }
1023
1024 # Handle error
1025 unless ($res->headers->{location}) {
1026 confess('POST '.$self->{req}{location}.' missing location: '.$res->status_line);
1027 }
1028
1029 # Extract content
1030 $content = from_json($res->content);
1031
1032 # Check status
1033 unless ($content->{status} eq 'ready' or $content->{status} eq 'pending') {
1034 confess('POST '.$self->{req}{location}.' invalid status: '.$content->{status}.': '.$res->status_line);
1035 }
1036
1037 # Store location
1038 $self->{req}{location} = $res->headers->{location};
1039
1040 # Store retry after
1041 $self->{req}{retryafter} = (defined $res->headers->{'retry-after'} and $res->headers->{'retry-after'}) ? $res->headers->{'retry-after'} : 1;
1042
1043 # Store status
1044 $self->{req}{status} = $content->{status};
1045
1046 # Write to file
1047 write_file($file, to_json($content));
1048 }
1049
1050 # Without ready state
1051 unless($self->{req}{status} eq 'ready') {
1052 confess('POST '.$self->{req}{location}.' invalid status: '.$self->{req}{status});
1053 }
1054 }
1055 }
1056
1057 # Generate certificate request
1058 sub genCsr {
1059 my ($self) = @_;
1060
1061 # Init csr file
1062 #XXX: tmpdir.'/'.<orderuri>.'/'.<thumbprint>.':'.<mail>.':'.join(',', @domains).'.<prodstaging>.'.CSR_SUFFIX
1063 $self->{req}{csr} = $self->{req}{pending}.'/'.(((sha256_base64(join(',', ($self->{domain}{domain}, @{$self->{domain}{domains}})))) =~ s/=+\z//r) =~ tr[+/][-_]r).CSR_SUFFIX;
1064
1065 # Reuse certificate request file without domain/mail change
1066 if (! -f $self->{req}{csr}) {
1067 # Openssl config template
1068 my $oct = File::Temp->new(UNLINK => 0);
1069
1070 # Save data start position
1071 my $pos = tell DATA;
1072
1073 # Init counter
1074 my ($i, $j) = (0) x 2;
1075
1076 # Prepare mail
1077 my $mail = join("\n", map { $i++.'.emailAddress'."\t\t\t".'= '.$_; } @{$self->{domain}{mail}});
1078
1079 # Load template from data
1080 map { s/__EMAIL_ADDRESS__/$mail/; s/__COMMON_NAME__/$self->{domain}{domain}/; print $oct $_; } <DATA>;
1081
1082 # Reseek data
1083 seek(DATA, $pos, 0);
1084
1085 # Append domain names and ips
1086 $i = 0;
1087 map { print $oct (is_public_ip($_)?'IP.'.$j++:'DNS.'.$i++).' = '.$_."\n"; } ($self->{domain}{domain}, @{$self->{domain}{domains}});
1088
1089 # Generate csr
1090 #XXX: read certificate request with: openssl req -inform DER -in $self->{req}{csr} -text
1091 capturex('openssl', ('req', '-new', '-outform', 'DER', '-key', $self->{domain}{key}, '-config', $oct->filename, '-out', $self->{req}{csr}));
1092
1093 # Close oct
1094 close($oct);
1095 }
1096 }
1097
1098 # Issue certificate
1099 sub issue {
1100 my ($self) = @_;
1101
1102 # Open csr file
1103 open(my $fh, '<', $self->{req}{csr}) or die $!;
1104
1105 # Load csr
1106 my $csr = encode_base64url(join('', <$fh>) =~ s/^\0+//r);
1107
1108 # Close csr file
1109 close($fh) or die $!;
1110
1111 # Init file
1112 #XXX: tmpdir.'/'.<orderuri>.'/'.<finalizeuri>
1113 my $file = $self->{req}{pending}.'/'.encode_base64url($self->{req}{finalize});
1114
1115 # Init content
1116 my $content = undef;
1117
1118 # Init res
1119 my $res = undef;
1120
1121 # Load auth request content or post a new one
1122 #TODO: add more check on cache file ???
1123 if (
1124 #XXX: use eval to workaround a fatal in from_json
1125 ! defined eval {
1126 # Check that file exists
1127 -f $file &&
1128 # Read it
1129 ($content = read_file($file)) &&
1130 # Decode it
1131 ($content = from_json($content))
1132 # Check file modification time ? There is no expires field in json answer
1133 } || (str2time($content->{expires}) <= time()+3600)
1134 ) {
1135 # Post certificate request
1136 $res = $self->_post($self->{req}{finalize}, {csr => $csr});
1137
1138 # Handle error
1139 unless ($res->is_success) {
1140 confess('POST '.$self->{req}{finalize}.' failed: '.$res->status_line);
1141 }
1142
1143 # Extract content
1144 $content = from_json($res->content);
1145
1146 # Check status
1147 unless ($content->{status} eq 'processing' or $content->{status} eq 'valid') {
1148 confess('POST '.$self->{req}{location}.' invalid status: '.$content->{status}.': '.$res->status_line);
1149 }
1150
1151 # Store location
1152 # XXX: used with async response
1153 $content->{location} = $res->headers->{location};
1154
1155 # Store retry after
1156 $content->{retryafter} = (defined $res->headers->{'retry-after'} and $res->headers->{'retry-after'}) ? $res->headers->{'retry-after'} : 1;
1157
1158 # Write to file
1159 write_file($file, to_json($content));
1160 }
1161
1162 # Store location
1163 $self->{req}{location} = $content->{location};
1164
1165 # Store restry after
1166 $self->{req}{retryafter} = $content->{retryafter};
1167
1168 # Store status
1169 $self->{req}{status} = $content->{status};
1170
1171 # With processing
1172 if ($self->{req}{status} eq 'processing') {
1173 # Init max run
1174 my $remaining = TIMEOUT;
1175
1176 # Iterate until processing order becomes ready
1177 while (--$remaining >= 0 and $self->{req}{status} eq 'processing') {
1178 # Sleep
1179 sleep($self->{req}{retryafter});
1180
1181 # Refresh content
1182 my $res = $self->_post($self->{req}{location}, '');
1183
1184 # Handle error
1185 unless ($res->is_success) {
1186 confess('POST '.$self->{req}{location}.' failed: '.$res->status_line);
1187 }
1188
1189 # Handle error
1190 unless ($res->content) {
1191 confess('POST '.$self->{req}{location}.' empty content: '.$res->status_line);
1192 }
1193
1194 # Handle error
1195 unless ($res->headers->{location}) {
1196 confess('POST '.$self->{req}{location}.' missing location: '.$res->status_line);
1197 }
1198
1199 # Extract content
1200 $content = from_json($res->content);
1201
1202 # Check status
1203 unless ($content->{status} eq 'valid' or $content->{status} eq 'processing') {
1204 confess('POST '.$self->{req}{location}.' invalid status: '.$content->{status}.': '.$res->status_line);
1205 }
1206
1207 # Store location
1208 # XXX: used with async response
1209 $self->{req}{location} = $res->headers->{location};
1210
1211 # Store retry after
1212 $self->{req}{retryafter} = (defined $res->headers->{'retry-after'} and $res->headers->{'retry-after'}) ? $res->headers->{'retry-after'} : 1;
1213
1214 # Store status
1215 $self->{req}{status} = $content->{status};
1216
1217 # Write to file
1218 write_file($file, to_json($content));
1219 }
1220
1221 # Without valid state
1222 unless($self->{req}{status} eq 'valid') {
1223 confess('POST '.$self->{req}{location}.' invalid status: '.$self->{req}{status});
1224 }
1225 }
1226
1227 # Set certificate
1228 $self->{req}{certificate} = $content->{certificate};
1229
1230 # Set file
1231 #XXX: tmpdir.'/'.<orderuri>.'/'.<certificateuri>
1232 $file = $self->{req}{pending}.'/'.encode_base64url($self->{req}{certificate});
1233
1234 # Reset content
1235 $content = undef;
1236
1237 # Load certificate request content or post a new one
1238 #TODO: add more check on cache file ???
1239 if (
1240 #XXX: use eval to workaround a fatal in from_json
1241 ! defined eval {
1242 # Check that file exists
1243 -f $file &&
1244 # Read it
1245 ($content = read_file($file))
1246 # Check file modification time ? There is no expires field in json answer
1247 #TODO: add a checck on modification time ???
1248 }# || (str2time($content->{expires}) <= time()+3600)
1249 ) {
1250 # Post certificate request
1251 $res = $self->_post($self->{req}{certificate}, '');
1252
1253 # Handle error
1254 unless ($res->is_success) {
1255 confess('POST '.$self->{req}{certificate}.' failed: '.$res->status_line);
1256 }
1257
1258 # Set content
1259 $content = $res->content;
1260
1261 # Write to file
1262 write_file($file, $content);
1263 }
1264
1265 # Write to raw cert file
1266 write_file($self->{domain}{cert}.'.raw', $content);
1267
1268 # Remove multi-line jump
1269 $content =~ s/\n\n/\n/s;
1270
1271 # Remove ISRG Root X1 certificate signed by DST Root CA X3 present after second multi-line jump
1272 #$content =~ s/\n\n.*//s;
1273
1274 # Remove trailing line jump
1275 chomp $content;
1276
1277 # Write to cert file
1278 write_file($self->{domain}{cert}, $content);
1279
1280 # Print success
1281 carp 'Saved '.$self->{domain}{cert}.' pem certificate' if ($self->{verbose});
1282 }
1283
1284 1;
1285
1286 __DATA__
1287 #
1288 # OpenSSL configuration file.
1289 # This is mostly being used for generation of certificate requests.
1290 #
1291
1292 [ req ]
1293 default_bits = 2048
1294 default_md = sha256
1295 prompt = no
1296 distinguished_name = req_distinguished_name
1297 # The extentions to add to the self signed cert
1298 x509_extensions = v3_ca
1299 # The extensions to add to a certificate request
1300 req_extensions = v3_req
1301
1302 # This sets a mask for permitted string types. There are several options.
1303 # utf8only: only UTF8Strings (PKIX recommendation after 2004).
1304 # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings.
1305 string_mask = utf8only
1306
1307 [ req_distinguished_name ]
1308 countryName = US
1309 stateOrProvinceName = State or Province Name
1310 localityName = Locality Name
1311 organizationName = Organization Name
1312 organizationalUnitName = Organizational Unit Name
1313 commonName = __COMMON_NAME__
1314 __EMAIL_ADDRESS__
1315
1316 [ v3_req ]
1317 basicConstraints = CA:false
1318 keyUsage = nonRepudiation, digitalSignature, keyEncipherment
1319 subjectAltName = email:move
1320 subjectAltName = @alt_names
1321
1322 [ v3_ca ]
1323 subjectKeyIdentifier = hash
1324 authorityKeyIdentifier = keyid:always,issuer
1325 basicConstraints = CA:true
1326
1327 [alt_names]