File Coverage

blib/lib/Net/ACME.pm
Criterion Covered Total %
statement 103 114 90.3
branch 12 26 46.1
condition 3 6 50.0
subroutine 25 26 96.1
pod 3 7 42.8
total 146 179 81.5


line stmt bran cond sub pod time code
1             package Net::ACME;
2              
3             =encoding utf-8
4              
5             =head1 NAME
6              
7             Net::ACME - Client for the ACME protocol (e.g., L)
8              
9             =head1 SYNOPSIS
10              
11             package MyACME::SomeService;
12              
13             sub _HOST { } #return the name of the ACME host
14              
15             #See below for full examples.
16              
17             =head1 DESCRIPTION
18              
19             This module implements client logic (including SSL certificate issuance)
20             for the ACME protocol, the system for automated issuance of SSL
21             certificates used by Let’s Encrypt.
22              
23             The methods of this class return objects that correspond to the
24             respective ACME resource:
25              
26             =over 4
27              
28             =item * C: C
29              
30             =item * C: C
31              
32             =item * C: C or C
33              
34             =back
35              
36             =head1 WHY USE THIS MODULE?
37              
38             =over 4
39              
40             =item * Closely based on cPanel’s widely used Let’s Encrypt plugin.
41              
42             =item * Support for both RSA and ECDSA encryption (via L).
43              
44             =item * Thorough error-checking: any deviation from what the ACME protocol
45             expects is reported immediately via an exception.
46              
47             =item * Well-defined object system, including typed, queryable exceptions.
48              
49             =item * Extensive test coverage.
50              
51             =item * Light memory footprint - no Moose/Moo/etc.
52              
53             =item * No careless overwriting of globals like C<$@>, C<$!>, and C<$?>.
54             (Hopefully your code isn’t susceptible to this anyway, but it’s just a good
55             precaution.)
56              
57             =item * All dependencies are either core or pure Perl. Net::ACME will run
58             anywhere that Perl runs!
59              
60             =back
61              
62             =head1 STATUS
63              
64             This module is now well-tested and should be safe for use in your application.
65              
66             =head1 CUSTOMIZATION
67              
68             B: This module uses C for its network operations.
69             In some instances it is desirable to specify custom C in that
70             module’s constructor; to do this, populate
71             C<@Net::ACME::HTTP_Tiny::SSL_OPTIONS>.
72              
73             =head1 URI vs. URL
74              
75             This module uses “uri” for ACME-related objects and “url” for
76             HTTP-related ones. This apparent conflict is a result of maintaining
77             consistency with both the ACME specification (“uri”) and L (“url”).
78              
79             =head1 EXAMPLES
80              
81             See the C directory in the distribution for complete, interactive
82             example scripts that also illustrate a bit of how ACME works.
83              
84             See below for cut-paste-y examples.
85              
86             =head1 EXAMPLE: REGISTRATION
87              
88             my $tos_url = Net::ACME::LetsEncrypt->get_terms_of_service();
89              
90             my $acme = Net::ACME::LetsEncrypt->new( key => $reg_rsa_pem );
91              
92             #Use this method any time you want to update contact information,
93             #not just when you set up a new account.
94             my $reg = $acme->register('mailto:who.am@i.tld', 'mailto:who@else.tld');
95              
96             $acme->accept_tos( $reg->uri(), $tos_url );
97              
98             =head1 EXAMPLE: DOMAIN AUTHORIZATION & CERTIFICATE PROCUREMENT
99              
100             for my $domain (@domains) {
101             my $authz_p = $acme->start_domain_authz($domain);
102              
103             for my $cmb_ar ( $authz_p->combinations() ) {
104              
105             #$cmb_ar is a set of challenges that the ACME server will
106             #accept as proof of domain control. As of November 2016, these
107             #sets all contain exactly one challenge each: “http-01”, etc.
108              
109             #Each member of @$cmb_ar is an instance of
110             #Net::ACME::Challenge::Pending--maybe a subclass thereof such as
111             #Net::ACME::Challenge::Pending::http_01.
112              
113             #At this point, you examine $cmb_ar and determine if this
114             #combination is one that you’re interested in. You might try
115             #something like:
116             #
117             # next if @$cmb_ar > 1;
118             # next if $cmb_ar->[0]->type() ne 'http-01';
119              
120             #Once you’ve examined $cmb_ar and set up the appropriate response(s),
121             #it’s time to tell the ACME server to send its challenge query.
122             $acme->do_challenge($_) for @$cmb_ar;
123              
124             while (1) {
125             if ( $authz_p->is_time_to_poll() ) {
126             my $poll = $authz_p->poll();
127              
128             last if $poll->status() eq 'valid';
129              
130             if ( $poll->status() eq 'invalid' ) {
131             my @failed = map { $_->error() } $poll->challenges();
132              
133             warn $_->to_string() . $/ for @failed;
134              
135             die "Failed authorization for “$domain”!";
136             }
137              
138             }
139              
140             sleep 1;
141             }
142             }
143             }
144              
145             #Make a key and CSR.
146             #Creation of CSRs is well-documented so won’t be discussed here.
147              
148             my $cert = $acme->get_certificate($csr_pem);
149              
150             #This shouldn’t actually be necessary for Let’s Encrypt,
151             #but the ACME protocol describes it.
152             while ( !$cert->pem() ) {
153             sleep 1;
154             next if !$cert->is_time_to_poll();
155             $cert = $cert->poll() || $cert;
156             }
157              
158             =head1 TODO
159              
160             =over 4
161              
162             =item * Once the L
163             is finalized, update this module to take advantage of the full specification.
164             As Let’s Encrypt’s L is currently
165             the only widely-used ACME server, and that software is compatible with
166             L,
167             there’s little reason to update for the time being.
168              
169             =back
170              
171             =head1 THANKS
172              
173             =over 4
174              
175             =item * cPanel, Inc. for permission to adapt their ACME framework for
176             public consumption.
177              
178             =item * Stephen Ludin for developing and maintaining L, from which
179             this module took its inspiration.
180              
181             =back
182              
183             =head1 SEE ALSO
184              
185             I am aware of the following additional CPAN modules that implement this protocol:
186              
187             =over 4
188              
189             =item * L
190              
191             =item * L
192              
193             =item * L
194              
195             =item * L
196              
197             =back
198              
199             =head1 REPOSITORY (FEEDBACK/BUGS)
200              
201             L
202              
203             =head1 AUTHOR
204              
205             Felipe Gasper (FELIPE)
206              
207             =head1 LICENSE
208              
209             This module is licensed under the same terms as Perl.
210              
211             =cut
212              
213 2     2   89000 use strict;
  2         3  
  2         44  
