File Coverage

blib/lib/Net/ACME.pm
Criterion Covered Total %
statement 103 121 85.1
branch 12 32 37.5
condition 3 6 50.0
subroutine 25 27 92.5
pod 3 8 37.5
total 146 194 75.2


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