File Coverage

blib/lib/Net/ACME2.pm
Criterion Covered Total %
statement 87 127 68.5
branch 16 34 47.0
condition 6 17 35.2
subroutine 21 30 70.0
pod 9 9 100.0
total 139 217 64.0


line stmt bran cond sub pod time code
1             package Net::ACME2;
2              
3 1     1   1682 use strict;
  1         2  
  1         23  
4 1     1   4 use warnings;
  1         2  
  1         31  
5              
6             =encoding utf-8
7              
8             =head1 NAME
9              
10             Net::ACME2 - L-standard ACME (Let’s Encrypt) client
11              
12             X X X
13              
14             =head1 SYNOPSIS
15              
16             package SomeCA::ACME;
17              
18             use parent qw( Net::ACME2 );
19              
20             use constant {
21             HOST => 'acme.someca.net',
22             DIRECTORY_PATH => '/acme-directory',
23             };
24              
25             package main;
26              
27             my $acme = SomeCA::ACME->new(
28             key => $account_key_pem_or_der,
29             key_id => undef,
30             );
31              
32             #for a new account
33             {
34             my $terms_url = $acme->get_terms_of_service();
35              
36             $acme->create_new_account(
37             termsOfServiceAgreed => 1,
38             );
39             }
40              
41             #Save $acme->key_id() somewhere so you can use it again.
42              
43             my $order = $acme->create_new_order(
44             identifiers => [
45             { type => 'dns', value => '*.example.com' },
46             ],
47             );
48              
49             my $authz = $acme->get_authorization( ($order->authorizations())[0] );
50              
51             my @challenges = $authz->challenges();
52              
53             # ... Pick a challenge, and satisfy it.
54              
55             $acme->accept_challenge($challenge);
56              
57             sleep 1 while 'valid' ne $acme->poll_authorization($authz);
58              
59             # ... Make a key and CSR for *.example.com
60              
61             $acme->finalize_order($order, $csr_pem_or_der);
62              
63             while ($order->status() ne 'valid') {
64             sleep 1;
65             $acme->poll_order($order);
66             }
67              
68             my $certificate_url = $order->certificate();
69              
70             # ... Download your certificate! :)
71              
72             See F in the distribution for a more fleshed-out example.
73              
74             To use L, see
75             L.
76              
77             =head1 DESCRIPTION
78              
79             This library implements client logic for the L-standard ACME
80             (Automated Certificate Management Environment) protocol. As of this
81             writing, that protocol remains in development; the latest draft will
82             be available from L.
83              
84             Net::ACME2 derives from L, which implements the
85             (significantly different) earlier draft of that protocol as initially
86             deployed by L.
87              
88             Net::ACME2 is pure Perl, and all of its dependencies are either pure Perl
89             or core modules.
90              
91             =head1 STATUS
92              
93             This is an alpha-grade implementation. While the underlying protocol
94             is L,
95             it’s still not finalized; consequently, this distribution remains
96             subject to change.
97              
98             =head1 FEATURES
99              
100             =over
101              
102             =item * This is a pure-Perl solution, all of whose dependencies are either
103             core modules or pure-Perl themselves. Net::ACME2 will run anywhere that Perl
104             runs. :)
105              
106             =item * Support for both ECDSA and RSA encrytion.
107              
108             =item * Comprehensive error handling with typed, L-based exceptions.
109              
110             =back
111              
112             =head1 ERROR HANDLING
113              
114             All thrown exceptions are instances of L.
115             Specific error classes aren’t yet defined.
116              
117             =cut
118              
119 1     1   373 use Crypt::Format ();
  1         442  
  1         16  
120 1     1   5 use Crypt::Perl::PK ();
  1         1  
  1         11  
121 1     1   3 use MIME::Base64 ();
  1         2  
  1         11  
122              
123 1     1   4 use Net::ACME2::Constants ();
  1         1  
  1         11  
124 1     1   371 use Net::ACME2::HTTP ();
  1         3  
  1         16  
125 1     1   355 use Net::ACME2::Order ();
  1         2  
  1         15  
126 1     1   386 use Net::ACME2::Authorization ();
  1         3  
  1         34  
127              
128             our $VERSION;
129             *VERSION = *Net::ACME2::Constants::VERSION;
130              
131             use constant {
132 1         64 JWS_FORMAT => undef,
133              
134             _JWK_THUMBPRINT_DIGEST => 'sha256',
135 1     1   8 };
  1         2  
136              
137             # accessed from test
138 1         45 use constant newAccount_booleans => qw(
139             termsOfServiceAgreed
140             onlyReturnExisting
141 1     1   5 );
  1         2  
