File Coverage

blib/lib/Net/ACME2.pm
Criterion Covered Total %
statement 89 124 71.7
branch 17 34 50.0
condition 9 17 52.9
subroutine 22 29 75.8
pod 9 9 100.0
total 146 213 68.5


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