File Coverage

blib/lib/Net/ACME.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


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