142              
143             # the list of methods that need a “jwk” in their JWS Protected Header
144             # (cf. section 6.2 of the spec)
145 1         1262 use constant FULL_JWT_METHODS => qw(
146             newAccount
147             revokeCert
148 1     1   5 );
  1         1  
149              
150             =head1 METHODS
151              
152             =head2 I->new( %OPTS )
153              
154             Instantiates an ACME2 object, which you’ll use for all
155             interactions with the ACME server. %OPTS is:
156              
157             =over
158              
159             =item * C - Required. The private key to associate with the ACME2
160             user. Anything that C can parse is acceptable.
161              
162             =item * C - Optional. As returned by C.
163             Saves a round-trip to the ACME2 server, so you should give this
164             if you have it.
165              
166             =item * C - Optional. A hash reference to use as the
167             directory contents. Saves a round-trip to the ACME2 server, but there’s
168             no built-in logic to determine when the cache goes invalid. Caveat
169             emptor.
170              
171             =back
172              
173             =cut
174              
175             sub new {
176 1     1 1 103 my ( $class, %opts ) = @_;
177              
178 1 50       4 _die_generic('Need “key”!') if !$opts{'key'};
179              
180             my $self = {
181             _host => $class->HOST(),
182             _key => $opts{'key'},
183             _key_id => $opts{'key_id'},
184 1         6 _directory => $opts{'directory'},
185             };
186              
187 1         1 bless $self, $class;
188              
189 1         5 $self->_set_ua();
190              
191 1         4 return $self;
192             }
193              
194             =head2 I->key_id()
195              
196             Returns the object’s cached key ID, either as given at instantiation
197             or as fetched in C.
198              
199             =cut
200              
201             sub key_id {
202 2     2 1 1666 my ($self) = @_;
203              
204 2         8 return $self->{'_key_id'};
205             }
206              
207             =head2 I->get_terms_of_service()
208              
209             Callable as either an instance method or a class method.
210             Returns the URL for the terms of service.
211              
212             B For L you can
213             unofficially resolve against
214             L to see the terms
215             of service.
216              
217             =cut
218              
219             sub get_terms_of_service {
220 1     1 1 624 my ($self) = @_;
221              
222             #We want to be able to call this as a class method.
223 1 50       5 if (!ref $self) {
224 0         0 $self = $self->new();
225             }
226              
227 1         7 my $dir = $self->_get_directory();
228              
229             # Exceptions here indicate an ACME violation and should be
230             # practically nonexistent.
231 1 50       5 my $url = $dir->{'meta'} or _die_generic('No “meta” in directory!');
232 1 50       4 $url = $url->{'termsOfService'} or _die_generic('No “termsOfService” in directory metadata!');
233              
234 1         4 return $url;
235             }
236              
237             =head2 I->create_new_account( %OPTS )
238              
239             Creates a new account using the ACME2 object’s key and the passed
240             %OPTS, which are as described in the ACME2 spec (cf. C).
241             Boolean values may be given as simple Perl booleans.
242              
243             Returns 1 if the account is newly created
244             or 0 if the account already existed.
245              
246             =cut
247              
248             sub create_new_account {
249 2     2 1 839 my ($self, %opts) = @_;
250              
251 2         7 for my $name (newAccount_booleans()) {
252 4 100       62 next if !exists $opts{$name};
253 1   33     12 ($opts{$name} &&= JSON::true()) ||= JSON::false();
      33        
254             }
255              
256 2         12 my $resp = $self->_post(
257             'newAccount',
258             \%opts,
259             );
260              
261 2         11 $self->{'_key_id'} = $resp->header('location');
262              
263 2         27 $self->{'_ua'}->set_key_id( $self->{'_key_id'} );
264              
265 2 100       41 return 0 if $resp->status() == 200;
266              
267 1 50       21 $resp->die_because_unexpected() if $resp->status() != 201;
268              
269 1         21 my $struct = $resp->content_struct();
270              
271 1 50       20 if ($struct) {
272 1         4 for my $name (newAccount_booleans()) {
273 2 100       16 next if !exists $struct->{$name};
274 1 50       8 $struct->{$name} = $struct->{$name} ? 1 : 0;
275             }
276             }
277              
278 1         5 return 1;
279             }
280              
281             #sub update_account {
282             # my ($self, %opts) = @_;
283             #
284             # $self->_require_key_id(\%opts);
285             #
286             # my $set = $self->_post_url(
287             # $opts{'kid'},
288             # \%opts,
289             # );
290             #
291             # return $set;
292             #}
293              
294             =head2 I->create_new_order( %OPTS )
295              
296             Returns a L object. %OPTS is as described in the
297             ACME spec (cf. C). Boolean values may be given as simple
298             Perl booleans.
299              
300             =cut
301              
302             sub create_new_order {
303 0     0 1 0 my ($self, %opts) = @_;
304              
305 0         0 $self->_require_key_id(\%opts);
306              
307 0         0 my $resp = $self->_post( 'newOrder', \%opts );
308              
309 0 0       0 $resp->die_because_unexpected() if $resp->status() != 201;
310              
311             return Net::ACME2::Order->new(
312             id => $resp->header('location'),
313 0         0 %{ $resp->content_struct() },
  0         0  
314             );
315             }
316              
317             =head2 I->get_authorization( URL )
318              
319             Fetches the authorization’s information based on the given URL
320             and returns a L object.
321              
322             The URL is as given by L’s C method.
323              
324             =cut
325              
326             sub get_authorization {
327 0     0 1 0 my ($self, $id) = @_;
328              
329 0         0 my $resp = $self->{'_ua'}->get($id);
330              
331             return Net::ACME2::Authorization->new(
332             id => $id,
333 0         0 %{ $resp->content_struct() },
  0         0  
334             );
335             }
336              
337             #Server may not support! (Pebble doesn’t, and LE won’t?)
338             #sub create_new_authorization {
339             # my ($self, $type, $value) = @_;
340             #
341             # my %opts = (
342             # identifier => { type => $type, value => $value },
343             # );
344             #
345             # $self->_require_key_id(\%opts);
346             #
347             # return $self->_post( 'newAuthz', \%opts );
348             #}
349              
350             #TODO: separate distribution?
351             #sub __unix2iso {
352             # my ($unix) = @_;
353             #
354             # my (@smhdmy) = gmtime $unix;
355             # $smhdmy[5] += 1900;
356             # $smhdmy[4]++;
357             #
358             # return join( q<>,
359             # join( '-', @smhdmy[ 5, 4, 3 ] ),
360             # 'T',
361             # join( ':', @smhdmy[ 2, 1, 0 ] ),
362             # 'Z',
363             # );
364             #}
365              
366             =head2 I->make_key_authorization( CHALLENGE )
367              
368             Accepts an instance of L (probably a subclass
369             thereof) and returns
370             a key authorization string suitable for handling the given CHALLENGE.
371             See F in the distribution for example usage.
372              
373             If you’re using HTTP authorization and are on the same server as the
374             domains’ document roots, then look at the handler logic in
375             L for a potentially simpler way to
376             handle HTTP challenges.
377              
378             =cut
379              
380             sub make_key_authorization {
381 0     0 1 0 my ($self, $challenge_obj) = @_;
382              
383 0 0       0 _die_generic('Need a challenge object!') if !$challenge_obj;
384              
385 0         0 return $challenge_obj->token() . '.' . $self->_key_thumbprint();
386             }
387              
388             =head2 I->accept_challenge( CHALLENGE )
389              
390             Signal to the ACME server that the CHALLENGE is ready.
391              
392             =cut
393              
394             sub accept_challenge {
395 0     0 1 0 my ($self, $challenge_obj) = @_;
396              
397 0         0 my $post = $self->_post_url(
398             $challenge_obj->url(),
399             {
400             keyAuthorization => $self->make_key_authorization($challenge_obj),
401             },
402             );
403              
404 0         0 return;
405             }
406              
407             =head2 $status = I->poll_authorization( AUTHORIZATION )
408              
409             Accepts a L instance and polls the
410             ACME server for that authorization’s status. The AUTHORIZATION
411             object is then updated with the results of the poll.
412              
413             As a courtesy, this returns the object’s new C.
414              
415             =cut
416              
417             #This has to handle updates to the authz and challenge objects
418             *poll_authorization = *_poll_order_or_authz;
419              
420             =head2 $status = I->finalize_order( ORDER, CSR )
421              
422             Finalizes an order and updates the ORDER object with the returned
423             status. The CSR may be in either DER or PEM format.
424              
425             As a courtesy, this returns the ORDER’s C. If this does
426             not equal C, then you should probably C
427             until it does.
428              
429             =cut
430              
431             sub finalize_order {
432 0     0 1 0 my ($self, $order_obj, $csr) = @_;
433              
434 0         0 my $csr_der;
435 0 0       0 if (index($csr, '-----') == 0) {
436 0         0 $csr_der = Crypt::Format::pem2der($csr);
437             }
438             else {
439 0         0 $csr_der = $csr;
440             }
441              
442 0         0 $csr = MIME::Base64::encode_base64url($csr_der);
443              
444 0         0 my $post = $self->_post_url(
445             $order_obj->finalize(),
446             {
447             csr => $csr,
448             },
449             );
450              
451 0         0 my $content = $post->content_struct();
452              
453 0         0 $order_obj->update($content);
454              
455 0         0 return $order_obj->status();
456             }
457              
458             =head2 I->poll_order( ORDER )
459              
460             Like C but handles a
461             L object instead.
462              
463             =cut
464              
465             *poll_order = *_poll_order_or_authz;
466              
467             #----------------------------------------------------------------------
468              
469             sub _key_thumbprint {
470 0     0   0 my ($self) = @_;
471              
472 0   0     0 return $self->{'_key_thumbprint'} ||= $self->_key_obj()->get_jwk_thumbprint( _JWK_THUMBPRINT_DIGEST() );
473             }
474              
475             sub _get_directory {
476 5     5   9 my ($self) = @_;
477              
478 5   66     15 $self->{'_directory'} ||= do {
479 1         4 my $dir_path = $self->DIRECTORY_PATH();
480 1         7 $self->{'_ua'}->get("https://$self->{'_host'}$dir_path")->content_struct();
481             };
482              
483 5 50       36 my $new_nonce_url = $self->{'_directory'}{'newNonce'} or do {
484 0         0 _die_generic('Directory is missing “newNonce”.');
485             };
486              
487 5         32 $self->{'_ua'}->set_new_nonce_url( $new_nonce_url );
488              
489 5         13 return $self->{'_directory'};
490             }
491              
492             sub _require_key_id {
493 0     0   0 my ($self, $opts_hr) = @_;
494              
495 0 0       0 $opts_hr->{'_key_id'} = $self->{'_key_id'} or do {
496 0         0 _die_generic('No key ID has been set. Either pass “key_id” to new(), or create_new_account().');
497             };
498              
499             return
500 0         0 }
501              
502             sub _poll_order_or_authz {
503 0     0   0 my ($self, $order_or_authz_obj) = @_;
504              
505 0         0 my $get = $self->{'_ua'}->get( $order_or_authz_obj->id() );
506              
507 0         0 my $content = $get->content_struct();
508              
509 0         0 $order_or_authz_obj->update($content);
510              
511 0         0 return $order_or_authz_obj->status();
512             }
513              
514             sub _key_obj {
515 1     1   2 my ($self) = @_;
516              
517 1   33     7 return $self->{'_key_obj'} ||= Crypt::Perl::PK::parse_key($self->{'_key'});
518             }
519              
520             sub _set_ua {
521 1     1   2 my ($self) = @_;
522              
523             $self->{'_ua'} = Net::ACME2::HTTP->new(
524             key => $self->_key_obj(),
525 1         3 key_id => $self->{'_key_id'},
526             jws_format => $self->JWS_FORMAT(),
527             );
528              
529 1         3 return;
530             }
531              
532             our $_POST_METHOD;
533              
534             sub _post {
535 2     2   6 my ( $self, $link_name, $data ) = @_;
536              
537 2         4 my $post_method;
538 2 50       6 $post_method = 'post_full_jwt' if grep { $link_name eq $_ } FULL_JWT_METHODS();
  4         14  
539              
540             # Since the $link_name will come from elsewhere in this module
541             # there really shouldn’t be an error here, but just in case.
542 2 50       9 my $url = $self->_get_directory()->{$link_name} or _die_generic("Unknown link name: “$link_name”");
543              
544 2         12 return $self->_post_url( $url, $data, $post_method );
545             }
546              
547             sub _post_url {
548 2     2   8 my ( $self, $url, $data, $opt_post_method ) = @_;
549              
550             #Do this in case we haven’t initialized the directory yet.
551             #Initializing the directory is necessary to get a nonce.
552 2         5 $self->_get_directory();
553              
554 2   50     8 my $post_method = $opt_post_method || 'post_key_id';
555              
556 2         16 return $self->{'_ua'}->$post_method( $url, $data );
557             }
558              
559             sub _die_generic {
560 0     0     die Net::ACME2::X->create('Generic', @_);
561             }
562              
563             1;
564              
565             =head1 TODO
566              
567             =over
568              
569             =item * Add pre-authorization support if there is ever a production
570             use for it.
571              
572             =item * Expose the Retry-After header via the module API.
573              
574             =item * Add (more) tests.
575              
576             =back
577              
578             =head1 SEE ALSO
579              
580             L provides pure-Perl cryptography for this library. See the
581             present library distribution’s F directory for sample usage
582             to generate keys and CSRs.
583              
584             =cut