]>
Raphaël G. Git Repositories - acme/blob - Acme.pm
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>
25 # Add acl support to file tests
26 use filetest
qw(access) ;
30 our @ISA = qw(Exporter) ;
31 our @EXPORT_OK = qw(ACCOUNT CONFIG MAIL PENDING TERM THUMBPRINT VERSION) ;
34 use Carp
qw(carp confess) ;
35 use Data
:: Validate
:: IP
qw(is_public_ip is_public_ipv6) ;
36 use Date
:: Parse
qw(str2time) ;
38 use Digest
:: SHA
qw(sha256_base64) ;
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) ;
49 use MIME
:: Base64
qw(encode_base64url encode_base64) ;
51 use Net
:: Domain
:: TLD
qw(tld_exists) ;
52 use POSIX
qw(EXIT_FAILURE) ;
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
65 #XXX: see https://community.letsencrypt.org/t/shortlived-certificate-stuck-as-processing/241006/14
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 ?
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' ,
87 RH_CERTS
=> '/etc/pki/tls/certs' ,
88 RH_PRIVATE
=> '/etc/pki/tls/private' ,
92 DEB_CERTS
=> '/etc/ssl/certs' ,
93 DEB_PRIVATE
=> '/etc/ssl/private' ,
94 DEB_CERTS_SUFFIX
=> '.crt' ,
95 DEB_PRIVATE_SUFFIX
=> '.key' ,
98 DNS_PREFIX
=> '_acme-challenge.' ,
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' ,
127 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
136 # kty => uc(KEY_TYPE),
141 # thumbprint => undef
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 );
151 my ( $class , $verbose , $domain , $config ) = @_ ;
156 # Link self to package
157 bless ( $self , $class );
163 $self ->{ verbose
} = $verbose ;
166 $self ->{ domain
} = $domain ;
169 $self ->{ config
} = $config ;
172 my @domains = ( $domain ->{ domain
}, @{ $domain ->{ domains
}});
174 # Show error if check fail
175 unless ( defined $self ->{ domain
}{ mail
}) {
176 confess
( 'Missing mail' );
179 # Transform mail in an array
180 unless ( ref ( $self ->{ domain
}{ mail
}) eq 'ARRAY' ) {
181 $self ->{ domain
}{ mail
} = [ $self ->{ domain
}{ mail
} ];
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 );
191 if (! defined $ev -> address ( $_ )) {
192 map { carp
'failed check: ' . $_ if ( $self ->{ verbose
}) } $ev -> details ();
193 confess
( 'Validate ' . $_ . ' mail address failed' );
195 } @{ $self ->{ domain
}{ mail
}};
201 # With non-numeric tld
202 if (! is_public_ip
( $_ )) {
204 unless (( $tld ) = $_ =~ m/\.(\w+)$/ ) {
205 confess
( 'Extract ' . $_ . ' tld failed' );
208 # Check if tld exists
209 unless ( Net
:: Domain
:: TLD
:: tld_exists
( $tld )) {
210 confess
( 'Extracted ' . $_ . ' tld ' . $tld . ' do not exists' );
214 my $a = Net
:: DNS
:: Resolver-
> new -> search ( $_ , 'A' , 'IN' );
217 my $aaaa = Net
:: DNS
:: Resolver-
> new -> search ( $_ , 'AAAA' , 'IN' );
219 # Trigger error for unresolvable domain
221 # Check if either has a A or AAAA record
223 ( $_ -> type eq 'A' or $_ -> type eq 'AAAA' ) ? 1 : ();
227 ( defined $a and defined $a -> answer ) ? $a -> answer : (),
228 ( defined $aaaa and defined $aaaa -> answer ) ? $aaaa -> answer : ()
231 confess
( 'Resolve ' . $_ . ' to an A or AAAA record failed' );
236 # Return class reference
240 # Prepare environement
244 # Extract cert directory and filename
245 my ( $certFile , $certDir ) = File
:: Spec-
> splitpath ( $self ->{ domain
}{ cert
});
247 # Extract key directory and filename
248 my ( $keyFile , $keyDir ) = File
:: Spec-
> splitpath ( $self ->{ domain
}{ key
});
250 # Extract account directory and filename
251 my ( $accountFile , $accountDir ) = File
:: Spec-
> splitpath ( $self ->{ domain
}{ account
});
255 make_path
( $certDir , $keyDir , $accountDir , $self ->{ config
}{ pending
}, { error
=> \
my $err });
258 my ( $file , $msg ) = %{ $_ };
259 carp
'Mkdir ' .( $file ? $file . ' ' : '' ). 'failed: ' . $msg if ( $self ->{ verbose
});
261 confess
( 'Make path failed' );
266 $ua = LWP
:: UserAgent-
> new ;
267 $ua -> agent ( __PACKAGE__
. '/' . VERSION
);
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: ' . $! );
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: ' . $! );
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: ' . $! );
284 # Backup old certificate if possible
285 if (- w
$certDir && - f
$self ->{ domain
}{ cert
}) {
286 my ( $dt , $suffix ) = undef ;
288 # Extract datetime suffix
289 $suffix = ( $dt = DateTime-
> from_epoch ( epoch
=> stat ( $self ->{ domain
}{ cert
})-> mtime ))-> ymd ( '' ). $dt -> hms ( '' );
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: ' . $! );
301 open ( $_stderr , '>&STDERR' ) or die $! ;
303 close ( STDERR
) or die $! ;
305 open ( STDERR
, '>' , '/dev/null' ) or die $! ;
313 open ( STDERR
, '>&' , $_stderr ) or die $! ;
316 # Generate required keys
320 # Generate account and server key if required
322 # Check key existence
327 #XXX: we drop stderr here because openssl can't be quiet on this command
328 capturex
( 'openssl' , ( 'genrsa' , '-out' , $_ , KEY_SIZE
));
332 } ( $self ->{ domain
}{ account
}, $self ->{ domain
}{ key
});
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 );
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 ));
345 } capturex
( 'openssl' , ( 'rsa' , '-text' , '-in' , $self ->{ domain
}{ account
}, '-noout' , '-modulus' ));
349 # Extract account public key
350 $self ->{ account
}{ pubkey
} = join ( '' , map { chomp ; $_ ; } capturex
( 'openssl' , ( 'rsa' , '-in' , $self ->{ domain
}{ account
}, '-pubout' )));
355 #XXX: convert base64 to base64 url
356 $self ->{ account
}{ thumbprint
} = ( sha256_base64
( to_json
( $self ->{ account
}{ jwk
}{ jwk
})) =~ s/=+\z//r ) =~ tr
[+/][- _
] r
;
367 my $dir = $self ->{ domain
}{ prod
} ? ACME_PROD_DIR
: ACME_DIR
;
370 my $req = HTTP
:: Request-
> new ( GET
=> $dir . '?' . $time );
373 my $res = $ua -> request ( $req );
376 unless ( $res -> is_success ) {
377 confess
( 'GET ' . $dir . '?' . $time . ' failed: ' . $res -> status_line );
384 unless ( %content = %{ from_json
( $res -> content )}) {
385 confess
( 'GET ' . $dir . '?' . $time . ' from_json failed: ' . $res -> status_line );
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
};
397 unless ( $self ->{ config
}{ term
} eq $content { meta
}{ termsOfService
}) {
398 confess
( 'GET ' . $dir . '?' . $time . ' term: ' . $content { meta
}{ termsOfService
}. ' differ from config: ' . $self ->{ config
}{ term
});
410 my $req = HTTP
:: Request-
> new ( HEAD
=> $self ->{ req
}{ newNonce
}. '?' . $time );
413 my $res = $ua -> request ( $req );
416 unless ( $res -> is_success ) {
417 confess
( 'HEAD ' . $self ->{ req
}{ newNonce
}. '?' . $time . ' failed: ' . $res -> status_line );
421 $self ->{ req
}{ nonce
} = $res -> headers ->{ 'replay-nonce' };
426 my ( $self , $uri , $payload ) = @_ ;
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 );
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 );
441 my $protected = encode_base64url
( to_json
( \
%protected ));
444 $payload = encode_base64url
( to_json
( $payload )) unless ( $payload eq '' );
447 my $stf = File
:: Temp-
> new ();
449 # Append protect.payload to stf
450 print $stf $protected . '.' . $payload ;
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 );
459 my $req = HTTP
:: Request-
> new ( POST
=> $uri );
462 $req -> header ( 'Content-Type' => 'application/jose+json' );
464 # Set new-reg request content
465 $req -> content ( to_json
({
466 protected
=> $protected ,
468 signature
=> $signature
472 my $res = $ua -> request ( $req );
475 if ( defined $res -> headers ->{ 'replay-nonce' }) {
476 $self ->{ req
}{ nonce
} = $res -> headers ->{ 'replay-nonce' };
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 ) {
484 confess
( 'POST ' . $uri . ' failed: ' . $res -> status_line . ':' . $res -> content ) if ( $self ->{ verbose
});
493 $res = $self -> _post ( $uri , $payload );
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
506 my ( $self , $domain , $token ) = @_ ;
508 # Generate signature from content
509 my $signature = (( sha256_base64
( $token . '.' . $self ->{ account
}{ thumbprint
})) =~ s/=+\z//r ) =~ tr
[+/][- _
] r
;
512 my $txt = Net
:: DNS
:: Resolver-
> new -> search ( DNS_PREFIX
. $domain . DNS_SUFFIX
, 'TXT' , 'IN' );
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
});
520 # Check that txt record data match signature
521 unless ( scalar map { ( $_ -> type eq 'TXT' and $_ -> txtdata eq $signature ) ? 1 : (); } $txt -> answer ) {
523 if ( $self ->{ verbose
}) {
524 # Loop on each answer
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 . '"' ;
539 # Get uri and check content
541 my ( $self , $domain , $token ) = @_ ;
544 my $uri = 'http://' .( is_public_ipv6
( $domain )? '[' . $domain . ']' : $domain ). '/.well-known/acme-challenge/' . $token ;
547 my $req = HTTP
:: Request-
> new ( GET
=> $uri );
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
});
556 my $res = $ua -> request ( $req );
559 unless ( $res -> is_success ) {
560 carp
'Fetch ' . $uri . ' failed: ' . $res -> status_line if ( $self ->{ verbose
});
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
});
575 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3
579 # Init pending directory
580 $self ->{ req
}{ pending
} = $self ->{ config
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ dir
}). '/' . encode_base64url
( join ( ',' , @{ $self ->{ domain
}{ mail
}}));
582 # Create pending directory
584 make_path
( $self ->{ req
}{ pending
}, { error
=> \
my $err });
587 my ( $file , $msg ) = %{ $_ };
588 carp
'Mkdir ' .( $file ? $file . ' ' : '' ). 'failed: ' . $msg if ( $self ->{ verbose
});
590 confess
( 'Make path failed' );
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
);
601 # Load account content or post a new one
603 #XXX: use eval to workaround a fatal in from_json
605 # Check that file exists
608 ( $content = read_file
( $file )) &&
610 ( $content = from_json
( $content ))
614 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
615 tie
( my %payload , 'Tie::IxHash' , termsOfServiceAgreed
=> JSON
:: true
, contact
=> []);
619 # Append mail to payload
620 $payload { contact
}[ scalar @{ $payload { contact
}}] = 'mailto:' . $_ ;
621 } @{ $self ->{ domain
}{ mail
}};
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 );
629 unless ( $res -> is_success ) {
630 confess
( 'POST ' . $self ->{ req
}{ 'newAccount' }. ' failed: ' . $res -> status_line )
633 # Store kid from header location
635 'kid' => $res -> headers ->{ location
},
639 write_file
( $file , to_json
( $content ));
642 # Set kid from content
643 $self ->{ req
}{ kid
} = $content ->{ kid
};
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
);
659 # Load account content or post a new one
661 #XXX: use eval to workaround a fatal in from_json
663 # Check that file exists
666 ( $content = read_file
( $file )) &&
668 ( $content = from_json
( $content ))
670 } || ( str2time
( $content ->{ expires
}) <= time ()+ 3600 )
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
=> []);
681 if ( is_public_ip
( $_ )) {
682 # Set shortlived profile
683 $payload { profile
} = 'shortlived' ;
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
=> $_ );
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
=> $_ );
694 } ( $self ->{ domain
}{ domain
}, @{ $self ->{ domain
}{ domains
}});
696 # Post new order request
697 my $res = $self -> _post ( $self ->{ req
}{ 'newOrder' }, \
%payload );
700 unless ( $res -> is_success ) {
701 confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' failed: ' . $res -> status_line );
705 unless ( $res -> content ) {
706 confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' empty content: ' . $res -> status_line );
710 unless ( $res -> headers ->{ location
}) {
711 confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' missing location: ' . $res -> status_line );
715 $content = from_json
( $res -> content );
718 unless ( $content ->{ status
} eq 'ready' or $content ->{ status
} eq 'pending' ) {
719 confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' invalid status: ' . $content ->{ status
}. ': ' . $res -> status_line );
723 # XXX: used with async response
724 $content ->{ location
} = $res -> headers ->{ location
};
727 $content ->{ retryafter
} = ( defined $res -> headers ->{ 'retry-after' } and $res -> headers ->{ 'retry-after' }) ? $res -> headers ->{ 'retry-after' } : 1 ;
730 write_file
( $file , to_json
( $content ));
733 # Save the authorizations
734 $self ->{ req
}{ authorizations
} = [ keys %{{ map { $_ => undef } @{ $content ->{ authorizations
}} }} ];
736 # Create challenges hash
737 %{ $self ->{ req
}{ challenges
}} = ();
740 $self ->{ req
}{ finalize
} = $content ->{ finalize
};
743 $self ->{ req
}{ location
} = $content ->{ location
};
746 $self ->{ req
}{ retryafter
} = $content ->{ retryafter
};
749 $self ->{ req
}{ status
} = $content ->{ status
};
751 # Extract authorizations
760 #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>
761 my $authFile = $self ->{ req
}{ pending
}. '/' . encode_base64url
( $uri );
763 # Load auth request content or post a new one
764 #TODO: add more check on cache file ???
766 #XXX: use eval to workaround a fatal in from_json
768 # Check that file exists
771 ( $content = read_file
( $authFile )) &&
773 ( $content = from_json
( $content ))
775 } || ( str2time
( $content ->{ expires
}) <= time ()+ 3600 )
777 # Post new-authz request
778 my $res = $self -> _post ( $uri , '' );
781 unless ( $res -> is_success ) {
782 confess
( 'POST ' . $uri . ' failed: ' . $res -> status_line );
786 $content = from_json
( $res -> content );
790 defined $content ->{ identifier
} and
791 defined $content ->{ identifier
}{ type
} and
792 defined $content ->{ identifier
}{ value
}
794 confess
( 'POST ' . $uri . ' missing identifier: ' . $res -> status_line );
798 $content ->{ identifier
}{ type
} eq 'dns' or
799 $content ->{ identifier
}{ type
} eq 'ip'
801 $content ->{ identifier
}{ value
}
803 confess
( 'POST ' . $uri . ' invalid identifier: ' . $res -> status_line );
808 unless ( $content ->{ status
} eq 'valid' or $content ->{ status
} eq 'pending' ) {
809 confess
( 'POST ' . $uri . ' for ' . $content ->{ identifier
}{ value
}. ' failed: ' . $res -> status_line );
813 write_file
( $authFile , to_json
( $content ));
817 %{ $self ->{ req
}{ challenges
}{ $content ->{ identifier
}{ value
}}} = (
818 status
=> $content ->{ status
},
819 expires
=> $content ->{ expires
},
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
},
838 } @{ $content ->{ challenges
}};
841 my $identifier = $content ->{ identifier
}{ value
};
844 if ( $self ->{ req
}{ challenges
}{ $identifier }{ status
} eq 'pending' ) {
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
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
}))
860 #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>
861 my $authFile = $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
});
866 # Load auth request content or post a new one
867 #TODO: add more check on cache file ???
869 #XXX: use eval to workaround a fatal in from_json
871 # Check that file exists
874 ( $content = read_file
( $authFile )) &&
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)
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
}}
887 unless ( $res -> is_success ) {
888 confess
( 'POST ' . $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
}. ' failed: ' . $res -> status_line );
892 $content = from_json
( $res -> content );
895 write_file
( $authFile , to_json
( $content ));
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 ;
909 } keys %{ $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}};
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
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 " ;
923 } keys %{ $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}};
926 } @{ $self ->{ req
}{ authorizations
}};
929 my $remaining = TIMEOUT
;
932 while (-- $remaining >= 0 and scalar map { ( $_ ->{ status
} eq 'pending' and scalar keys %{ $_ ->{ polls
}}) ? 1 : (); } values %{ $self ->{ req
}{ challenges
}}) {
936 # Poll remaining pending
941 # Poll remaining polls
944 #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>
945 my $authFile = $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
});
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
}}
955 unless ( $res -> is_success ) {
956 confess
( 'POST ' . $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
}. ' failed: ' . $res -> status_line );
960 $content = from_json
( $res -> content );
963 write_file
( $authFile , to_json
( $content ));
966 if ( $content ->{ status
} ne 'pending' ) {
967 $self ->{ req
}{ challenges
}{ $identifier }{ status
} = $content ->{ status
};
969 } keys %{ $self ->{ req
}{ challenges
}{ $identifier }{ polls
}};
970 } map { $self ->{ req
}{ challenges
}{ $_ }{ status
} eq 'pending' ? $_ : (); } keys %{ $self ->{ req
}{ challenges
}};
973 # Check if thumbprint is writeable
974 if (- w
$self ->{ config
}{ thumbprint
}) {
975 # Try to write thumbprint
976 write_file
( $self ->{ config
}{ thumbprint
}, '' );
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
984 # # Post deactivation request
985 # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'});
987 # unless ($res->is_success) {
988 # confess('POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line);
990 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};
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 );
1002 if ( $self ->{ req
}{ status
} eq 'pending' ) {
1004 $remaining = TIMEOUT
;
1006 # Iterate until processing order becomes ready
1007 while (-- $remaining >= 0 and $self ->{ req
}{ status
} eq 'pending' ) {
1009 sleep ( $self ->{ req
}{ retryafter
});
1012 my $res = $self -> _post ( $self ->{ req
}{ location
}, '' );
1015 unless ( $res -> is_success ) {
1016 confess
( 'POST ' . $self ->{ req
}{ location
}. ' failed: ' . $res -> status_line );
1020 unless ( $res -> content ) {
1021 confess
( 'POST ' . $self ->{ req
}{ location
}. ' empty content: ' . $res -> status_line );
1025 unless ( $res -> headers ->{ location
}) {
1026 confess
( 'POST ' . $self ->{ req
}{ location
}. ' missing location: ' . $res -> status_line );
1030 $content = from_json
( $res -> content );
1033 unless ( $content ->{ status
} eq 'ready' or $content ->{ status
} eq 'pending' ) {
1034 confess
( 'POST ' . $self ->{ req
}{ location
}. ' invalid status: ' . $content ->{ status
}. ': ' . $res -> status_line );
1038 $self ->{ req
}{ location
} = $res -> headers ->{ location
};
1041 $self ->{ req
}{ retryafter
} = ( defined $res -> headers ->{ 'retry-after' } and $res -> headers ->{ 'retry-after' }) ? $res -> headers ->{ 'retry-after' } : 1 ;
1044 $self ->{ req
}{ status
} = $content ->{ status
};
1047 write_file
( $file , to_json
( $content ));
1050 # Without ready state
1051 unless ( $self ->{ req
}{ status
} eq 'ready' ) {
1052 confess
( 'POST ' . $self ->{ req
}{ location
}. ' invalid status: ' . $self ->{ req
}{ status
});
1057 # Generate certificate request
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
;
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 );
1070 # Save data start position
1071 my $pos = tell DATA
;
1074 my ( $i , $j ) = ( 0 ) x
2 ;
1077 my $mail = join ( " \n " , map { $i++ . '.emailAddress' . " \t\t\t " . '= ' . $_ ; } @{ $self ->{ domain
}{ mail
}});
1079 # Load template from data
1080 map { s/__EMAIL_ADDRESS__/$mail/ ; s/__COMMON_NAME__/$self->{domain}{domain}/ ; print $oct $_ ; } < DATA
>;
1083 seek ( DATA
, $pos , 0 );
1085 # Append domain names and ips
1087 map { print $oct ( is_public_ip
( $_ )? 'IP.' . $j++ : 'DNS.' . $i++ ). ' = ' . $_ . " \n " ; } ( $self ->{ domain
}{ domain
}, @{ $self ->{ domain
}{ domains
}});
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
}));
1103 open ( my $fh , '<' , $self ->{ req
}{ csr
}) or die $! ;
1106 my $csr = encode_base64url
( join ( '' , < $fh >) =~ s/^\0+//r );
1109 close ( $fh ) or die $! ;
1112 #XXX: tmpdir.'/'.<orderuri>.'/'.<finalizeuri>
1113 my $file = $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ finalize
});
1116 my $content = undef ;
1121 # Load auth request content or post a new one
1122 #TODO: add more check on cache file ???
1124 #XXX: use eval to workaround a fatal in from_json
1126 # Check that file exists
1129 ( $content = read_file
( $file )) &&
1131 ( $content = from_json
( $content ))
1132 # Check file modification time ? There is no expires field in json answer
1133 } || ( str2time
( $content ->{ expires
}) <= time ()+ 3600 )
1135 # Post certificate request
1136 $res = $self -> _post ( $self ->{ req
}{ finalize
}, { csr
=> $csr });
1139 unless ( $res -> is_success ) {
1140 confess
( 'POST ' . $self ->{ req
}{ finalize
}. ' failed: ' . $res -> status_line );
1144 $content = from_json
( $res -> content );
1147 unless ( $content ->{ status
} eq 'processing' or $content ->{ status
} eq 'valid' ) {
1148 confess
( 'POST ' . $self ->{ req
}{ location
}. ' invalid status: ' . $content ->{ status
}. ': ' . $res -> status_line );
1152 # XXX: used with async response
1153 $content ->{ location
} = $res -> headers ->{ location
};
1156 $content ->{ retryafter
} = ( defined $res -> headers ->{ 'retry-after' } and $res -> headers ->{ 'retry-after' }) ? $res -> headers ->{ 'retry-after' } : 1 ;
1159 write_file
( $file , to_json
( $content ));
1163 $self ->{ req
}{ location
} = $content ->{ location
};
1165 # Store restry after
1166 $self ->{ req
}{ retryafter
} = $content ->{ retryafter
};
1169 $self ->{ req
}{ status
} = $content ->{ status
};
1172 if ( $self ->{ req
}{ status
} eq 'processing' ) {
1174 my $remaining = TIMEOUT
;
1176 # Iterate until processing order becomes ready
1177 while (-- $remaining >= 0 and $self ->{ req
}{ status
} eq 'processing' ) {
1179 sleep ( $self ->{ req
}{ retryafter
});
1182 my $res = $self -> _post ( $self ->{ req
}{ location
}, '' );
1185 unless ( $res -> is_success ) {
1186 confess
( 'POST ' . $self ->{ req
}{ location
}. ' failed: ' . $res -> status_line );
1190 unless ( $res -> content ) {
1191 confess
( 'POST ' . $self ->{ req
}{ location
}. ' empty content: ' . $res -> status_line );
1195 unless ( $res -> headers ->{ location
}) {
1196 confess
( 'POST ' . $self ->{ req
}{ location
}. ' missing location: ' . $res -> status_line );
1200 $content = from_json
( $res -> content );
1203 unless ( $content ->{ status
} eq 'valid' or $content ->{ status
} eq 'processing' ) {
1204 confess
( 'POST ' . $self ->{ req
}{ location
}. ' invalid status: ' . $content ->{ status
}. ': ' . $res -> status_line );
1208 # XXX: used with async response
1209 $self ->{ req
}{ location
} = $res -> headers ->{ location
};
1212 $self ->{ req
}{ retryafter
} = ( defined $res -> headers ->{ 'retry-after' } and $res -> headers ->{ 'retry-after' }) ? $res -> headers ->{ 'retry-after' } : 1 ;
1215 $self ->{ req
}{ status
} = $content ->{ status
};
1218 write_file
( $file , to_json
( $content ));
1221 # Without valid state
1222 unless ( $self ->{ req
}{ status
} eq 'valid' ) {
1223 confess
( 'POST ' . $self ->{ req
}{ location
}. ' invalid status: ' . $self ->{ req
}{ status
});
1228 $self ->{ req
}{ certificate
} = $content ->{ certificate
};
1231 #XXX: tmpdir.'/'.<orderuri>.'/'.<certificateuri>
1232 $file = $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ certificate
});
1237 # Load certificate request content or post a new one
1238 #TODO: add more check on cache file ???
1240 #XXX: use eval to workaround a fatal in from_json
1242 # Check that file exists
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)
1250 # Post certificate request
1251 $res = $self -> _post ( $self ->{ req
}{ certificate
}, '' );
1254 unless ( $res -> is_success ) {
1255 confess
( 'POST ' . $self ->{ req
}{ certificate
}. ' failed: ' . $res -> status_line );
1259 $content = $res -> content ;
1262 write_file
( $file , $content );
1265 # Write to raw cert file
1266 write_file
( $self ->{ domain
}{ cert
}. '.raw' , $content );
1268 # Remove multi-line jump
1269 $content =~ s/\n\n/\n/s ;
1271 # Remove ISRG Root X1 certificate signed by DST Root CA X3 present after second multi-line jump
1272 #$content =~ s/\n\n.*//s;
1274 # Remove trailing line jump
1277 # Write to cert file
1278 write_file
( $self ->{ domain
}{ cert
}, $content );
1281 carp
'Saved ' . $self ->{ domain
}{ cert
}. ' pem certificate' if ( $self ->{ verbose
});
1288 # OpenSSL configuration file.
1289 # This is mostly being used for generation of certificate requests.
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
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
1307 [ req_distinguished_name
]
1309 stateOrProvinceName
= State
or Province Name
1310 localityName
= Locality Name
1311 organizationName
= Organization Name
1312 organizationalUnitName
= Organizational Unit Name
1313 commonName
= __COMMON_NAME__
1317 basicConstraints
= CA
: false
1318 keyUsage
= nonRepudiation
, digitalSignature
, keyEncipherment
1319 subjectAltName
= email
: move
1320 subjectAltName
= @alt_names
1323 subjectKeyIdentifier
= hash
1324 authorityKeyIdentifier
= keyid
: always
, issuer
1325 basicConstraints
= CA
: true