]>
Raphaƫl G. Git Repositories - acme/blob - acme.pm
e44c341336f6d4da045796dac8c69d50fccce6b4
  10 our @ISA = qw(Exporter); 
  13 use Carp 
qw(carp confess); 
  14 use Digest
::SHA 
qw(sha256_base64); 
  16 use File
::Path 
qw(make_path); 
  17 use File
::Temp
; # qw( :seekable ); 
  18 use IPC
::System
::Simple 
qw(capturex); 
  19 use JSON 
qw(encode_json decode_json); 
  21 use MIME
::Base64 
qw(encode_base64url encode_base64); 
  24 use POSIX 
qw(EXIT_FAILURE); 
  30 #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/) 
  31 #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt 
  32 #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js 
  41         ACCOUNT_KEY 
=> 'account.pem', 
  42         ACCOUNT_PUB 
=> 'account.pub', 
  43         SERVER_KEY 
=> 'server.pem', 
  44         REQUEST_CSR 
=> 'request.der', 
  45         SERVER_CRT 
=> 'server.crt', 
  51         ACME_DIR 
=> 'https://acme-staging.api.letsencrypt.org/directory', 
  52         #ACME_DIR => 'https://acme-v01.api.letsencrypt.org/directory', 
  53         ACME_TERMS 
=> 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', 
  68 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys 
  77 #                       kty => uc(KEY_TYPE), 
  84 tie
(our %jwk, 'Tie::IxHash', pubkey 
=> undef, jwk 
=> undef, thumbprint 
=> undef); 
  85 tie
(%{$jwk{jwk
}}, 'Tie::IxHash', alg 
=> 'RS256', jwk 
=> undef); 
  86 #XXX: strict ordering only really needed here for thumbprint sha256 digest 
  87 tie
(%{$jwk{jwk
}{jwk
}}, 'Tie::IxHash', e 
=> undef, kty 
=> uc(KEY_TYPE
), n 
=> undef); 
  92         my ($class, $mail, @domains) = @_; 
  97         # Link self to package 
 100         # Add extra check to mail validity 
 101         #XXX: mxcheck fail if there is only a A record on the domain 
 102         my $ev = Email
