File Coverage

blib/lib/Protocol/ACME.pm
Criterion Covered Total %
statement 214 413 51.8
branch 64 162 39.5
condition 4 13 30.7
subroutine 32 46 69.5
pod 14 18 77.7
total 328 652 50.3


line stmt bran cond sub pod time code
1             package Protocol::ACME;
2              
3 6     6   563480 use 5.007003;
  6         70  
4 6     6   31 use strict;
  6         10  
  6         118  
5 6     6   27 use warnings;
  6         12  
  6         153  
6              
7 6     6   3061 use Data::Dumper;
  6         33746  
  6         788  
8              
9             our $VERSION = '1.02';
10              
11             =head1 NAME
12              
13             Protocol::ACME - Interface to the Let's Encrypt ACME API
14              
15             =head1 VERSION
16              
17             Version 1.02
18              
19             =head1 SYNOPSIS
20              
21             use Protocol::ACME;
22              
23             my @names = qw( www.example.com cloud.example.com );
24              
25             my $challenges = {
26             'www.example.com' => Protocol::ACME::Challenge::SimpleSSH->new(
27             { ssh_host => "host1", www_root => "~/www" }
28             ),
29             'cloud.example.com' => Protocol::ACME::Challenge::SimpleSSH->new(
30             { ssh_host => "home2", www_root => "/opt/local/www/htdocs" }
31             )
32             };
33              
34             eval
35             {
36             my $acme = Protocol::ACME->new( host => $host,
37             account_key => $account_key_pem_or_der,
38             );
39              
40             $acme->directory();
41             $acme->register();
42             $acme->accept_tos();
43              
44             for my $domain ( @names )
45             {
46             $acme->authz( $domain );
47             $acme->handle_challenge( $challenges->{$domain} );
48             $acme->check_challenge();
49             $acme->cleanup_challenge( $challenges->{$domain} );
50             }
51              
52             my $cert = $acme->sign( $csr_file );
53             };
54             if ( $@ )
55             {
56             die if !UNIVERSAL::isa($@, 'Protocol::ACME::Exception');
57             die "Error occurred: Status: $@->{status},
58             Detail: $@->{detail},
59             Type: $@->{type}\n";
60             }
61             else
62             {
63             # do something appropriate with the DER encoded cert
64             print "Success\n";
65             }
66              
67             =head1 DESCRIPTION
68              
69             The C is a class implementing an interface for the
70             Let's Encrypt ACME API.
71              
72             The class handles the protocol details behind provisioning a Let's
73             Encrypt certificate.
74              
75             =head1 CONSTRUCTOR METHODS
76              
77             The following constructor methods are available:
78              
79             =over 4
80              
81             =item $acme = Protcol::ACME->new( %options )
82              
83             This method constructs a new C object and returns it.
84             Key/value pair arguments may be provided to set up the initial state.
85             The may be passed in as a hash or a hashref. The following options
86             correspond to attribute methods described below. Items marked with
87             a * are required.
88              
89             KEY DEFAULT
90             ----------- --------------------
91             *host undef
92             account_key undef
93             openssl undef
94             ua HTTP::Tiny->new()
95             loglevel error
96             debug 0
97             mailto undef
98              
99             B: The API end point to connect to. This will generally be acme-staging.api.letsencrypt.org
100             or acme-v01.api.letsencrypt.org
101              
102             B: The account private key in a scalar ref or filename. See C<$self->account_key>
103             for details on this arguemtn.
104              
105             B: The path to openssl. If this option is used a local version of the openssl binary will
106             be used for crypto operations rather than C.
107              
108             B: An HTTP::Tiny object customized as you see fit
109              
110             B: Set the loglevel to one of the C values.
111              
112             B: If set to non-zero this is a shortcut for C debug>
113              
114             B: This should be the email address that you want associated with your account. This is used
115             my Let's Encrypt for expiration notification.
116              
117             =back
118              
119             =head2 METHODS
120              
121             =over
122              
123             =item account_key( $key_filename )
124              
125             =item account_key( \$buffer )
126              
127             =item account_key( \%explicit_args )
128              
129              
130             C will load a the private account key if it was not already loaded
131             when the C object was constructed. There are three ways to call this:
132              
133             If the arg is a B it is assumed to be the filename of the
134             key. C will throw an error if there are problems reading the file.
135              
136             If the arg is a B reference it is assumed to be a buffer that
137             contains the KEY.
138              
139             If the arg is a B reference it contains named arguments. The arguments
140             are:
141              
142             KEY DEFAUL DESC
143             ----------- ----------- -------------------
144             filename undef The key Filename
145             buffer undef Buffer containing the key
146             format undef Explicitly state the format ( DER | PEM )
147              
148             If both C and C are set the C argument will be ignored.
149              
150             If the format is not explcitly set C will look at the key and
151             try and determine what the format it.
152              
153              
154             =item load_key_from_disk( $key_path )
155              
156             B
157              
158             Load a key from disk. Currently the key needs to be unencrypted.
159             Callbacks for handling password protected keys are still to come.
160              
161             =item directory()
162              
163             Loads the directory from the ACME host. This call must be made first
164             before any other calls to the API in order the bootstrap the API
165             resource list.
166              
167             =item register( %args )
168              
169             Call the new-reg resource and create an account associated with the
170             loaded account key. If that key has already been registered this method
171             will gracefully and silently handle that.
172              
173             Arguments that can be passed in:
174              
175             KEY DEFAULT
176             ----------- --------------------
177             mailto undef
178              
179             B: See C for a desciption. This will override the value passed to new
180             if any.
181              
182              
183             =item accept_tos()
184              
185             In order to use the Let's Encrypt service, the account needs to accept
186             the Terms of Service. This is provided in a link header in response
187             to the new-reg ( or reg ) resource call. If the TOS have already been
188             accepted as indicated by the reg structure returned by the API this
189             call will be a noop.
190              
191             =item authz( $domain )
192              
193             C needs to be called for each domain ( called identifiers in
194             ACME speak ) in the certificate. This included the domain in the subject
195             as well as the Subject Alternate Name (SAN) fields. Each call to
196             C will result in a challenge being issued from Let's Encrypt.
197             These challenges need to be handled individually.
198              
199             =item handle_challenge( $challenge_object )
200              
201             C is called for each challenge issued by C.
202             The challenge object must be a subclass of C
203             which implements a 'handle' method. This objects handle method
204             will be passed three arguments and is expected to fulfill the
205             preconditions for the chosen challenge. The three areguments
206             are:
207              
208             fingerprint: the sha256 hex digest of the account key
209             token: the challenge token
210             url: the url returned by the challenge
211              
212             Fully describing how to handle every challenge type of out of the
213             scope of this documentation ( at least for now ). Two challenge
214             classes have been included for reference:
215              
216             C is initialized with the
217             ssh host name and the www root for the web server for the http-01
218             challenge. It will ssh to the host and create the file in
219             the correct location for challenge fulfillment.
220              
221             C is initialized with just the
222             www root for the web server for the http-01 challenge. It will
223             simply create the challenge file in the correct place on the local
224             filesystem.
225              
226             C is intended to be run in an
227             interactive manner and will stop and prompt the user with the relevant
228             information so they can fulfill the challenge manually.
229              
230             but below is an example for handling the simpleHTTP ( http-01 )
231             challenge.
232              
233              
234             =item check_challenge()
235              
236             Called after C. This will poll the challenge status
237             resource and will return when the state changes from 'pending'.
238              
239             =item cleanup_challenge()
240              
241             Called after C to remove the challenge files.
242              
243             =item $cert = sign( $csr_filename )
244              
245             =item $cert = sign( \$buffer )
246              
247             =item $cert = sign( \%explicit_args )
248              
249              
250             Call C after the challenge for each domain ( itentifier ) has
251             been fulfilled. There are three ways to call this:
252              
253             If the arg is a B it is assumed to be the filename of the
254             CSR. C will throw an error if there are problems reading the file.
255              
256             If the arg is a B reference it is assumed to be a buffer that
257             contains the CSR.
258              
259             If the arg is a B reference it contains named arguments. The arguments
260             are:
261              
262             KEY DEFAUL DESC
263             ----------- ----------- -------------------
264             filename undef The CSR Filename
265             buffer undef Buffer containing the CSR
266             format undef Explicitly state the format ( DER | PEM )
267              
268             If both C and C are set the C argument will be ignored.
269              
270             If the format is not explcitly set Protocol::ACME will look at the CSR and
271             try and determine what the format it.
272              
273             On success C will return the DER encoded signed certificate.
274              
275             =item $cert_chain = chain()
276              
277             After C has been called and a cert successfully created, C will
278             fetch and return the DER encoded certificate issuer.
279              
280             =item revoke( $certfile )
281              
282             Call C to revoke an already issued certificate. C<$certfile>
283             must point the a DER encoded form of the certificate.
284              
285             =item recovery_key()
286              
287             LE does not yet support recovery keys. This method will die when
288             called.
289              
290              
291             =back
292              
293             =cut
294              
295             package Protocol::ACME;
296              
297 6     6   47 use strict;
  6         12  
  6         176  
