]>
Raphaƫl G. Git Repositories - acme/blob - acme.pm
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