]>
Raphaël G. Git Repositories - acme/blob - Acme.pm 
e8679c14834094c43840b045818891b5c5bd3f3d
   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  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
:: Spec 
qw(splitpath) ;   43  use  File
:: stat  qw(stat) ;   44  use  File
:: Temp
;  # qw( :seekable );   45  use  IPC
:: System
:: Simple 
qw(capturex) ;   46  use  JSON 
qw(from_json to_json) ;   48  use  MIME
:: Base64 
qw(encode_base64url encode_base64) ;   50  use  Net
:: Domain
:: TLD 
qw(tld_exists) ;   51  use  POSIX 
qw(EXIT_FAILURE) ;   58  #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/)   59  #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt   60  #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js   65          ACCOUNT 
=>  '/etc/acme/account.pem' ,   66          CONFIG 
=>  '/etc/acme/config' ,   67          PENDING 
=>  '/tmp/acme' ,   68          THUMBPRINT 
=>  '/etc/acme/thumbprint' ,   69          TERM 
=>  'https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf' ,   76          RH_CERTS 
=>  '/etc/pki/tls/certs' ,   77          RH_PRIVATE 
=>  '/etc/pki/tls/private' ,   81          DEB_CERTS 
=>  '/etc/ssl/certs' ,   82          DEB_PRIVATE 
=>  '/etc/ssl/private' ,   83          DEB_CERTS_SUFFIX 
=>  '.crt' ,   84          DEB_PRIVATE_SUFFIX 
=>  '.key' ,   87          DNS_PREFIX 
=>  '_acme-challenge.' ,   95          #ACME_CERT => 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem',   96          ACME_DIR 
=>  'https://acme-staging-v02.api.letsencrypt.org/directory' ,   97          ACME_PROD_DIR 
=>  'https://acme-v02.api.letsencrypt.org/directory' ,  113  #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys  122  #                       kty => uc(KEY_TYPE),  127  #       thumbprint => undef  129  tie
( our  %jwk ,  'Tie::IxHash' ,  pubkey 
=>  undef ,  jwk 
=>  undef ,  thumbprint 
=>  undef );  130  tie
(%{ $jwk { jwk
}},  'Tie::IxHash' ,  alg 
=>  'RS256' ,  jwk 
=>  undef );  131  #XXX: strict ordering only really needed here for thumbprint sha256 digest  132  tie
(%{ $jwk { jwk
}{ jwk
}},  'Tie::IxHash' ,  e 
=>  undef ,  kty 
=>  uc ( KEY_TYPE
),  n 
=>  undef );  137          my  ( $class ,  $verbose ,  $domain ,  $config ) =  @_ ;  142          # Link self to package  143          bless ( $self ,  $class );  146          $self ->{ verbose
} =  $verbose ;  149          $self ->{ domain
} =  $domain ;  152          $self ->{ config
} =  $config ;  155          my  @domains  = ( $domain ->{ domain
}, @{ $domain ->{ domains
}});  157          # Show error if check fail  158          unless  ( defined  $self ->{ domain
}{ mail
}) {  159                  confess
( 'Missing mail' );  162          # Transform mail in an array  163          unless  ( ref ( $self ->{ domain
}{ mail
})  eq  'ARRAY' ) {  164                  $self ->{ domain
}{ mail
} = [  $self ->{ domain
}{ mail
} ];  167          # Add extra check to mail validity  168          #XXX: mxcheck fail if there is only a A record on the domain  169          my  $ev  =  Email
:: Valid-
> new (- fqdn 
=>  1 , - tldcheck 
=>  1 , - mxcheck 
=>  1 );  174                  if  (!  defined  $ev -> address ( $_ )) {  175                          map  {  carp 
'failed check: ' . $_  if  ( $self ->{ verbose
}) }  $ev -> details ();  176                          confess
( 'Validate ' . $_ . ' mail address failed' );  178          } @{ $self ->{ domain
}{ mail
}};  185                  unless  (( $tld ) =  $_  =~  m/\.(\w+)$/ ) {  186                          confess
( 'Extract ' . $_ . ' tld failed' );  189                  # Check if tld exists  190                  unless ( Net
:: Domain
:: TLD
:: tld_exists
( $tld )) {  191                          confess
( 'Extracted ' . $_ . ' tld ' . $tld . ' do not exists' );  195                  my  $a  =  Net
:: DNS
:: Resolver-
> new -> search ( $_ ,  'A' ,  'IN' );  198                  my  $aaaa  =  Net
:: DNS
:: Resolver-
> new -> search ( $_ ,  'AAAA' ,  'IN' );  200                  # Trigger error for unresolvable domain  202                          # Check if either has a A or AAAA record  204                                  ( $_ -> type  eq  'A'  or  $_ -> type  eq  'AAAA' ) ?  1  : ();  208                                  ( defined  $a  and defined  $a -> answer ) ?  $a -> answer  : (),  209                                  ( defined  $aaaa  and defined  $aaaa -> answer ) ?  $aaaa -> answer  : ()  212                          confess
( 'Resolve ' . $_ . ' to an A or AAAA record failed' );  216          # Return class reference  220  # Prepare environement  224          # Extract cert directory and filename  225          my  ( $certFile ,  $certDir ) =  File
:: Spec-
> splitpath ( $self ->{ domain
}{ cert
});  227          # Extract key directory and filename  228          my  ( $keyFile ,  $keyDir ) =  File
:: Spec-
> splitpath ( $self ->{ domain
}{ key
});  230          # Extract account directory and filename  231          my  ( $accountFile ,  $accountDir ) =  File
:: Spec-
> splitpath ( $self ->{ domain
}{ account
});  235                  make_path
( $certDir ,  $keyDir ,  $accountDir ,  $self ->{ config
}{ pending
}, { error 
=>  \
my  $err });  238                                  my  ( $file ,  $msg ) = %{ $_ };  239                                  carp 
'Mkdir ' .( $file  ?  $file . ' '  :  '' ). 'failed: ' . $msg  if  ( $self ->{ verbose
});  241                          confess
( 'Make path failed' );  246          $ua  =  LWP
:: UserAgent-
> new ;  247          $ua -> agent ( __PACKAGE__
. '/' . VERSION
);  249          # Check that certificate is writable  250          unless  (- w 
$certDir  || - w 
$self ->{ domain
}{ cert
}) {  251                  confess
( 'Directory ' . $certDir . ' or file ' . $self ->{ domain
}{ cert
}. ' must be writable: ' . $! );  254          # Check that key is readable or parent directory is writable  255          unless  (- r 
$self ->{ domain
}{ key
} || - w 
$keyDir ) {  256                  confess
( 'File ' . $self ->{ domain
}{ key
}. ' must be readable or directory ' . $keyDir . ' must be writable: ' . $! );  259          # Check that account key is readable or parent directory is writable  260          unless  (- r 
$self ->{ domain
}{ account
} || - w 
$accountDir ) {  261                  confess
( 'File ' . $self ->{ domain
}{ account
}. ' must be readable or directory ' . $accountDir . ' must be writable: ' . $! );  264          # Backup old certificate if possible  265          if  (- w 
$certDir  && - f 
$self ->{ domain
}{ cert
}) {  266                  my  ( $dt ,  $suffix ) =  undef ;  268                  # Extract datetime suffix  269                  $suffix  = ( $dt  =  DateTime-
> from_epoch ( epoch 
=>  stat ( $self ->{ domain
}{ cert
})-> mtime ))-> ymd ( '' ). $dt -> hms ( '' );  271                  # Rename old certificate  272                  unless ( copy
( $self ->{ domain
}{ cert
},  $self ->{ domain
}{ cert
}. '.' . $suffix )) {  273                          carp
( 'Copy ' . $self ->{ domain
}{ cert
}. ' to ' . $self ->{ domain
}{ cert
}. '.' . $suffix . ' failed: ' . $! );  281          open ( $_stderr ,  '>&STDERR' )  or die  $! ;  283          close ( STDERR
)  or die  $! ;  285          open ( STDERR
,  '>' ,  '/dev/null' )  or die  $! ;  293          open ( STDERR
,  '>&' ,  $_stderr )  or die  $! ;  296  # Generate required keys  300          # Generate account and server key if required  302                  # Check key existence  307                          #XXX: we drop stderr here because openssl can't be quiet on this command  308                          capturex
( 'openssl' , ( 'genrsa' ,  '-out' ,  $_ ,  KEY_SIZE
));  312          } ( $self ->{ domain
}{ account
},  $self ->{ domain
}{ key
});  314          # Extract modulus and publicExponent jwk  315          #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys  316          tie
(%{ $self ->{ account
}},  'Tie::IxHash' ,  %jwk );  318                  if  ( /^Modulus=([0-9A-F]+)$/ ) {  319                          # Extract to binary from hex and convert to base64 url  320                          $self ->{ account
}{ jwk
}{ jwk
}{ n
} =  encode_base64url
( pack ( "H*" ,  $1 ) =~  s/^\0+//r );  321                  }  elsif  ( /^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/ ) {  322                          # Extract to binary from int, trim leading zeros and convert to base64 url  323                          chomp  ( $self ->{ account
}{ jwk
}{ jwk
}{ e
} =  encode_base64url
( pack ( "N" ,  $1 ) =~  s/^\0+//r ));  325          }  capturex
( 'openssl' , ( 'rsa' ,  '-text' ,  '-in' ,  $self ->{ domain
}{ account
},  '-noout' ,  '-modulus' ));  329          # Extract account public key  330          $self ->{ account
}{ pubkey
} =  join ( '' ,  map  {  chomp ;  $_ ; }  capturex
( 'openssl' , ( 'rsa' ,  '-in' ,  $self ->{ domain
}{ account
},  '-pubout' )));  335          #XXX: convert base64 to base64 url  336          $self ->{ account
}{ thumbprint
} = ( sha256_base64
( to_json
( $self ->{ account
}{ jwk
}{ jwk
})) =~  s/=+\z//r ) =~  tr
[+/][- _
] r
;  347          my  $dir  =  $self ->{ domain
}{ prod
} ?  ACME_PROD_DIR 
:  ACME_DIR
;  350          my  $req  =  HTTP
:: Request-
> new ( GET 
=>  $dir . '?' . $time );  353          my  $res  =  $ua -> request ( $req );  356          unless  ( $res -> is_success ) {  357                  confess
( 'GET ' . $dir . '?' . $time . ' failed: ' . $res -> status_line );  364          unless  ( %content  = %{ from_json
( $res -> content )}) {  365                  confess
( 'GET ' . $dir . '?' . $time . ' from_json failed: ' . $res -> status_line );  368          # Merge uris in self content  369          $self ->{ req
}{ dir
} =  $dir ;  370          $self ->{ req
}{ keyChange
} =  $content { keyChange
};  371          $self ->{ req
}{ newNonce
} =  $content { newNonce
};  372          $self ->{ req
}{ newAccount
} =  $content { newAccount
};  373          $self ->{ req
}{ revokeCert
} =  $content { revokeCert
};  374          $self ->{ req
}{ newOrder
} =  $content { newOrder
};  377          unless  ( $self ->{ config
}{ term
}  eq  $content { meta
}{ termsOfService
}) {  378                  confess
( 'GET ' . $dir . '?' . $time . ' term: ' . $content { meta
}{ termsOfService
}. ' differ from config: ' . $self ->{ config
}{ term
});  390          my  $req  =  HTTP
:: Request-
> new ( HEAD 
=>  $self ->{ req
}{ newNonce
}. '?' . $time );  393          my  $res  =  $ua -> request ( $req );  396          unless  ( $res -> is_success ) {  397                  confess
( 'HEAD ' . $self ->{ req
}{ newNonce
}. '?' . $time . ' failed: ' . $res -> status_line );  401          $self ->{ req
}{ nonce
} =  $res -> headers ->{ 'replay-nonce' };  406          my  ( $self ,  $uri ,  $payload ) =  @_ ;  409          #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys  410          #XXX: strict ordering only really needed here for thumbprint sha256 digest  411          tie
( my  %protected ,  'Tie::IxHash' ,  alg 
=>  $self ->{ account
}{ jwk
}{ alg
},  jwk 
=>  $self ->{ account
}{ jwk
}{ jwk
},  nonce 
=>  $self ->{ req
}{ nonce
},  url 
=>  $uri );  414          if  ( defined ( $self ->{ req
}{ kid
})) {  415                  # Replace jwk entry with it  416                  #XXX: when kid is available all request with jwk are rejected by the api  417                  %protected  = ( alg 
=>  $self ->{ account
}{ jwk
}{ alg
},  kid 
=>  $self ->{ req
}{ kid
},  nonce 
=>  $self ->{ req
}{ nonce
},  url 
=>  $uri );  421          my  $protected  =  encode_base64url
( to_json
( \
%protected ));  424          $payload  =  encode_base64url
( to_json
( $payload ))  unless  ( $payload  eq  '' );  427          my  $stf  =  File
:: Temp-
> new ();  429          # Append protect.payload to stf  430          print  $stf $protected . '.' . $payload ;  435          # Generate digest of stf  436          my  $signature  =  encode_base64url
( join ( '' ,  capturex
( 'openssl' , ( 'dgst' ,  '-sha256' ,  '-binary' ,  '-sign' ,  $self ->{ domain
}{ account
},  $stf -> filename ))) =~  s/^\0+//r );  439          my  $req  =  HTTP
:: Request-
> new ( POST 
=>  $uri );  442          $req -> header ( 'Content-Type'  =>  'application/jose+json' );  444          # Set new-reg request content  445          $req -> content ( to_json
({  446                  protected 
=>  $protected ,  448                  signature 
=>  $signature  452          my  $res  =  $ua -> request ( $req );  455          if  ( defined  $res -> headers ->{ 'replay-nonce' }) {  456                  $self ->{ req
}{ nonce
} =  $res -> headers ->{ 'replay-nonce' };  463  # Resolve dns and check content  464  #XXX: see https://community.centminmod.com/threads/looks-like-letsencrypt-dns-01-is-ready.5845/#12 for example  466          my  ( $self ,  $domain ,  $token ) =  @_ ;  468          # Generate signature from content  469          my  $signature  = (( sha256_base64
( $token . '.' . $self ->{ account
}{ thumbprint
})) =~  s/=+\z//r ) =~  tr
[+/][- _
] r
;  472          my  $txt  =  Net
:: DNS
:: Resolver-
> new -> search ( DNS_PREFIX
. $domain . DNS_SUFFIX
,  'TXT' ,  'IN' );  474          # Check that we have a txt record  475          unless  ( defined  $txt  and defined  $txt -> answer  and scalar map  {  $_ -> type  eq  'TXT'  ?  1  : (); }  $txt -> answer ) {  476                  carp 
'Resolve ' . DNS_PREFIX
. $domain . DNS_SUFFIX
. ' to a TXT record failed'  if  ( $self ->{ verbose
});  480          # Check that txt record data match signature  481          unless  ( scalar map  { ( $_ -> type  eq  'TXT'  and  $_ -> txtdata  eq  $signature ) ?  1  : (); }  $txt -> answer ) {  483                  if  ( $self ->{ verbose
}) {  484                          # Loop on each answer  486                                  # Check if we have a TXT record with different value  487                                  if  ( $_ -> type  eq  'TXT'  and  $_ -> txtdata  ne  $signature ) {  488                                          carp 
'Resolved ' . DNS_PREFIX
. $domain . DNS_SUFFIX
. ' with "' . $_ -> txtdata . '" instead of "' . $signature . '"' ;  499  # Get uri and check content  501          my  ( $self ,  $domain ,  $token ) =  @_ ;  504          my  $req  =  HTTP
:: Request-
> new ( GET 
=>  'http://' . $domain . '/.well-known/acme-challenge/' . $token );  506          # Check if thumbprint is writeable  507          if  (- w 
$self ->{ config
}{ thumbprint
}) {  508                  # Try to write thumbprint  509                  write_file
( $self ->{ config
}{ thumbprint
},  $self ->{ account
}{ thumbprint
});  513          my  $res  =  $ua -> request ( $req );  516          unless  ( $res -> is_success ) {  517                  carp 
'Fetch http://' . $domain . '/.well-known/acme-challenge/' . $token . ' failed: ' . $res -> status_line  if  ( $self ->{ verbose
});  521          # Handle invalid content  522          unless ( $res -> content  =~  /^$token.$self->{account}{thumbprint}\s*$/ ) {  523                  carp 
'Fetched http://' . $domain . '/.well-known/acme-challenge/' . $token . ' with "' . $res -> content . '" instead of "' . $token . '.' . $self ->{ account
}{ thumbprint
}. '"'  if  ( $self ->{ verbose
});  532  #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3  536          # Init pending directory  537          $self ->{ req
}{ pending
} =  $self ->{ config
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ dir
}). '/' . encode_base64url
( join ( ',' , @{ $self ->{ domain
}{ mail
}}));  539          # Create pending directory  541                  make_path
( $self ->{ req
}{ pending
}, { error 
=>  \
my  $err });  544                                  my  ( $file ,  $msg ) = %{ $_ };  545                                  carp 
'Mkdir ' .( $file  ?  $file . ' '  :  '' ). 'failed: ' . $msg  if  ( $self ->{ verbose
});  547                          confess
( 'Make path failed' );  552          #XXX: we use this file to store the fetched account  553          my  $file  =  $self ->{ req
}{ pending
}. '/' .((( sha256_base64
( join ( ',' , @{ $self ->{ domain
}{ mail
}}))) =~  s
/=+ \z
// r
) =~  tr
[+/][- _
] r
);  558          # Load account content or post a new one  560                  #XXX: use eval to workaround a fatal in from_json  562                          # Check that file exists  565                          ( $content  =  read_file
( $file )) &&  567                          ( $content  =  from_json
( $content ))  571                  #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys  572                  tie
( my  %payload ,  'Tie::IxHash' ,  termsOfServiceAgreed 
=>  JSON
:: true
,  contact 
=> []);  576                          # Append mail to payload  577                          $payload { contact
}[ scalar  @{ $payload { contact
}}] =  'mailto:' . $_ ;  578                  } @{ $self ->{ domain
}{ mail
}};  580                  # Post newAccount request  581                  # TODO: change contact field in config to contain directly the array [mailto:example@example.com,...] ???  582                  #XXX: contact array may contain a tel:+33612345678 for example (supported ???)  583                  my  $res  =  $self -> _post ( $self ->{ req
}{ 'newAccount' },  \
%payload );  586                  unless  ( $res -> is_success ) {  587                          confess
( 'POST ' . $self ->{ req
}{ 'newAccount' }. ' failed: ' . $res -> status_line )  590                  # Store kid from header location  592                          'kid'  =>  $res -> headers ->{ location
},  596                  write_file
( $file ,  to_json
( $content ));  599          # Set kid from content  600          $self ->{ req
}{ kid
} =  $content ->{ kid
};  609          #XXX: we use this file to store the requested domains on our side  610          #XXX: see bug https://github.com/letsencrypt/boulder/issues/3335 and https://community.letsencrypt.org/t/acmev2-orders-list/51662  611          my  $file  =  $self ->{ req
}{ pending
}. '/' .((( sha256_base64
( join ( ',' , ( $self ->{ domain
}{ domain
}, @{ $self ->{ domain
}{ domains
}})))) =~  s
/=+ \z
// r
) =~  tr
[+/][- _
] r
);  616          # Load account content or post a new one  618                  #XXX: use eval to workaround a fatal in from_json  620                          # Check that file exists  623                          ( $content  =  read_file
( $file )) &&  625                          ( $content  =  from_json
( $content ))  627                  } || ( str2time
( $content ->{ expires
}) <=  time ()+ 3600 )  630                  #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys  631                  #XXX: https://www.perlmonks.org/?node_id=1215976  632                  #XXX: optional notBefore, notAfter, see https://ietf-wg-acme.github.io/acme/draft-ietf-acme-acme.html#applying-for-certificate-issuance  633                  tie
( my  %payload ,  'Tie::IxHash' ,  identifiers 
=> []);  637                          # Tie in a stable hash and append to identifiers array  638                          #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys  639                          tie
(%{ $payload { identifiers
}[ scalar  @{ $payload { identifiers
}}]},  'Tie::IxHash' ,  type 
=>  'dns' ,  value 
=>  $_ );  640                  } ( $self ->{ domain
}{ domain
}, @{ $self ->{ domain
}{ domains
}});  642                  # Post new order request  643                  my  $res  =  $self -> _post ( $self ->{ req
}{ 'newOrder' },  \
%payload );  646                  unless  ( $res -> is_success ) {  647                          confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' failed: ' . $res -> status_line );  651                  unless  ( $res -> content ) {  652                          confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' empty content: ' . $res -> status_line );  656                  unless  ( $res -> headers ->{ location
}) {  657                          confess
( 'POST ' . $self ->{ req
}{ 'newOrder' }. ' missing location: ' . $res -> status_line );  661                  $content  =  from_json
( $res -> content );  664                  write_file
( $file ,  to_json
( $content ));  667          # Save the authorizations  668          $self ->{ req
}{ authorizations
} = [  keys  %{{  map  {  $_  =>  undef  } @{ $content ->{ authorizations
}} }} ];  670          # Save the finalize uri  671          $self ->{ req
}{ finalize
} =  $content ->{ finalize
};  673          # Create challenges hash  674          %{ $self ->{ req
}{ challenges
}} = ();  676          # Extract authorizations  685                  #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>  686                  my  $file  =  $self ->{ req
}{ pending
}. '/' . encode_base64url
( $uri );  688                  # Load auth request content or post a new one  689                  #TODO: add more check on cache file ???  691                          #XXX: use eval to workaround a fatal in from_json  693                                  # Check that file exists  696                                  ( $content  =  read_file
( $file )) &&  698                                  ( $content  =  from_json
( $content ))  700                          } || ( str2time
( $content ->{ expires
}) <=  time ()+ 3600 )  702                          # Post new-authz request  703                          my  $res  =  $self -> _post ( $uri ,  '' );  706                          unless  ( $res -> is_success ) {  707                                  confess
( 'POST ' . $uri . ' failed: ' . $res -> status_line );  711                          $content  =  from_json
( $res -> content );  715                                  defined  $content ->{ identifier
}  and  716                                  defined  $content ->{ identifier
}{ type
}  and  717                                  defined  $content ->{ identifier
}{ value
}  719                                  confess
( 'POST ' . $uri . ' missing identifier: ' . $res -> status_line );  722                                          $content ->{ identifier
}{ type
}  eq  'dns'  and  723                                          $content ->{ identifier
}{ value
}  725                                          confess
( 'POST ' . $uri . ' invalid identifier: ' . $res -> status_line );  730                          unless  ( $content ->{ status
}  eq  'valid'  or  $content ->{ status
}  eq  'pending' ) {  731                                  confess
( 'POST ' . $uri . ' for ' . $content ->{ identifier
}{ value
}. ' failed: ' . $res -> status_line );  735                          write_file
( $file ,  to_json
( $content ));  739                  %{ $self ->{ req
}{ challenges
}{ $content ->{ identifier
}{ value
}}} = (  740                          status 
=>  $content ->{ status
},  741                          expires 
=>  $content ->{ expires
},  749                          if  ( $_ ->{ status
}  eq  'valid' ) {  750                                  $self ->{ req
}{ challenges
}{ $content ->{ identifier
}{ value
}}{ status
} =  $_ ->{ status
};  751                          # Check is still polling  752                          }  elsif  ( $content ->{ status
}  eq  'pending' ) {  753                                  # Add to challenges list for later use  754                                  $self ->{ req
}{ challenges
}{ $content ->{ identifier
}{ value
}}{ challenges
}{ $_ ->{ type
}} = {  755                                          status 
=>  $_ ->{ status
},  756                                          token 
=>  $_ ->{ token
},  760                  } @{ $content ->{ challenges
}};  763                  my  $identifier  =  $content ->{ identifier
}{ value
};  766                  if  ( $self ->{ req
}{ challenges
}{ $identifier }{ status
}  eq  'pending' ) {  769                                  # One test already validated this auth request  770                                  unless ( $self ->{ req
}{ challenges
}{ $identifier }{ status
}  eq  'valid' ) {  771                                          # One challenge validated  772                                          if  ( $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ status
}  eq  'valid' ) {  773                                                  $self ->{ req
}{ challenges
}{ $identifier }{ status
} =  $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ status
};  774                                          # This challenge is to be validated  775                                          }  elsif  ( $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ status
}  eq  'pending' ) {  776                                                  #TODO: implement tls-alpn-01 challenge someday if possible  778                                                          ( $_  eq  'http-01'  and  $self -> _httpCheck ( $identifier ,  $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ token
}))  or  779                                                          ( $_  eq  'dns-01'  and  $self -> _dnsCheck ( $identifier ,  $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ token
}))  782                                                          #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>  783                                                          my  $file  =  $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
});  788                                                          # Load auth request content or post a new one  789                                                          #TODO: add more check on cache file ???  791                                                                  #XXX: use eval to workaround a fatal in from_json  793                                                                          # Check that file exists  796                                                                          ( $content  =  read_file
( $file )) &&  798                                                                          ( $content  =  from_json
( $content ))  799                                                                  #TODO: Check file modification time ? There is no expires field in json answer  800                                                                  } # || (str2time($content->{expires}) <= time()+3600)  802                                                                  # Post challenge request  803                                                                  my  $res  =  $self -> _post (  804                                                                          $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
},  805                                                                          { keyAuthorization 
=>  $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ token
}. '.' . $self ->{ account
}{ thumbprint
}}  809                                                                  unless  ( $res -> is_success ) {  810                                                                          confess
( 'POST ' . $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
}. ' failed: ' . $res -> status_line );  814                                                                  $content  =  from_json
( $res -> content );  817                                                                  write_file
( $file ,  to_json
( $content ));  821                                                          if  ( $content ->{ status
}  eq  'valid' ) {  822                                                                  $self ->{ req
}{ challenges
}{ $identifier }{ status
} =  $content ->{ status
};  823                                                          # Check is still polling  824                                                          }  elsif  ( $content ->{ status
}  eq  'pending' ) {  825                                                                  # Add to poll list for later use  826                                                                  $self ->{ req
}{ challenges
}{ $identifier }{ polls
}{ $content ->{ type
}} =  1 ;  831                          }  keys  %{ $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}};  833                          # Check if check is challenge still in pending and no polls  834                          if  ( $self ->{ req
}{ challenges
}{ $identifier }{ status
}  eq  'pending'  &&  scalar keys  %{ $self ->{ req
}{ challenges
}{ $identifier }{ polls
}} ==  0 ) {  835                                  # Loop on all remaining challenges  837                                          #TODO: implement tls-alpn-01 challenge someday if possible  838                                          # Display help for http-01 check  839                                          if  ( $_  eq  'http-01' ) {  840                                                  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 " ;  841                                          # Display help for dns-01 check  842                                          }  elsif  ( $_  eq  'dns-01' ) {  843                                                  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 " ;  845                                  }  keys  %{ $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}};  848          } @{ $self ->{ req
}{ authorizations
}};  851          my  $remaining  =  TIMEOUT
;  854          while  (-- $remaining  >=  0  and scalar map  { ( $_ ->{ status
}  eq  'pending'  and scalar keys  %{ $_ ->{ polls
}}) ?  1  : (); }  values  %{ $self ->{ req
}{ challenges
}}) {  858                  # Poll remaining pending  863                          # Poll remaining polls  865                                  # Post challenge request  866                                  #XXX: no cache here we force update  867                                  my  $res  =  $self -> _post (  868                                          $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
},  869                                          { keyAuthorization 
=>  $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ token
}. '.' . $self ->{ account
}{ thumbprint
}}  873                                  unless  ( $res -> is_success ) {  874                                          confess
( 'POST ' . $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
}. ' failed: ' . $res -> status_line );  878                                  $content  =  from_json
( $res -> content );  881                                  #XXX: tmpdir.'/'.<orderuri>.'/'.<authuri>  882                                  my  $file  =  $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ challenges
}{ $identifier }{ challenges
}{ $_ }{ url
});  885                                  write_file
( $file ,  to_json
( $content ));  888                                  if  ( $content ->{ status
}  ne  'pending' ) {  889                                          $self ->{ req
}{ challenges
}{ $identifier }{ status
} =  $content ->{ status
};  891                          }  keys  %{ $self ->{ req
}{ challenges
}{ $identifier }{ polls
}};  892                  }  map  {  $self ->{ req
}{ challenges
}{ $_ }{ status
}  eq  'pending'  ?  $_  : (); }  keys  %{ $self ->{ req
}{ challenges
}};  896          # Check if thumbprint is writeable  897          if  (- w 
$self ->{ config
}{ thumbprint
}) {  898                  # Try to write thumbprint  899                  write_file
( $self ->{ config
}{ thumbprint
},  '' );  902          # Stop here with remaining challenge  903          if  ( scalar map  {  $_ ->{ status
}  ne  'valid'  ?  1  : (); }  values  %{ $self ->{ req
}{ challenges
}}) {  904                  #TODO: Deactivate all activated domains ?  905                  #XXX: see if implemented by letsencrypt ACMEv2  907                  #       # Post deactivation request  908                  #       my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'});  910                  #       unless ($res->is_success) {  911                  #               confess('POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line);  913                  #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};  915                  # Stop here as a domain of csr list failed authorization  916                  if  ( $self ->{ verbose
}) {  917                          my  @domains  =  map  {  $self ->{ req
}{ challenges
}{ $_ }{ status
}  ne  'valid'  ?  $_  : (); }  keys  %{ $self ->{ req
}{ challenges
}};  918                          #my @domains = map { ! defined $self->{challenges}{$_}{status} or $self->{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{challenges}};  919                          carp 
'Fix challenge' .( scalar  @domains  >  1 ? 's' : '' ). ' for: ' . join ( ', ' ,  @domains );  925  # Generate certificate request  930          #XXX: tmpdir.'/'.<orderuri>.'/'.<thumbprint>.':'.<mail>.':'.join(',', @domains).'.<prodstaging>.'.CSR_SUFFIX  931          $self ->{ req
}{ csr
} =  $self ->{ req
}{ pending
}. '/' .((( sha256_base64
( join ( ',' , ( $self ->{ domain
}{ domain
}, @{ $self ->{ domain
}{ domains
}})))) =~  s
/=+ \z
// r
) =~  tr
[+/][- _
] r
). CSR_SUFFIX
;  933          # Reuse certificate request file without domain/mail change  934          if  (! - f 
$self ->{ req
}{ csr
}) {  935                  # Openssl config template  936                  my  $oct  =  File
:: Temp-
> new ( UNLINK 
=>  0 );  938                  # Save data start position  945                  my  $mail  =  join ( " \n " ,  map  {  $i++ . '.emailAddress' . " \t\t\t " . '= ' . $_ ; } @{ $self ->{ domain
}{ mail
}});  947                  # Load template from data  948                  map  {  s/__EMAIL_ADDRESS__/$mail/ ;  s/__COMMON_NAME__/$self->{domain}{domain}/ ;  print  $oct $_ ; } < DATA
>;  953                  # Append domain names  955                  map  {  print  $oct  'DNS.' . $i++ . ' = ' . $_ . " \n " ; } ( $self ->{ domain
}{ domain
}, @{ $self ->{ domain
}{ domains
}});  958                  #XXX: read certificate request with: openssl req -inform DER -in $self->{req}{csr} -text  959                  capturex
( 'openssl' , ( 'req' ,  '-new' ,  '-outform' ,  'DER' ,  '-key' ,  $self ->{ domain
}{ key
},  '-config' ,  $oct -> filename ,  '-out' ,  $self ->{ req
}{ csr
}));  971          open ( my  $fh ,  '<' ,  $self ->{ req
}{ csr
})  or die  $! ;  974          my  $csr  =  encode_base64url
( join ( '' , < $fh >) =~  s/^\0+//r );  977          close ( $fh )  or die  $! ;  980          #XXX: tmpdir.'/'.<orderuri>.'/'.<finalizeuri>  981          my  $file  =  $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ finalize
});  989          # Load auth request content or post a new one  990          #TODO: add more check on cache file ???  992                  #XXX: use eval to workaround a fatal in from_json  994                          # Check that file exists  997                          ( $content  =  read_file
( $file )) &&  999                          ( $content  =  from_json
( $content )) 1000                  # Check file modification time ? There is no expires field in json answer 1001                  } || ( str2time
( $content ->{ expires
}) <=  time ()+ 3600 ) 1003                  # Post certificate request 1004                  $res  =  $self -> _post ( $self ->{ req
}{ finalize
}, { csr 
=>  $csr }); 1007                  unless  ( $res -> is_success ) { 1008                          confess
( 'POST ' . $self ->{ req
}{ finalize
}. ' failed: ' . $res -> status_line ); 1012                  $content  =  from_json
( $res -> content ); 1015                  unless  ( defined  $content ->{ status
}  and  $content ->{ status
}  eq  'valid' ) { 1016                          confess
( 'POST ' . $self ->{ req
}{ finalize
}. ' failed: invalid status: ' .( defined  $content ->{ status
}? $content ->{ status
}: 'undefined' )); 1020                  unless  ( defined  $content ->{ certificate
}  and  $content ->{ certificate
}) { 1021                          confess
( 'POST ' . $self ->{ req
}{ finalize
}. ' failed: invalid certificate: ' .( defined  $content ->{ certificate
}? $content ->{ certificate
}: 'undefined' )); 1025                  write_file
( $file ,  to_json
( $content )); 1029          $self ->{ req
}{ certificate
} =  $content ->{ certificate
}; 1032          #XXX: tmpdir.'/'.<orderuri>.'/'.<certificateuri> 1033          $file  =  $self ->{ req
}{ pending
}. '/' . encode_base64url
( $self ->{ req
}{ certificate
}); 1038          # Load auth request content or post a new one 1039          #TODO: add more check on cache file ??? 1041                  #XXX: use eval to workaround a fatal in from_json 1043                          # Check that file exists 1046                          ( $content  =  read_file
( $file )) 1047                  # Check file modification time ? There is no expires field in json answer 1048                  #TODO: add a checck on modification time ??? 1049                  } # || (str2time($content->{expires}) <= time()+3600) 1051                  # Post certificate request 1052                  $res  =  $self -> _post ( $self ->{ req
}{ certificate
},  '' ); 1055                  unless  ( $res -> is_success ) { 1056                          confess
( 'POST ' . $self ->{ req
}{ certificate
}. ' failed: ' . $res -> status_line ); 1060                  $content  =  $res -> content ; 1063                  write_file
( $file ,  $content ); 1066          # Remove first multi-line jump 1067          $content  =~  s/\n\n/\n/ ; 1069          # Remove ISRG Root X1 certificate signed by DST Root CA X3 present after second multi-line jump 1070          $content  =~  s/\n\n.*//s ; 1072          # Remove trailing line jump 1075          # Write to cert file 1076          write_file
( $self ->{ domain
}{ cert
},  $content ); 1079          carp 
'Saved ' . $self ->{ domain
}{ cert
}. ' pem certificate'  if  ( $self ->{ verbose
}); 1086  # OpenSSL configuration file. 1087  # This is mostly being used for generation of certificate requests. 1094  distinguished_name      
=  req_distinguished_name
1095  # The extentions to add to the self signed cert 1096  x509_extensions 
=  v3_ca
1097  # The extensions to add to a certificate request 1098  req_extensions 
=  v3_req
1100  # This sets a mask for permitted string types. There are several options.  1101  # utf8only: only UTF8Strings (PKIX recommendation after 2004). 1102  # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings. 1103  string_mask 
=  utf8only
1105  [  req_distinguished_name 
] 1107  stateOrProvinceName             
=  State 
or  Province Name
1108  localityName                    
=  Locality Name
1109  organizationName                
=  Organization Name
1110  organizationalUnitName          
=  Organizational Unit Name
1111  commonName                      
=  __COMMON_NAME__
1115  basicConstraints 
=  CA
: false
1116  keyUsage 
=  nonRepudiation
,  digitalSignature
,  keyEncipherment
1117  subjectAltName 
=  email
: move
1118  subjectAltName 
=  @alt_names 1121  subjectKeyIdentifier 
=  hash
1122  authorityKeyIdentifier 
=  keyid
: always
, issuer
1123  basicConstraints 
=  CA
: true