298 6     6   34 use warnings;
  6         12  
  6         179  
299              
300 6     6   2489 use Protocol::ACME::Exception;
  6         16  
  6         166  
301 6     6   2368 use Protocol::ACME::Utils;
  6         15  
  6         177  
302              
303 6     6   2185 use Crypt::Format;
  6         2839  
  6         154  
304 6     6   2141 use Crypt::RSA::Parse ();
  6         231429  
  6         186  
305              
306 6     6   2742 use MIME::Base64 qw( encode_base64url decode_base64url decode_base64 encode_base64 );
  6         3972  
  6         441  
307              
308 6     6   4140 use HTTP::Tiny;
  6         240391  
  6         300  
309 6     6   4007 use JSON;
  6         60204  
  6         36  
310 6     6   3896 use Digest::SHA qw( sha256 );
  6         16462  
  6         461  
311 6     6   46 use Carp;
  6         16  
  6         27792  
312              
313              
314             my $USERAGENT = "Protocol::ACME v$VERSION";
315             my $NONCE_HEADER = "replay-nonce";
316             #my $DEFAULT_VERSION = 2;
317             my $VERSION_MAX = 2;
318             my $VERSION_MIN = 1;
319              
320             my $endpoints =
321             {
322             1 => { primary => [],
323             meta => [] },
324             2 => { primary => [ qw( newNonce newAccount newOrder newAuthz revokeCert keyChange ) ] ,
325             meta => [ qw( website caaIdentities caaIdentities termsOfService ) ] }
326             };
327              
328             sub new
329             {
330 6     6 1 353909 my $class = shift;
331 6         18 my $self = {};
332 6         14 bless $self, $class;
333 6         31 $self->_init( @_ );
334 6         53 return $self;
335             }
336              
337             sub _init
338             {
339 6     6   12 my $self = shift;
340              
341 6         13 my $args;
342              
343 6 50       28 if ( ref $_[0] eq "HASH" )
344             {
345 0         0 $args = $_[0];
346             }
347             else
348             {
349 6         32 %$args = @_;
350             }
351              
352             # TODO: There are more elegant and well baked ways to take care of the
353             # parameter handling that I am doing here
354 6 50       30 $self->{version} = $args->{host} if exists $args->{version};
355 6 50       44 $self->{host} = $args->{host} if exists $args->{host};
356 6 50       25 $self->{ua} = $args->{ua} if exists $args->{ua};
357 6 100       20 $self->{openssl} = $args->{openssl} if exists $args->{openssl};
358 6 100       24 $self->{debug} = $args->{debug} if exists $args->{debug};
359 6 100       24 $self->{loglevel} = exists $args->{loglevel} ? $args->{loglevel} : "error";
360 6 50       17 $self->{contact}->{mailto} = $args->{mailto} if exists $args->{mailto};
361              
362 6 100       28 if ( $self->{debug} )
363             {
364 2         6 $self->{loglevel} = "debug";
365             }
366              
367 6 50       22 if ( ! exists $self->{ua} )
368             {
369 6         55 $self->{ua} = HTTP::Tiny->new( agent => $USERAGENT, verify_SSL => 1 );
370             }
371              
372 6 50       611 if ( ! exists $self->{host} )
373             {
374 0         0 _throw( detail => "host parameter is required for Protocol::ACME::new" );
375             }
376              
377             $self->{log} = $args->{'logger'} || do
378 6   33     26 {
379             require Log::Any::Adapter;
380             Log::Any::Adapter->set('+Protocol::ACME::Logger', log_level => $self->{loglevel});
381             Log::Any->get_logger;
382             };
383              
384 6 100       734 if ( exists $args->{account_key} )
385             {
386 4         31 $self->account_key( $args->{account_key} );
387             }
388              
389 6         75 $self->{links}->{directory} = "https://" . $self->{host} . '/directory';
390              
391 6 50       31 if ( exists $self->{version} )
392             {
393 0 0 0     0 if ( $self->{version} < $VERSION_MIN || $self->{version} > $VERSION_MAX )
394             {
395 0         0 _throw( detail => "ACME version number must an integer between $VERSION_MIN and VERSION_MAX" );
396             }
397             }
398              
399 6         31 $self->{nonce} = undef;
400              
401              
402             }
403              
404             sub _throw
405             {
406 2     2   6 my (@args) = @_;
407 2 50       9 if ( scalar(@_) == 1 )
408             {
409 2         6 @args = ( detail => $_[0] );
410             }
411 2         20 croak ( Protocol::ACME::Exception->new( { @args } ) );
412             }
413              
414             sub load_key
415             {
416 0     0 0 0 my ($self, $keystring) = @_;
417 0         0 return $self->account_key( \$keystring );
418             }
419              
420             sub load_key_from_disk
421             {
422 0     0 1 0 my $self = shift;
423 0         0 my $path = shift;
424              
425 0         0 return $self->account_key($path);
426             }
427              
428             sub account_key
429             {
430 28     28 1 6428480 my $self = shift;
431 28         71 my $key = shift;
432              
433 28         168 my %args = ( filename => undef,
434             buffer => undef,
435             format => undef );
436              
437 28 100       157 if ( ! ref $key )
    100          
438             {
439 4         13 $args{filename} = $key;
440              
441 4 50       174 if ( ! -f $key )
442             {
443 0         0 _throw( "account_key file $key does not exist" );
444             }
445             }
446             elsif( ref $key eq "SCALAR" )
447             {
448 8         29 $args{buffer} = $$key;
449             }
450             else
451             {
452 16         78 @args{ keys %$key } = values %$key;
453             }
454              
455 28 100       118 if ( $args{filename} )
456             {
457 8         59 $args{buffer} = _slurp( $args{filename} );
458 8 50       41 if ( ! $args{buffer} )
459             {
460 0         0 _throw( "Could not load the account key from file $args{filename}: $!" );
461             }
462             }
463              
464 28 50       92 if ( ! $args{buffer} )
465             {
466 0         0 _throw( "Either an account key buffer or filename must be passed into account_key" );
467             }
468              
469 28 100       73 if ( ! $args{format} )
470             {
471 20 100       117 $args{format} = Protocol::ACME::Utils::looks_like_pem( $args{buffer} ) ? "PEM" : "DER";
472             }
473              
474 28         73 my $keystring = $args{buffer};
475             # TODO: This should detect/handle PKCS8-formatted private keys as well.
476 28 100       121 if ( $args{format} eq "DER" )
477             {
478 12         75 $keystring = Crypt::Format::der2pem( $keystring, "RSA PRIVATE KEY" );
479             }
480              
481 28 100       780 if ( exists $self->{openssl} )
482             {
483 15         2293 require Protocol::ACME::Key;
484             $key = Protocol::ACME::Key->new( keystring => $keystring,
485 15         127 openssl => $self->{openssl} );
486             }
487             else
488             {
489             eval
490 13         33 {
491 13         112 require Crypt::OpenSSL::RSA;
492 13         60 require Crypt::OpenSSL::Bignum;
493             };
494 13 50       46 if ( $@ )
495             {
496 0         0 die "Invoked usage requires Crypt::OpenSSL::RSA and Crypt::OpenSSL::Bignum. " .
497             "To avoid these dependencies use the openssl parameter when creating the " .
498             "Protocol::ACME object. This will use a native openssl binary instead.";
499             }
500              
501             eval
502 13         27 {
503 13         1231 $key = Crypt::OpenSSL::RSA->new_private_key($keystring);
504             };
505 13 100       57 if ( $@ )
506             {
507 2         10 _throw( "Error creating a key structure from the account key" );
508             }
509             }
510              
511 25 50       197 if ( ! $key )
512             {
513 0         0 _throw( "Could not load account key into key structure" );
514             }
515              
516 25         330 $key->use_sha256_hash();
517              
518 25         112 $self->{key}->{key} = $key;
519              
520 25         722 my ( $n_b64, $e_b64 ) = map { encode_base64url(_bigint_to_binary($_)) } $key->get_key_parameters();
  116         2868  
521 25         438 $self->{key}->{n} = $n_b64;
522 25         101 $self->{key}->{e} = $e_b64;
523              
524 25         224 $self->{log}->debug( "Private key loaded" );
525              
526             }
527              
528              
529              
530              
531             sub directory
532             {
533 4     4 1 3393 my $self = shift;
534              
535 4         38 my $resp = $self->_request_get( $self->{links}->{directory} );
536              
537              
538              
539 4 50       26 if ( $resp->{status} != 200 )
540             {
541 0         0 _throw( detail => "Failed to fetch the directory for $self->{host}", resp => $resp );
542             }
543              
544 4         16 my $data = _decode_json( $resp->{content} );
545              
546 4 50       36 if ( ! $self->{version} )
547             {
548 4 50       21 if ( exists $data->{'new-reg'} )
    0          
549             {
550 4         13 $self->{version} = 1;
551             }
552             elsif ( exists $data->{newNonce} )
553             {
554 0         0 $self->{version} = 2;
555             }
556             else
557             {
558 0         0 _throw( detail => "Unable to determine the ACME endpoint version" );
559             }
560             }
561              
562 4 50       28 if ( $self->{version} == 1 )
563             {
564 4 50       19 if ( ! exists $data->{'new-reg'} )
565             {
566 0         0 _throw( detail => "Requested ACME v1 but the endpoint does not seem to support v1" );
567             }
568 4         27 @{$self->{links}}{keys %$data} = values %$data;
  4         37  
569             }
570             else
571             {
572 0 0       0 if ( ! exists $data->{'newNonce'} )
573             {
574 0         0 _throw( detail => "Requested ACME v2 but the endpoint does not seem to support v2" );
575             }
576              
577 0         0 for my $endpoint ( @{$endpoints->{2}->{primary}} )
  0         0  
578             {
579 0         0 $self->{links}->{$endpoint} = $data->{$endpoint};
580             }
581              
582 0         0 for my $endpoint ( @{$endpoints->{2}->{meta}} )
  0         0  
583             {
584 0         0 $self->{links}->{meta}->{$endpoint} = $data->{meta}->{$endpoint};
585             }
586              
587             }
588              
589 4 50       20 if ( $self->{version} > 1 )
590             {
591 0         0 $self->fetch_nonce();
592             }
593              
594 4         42 $self->{log}->debug( "Let's Encrypt Directories loaded." );
595             }
596              
597              
598             sub fetch_nonce
599             {
600 0     0 0 0 my $self = shift;
601              
602 0 0       0 if ( $self->{version} < 2 )
603             {
604 0         0 _throw( detail => "fetchNonce only supporeted in version 2 and above" );
605             }
606              
607 0 0       0 if ( ! $self->{nonce} )
608             {
609 0         0 $self->{log}->debug( "Fetchign nonce" );
610              
611 0         0 my $resp = $self->_request_head( $self->{links}->{newNonce} );
612              
613 0         0 $self->{nonce} = $resp->{headers}->{$NONCE_HEADER};
614             }
615              
616 0         0 return $self->{nonce};
617             }
618              
619             #
620             # Register the account or load the reg url for an existing account ( new-reg or reg )
621             #
622             sub register
623             {
624 4     4 1 2267 my $self = shift;
625              
626 4 50       24 if ( $self->{version} == 1 )
    0          
627             {
628 4         25 $self->register_v1( @_ );
629             }
630             elsif ( $self->{version} == 2 )
631             {
632 0         0 $self->register_v2( @_ );
633             }
634             else
635             {
636 0         0 _throw( details => "Unknown version: $self-{version}" );
637             }
638              
639             }
640              
641             sub register_v2
642             {
643 0     0 0 0 my $self = shift;
644 0         0 my %args = @_;
645              
646 0         0 my $obj = {};
647              
648 0 0       0 if ( exists $args{mailto} )
    0          
649             {
650 0         0 push @{$obj->{contact}}, "mailto:$args{mailto}";
  0         0  
651             }
652             elsif ( exists $self->{contact}->{mailto} )
653             {
654 0         0 push @{$obj->{contact}}, "mailto:$self->{contact}->{mailto}";
  0         0  
655             }
656              
657 0         0 $obj->{termsOfServiceAgreed} = "true";
658              
659 0         0 my $msg = _encode_json( $obj );
660              
661 0         0 my $json = $self->_create_jws( $msg );
662              
663 0         0 $self->{log}->debug( "Sending registration message" );
664              
665 0         0 my $resp = $self->_request_post( $self->{links}->{'newAccount'}, $json );
666              
667 0         0 print Dumper( $resp );
668             }
669              
670             sub register_v1
671             {
672 4     4 0 12 my $self = shift;
673 4         12 my %args = @_;
674              
675 4         9 my $obj = {};
676 4         16 $obj->{resource} = 'new-reg';
677              
678 4 50       28 if ( exists $args{mailto} )
    50          
679             {
680 0         0 push @{$obj->{contact}}, "mailto:$args{mailto}";
  0         0  
681             }
682             elsif ( exists $self->{contact}->{mailto} )
683             {
684 0         0 push @{$obj->{contact}}, "mailto:$self->{contact}->{mailto}";
  0         0  
685             }
686              
687 4         20 my $msg = _encode_json( $obj );
688              
689 4         25 my $json = $self->_create_jws( $msg );
690              
691 3         74 $self->{log}->debug( "Sending registration message" );
692              
693 3         56 my $resp = $self->_request_post( $self->{links}->{'new-reg'}, $json );
694              
695 3 50       29 if ( $resp->{status} == 409 )
    0          
696             {
697 3         33 $self->{links}->{'reg'} = $resp->{headers}->{'location'};
698              
699 3         36 $self->{log}->debug( "Known key used" );
700 3         27 $self->{log}->debug( "Refetching with location URL" );
701              
702 3         45 my $json = $self->_create_jws( _encode_json( { "resource" => 'reg' } ) );
703              
704 2         50 $resp = $self->_request_post( $self->{links}->{'reg'}, $json );
705              
706 2 50       57 if ( $resp->{status} == 202 )
707             {
708 2         32 my $links = _link_to_hash( $resp->{headers}->{'link'} );
709              
710 2         20 @{$self->{links}}{keys %$links} = values %$links;
  2         28  
711             }
712             else
713             {
714 0         0 _throw( %{ $self->{content} } );
  0         0  
715             }
716             }
717             elsif ( $resp->{status} == 201 )
718             {
719 0         0 my $links = _link_to_hash( $resp->{headers}->{'link'} );
720              
721 0         0 @{$self->{links}}{keys %$links} = values %$links;
  0         0  
722              
723 0         0 $self->{links}->{'reg'} = $resp->{headers}->{'location'};
724 0         0 $self->{log}->debug( "New key used" );
725             }
726             else
727             {
728 0         0 _throw( %{ $self->{content} } );
  0         0  
729             }
730              
731              
732 2         113 $self->{reg} = $self->{content};
733             }
734              
735             sub recovery_key
736             {
737             # LE does not yet support the key recovery resource
738             # the below can be considered debug code
739              
740 0     0 1 0 die "Let's Encrypt does not yet support key recovery";
741              
742 0         0 my $self = shift;
743              
744 0         0 my $keyfile = shift;
745              
746              
747 0         0 my $pem = _slurp( $keyfile );
748 0 0       0 _throw( "recovery_key: $keyfile: $!" ) if ! $pem;
749              
750 0         0 my $url = "https://acme-staging.api.letsencrypt.org/acme/reg/101834";
751              
752 0         0 my $der = Crypt::Format::pem2der( $pem );
753              
754 0         0 my $pub = Crypt::PK::ECC->new( \$der );
755              
756 0         0 my $public_json_text = $pub->export_key_jwk('public');
757              
758 0         0 my $hash = $pub->export_key_jwk( 'public', 1 );
759              
760             my $msg = { "resource" => "reg",
761             "recoveryToken" => {
762             "client" => { "kty" => "EC",
763             "crv" => "P-256",
764             "x" => $hash->{x},
765             "y" => $hash->{y}
766             }
767             }
768 0         0 };
769              
770 0         0 my $json = $self->_create_jws( _encode_json($msg) );
771              
772 0         0 my $resp = $self->_request_post( $url, $json );
773              
774             # TODO: This is not complete
775             }
776              
777             sub accept_tos
778             {
779 2     2 1 1669 my $self = shift;
780              
781 2 50       20 if ( exists $self->{reg}->{agreement} )
782             {
783 2         36 $self->{log}->debug( "TOS already accepted. Skipping" );
784 2         19 return;
785             }
786              
787 0         0 $self->{log}->debug( "Accepting TOS" );
788             # TODO: check for existance of terms-of-service link
789             # TODO: assert on reg url being present
790              
791             my $msg = _encode_json( { "resource" => "reg",
792             "agreement" => $self->{links}->{'terms-of-service'},
793             "key" => { "e" => $self->{key}->{e},
794             "kty" => "RSA",
795 0         0 "n" => $self->{key}->{n} } } );
796              
797              
798 0         0 my $json = $self->_create_jws( $msg );
799              
800 0         0 my $resp = $self->_request_post( $self->{links}->{'reg'}, $json );
801              
802 0 0       0 if ( $resp->{status} == 202 )
803             {
804 0         0 $self->{log}->debug( "Accepted TOS" );
805             }
806             else
807             {
808 0         0 _throw( %{ $self->{content} } );
  0         0  
809             }
810             }
811              
812             sub revoke
813             {
814 0     0 1 0 my $self = shift;
815 0         0 my $certfile = shift;
816              
817 0         0 $self->{log}->debug( "Revoking Cert" );
818              
819 0         0 my $cert = _slurp( $certfile );
820              
821 0 0       0 if ( ! $cert )
822             {
823 0         0 _throw("revoke: Could not load cert from $certfile: $!");
824             }
825              
826              
827 0         0 my $msg = _encode_json( { "resource" => "revoke-cert",
828             "certificate" => encode_base64url( $cert ) } );
829              
830              
831 0         0 my $json = $self->_create_jws( $msg );
832              
833 0         0 my $resp = $self->_request_post( $self->{links}->{'revoke-cert'}, $json );
834              
835 0 0       0 if ( $resp->{status} != 200 )
836             {
837 0         0 _throw( %{ $self->{content} } );
  0         0  
838             }
839              
840             }
841              
842             sub authz
843             {
844 0     0 1 0 my $self = shift;
845 0         0 my $domain = shift;
846              
847 0         0 $self->{log}->debug( "Sending authz message for $domain" );
848             # TODO: check for 'next' URL and that is it authz
849              
850 0         0 my $msg = _encode_json( { "identifier" => { "type" => "dns", "value" => $domain },
851             "resource" => "new-authz" } );
852              
853 0         0 my $json = $self->_create_jws( $msg );
854              
855 0         0 my $resp = $self->_request_post( $self->{links}->{next}, $json );
856              
857 0 0       0 if ( $resp->{status} == 201 )
858             {
859 0         0 $self->{challenges} = $self->{content}->{challenges};
860             }
861             else
862             {
863 0         0 _throw( %{ $self->{content} } );
  0         0  
864             }
865             }
866              
867             sub handle_challenge
868             {
869 0     0 1 0 my $self = shift;
870 0         0 my $challenge = shift;
871 0         0 my @args = @_;
872              
873 0         0 my $key = $self->{key};
874              
875 0         0 my $jwk = _encode_json( { "e" => $key->{e}, "kty" => "RSA", "n" => $key->{n} } );
876 0         0 my $token;
877             my $challenge_url;
878              
879             # TODO: this is feeling hardcoded and messy - and fragile
880             # how do we handle other auth challenges?
881             # This is hardcoded for http-01
882 0         0 for ( @{$self->{challenges}} )
  0         0  
883             {
884 0 0       0 if ( $_->{type} eq "http-01" )
885             {
886 0         0 $token = $_->{token};
887 0         0 $challenge_url = $_->{uri};
888             }
889             }
890              
891              
892 0         0 my $fingerprint = encode_base64url( sha256( $jwk ) );
893              
894 0         0 $self->{log}->debug( "Handing challenge for token: $token.$fingerprint" );
895              
896 0         0 my $ret = $challenge->handle( $token, $fingerprint, @args );
897              
898 0 0       0 if ( $ret == 0 )
899             {
900 0         0 $self->{fingerprint} = $fingerprint;
901 0         0 $self->{token} = $token;
902 0         0 $self->{links}->{challenge} = $challenge_url;
903             }
904             else
905             {
906 0         0 _throw( status => 0, detail => "Error in handling challenge: $ret", type => "challenge_exec" );
907             }
908             }
909              
910              
911             sub check_challenge
912             {
913 0     0 1 0 my $self = shift;
914              
915 0         0 my $msg = _encode_json( { "resource" => "challenge", "keyAuthorization" => $self->{token} . '.' . $self->{fingerprint} } );
916              
917 0         0 my $json = $self->_create_jws( $msg );
918              
919              
920 0         0 my $resp = $self->_request_post( $self->{links}->{challenge}, $json );
921              
922 0         0 my $status_url = $self->{content}->{uri};
923              
924             # TODO: check for failure of challenge check
925             # TODO: check for other HTTP failures
926              
927 0         0 $self->{log}->debug( "Polling for challenge fulfillment" );
928 0         0 while( 1 )
929             {
930 0         0 $self->{log}->debug( "Status: $self->{content}->{status}" );
931 0 0       0 if ( $self->{content}->{status} eq "pending" )
    0          
932             {
933 0         0 sleep(2);
934 0         0 $resp = $self->_request_get( $status_url );
935             }
936             elsif ( $self->{content}{status} eq "invalid" )
937             {
938 0         0 _throw(%{ $self->{content} });
  0         0  
939             }
940             else
941             {
942 0         0 last;
943             }
944             }
945             }
946              
947             sub cleanup_challenge
948             {
949 0     0 1 0 my $self = shift;
950 0         0 my $challenge = shift;
951 0         0 return $challenge->cleanup();
952             }
953              
954             sub sign
955             {
956 0     0 1 0 my $self = shift;
957 0         0 my $csr = shift;
958              
959 0         0 $self->{log}->debug( "Signing" );
960              
961 0         0 my %args = ( filename => undef,
962             buffer => undef,
963             format => undef );
964              
965 0 0       0 if ( ! ref $csr )
    0          
966             {
967 0         0 $args{filename} = $csr;
968             }
969             elsif( ref $csr eq "SCALAR" )
970             {
971 0         0 $args{buffer} = $$csr;
972             }
973             else
974             {
975 0         0 @args{keys %$csr} = values %$csr;
976             }
977              
978 0 0       0 if ( $args{filename} )
979             {
980 0         0 $args{buffer} = _slurp( $args{filename} );
981 0 0       0 if ( ! $args{buffer} )
982             {
983 0         0 _throw( "Could not load CSR from file $args{filename}" );
984             }
985             }
986              
987 0 0       0 if ( ! $args{buffer} )
988             {
989 0         0 _throw( "Either a buffer or filename must be passed to sign" );
990             }
991              
992 0 0       0 if ( ! $args{format} )
993             {
994 0 0       0 $args{format} = Protocol::ACME::Utils::looks_like_pem( $args{buffer} ) ? "PEM" : "DER";
995             }
996              
997 0 0       0 my $der = $args{format} eq "DER" ? $args{buffer} : Crypt::Format::pem2der( $args{buffer} );
998              
999 0         0 my $msg = _encode_json( { "resource" => "new-cert", "csr" => encode_base64url( $der ) } );
1000              
1001 0         0 my $json = $self->_create_jws( $msg );
1002              
1003 0         0 my $resp = $self->_request_post( $self->{links}->{'new-cert'}, $json, 1 );
1004              
1005 0 0       0 if ( $resp->{status} != 201 )
1006             {
1007 0         0 _throw( %{_decode_json($resp->{content}) } );
  0         0  
1008             }
1009              
1010 0         0 my $links = _link_to_hash( $resp->{headers}->{'link'} );
1011              
1012 0 0       0 $self->{links}->{chain} = $links->{up} if exists $links->{up};
1013 0 0       0 $self->{links}->{cert} = $resp->{headers}->{location} if exists $resp->{headers}->{location};
1014              
1015 0         0 $self->{cert} = $resp->{content};
1016              
1017 0         0 return $self->{cert};
1018             }
1019              
1020             sub chain
1021             {
1022 0     0 1 0 my $self = shift;
1023              
1024 0 0       0 if ( ! exists $self->{links}->{chain} )
1025             {
1026 0         0 _throw( "URL for the cert chain missing. Has sign() been called yet?" );
1027             }
1028              
1029 0         0 my $resp = $self->_request_get( $self->{links}->{chain}, 1 );
1030              
1031 0 0       0 if ( $resp->{status} != 200 )
1032             {
1033             _throw( detail => "Error received fetching the certificate chain",
1034 0         0 status => $resp->{status} );
1035             }
1036              
1037 0         0 $self->{chain} = $resp->{content};
1038              
1039 0         0 return $self->{chain};
1040             }
1041              
1042             #############################################################
1043             ### "Private" functions
1044              
1045             sub _request_get
1046             {
1047 4     4   12 my $self = shift;
1048 4         8 my $url = shift;
1049 4   50     28 my $nodecode = shift || 0;
1050              
1051 4         116 my $resp = $self->{ua}->get( $url );
1052              
1053 4         1154182 $self->{nonce} = $resp->{headers}->{$NONCE_HEADER};
1054 4         18 $self->{json} = $resp->{content};
1055              
1056             #Exception here should be fatal.
1057 4         15 $self->{content} = undef;
1058 4 50       45 $self->{content} = _decode_json( $resp->{content} ) unless $nodecode;
1059              
1060 4         13 $self->{response} = $resp;
1061              
1062 4         16 return $resp;
1063             }
1064              
1065             sub _request_head
1066             {
1067 0     0   0 my $self = shift;
1068 0         0 my $url = shift;
1069              
1070 0         0 my $resp = $self->{ua}->head( $url );
1071              
1072 0         0 $self->{nonce} = $resp->{headers}->{$NONCE_HEADER};
1073 0         0 $self->{json} = undef;
1074 0         0 $self->{response} = $resp;
1075              
1076 0         0 return $resp;
1077             }
1078              
1079             sub _request_post
1080             {
1081 5     5   19 my $self = shift;
1082 5         11 my $url = shift;
1083 5         23 my $content = shift;
1084 5   50     61 my $nodecode = shift || 0;
1085              
1086 5         459 my $resp = $self->{ua}->post( $url, { content => $content } );
1087              
1088 5         1278879 $self->{nonce} = $resp->{headers}->{$NONCE_HEADER};
1089              
1090 5         22 $self->{json} = $resp->{content};
1091              
1092             #Let exception from decode_json() propagate:
1093             #if we failed to decode the JSON, that’s a show-stopper.
1094 5         16 $self->{content} = undef;
1095 5 50       138 $self->{content} = _decode_json( $resp->{content} ) unless $nodecode;
1096              
1097 5         64 $self->{response} = $resp;
1098              
1099 5         42 return $resp;
1100             }
1101              
1102             sub _create_jws
1103             {
1104 7     7   17 my $self = shift;
1105              
1106 7         22 my $msg = shift;
1107              
1108 7 50       29 if ( $self->{version} == 1 )
    0          
1109             {
1110 7         39 return _create_jws_internal_v1( $self->{key}, $msg, $self->{nonce} );
1111             }
1112             elsif ( $self->{version} == 2 )
1113             {
1114 0         0 return _create_jws_internal_v2( $self->{key}, $msg, $self->{nonce} );
1115             }
1116             else
1117             {
1118 0         0 _throw( "Unknown ACME version: $self->{version}" );
1119             }
1120              
1121             }
1122              
1123              
1124             #############################################################
1125             ### Helper functions - not class methods
1126              
1127             sub _slurp
1128             {
1129 8     8   21 my $filename = shift;
1130              
1131 8 50       594 open my $fh, '<', $filename or return undef;
1132              
1133 8 50       226 sysread( $fh, my $content, -s $fh ) or return undef;
1134              
1135 8         176 return $content;
1136             }
1137              
1138              
1139             sub _link_to_hash
1140             {
1141 2     2   11 my $arrayref = shift;
1142 2         15 my $links;
1143              
1144 2 50       11 return {} unless $arrayref;
1145              
1146 2 50       16 if ( ! ref $arrayref )
1147             {
1148 0         0 $arrayref = [ $arrayref ];
1149             }
1150              
1151 2         16 for my $link ( @$arrayref )
1152             {
1153 4         23 my ( $value, $key ) = split( ';', $link );
1154 4         70 my ($url) = $value =~ /<([^>]*)>/;
1155 4         34 my ($rel) = $key =~ /rel=\"([^"]*)"/;
1156              
1157 4 50 33     32 if ( $url && $rel )
1158             {
1159 4         25 $links->{$rel} = $url;
1160             }
1161             else
1162             {
1163             # TODO: Something wonderful
1164             }
1165             }
1166              
1167 2         16 return $links;
1168             }
1169              
1170             sub _bigint_to_binary {
1171 116     116   221 my ( $bigint ) = @_;
1172              
1173             # TODO: Inelegant hack to deal with different Bignum implementations
1174 116         170 my $hex;
1175 116 100       468 if ( UNIVERSAL::isa( $bigint, "Math::BigInt" ) )
1176             {
1177 28         138 $hex = substr( $bigint->as_hex(), 2 );
1178             #Prefix a 0 as needed to get an even number of digits.
1179 28 100       376815 if (length($hex) % 2) {
1180 14         45 substr( $hex, 0, 0, 0 );
1181             }
1182              
1183 28         276 return pack 'H*', $hex;
1184             }
1185             else
1186             {
1187 88         360 $bigint->to_bin();
1188             }
1189              
1190             }
1191              
1192             sub _create_jws_internal_v2
1193             {
1194 0     0   0 my $key = shift;
1195 0         0 my $msg = shift;
1196 0         0 my $nonce = shift;
1197 0         0 my $url = shift;
1198              
1199 0         0 my $protected_header = _encode_json( { nonce => $nonce, alg => "RS256", url => $url }, jwk => { "e" => $key->{e}, "kty" => "RSA", "n" => $key->{n} } );
1200              
1201 0         0 my $sig = encode_base64url( $key->{key}->sign( encode_base64url($protected_header) . "." . encode_base64url($msg) ) );
1202              
1203 0         0 my $jws = { protected => encode_base64url( $protected_header ),
1204             payload => encode_base64url( $msg ),
1205             signature => $sig };
1206              
1207 0         0 print Dumper( $jws );
1208            
1209 0         0 my $json = _encode_json( $jws );
1210              
1211 0         0 return $json;
1212              
1213             }
1214              
1215             sub _create_jws_internal_v1
1216             {
1217 7     7   15 my $key = shift;
1218 7         16 my $msg = shift;
1219 7         15 my $nonce = shift;
1220              
1221 7         28 my $protected_header = '{"nonce": "' . $nonce . '"}';
1222              
1223 7         54 my $sig = encode_base64url( $key->{key}->sign( encode_base64url($protected_header) . "." . encode_base64url($msg) ) );
1224              
1225             my $jws = { header => { alg => "RS256", jwk => { "e" => $key->{e}, "kty" => "RSA", "n" => $key->{n} } },
1226 5         6650 protected => encode_base64url( $protected_header ),
1227             payload => encode_base64url( $msg ),
1228             signature => $sig };
1229              
1230 5         214 my $json = _encode_json( $jws );
1231              
1232 5         105 return $json;
1233              
1234             }
1235              
1236             sub _decode_json
1237             {
1238 13     13   37 my $ref = shift;
1239              
1240 13         29 my $json = "";
1241              
1242             eval
1243 13         31 {
1244 13         518 $json = JSON->new->allow_nonref->decode($ref);
1245             };
1246              
1247 13         101 return $json;
1248             }
1249              
1250             sub _encode_json
1251             {
1252 12     12   39 my $ref = shift;
1253             # my $json = JSON->new();
1254             # $json->canonical();
1255             # return $json->encode($ref);
1256 12         368 return JSON->new->canonical->encode($ref);
1257             }
1258              
1259              
1260             =head1 AUTHOR
1261              
1262             Stephen Ludin, C<< >>
1263              
1264             =head1 BUGS
1265              
1266             Please report any bugs or feature requests to C, or through
1267             the web interface at L. I will be notified, and then you'll
1268             automatically be notified of progress on your bug as I make changes.
1269              
1270             =head1 REPOSITORY
1271              
1272             https://github.com/sludin/Protocol-ACME
1273              
1274              
1275             =head1 SUPPORT
1276              
1277             You can find documentation for this module with the perldoc command.
1278              
1279             perldoc Protocol::ACME
1280              
1281              
1282             You can also look for information at:
1283              
1284             =over 4
1285              
1286             =item * RT: CPAN's request tracker (report bugs here)
1287              
1288             L
1289              
1290             =item * AnnoCPAN: Annotated CPAN documentation
1291              
1292             L
1293              
1294             =item * CPAN Ratings
1295              
1296             L
1297              
1298             =item * Search CPAN
1299              
1300             L
1301              
1302             =back
1303              
1304              
1305             =head1 CONTRIBUTORS
1306              
1307             Felipe Gasper, C<< >>
1308              
1309             =head1 ACKNOWLEDGEMENTS
1310              
1311              
1312              
1313             =head1 LICENSE AND COPYRIGHT
1314              
1315             Copyright 2015 Stephen Ludin.
1316              
1317             This program is free software; you can redistribute it and/or modify it
1318             under the terms of the the Artistic License (2.0). You may obtain a
1319             copy of the full license at:
1320              
1321             L
1322              
1323             Any use, modification, and distribution of the Standard or Modified
1324             Version 1.02
1325             distributing the Package, you accept this license. Do not use, modify,
1326             or distribute the Package, if you do not accept this license.
1327              
1328             If your Modified Version has been derived from a Modified Version made
1329             by someone other than you, you are nevertheless required to ensure that
1330             your Modified Version complies with the requirements of this license.
1331              
1332             This license does not grant you the right to use any trademark, service
1333             mark, tradename, or logo of the Copyright Holder.
1334              
1335             This license includes the non-exclusive, worldwide, free-of-charge
1336             patent license to make, have made, use, offer to sell, sell, import and
1337             otherwise transfer the Package with respect to any patent claims
1338             licensable by the Copyright Holder that are necessarily infringed by the
1339             Package. If you institute patent litigation (including a cross-claim or
1340             counterclaim) against any party alleging that the Package constitutes
1341             direct or contributory patent infringement, then this Artistic License
1342             to you shall terminate on the date that such litigation is filed.
1343              
1344             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
1345             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
1346             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
1347             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
1348             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
1349             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
1350             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
1351             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
1352              
1353              
1354             =cut
1355              
1356             1; # End of Protocol::ACME