]>
Raphaƫl G. Git Repositories - acme/blob - acme.pm
dcfabf3ea5e97db38ee6484206841b02278fde00
  10 our @ISA = qw(Exporter); 
  13 use Carp 
qw(carp confess); 
  14 use Date
::Parse 
qw(str2time); 
  16 use Digest
::SHA 
qw(sha256_base64); 
  18 use File
::Path 
qw(make_path); 
  19 use File
::Slurp 
qw(read_file write_file); 
  20 use File
::Temp
; # qw( :seekable ); 
  21 use IPC
::System
::Simple 
qw(capturex); 
  22 use JSON 
qw(encode_json decode_json); 
  24 use MIME
::Base64 
qw(encode_base64url encode_base64); 
  27 use POSIX 
qw(EXIT_FAILURE); 
  33 #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/) 
  34 #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt 
  35 #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js 
  43         PENDING_DIR 
=> 'pending', 
  45         ACCOUNT_KEY 
=> 'account.pem', 
  46         ACCOUNT_PUB 
=> 'account.pub', 
  47         SERVER_KEY 
=> 'server.pem', 
  48         REQUEST_CSR 
=> 'request.der', 
  49         SERVER_CRT 
=> 'server.crt', 
  55         ACME_DIR 
=> 'https://acme-staging.api.letsencrypt.org/directory', 
  56         #ACME_DIR => 'https://acme-v01.api.letsencrypt.org/directory', 
  57         ACME_TERMS 
=> 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', 
  72 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys 
  81 #                       kty => uc(KEY_TYPE), 
  88 tie
(our %jwk, 'Tie::IxHash', pubkey 
=> undef, jwk 
=> undef, thumbprint 
=> undef); 
  89 tie
(%{$jwk{jwk
}}, 'Tie::IxHash', alg 
=> 'RS256', jwk 
=> undef); 
  90 #XXX: strict ordering only really needed here for thumbprint sha256 digest 
  91 tie
(%{$jwk{jwk
}{jwk
}}, 'Tie::IxHash', e 
=> undef, kty 
=> uc(KEY_TYPE
), n 
=> undef); 
  96         my ($class, $mail, @domains) = @_; 
 101         # Link self to package 
 102         bless($self, $class); 
 104         # Add extra check to mail validity 
 105         #XXX: mxcheck fail if there is only a A record on the domain 
 106         my $ev = Email