::Valid-
>new(-fqdn 
=> 1, -tldcheck 
=> 1, -mxcheck 
=> 1); 
 104         # Show error if check fail 
 105         if (! defined $ev->address($mail)) { 
 106                 map { carp 
'failed check: '.$_ if ($_debug) } $ev->details(); 
 107                 confess 
'Email::Valid->address failed'; 
 111         $self->{mail
} = $mail; 
 114         my $res = new Net
::DNS
::Resolver
(); 
 121                 unless (($tld) = $_ =~ m/\.(\w+)$/) { 
 122                         confess 
$_.'\'s tld extraction failed'; 
 125                 # Check if tld exists 
 126                 unless(Net
::Domain
::TLD
::tld_exists
($tld)) { 
 127                         confess 
$tld.' tld from '.$_.' don\'t exists'; 
 130                 # Check if we get dns answer 
 131                 #XXX: only search A type because letsencrypt don't support ipv6 (AAAA) yet 
 132                 unless(my $rep = $res->search($_, 'A')) { 
 133                         confess 
'search A record for '.$_.' failed'; 
 135                         unless (scalar map { $_->type eq 'A' ? 1 : (); } $rep->answer) { 
 136                                 confess 
'search recursively A record for '.$_.' failed'; 
 142         @{$self->{domains
}} = @domains; 
 144         # Return class reference 
 148 # Prepare environement 
 151         make_path
(CERT_DIR
, KEY_DIR
, {error 
=> \
my $err}); 
 154                         my ($file, $msg) = %$_; 
 155                         carp 
($file eq '' ? '' : $file.': ').$msg if ($_debug); 
 157                 confess 
'make_path failed'; 
 161         $ua = LWP
::UserAgent-
>new; 
 162         $ua->agent(__PACKAGE__
.'/'.VERSION
) 
 168         open($_stderr, '>&STDERR') or die $!; 
 170         close(STDERR
) or die $!; 
 172         open(STDERR
, '>', '/dev/null') or die $!; 
 180         open(STDERR
, '>&', $_stderr) or die $!; 
 183 # Generate required keys 
 187         # Generate account and server key if required 
 189                 # Check key existence 
 194                         #XXX: we drop stderr here because openssl can't be quiet on this command 
 195                         capturex
('openssl', ('genrsa', '-out', $_, KEY_SIZE
)); 
 199         } (KEY_DIR
.DS
.ACCOUNT_KEY
, KEY_DIR
.DS
.SERVER_KEY
); 
 201         # Extract modulus and publicExponent jwk 
 202         #XXX: same here we tie to keep ordering 
 203         tie
(%{$self->{account
}}, 'Tie::IxHash', %jwk); 
 205                 if (/^Modulus=([0-9A-F]+)$/) { 
 206                         # Extract to binary from hex and convert to base64 url 
 207                         $self->{account
}{jwk
}{jwk
}{n
} = encode_base64url
(pack("H*", $1) =~ s/^\0+//r); 
 208                 } elsif (/^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/) { 
 209                         # Extract to binary from int, trim leading zeros and convert to base64 url 
 210                         chomp ($self->{account
}{jwk
}{jwk
}{e
} = encode_base64url
(pack("N", $1) =~ s/^\0+//r)); 
 212         } capturex
('openssl', ('rsa', '-text', '-in', KEY_DIR
.DS
.ACCOUNT_KEY
, '-noout', '-modulus')); 
 216         # Extract account public key 
 217         $self->{account
}{pubkey
} = join('', map { chomp; $_; } capturex
('openssl', ('rsa', '-in', KEY_DIR
.DS
.ACCOUNT_KEY
, '-pubout'))); 
 222         #XXX: convert base64 to base64 url 
 223         $self->{account
}{thumbprint
} = (sha256_base64
(encode_json
($self->{account
}{jwk
}{jwk
})) =~ s/=+\z//r) =~ tr
[+/][-_
]r
; 
 226 # Generate certificate request 
 230         # Openssl config template 
 231         my $oct = File
::Temp-
>new(); 
 233         # Load template from data 
 234         map { s/__EMAIL_ADDRESS__/$self->{mail}/; s/__COMMON_NAME__/$self->{domains}[0]/; print $oct $_; } <DATA
>; 
 239         # Append domain names 
 241         map { print $oct 'DNS.'.$i++.' = '.$_."\n"; } @{$self->{domains
}}; 
 244         capturex
('openssl', ('req', '-new', '-outform', 'DER', '-key', KEY_DIR
.DS
.SERVER_KEY
, '-config', $oct->filename, '-out', CERT_DIR
.DS
.REQUEST_CSR
)); 
 258         my $req = HTTP
::Request-
>new(GET 
=> ACME_DIR
.'?'.$time); 
 261         my $res = $ua->request($req); 
 264         unless ($res->is_success) { 
 265                 confess 
'GET '.ACME_DIR
.'?'.$time.' failed: '.$res->status_line; 
 269         $self->{nonce
} = $res->headers->{'replay-nonce'}; 
 271         # Merge uris in self content 
 272         %$self = (%$self, %{decode_json
($res->content)}); 
 277         my ($self, $uri, $payload) = @_; 
 280         my $protected = encode_base64url
(encode_json
({nonce 
=> $self->{nonce
}})); 
 283         $payload = encode_base64url
(encode_json
($payload)); 
 286         my $stf = File
::Temp-
>new(); 
 288         # Append protect.payload to stf 
 289         print $stf $protected.'.'.$payload; 
 294         # Generate digest of stf 
 295         my $signature = encode_base64url
(join('', capturex
('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR
.DS
.ACCOUNT_KEY
, $stf->filename))) =~ s/^\0+//r); 
 298         my $req = HTTP
::Request-
>new(POST 
=> $uri); 
 300         # Set new-reg request content 
 301         $req->content(encode_json
({ 
 302                 header 
=> $self->{account
}{jwk
}, 
 303                 protected 
=> $protected, 
 305                 signature 
=> $signature 
 309         my $res = $ua->request($req); 
 312         if (defined $res->headers->{'replay-nonce'}) { 
 313                 $self->{nonce
} = $res->headers->{'replay-nonce'}; 
 320 # Get uri and check content 
 322         my ($self, $uri, $content) = @_; 
 325         my $req = HTTP
::Request-
>new(GET 
=> $uri); 
 328         my $res = $ua->request($req); 
 331         unless ($res->is_success) { 
 332                 carp 
'GET '.$uri.' failed: '.$res->status_line if ($_debug); 
 336         # Handle invalid content 
 337         unless($res->content =~ /^$content\s*$/) { 
 338                 carp 
'GET '.$uri.' content match failed: /^'.$content.'\s*$/ !~ '.$res->content if ($_debug); 
 347 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3 
 351         # Post new-reg request 
 352         #XXX: contact array may contain a tel:+33612345678 for example 
 353         my $res = $self->_post($self->{'new-reg'}, {resource 
=> 'new-reg', contact 
=> ['mailto:'.$self->{mail
}], agreement 
=> ACME_TERMS
}); 
 356         unless ($res->is_success || $res->code eq 409) { 
 357                 confess 
'POST '.$self->{'new-reg'}.' failed: '.$res->status_line; 
 360         # Update mail informations 
 361         if ($res->code eq 409) { 
 362                 # Save registration uri 
 363                 $self->{'reg'} = $res->headers->{location
}; 
 366                 #XXX: contact array may contain a tel:+33612345678 for example 
 367                 $res = $self->_post($self->{'reg'}, {resource 
=> 'reg', contact 
=> ['mailto:'.$self->{mail
}]}); 
 370                 unless ($res->is_success) { 
 371                         confess 
'POST '.$self->{'reg'}.' failed: '.$res->status_line; 
 377 #TODO: implement combinations check one day 
 381         # Create challenges hash 
 382         %{$self->{challenges
}} = (); 
 387         # Create request for each domain 
 389                 # Post new-authz request 
 390                 my $res = $self->_post($self->{'new-authz'}, {resource 
=> 'new-authz', identifier 
=> {type 
=> 'dns', value 
=> $_}, existing 
=> 'accept'}); 
 393                 unless ($res->is_success) { 
 394                         confess 
'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line; 
 398                 my $content = decode_json
($res->content); 
 401                 unless (defined $content->{identifier
}{value
} && $content->{identifier
}{value
} eq $_) { 
 402                         confess 
'domain matching '.$content->{identifier
}{value
}.' for '.$_.' failed: '.$res->status_line; 
 406                 unless ($content->{status
} eq 'valid' or $content->{status
} eq 'pending') { 
 407                         confess 
'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line; 
 411                 %{$self->{challenges
}{$_}} = ( 
 418                         http_challenge 
=> undef 
 422                 $self->{challenges
}{$_}{status
} = $content->{status
}; 
 425                 if ($content->{status
} eq 'pending') { 
 426                         # Exctract validation data 
 427                         foreach my $challenge (@{$content->{challenges
}}) { 
 428                                 if ($challenge->{type
} eq 'http-01') { 
 429                                         $self->{challenges
}{$_}{http_uri
} = $challenge->{uri
}; 
 430                                         $self->{challenges
}{$_}{http_token
} = $challenge->{token
}; 
 431                                 #} elsif ($challenge->{type} eq 'dns-01') { 
 432                                 #       $self->{challenges}{$_}{dns_uri} = $challenge->{uri}; 
 433                                 #       $self->{challenges}{$_}{dns_token} = $challenge->{token}; 
 437                         # Check dns challenge 
 438                         #XXX: disabled for now 
 439                         #$self->_dnsCheck('_acme-challenge.'.$_.'.', $self->{challenges}{$_}{http_token}.'.'.$self->{account}{thumbprint}); 
 441                         # Check http challenge 
 442                         if ($self->_httpCheck( 
 444                                 'http://'.$_.'/.well-known/acme-challenge/'.$self->{challenges
}{$_}{http_token
}, 
 446                                 $self->{challenges
}{$_}{http_token
}.'.'.$self->{account
}{thumbprint
} 
 448                                 # Post challenge request 
 449                                 my $res = $self->_post($self->{challenges
}{$_}{http_uri
}, {resource 
=> 'challenge', keyAuthorization 
=> $self->{challenges
}{$_}{http_token
}.'.'.$self->{account
}{thumbprint
}}); 
 452                                 unless ($res->is_success) { 
 453                                         confess 
'POST '.$self->{challenges
}{$_}{http_uri
}.' failed: '.$res->status_line; 
 457                                 my $content = decode_json
($res->content); 
 460                                 $self->{challenges
}{$_}{status
} = $content->{status
}; 
 462                                 # Add challenge uri to poll 
 463                                 #XXX: in case it is still pending 
 464                                 if ($content->{status
} eq 'pending') { 
 465                                         $self->{challenges
}{$_}{http_challenge
} = $content->{uri
}; 
 469                                 $self->{challenges
}{$_}{status
} = 'invalid'; 
 471                                 # Display challenge to fix 
 472                                 print STDERR 
'Makes http://'.$_.'/.well-known/acme-challenge/'.$self->{challenges
}{$_}{http_token
}.' return '.$self->{challenges
}{$_}{http_token
}.'.'.$self->{account
}{thumbprint
}."\n"; 
 475         } @{$self->{domains
}}; 
 478         while (scalar map { $_->{status
} eq 'pending' ? 1 : (); } values %{$self->{challenges
}}) { 
 481                 # Poll remaining pending 
 484                         my $req = HTTP
::Request-
>new(GET 
=> $self->{challenges
}{$_}{http_challenge
}); 
 487                         my $res = $ua->request($req); 
 490                         unless ($res->is_success) { 
 491                                 carp 
'GET '.$self->{challenges
}{$_}{http_challenge
}.' failed: '.$res->status_line if ($_debug); 
 495                         my $content = decode_json
($res->content); 
 498                         $self->{challenges
}{$_}{status
} = $content->{status
}; 
 499                 } map { $self->{challenges
}{$_}{status
} eq 'pending' ? $_ : (); } keys %{$self->{challenges
}}; 
 502         # Stop here with remaining chanllenge 
 503         if (scalar map { ! defined $_->{status
} or $_->{status
} ne 'valid' ? 1 : (); } values %{$self->{challenges
}}) { 
 504                 # Deactivate all activated domains  
 505                 #XXX: not implemented by letsencrypt 
 507                 #       # Post deactivation request 
 508                 #       my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'}); 
 510                 #       unless ($res->is_success) { 
 511                 #               print Dumper($res); 
 512                 #               confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line; 
 514                 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}}; 
 516                 # Stop here as a domain of csr list failed authorization 
 518                         confess 
'Fix the challenges for domains: '.join(', ', map { ! defined $self->{challenges
}{$_}{status
} or $self->{challenges
}{$_}{status
} ne 'valid' ? $_ : (); } keys %{$self->{challenges
}}); 
 530         open(my $fh, '<', CERT_DIR
.DS
.REQUEST_CSR
) or die $!; 
 533         my $csr = encode_base64url
(join('', <$fh>) =~ s/^\0+//r); 
 536         close($fh) or die $!; 
 538         # Post certificate request 
 539         my $res = $self->_post($self->{'new-cert'}, {resource 
=> 'new-cert', csr 
=> $csr}); 
 542         unless ($res->is_success) { 
 544                 confess 
'POST '.$self->{'new-cert'}.' failed: '.$res->status_line; 
 548         open($fh, '>', CERT_DIR
.DS
.SERVER_CRT
) or die $!; 
 550         print $fh '-----BEGIN CERTIFICATE-----'."\n".encode_base64
($res->content).'-----END CERTIFICATE-----'."\n"; 
 551         #TODO: merge https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem here 
 553         close($fh) or die $!; 
 556         carp 
'Success, pem certificate in '.CERT_DIR
.DS
.SERVER_CRT 
if ($_debug); 
 559 # Resolve dns and check content 
 560 #XXX: this can't work without a plugin in dns to generate signature from token.thumbprint and store it in zone 
 561 #XXX: each identifier authorisation generate a new token, it's not possible to do a yescard answer 
 562 #XXX: the digest can be bigger than 255 TXT record limit and well known dns server will randomize TXT record order 
 564 #XXX: conclusion disabled for now 
 566         my ($self, $domain, $content) = @_; 
 569         my $stf = File
::Temp-
>new(); 
 571         # Append protect.payload to stf 
 577         # Generate digest of stf 
 578         my $signature = encode_base64url
(join('', capturex
('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR
.DS
.ACCOUNT_KEY
, $stf->filename)))); 
 581         my $res = new Net
::DNS
::Resolver
(); 
 583         # Check if we get dns answer 
 584         unless(my $rep = $res->search($domain, 'TXT')) { 
 585                 carp 
'search TXT record for '.$domain.' failed' if ($_debug); 
 588                 unless (scalar map { $_->type eq 'TXT' && $_->txtdata =~ /^$signature$/ ? 1 : (); } $rep->answer) { 
 589                         carp 
'search recursively TXT record for '.$_.' failed' if ($_debug); 
 601 # OpenSSL configuration file. 
 602 # This is mostly being used for generation of certificate requests. 
 609 distinguished_name      
= req_distinguished_name
 
 610 # The extentions to add to the self signed cert 
 611 x509_extensions 
= v3_ca
 
 612 # The extensions to add to a certificate request 
 613 req_extensions 
= v3_req
 
 615 # This sets a mask for permitted string types. There are several options.  
 616 # utf8only: only UTF8Strings (PKIX recommendation after 2004). 
 617 # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings. 
 618 string_mask 
= utf8only
 
 620 [ req_distinguished_name 
] 
 622 stateOrProvinceName             
= State 
or Province Name
 
 623 localityName                    
= Locality Name
 
 624 organizationName                
= Organization Name
 
 625 organizationalUnitName          
= Organizational Unit Name
 
 626 commonName                      
= __COMMON_NAME__
 
 627 emailAddress                    
= __EMAIL_ADDRESS__
 
 630 basicConstraints 
= CA
:false
 
 631 keyUsage 
= nonRepudiation
, digitalSignature
, keyEncipherment
 
 632 subjectAltName 
= email
:move
 
 633 subjectAltName 
= @alt_names 
 636 subjectKeyIdentifier 
= hash
 
 637 authorityKeyIdentifier 
= keyid
:always
,issuer
 
 638 basicConstraints 
= CA
:true