214 2     2   7 use warnings;
  2         2  
  2         37  
215              
216 2     2   797 use Crypt::Format ();
  2         712  
  2         27  
217 2     2   590 use JSON ();
  2         9846  
  2         32  
218 2     2   414 use MIME::Base64 ();
  2         504  
  2         28  
219              
220 2     2   694 use Net::ACME::Authorization::Pending ();
  2         5  
  2         33  
221 2     2   653 use Net::ACME::Certificate ();
  2         3  
  2         27  
222 2     2   631 use Net::ACME::Certificate::Pending ();
  2         2  
  2         26  
223 2     2   7 use Net::ACME::Constants ();
  2         2  
  2         19  
224 2     2   702 use Net::ACME::Challenge::Pending::http_01 ();
  2         5  
  2         30  
225 2     2   9 use Net::ACME::HTTP ();
  2         2  
  2         19  
226 2     2   807 use Net::ACME::Registration ();
  2         3  
  2         27  
227 2     2   7 use Net::ACME::Utils ();
  2         2  
  2         21  
228 2     2   5 use Net::ACME::X ();
  2         2  
  2         1619  
229              
230             our $VERSION;
231             *VERSION = \$Net::ACME::Constants::VERSION;
232              
233             *_to_base64url = \&MIME::Base64::encode_base64url;
234              
235             sub new {
236 5     5 0 737192 my ( $class, %opts ) = @_;
237              
238             my $self = {
239             _host => $class->_HOST(),
240 5         20 _key => $opts{'key'},
241             };
242              
243 5         26 bless $self, $class;
244              
245 5         16 $self->_set_ua();
246              
247 5         28 return $self;
248             }
249              
250 0     0   0 sub _HOST { die 'Not Implemented!' }
251              
252             sub accept_tos {
253 1     1 0 2 my ( $self, $reg_uri, $tos_url ) = @_;
254              
255 1         6 my $resp = $self->_post_url(
256             $reg_uri,
257             {
258             resource => 'reg',
259             agreement => $tos_url,
260             },
261             );
262              
263 1 50       21 $resp->die_because_unexpected() if $resp->status() != 202;
264              
265 1         10 return;
266             }
267              
268             #Returns a Net::ACME::Registration instance whose
269             #terms_of_service() will be current/useful.
270             sub register {
271 1     1 1 13 my ( $self, @contacts ) = @_;
272              
273 1         3 my $payload = {
274             resource => 'new-reg',
275             };
276              
277 1 50       4 if (@contacts) {
278 1         2 $payload->{'contact'} = \@contacts;
279             }
280              
281 1         2 my ( $resp, $reg_uri );
282              
283 1         5 $resp = $self->_post( 'new-reg', $payload );
284              
285 1 50       19 if ( $resp->status() != 201 ) {
286 0         0 $resp->die_because_unexpected();
287             }
288              
289 1         7 $reg_uri = $resp->header('location');
290              
291             #We don’t save the terms-of-service here because the terms
292             #of service might be updated between now and the next time we
293             #load this data. It’s better to make the caller call
294             #get_terms_of_service() each time.
295             my @metadata = (
296             uri => $reg_uri,
297 1         5 %{ $resp->content_struct() },
  1         5  
298             );
299              
300             #Even though we didn’t save the “terms-of-service” URL from
301             #this registration object, we might as well hold onto it
302             #for the current process to save a call to get_terms_of_service().
303             return Net::ACME::Registration->new(
304             @metadata,
305 1         15 terms_of_service => { $resp->links() }->{'terms-of-service'},
306             );
307             }
308              
309             #NOTE: This doesn’t actually seem to work with Let’s Encrypt.
310             #The POST keeps coming back with a 202 status rather than 200.
311             #(Looks like Boulder doesn’t handle this function yet?)
312             #sub rollover_key {
313             # my ($self, $reg_uri) = @_;
314             #
315             # my $new_key = $self->create_key_pem();
316             #
317             # my $sub_payload = {
318             # resource => 'reg',
319             # oldKey => $self->jwk_thumbprint(),
320             # };
321             #
322             # my $resp = $self->_post_url(
323             # $reg_uri,
324             # {
325             # resource => 'reg',
326             # newKey => Net::ACME::Utils::get_jws_data(
327             # $new_key,
328             # undef,
329             # JSON::encode_json($sub_payload),
330             # ),
331             # },
332             # );
333             #
334             # if ($resp->status() != 200) {
335             # die "Incorrect status: " . $resp->status() . $/ . $resp->content();
336             # }
337             #
338             # $self->{'_account_key'} = $new_key;
339             # $self->_set_ua();
340             #
341             # return $new_key;
342             #}
343              
344             sub start_domain_authz {
345 1     1 1 21 my ( $self, $domain_name ) = @_;
346              
347 1         8 my $resp = $self->_post(
348             'new-authz',
349             {
350             resource => 'new-authz',
351             identifier => {
352             type => 'dns',
353             value => $domain_name,
354             },
355             },
356             );
357              
358 1 50       35 $resp->die_because_unexpected() if $resp->status() != 201;
359              
360 1         13 my $content = $resp->content_struct();
361              
362             return Net::ACME::Authorization::Pending->new(
363             uri => $resp->header('location'),
364             combinations => $content->{'combinations'},
365             challenges => [
366             map {
367 2         3 my $class = 'Net::ACME::Challenge::Pending';
368 2 100       6 if ( $_->{'type'} eq 'http-01' ) {
369 1         3 $class .= '::http_01';
370             }
371 2         16 $class->new(%$_);
372 1         18 } @{ $content->{'challenges'} },
  1         9  
373             ],
374             );
375             }
376              
377             #NOTE: This doesn’t actually work with Boulder (Let’s Encrypt) because
378             #that server implements acme-01. Deletion of an authz was added in acme-02.
379             #
380             #It is critical, though, that when this doesn’t work we still request the
381             #challenge against the authz so that the LE account doesn’t exceed a rate
382             #limit. (cf. COBRA-3273)
383             sub delete_authz {
384 1     1 0 11 my ( $self, $authz ) = @_;
385              
386             #sanity
387 1 50       5 if ( !Net::ACME::Utils::thing_isa($authz, 'Net::ACME::Authorization::Pending') ) {
388 0         0 die "Must be a pending authz object, not “$authz”!";
389             }
390              
391 1         7 my $resp = $self->_post_url(
392             $authz->uri(),
393             {
394             resource => 'authz',
395             delete => JSON::true(),
396             },
397             );
398              
399 1 50       22 $resp->die_because_unexpected() if $resp->status() != 200;
400              
401 1         7 return;
402             }
403              
404             sub do_challenge {
405 1     1 0 13 my ( $self, $challenge_obj ) = @_;
406              
407 1         3 my ( $token, $uri ) = map { $challenge_obj->$_() } qw( token uri );
  2         15  
408              
409 1         5 my $key_obj = Net::ACME::Crypt::parse_key($self->{'_key'});
410              
411 1   33     82997 $self->{'_key_jwk'} ||= $key_obj->get_struct_for_public_jwk();
412              
413             my $resp = $self->_post_url(
414             $uri,
415             {
416             resource => 'challenge',
417 1         3991 keyAuthorization => $challenge_obj->make_key_authz( $self->{'_key_jwk'} ),
418             },
419             );
420              
421 1 50       21 $resp->die_because_unexpected() if $resp->status() != 202;
422              
423 1         8 return;
424             }
425              
426             sub get_certificate {
427 1     1 1 12 my ( $self, $csr_pem ) = @_;
428              
429 1         6 my $csr_der = Crypt::Format::pem2der($csr_pem);
430              
431 1         66 my $resp = $self->_post(
432             'new-cert',
433             {
434             resource => 'new-cert',
435             csr => _to_base64url($csr_der),
436             },
437             );
438              
439 1         28 my $status = $resp->status();
440              
441             #NB: Let’s Encrypt doesn’t seem to need this,
442             #but per the ACME spec it *could* work this way.
443 1 50       7 if ( $status == 202 ) {
444 0         0 my $pcert = Net::ACME::Certificate::Pending->new(
445             uri => $resp->header('location'),
446             retry_after => $resp->header('retry-after'),
447             );
448              
449 0         0 while (1) {
450 0 0       0 if ( $pcert->is_time_to_poll() ) {
451 0         0 my $c = $pcert->poll();
452 0 0       0 return $c if $c;
453             }
454 0         0 sleep 1;
455             }
456             }
457              
458 1 50       3 if ( $status == 201 ) {
459             return Net::ACME::Certificate->new(
460             content => $resp->content(),
461             type => $resp->header('content-type'),
462 1         16 issuer_cert_uri => { $resp->links() }->{'up'},
463             );
464             }
465              
466 0         0 $resp->die_because_unexpected();
467              
468 0         0 return;
469             }
470              
471             #This isn’t needed yet, nor is it useful because
472             #Let’s Encrypt (i.e., Boulder) doesn’t support it.
473             #Once Boulder supports this, we should switch to it
474             #in favor of the LE-specific logic in LetsEncrypt.pm.
475             #
476             #cf. https://ietf-wg-acme.github.io/acme/#rfc.section.6.1.1
477             #sub get_terms_of_service {
478             # my ($self) = @_;
479             #
480             # my $dir = $self->_get_directory();
481             # my $url = $self->_get_directory()->{'meta'} or die 'No “meta” in directory!';
482             # $url = $url->{'terms-of-service'} or die 'No “terms-of-service” in directory metadata!';
483             #
484             # return $url;
485             #}
486              
487             #----------------------------------------------------------------------
488              
489             sub _set_ua {
490 5     5   7 my ($self) = @_;
491             $self->{'_ua'} = Net::ACME::HTTP->new(
492 5         38 key => $self->{'_key'},
493             );
494              
495 5         14 return;
496             }
497              
498             #TODO: cache
499             sub _get_directory {
500 9     9   15 my ($self) = @_;
501              
502 9   66     56 return $self->{'_directory'} ||= $self->{'_ua'}->get("https://$self->{'_host'}/directory")->content_struct();
503             }
504              
505             sub _post {
506 3     3   23 my ( $self, $link_name, $data ) = @_;
507              
508 3 50       10 my $url = $self->_get_directory()->{$link_name} or die "Unknown link name: “$link_name”";
509              
510 3         45 return $self->_post_url( $url, $data );
511             }
512              
513             #mocked in tests
514             sub _post_url {
515 6     6   43 my ( $self, $url, $data ) = @_;
516              
517             #Do this in case we haven’t initialized the directory yet.
518             #Initializing the directory is necessary to get a nonce.
519 6         16 $self->_get_directory();
520              
521 6         41 return $self->{'_ua'}->post( $url, $data );
522             }
523              
524             1;