::Valid-
>new(-fqdn 
=> 1, -tldcheck 
=> 1, -mxcheck 
=> 1); 
 108         # Show error if check fail 
 109         if (! defined $ev->address($mail)) { 
 110                 map { carp 
'failed check: '.$_ if ($_debug) } $ev->details(); 
 111                 confess 
'Email::Valid->address failed'; 
 115         $self->{mail
} = $mail; 
 118         my $res = new Net
::DNS
::Resolver
(); 
 125                 unless (($tld) = $_ =~ m/\.(\w+)$/) { 
 126                         confess 
$_.'\'s tld extraction failed'; 
 129                 # Check if tld exists 
 130                 unless(Net
::Domain
::TLD
::tld_exists
($tld)) { 
 131                         confess 
$tld.' tld from '.$_.' don\'t exists'; 
 134                 # Check if we get dns answer 
 135                 #XXX: only search A type because letsencrypt don't support ipv6 (AAAA) yet 
 136                 unless(my $rep = $res->search($_, 'A')) { 
 137                         confess 
'search A record for '.$_.' failed'; 
 139                         unless (scalar map { $_->type eq 'A' ? 1 : (); } $rep->answer) { 
 140                                 confess 
'search recursively A record for '.$_.' failed'; 
 146         @{$self->{domains
}} = @domains; 
 148         # Return class reference 
 152 # Prepare environement 
 157         make_path
(CERT_DIR
, KEY_DIR
, PENDING_DIR
.'/'.$self->{mail
}, {error 
=> \
my $err}); 
 160                         my ($file, $msg) = %$_; 
 161                         carp 
($file eq '' ? '' : $file.': ').$msg if ($_debug); 
 163                 confess 
'make_path failed'; 
 167         $ua = LWP
::UserAgent-
>new; 
 168         $ua->agent(__PACKAGE__
.'/'.VERSION
) 
 174         open($_stderr, '>&STDERR') or die $!; 
 176         close(STDERR
) or die $!; 
 178         open(STDERR
, '>', '/dev/null') or die $!; 
 186         open(STDERR
, '>&', $_stderr) or die $!; 
 189 # Generate required keys 
 193         # Generate account and server key if required 
 195                 # Check key existence 
 200                         #XXX: we drop stderr here because openssl can't be quiet on this command 
 201                         capturex
('openssl', ('genrsa', '-out', $_, KEY_SIZE
)); 
 205         } (KEY_DIR
.DS
.ACCOUNT_KEY
, KEY_DIR
.DS
.SERVER_KEY
); 
 207         # Extract modulus and publicExponent jwk 
 208         #XXX: same here we tie to keep ordering 
 209         tie
(%{$self->{account
}}, 'Tie::IxHash', %jwk); 
 211                 if (/^Modulus=([0-9A-F]+)$/) { 
 212                         # Extract to binary from hex and convert to base64 url 
 213                         $self->{account
}{jwk
}{jwk
}{n
} = encode_base64url
(pack("H*", $1) =~ s/^\0+//r); 
 214                 } elsif (/^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/) { 
 215                         # Extract to binary from int, trim leading zeros and convert to base64 url 
 216                         chomp ($self->{account
}{jwk
}{jwk
}{e
} = encode_base64url
(pack("N", $1) =~ s/^\0+//r)); 
 218         } capturex
('openssl', ('rsa', '-text', '-in', KEY_DIR
.DS
.ACCOUNT_KEY
, '-noout', '-modulus')); 
 222         # Extract account public key 
 223         $self->{account
}{pubkey
} = join('', map { chomp; $_; } capturex
('openssl', ('rsa', '-in', KEY_DIR
.DS
.ACCOUNT_KEY
, '-pubout'))); 
 228         #XXX: convert base64 to base64 url 
 229         $self->{account
}{thumbprint
} = (sha256_base64
(encode_json
($self->{account
}{jwk
}{jwk
})) =~ s/=+\z//r) =~ tr
[+/][-_
]r
; 
 232 # Generate certificate request 
 236         # Openssl config template 
 237         my $oct = File
::Temp-
>new(); 
 239         # Load template from data 
 240         map { s/__EMAIL_ADDRESS__/$self->{mail}/; s/__COMMON_NAME__/$self->{domains}[0]/; print $oct $_; } <DATA
>; 
 245         # Append domain names 
 247         map { print $oct 'DNS.'.$i++.' = '.$_."\n"; } @{$self->{domains
}}; 
 250         capturex
('openssl', ('req', '-new', '-outform', 'DER', '-key', KEY_DIR
.DS
.SERVER_KEY
, '-config', $oct->filename, '-out', CERT_DIR
.DS
.REQUEST_CSR
)); 
 264         my $req = HTTP
::Request-
>new(GET 
=> ACME_DIR
.'?'.$time); 
 267         my $res = $ua->request($req); 
 270         unless ($res->is_success) { 
 271                 confess 
'GET '.ACME_DIR
.'?'.$time.' failed: '.$res->status_line; 
 275         $self->{nonce
} = $res->headers->{'replay-nonce'}; 
 277         # Merge uris in self content 
 278         %$self = (%$self, %{decode_json
($res->content)}); 
 283         my ($self, $uri, $payload) = @_; 
 286         my $protected = encode_base64url
(encode_json
({nonce 
=> $self->{nonce
}})); 
 289         $payload = encode_base64url
(encode_json
($payload)); 
 292         my $stf = File
::Temp-
>new(); 
 294         # Append protect.payload to stf 
 295         print $stf $protected.'.'.$payload; 
 300         # Generate digest of stf 
 301         my $signature = encode_base64url
(join('', capturex
('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR
.DS
.ACCOUNT_KEY
, $stf->filename))) =~ s/^\0+//r); 
 304         my $req = HTTP
::Request-
>new(POST 
=> $uri); 
 306         # Set new-reg request content 
 307         $req->content(encode_json
({ 
 308                 header 
=> $self->{account
}{jwk
}, 
 309                 protected 
=> $protected, 
 311                 signature 
=> $signature 
 315         my $res = $ua->request($req); 
 318         if (defined $res->headers->{'replay-nonce'}) { 
 319                 $self->{nonce
} = $res->headers->{'replay-nonce'}; 
 326 # Get uri and check content 
 328         my ($self, $uri, $content) = @_; 
 331         my $req = HTTP
::Request-
>new(GET 
=> $uri); 
 334         my $res = $ua->request($req); 
 337         unless ($res->is_success) { 
 338                 carp 
'GET '.$uri.' failed: '.$res->status_line if ($_debug); 
 342         # Handle invalid content 
 343         unless($res->content =~ /^$content\s*$/) { 
 344                 carp 
'GET '.$uri.' content match failed: /^'.$content.'\s*$/ !~ '.$res->content if ($_debug); 
 353 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3 
 357         # Post new-reg request 
 358         #XXX: contact array may contain a tel:+33612345678 for example 
 359         my $res = $self->_post($self->{'new-reg'}, {resource 
=> 'new-reg', contact 
=> ['mailto:'.$self->{mail
}], agreement 
=> ACME_TERMS
}); 
 362         unless ($res->is_success || $res->code eq 409) { 
 363                 confess 
'POST '.$self->{'new-reg'}.' failed: '.$res->status_line; 
 366         # Update mail informations 
 367         if ($res->code eq 409) { 
 368                 # Save registration uri 
 369                 $self->{'reg'} = $res->headers->{location
}; 
 372                 #XXX: contact array may contain a tel:+33612345678 for example 
 373                 $res = $self->_post($self->{'reg'}, {resource 
=> 'reg', contact 
=> ['mailto:'.$self->{mail
}]}); 
 376                 unless ($res->is_success) { 
 377                         confess 
'POST '.$self->{'reg'}.' failed: '.$res->status_line; 
 383 #TODO: implement combinations check one day 
 387         # Create challenges hash 
 388         %{$self->{challenges
}} = (); 
 393         # Create request for each domain 
 399                 my $file = PENDING_DIR
.'/'.$self->{mail
}.'/'.$_; 
 401                 # Load in content domain data or post a new authz request 
 402                 #TODO: add check on cache file ??? 
 404                         #XXX: use eval to workaround a fatal in decode_json 
 406                                 # Check that file exists 
 409                                 ($content = read_file
($file)) && 
 411                                 ($content = decode_json
($content)) && 
 413                                 (DateTime-
>from_epoch(epoch 
=> str2time
($content->{expires
})) >= DateTime-
>now()->add(hours 
=> 1)) 
 416                         # Post new-authz request 
 417                         my $res = $self->_post($self->{'new-authz'}, {resource 
=> 'new-authz', identifier 
=> {type 
=> 'dns', value 
=> $_}, existing 
=> 'accept'}); 
 420                         unless ($res->is_success) { 
 421                                 confess 
'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line; 
 425                         $content = decode_json
($res->content); 
 428                         unless (defined $content->{identifier
}{value
} && $content->{identifier
}{value
} eq $_) { 
 429                                 confess 
'domain matching '.$content->{identifier
}{value
}.' for '.$_.' failed: '.$res->status_line; 
 433                         unless ($content->{status
} eq 'valid' or $content->{status
} eq 'pending') { 
 434                                 confess 
'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line; 
 438                         write_file
($file, encode_json
($content)); 
 442                 %{$self->{challenges
}{$_}} = ( 
 449                         http_challenge 
=> undef 
 453                 $self->{challenges
}{$_}{status
} = $content->{status
}; 
 456                 if ($content->{status
} eq 'pending') { 
 458                         print Dumper
($content); 
 460                         # Exctract validation data 
 461                         foreach my $challenge (@{$content->{challenges
}}) { 
 462                                 if ($challenge->{type
} eq 'http-01') { 
 463                                         $self->{challenges
}{$_}{http_uri
} = $challenge->{uri
}; 
 464                                         $self->{challenges
}{$_}{http_token
} = $challenge->{token
}; 
 465                                 } elsif ($challenge->{type
} eq 'dns-01') { 
 466                                         $self->{challenges
}{$_}{dns_uri
} = $challenge->{uri
}; 
 467                                         $self->{challenges
}{$_}{dns_token
} = $challenge->{token
}; 
 471                         # Check dns challenge 
 472                         #XXX: disabled for now 
 473                         #$self->_dnsCheck('_acme-challenge.'.$_.'.', $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}); 
 475                         # Check http challenge 
 476 #                       if ($self->_httpCheck( 
 478 #                               'http://'.$_.'/.well-known/acme-challenge/'.$self->{challenges}{$_}{http_token}, 
 480 #                               $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint} 
 482 #                               # Post challenge request 
 483 #                               my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'challenge', keyAuthorization => $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}}); 
 486 #                               unless ($res->is_success) { 
 487 #                                       confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line; 
 491 #                               my $content = decode_json($res->content); 
 494 #                               $self->{challenges}{$_}{status} = $content->{status}; 
 496 #                               # Add challenge uri to poll 
 497 #                               #XXX: in case it is still pending 
 498 #                               if ($content->{status} eq 'pending') { 
 499 #                                       $self->{challenges}{$_}{http_challenge} = $content->{uri}; 
 502 #                               # Set failed status 
 503 #                               $self->{challenges}{$_}{status} = 'invalid'; 
 505 #                               # Display challenge to fix 
 506 #                               print STDERR 'Makes http://'.$_.'/.well-known/acme-challenge/'.$self->{challenges}{$_}{http_token}.' return '.$self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}."\n"; 
 509         } @{$self->{domains
}}; 
 515         while (scalar map { $_->{status
} eq 'pending' ? 1 : (); } values %{$self->{challenges
}}) { 
 518                 # Poll remaining pending 
 521                         my $req = HTTP
::Request-
>new(GET 
=> $self->{challenges
}{$_}{http_challenge
}); 
 524                         my $res = $ua->request($req); 
 527                         unless ($res->is_success) { 
 528                                 carp 
'GET '.$self->{challenges
}{$_}{http_challenge
}.' failed: '.$res->status_line if ($_debug); 
 532                         my $content = decode_json
($res->content); 
 535                         $self->{challenges
}{$_}{status
} = $content->{status
}; 
 536                 } map { $self->{challenges
}{$_}{status
} eq 'pending' ? $_ : (); } keys %{$self->{challenges
}}; 
 539         # Stop here with remaining chanllenge 
 540         if (scalar map { ! defined $_->{status
} or $_->{status
} ne 'valid' ? 1 : (); } values %{$self->{challenges
}}) { 
 541                 # Deactivate all activated domains  
 542                 #XXX: not implemented by letsencrypt 
 544                 #       # Post deactivation request 
 545                 #       my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'}); 
 547                 #       unless ($res->is_success) { 
 548                 #               print Dumper($res); 
 549                 #               confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line; 
 551                 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}}; 
 553                 # Stop here as a domain of csr list failed authorization 
 555                         confess 
'Fix the challenges for domains: '.join(', ', map { ! defined $self->{challenges
}{$_}{status
} or $self->{challenges
}{$_}{status
} ne 'valid' ? $_ : (); } keys %{$self->{challenges
}}); 
 567         open(my $fh, '<', CERT_DIR
.DS
.REQUEST_CSR
) or die $!; 
 570         my $csr = encode_base64url
(join('', <$fh>) =~ s/^\0+//r); 
 573         close($fh) or die $!; 
 575         # Post certificate request 
 576         my $res = $self->_post($self->{'new-cert'}, {resource 
=> 'new-cert', csr 
=> $csr}); 
 579         unless ($res->is_success) { 
 581                 confess 
'POST '.$self->{'new-cert'}.' failed: '.$res->status_line; 
 585         open($fh, '>', CERT_DIR
.DS
.SERVER_CRT
) or die $!; 
 587         print $fh '-----BEGIN CERTIFICATE-----'."\n".encode_base64
($res->content).'-----END CERTIFICATE-----'."\n"; 
 588         #TODO: merge https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem here 
 590         close($fh) or die $!; 
 593         carp 
'Success, pem certificate in '.CERT_DIR
.DS
.SERVER_CRT 
if ($_debug); 
 596 # Resolve dns and check content 
 597 #XXX: this can't work without a plugin in dns to generate signature from token.thumbprint and store it in zone 
 598 #XXX: each identifier authorisation generate a new token, it's not possible to do a yescard answer 
 599 #XXX: the digest can be bigger than 255 TXT record limit and well known dns server will randomize TXT record order 
 601 #XXX: conclusion disabled for now 
 603         my ($self, $domain, $content) = @_; 
 606         my $stf = File
::Temp-
>new(); 
 608         # Append protect.payload to stf 
 614         # Generate digest of stf 
 615         my $signature = encode_base64url
(join('', capturex
('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR
.DS
.ACCOUNT_KEY
, $stf->filename)))); 
 618         my $res = new Net
::DNS
::Resolver
(); 
 620         # Check if we get dns answer 
 621         unless(my $rep = $res->search($domain, 'TXT')) { 
 622                 carp 
'search TXT record for '.$domain.' failed' if ($_debug); 
 625                 unless (scalar map { $_->type eq 'TXT' && $_->txtdata =~ /^$signature$/ ? 1 : (); } $rep->answer) { 
 626                         carp 
'search recursively TXT record for '.$_.' failed' if ($_debug); 
 638 # OpenSSL configuration file. 
 639 # This is mostly being used for generation of certificate requests. 
 646 distinguished_name      
= req_distinguished_name
 
 647 # The extentions to add to the self signed cert 
 648 x509_extensions 
= v3_ca
 
 649 # The extensions to add to a certificate request 
 650 req_extensions 
= v3_req
 
 652 # This sets a mask for permitted string types. There are several options.  
 653 # utf8only: only UTF8Strings (PKIX recommendation after 2004). 
 654 # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings. 
 655 string_mask 
= utf8only
 
 657 [ req_distinguished_name 
] 
 659 stateOrProvinceName             
= State 
or Province Name
 
 660 localityName                    
= Locality Name
 
 661 organizationName                
= Organization Name
 
 662 organizationalUnitName          
= Organizational Unit Name
 
 663 commonName                      
= __COMMON_NAME__
 
 664 emailAddress                    
= __EMAIL_ADDRESS__
 
 667 basicConstraints 
= CA
:false
 
 668 keyUsage 
= nonRepudiation
, digitalSignature
, keyEncipherment
 
 669 subjectAltName 
= email
:move
 
 670 subjectAltName 
= @alt_names 
 673 subjectKeyIdentifier 
= hash
 
 674 authorityKeyIdentifier 
= keyid
:always
,issuer
 
 675 basicConstraints 
= CA
:true