File Coverage

blib/lib/Mojo/ACME.pm
Criterion Covered Total %
statement 89 132 67.4
branch 17 32 53.1
condition 6 12 50.0
subroutine 19 30 63.3
pod 0 7 0.0
total 131 213 61.5


line stmt bran cond sub pod time code
1             package Mojo::ACME;
2              
3 1     1   355531 use Mojo::Base -base;
  1         3  
  1         4  
4              
5             our $VERSION = '0.10';
6             $VERSION = eval $VERSION;
7              
8 1     1   141 use Mojo::Collection 'c';
  1         1  
  1         58  
9 1     1   9 use Mojo::JSON qw/encode_json/;
  1         4  
  1         49  
10 1     1   6 use Mojo::URL;
  1         2  
  1         5  
11              
12 1     1   287 use Crypt::OpenSSL::PKCS10;
  1         530  
  1         46  
13 1     1   5 use MIME::Base64 qw/encode_base64url encode_base64 decode_base64/;
  1         2  
  1         45  
14 1     1   8 use Scalar::Util ();
  1         3  
  1         27  
15              
16 1     1   257 use Mojo::ACME::Key;
  1         2  
  1         16  
17 1     1   297 use Mojo::ACME::ChallengeServer;
  1         2  
  1         6  
18              
19             has account_key => sub { Mojo::ACME::Key->new(path => 'account.key') };
20             has ca => sub { die 'ca is required' };
21             has challenges => sub { {} };
22             #TODO use cert_key->key if it exists
23             has cert_key => sub { Mojo::ACME::Key->new };
24              
25             has secret => sub { die 'secret is required' };
26             has server => sub { Mojo::ACME::ChallengeServer->new(acme => shift)->start };
27             has server_url => 'http://127.0.0.1:5000';
28             has ua => sub {
29             my $self = shift;
30             Scalar::Util::weaken $self;
31             my $ua = Mojo::UserAgent->new;
32             $ua->on(start => sub {
33             my (undef, $tx) = @_;
34             $tx->on(finish => sub {
35             my $tx = shift;
36             return unless $self && $tx->success;
37             return unless my $nonce = $tx->res->headers->header('Replay-Nonce');
38             push @{$self->{nonces} ||= []}, $nonce;
39             });
40             });
41             return $ua;
42             };
43              
44             sub check_all_challenges {
45 0     0 0 0 my ($self, $cb) = (shift, pop);
46 0         0 my @pending = $self->pending_challenges->each;
47             Mojo::IOLoop->delay(
48             sub {
49 0     0   0 my $delay = shift;
50 0 0       0 $delay->pass unless @pending;
51 0         0 $self->check_challenge_status($_, $delay->begin) for @pending;
52             },
53             sub {
54 0     0   0 my $delay = shift;
55 0 0       0 if (my $err = c(@_)->first(sub{ ref })) { return $self->$cb($err) }
  0         0  
  0         0  
56 0 0       0 return $self->$cb(undef) unless $self->pending_challenges->size;
57 0         0 Mojo::IOLoop->timer(2 => $delay->begin);
58             },
59 0     0   0 sub { $self->check_all_challenges($cb) },
60 0         0 );
61             }
62              
63             sub check_challenge_status {
64 3     3 0 3942 my ($self, $token, $cb) = @_;
65 1     1   180 return Mojo::IOLoop->next_tick(sub{ $self->$cb({token => $token, message => 'unknown token'}) })
66 3 100       9 unless my $challenge = $self->challenges->{$token};
67             $self->ua->get($challenge->{uri} => sub {
68 2     2   590 my ($ua, $tx) = @_;
69 2         4 my $err;
70 2 100       6 if (my $res = $tx->success) {
71 1         21 $self->challenges->{$token} = $res->json;
72             } else {
73 1         17 $err = $tx->error;
74 1         11 $err->{token} = $token;
75             }
76 2         182 $self->$cb($err);
77 2         17 });
78             }
79              
80             sub get_cert {
81 2     2 0 6235 my ($self, @names) = @_;
82 2         9 my $csr = _pem_to_der($self->generate_csr(@names));
83 2         85 my $req = $self->signed_request({
84             resource => 'new-cert',
85             csr => encode_base64url($csr),
86             });
87 2         169 my $url = $self->ca->url('/acme/new-cert');
88 2         5 my $tx = $self->ua->post($url, $req);
89 2         684 _die_if_error($tx, 'Failed to get cert');
90 1         21 return _der_to_cert($tx->res->body);
91             }
92              
93             sub get_nonce {
94 3     3   12656 my $self = shift;
95 3   100     24 my $nonces = $self->{nonces} ||= [];
96 3 100       11 return shift @$nonces if @$nonces;
97              
98             # try to populate the nonce cache
99 2         9 my $url = $self->ca->url('/directory');
100 2         7 my $tx = $self->ua->head($url);
101 2 100       7021 return shift @$nonces if @$nonces;
102              
103             # use result directly otherwise
104             # if say the default ua has been replaced
105 1         5 _die_if_error($tx, 'Could not get nonce');
106 1         71 my $nonce = $tx->res->headers->header('Replay-Nonce');
107 1 50       32 return $nonce if $nonce;
108 0 0       0 die "Response did not contain a nonce\n" unless @$nonces;
109             }
110              
111             sub generate_csr {
112 1     1   18 my ($self, $primary, @alts) = @_;
113              
114 1         3 my $rsa = $self->cert_key->key_clone;
115 1         32 my $req = Crypt::OpenSSL::PKCS10->new_from_rsa($rsa);
116 1         43 $req->set_subject("/CN=$primary");
117 1 50       5 if (@alts) {
118 0         0 my $alt = join ',', map { "DNS:$_" } ($primary, @alts);
  0         0  
119 0         0 $req->add_ext(Crypt::OpenSSL::PKCS10::NID_subject_alt_name, $alt);
120             }
121 1         4 $req->add_ext_final;
122 1         13113 $req->sign;
123 1         57 return $req->get_pem_req;
124             }
125              
126             sub keyauth {
127 1     1 0 1661 my ($self, $token) = @_;
128 1         6 return $token . '.' . $self->account_key->thumbprint;
129             }
130              
131             sub new_authz {
132 0     0 0 0 my ($self, $value) = @_;
133 0         0 $self->server; #ensure initialized
134 0         0 my $url = $self->ca->url('/acme/new-authz');
135 0         0 my $req = $self->signed_request({
136             resource => 'new-authz',
137             identifier => {
138             type => 'dns',
139             value => $value,
140             },
141             });
142 0         0 my $tx = $self->ua->post($url, $req);
143 0         0 _die_if_error($tx, 'Error requesting challenges', 201);
144              
145 0   0     0 my $challenges = $tx->res->json('/challenges') || [];
146             die "No http challenge available\n"
147 0 0   0   0 unless my $challenge = c(@$challenges)->first(sub{ $_->{type} eq 'http-01' });
  0         0  
148              
149 0         0 my $token = $challenge->{token};
150 0         0 $self->challenges->{$token} = $challenge;
151              
152 0         0 my $trigger = $self->signed_request({
153             resource => 'challenge',
154             keyAuthorization => $self->keyauth($token),
155             });
156 0         0 $tx = $self->ua->post($challenge->{uri}, $trigger);
157 0         0 _die_if_error($tx, 'Error triggering challenge', 202);
158             }
159              
160             sub pending_challenges {
161 0     0 0 0 my $self = shift;
162 0         0 c(values %{ $self->challenges })
163 0     0   0 ->grep(sub{ $_->{status} eq 'pending' })
164 0     0   0 ->map(sub{ $_->{token} })
165 0         0 }
166              
167             sub register {
168 3     3 0 5916 my $self = shift;
169 3         11 my $url = $self->ca->url('/acme/new-reg');
170 3         12 my $req = $self->signed_request({
171             resource => 'new-reg',
172             agreement => $self->ca->agreement,
173             });
174 3         225 my $code = $self->ua->post($url, $req)->res->code;
175             return
176 3 100       1108 $code == 201 ? 'Account Created' :
    100          
177             $code == 409 ? 'Account Exists' :
178             undef;
179             }
180              
181             sub signed_request {
182 1     1   15 my ($self, $payload) = @_;
183 1         5 $payload = encode_base64url(encode_json($payload));
184 1         71 my $key = $self->account_key;
185 1         7 my $jwk = $key->jwk;
186              
187 1         56 my $header = {
188             alg => 'RS256',
189             jwk => {%$jwk}, # clone the jwk for safety's sake
190             };
191              
192 1         3 my $protected = do {
193 1         5 local $header->{nonce} = $self->get_nonce;
194 1         23 encode_base64url(encode_json($header));
195             };
196              
197 1         166 my $sig = encode_base64url($key->sign("$protected.$payload"));
198 1         27 return encode_json {
199             header => $header,
200             payload => $payload,
201             protected => $protected,
202             signature => $sig,
203             };
204             }
205              
206             sub _die_if_error {
207 3     3   8 my ($tx, $msg, $code) = @_;
208 3 50 33     10 return if $tx->success && (!$code || $code == $tx->res->code);
      66        
209 1         17 my $error = $tx->error;
210 1 50       15 if ($error->{code}) { $msg .= " (code $error->{code})" }
  1         4  
211 1         4 $msg .= " $error->{message}";
212 1   50     3 my $json = $tx->res->json || {};
213 1 50       111 if (my $detail = $json->{detail}) { $msg .= " - $detail" }
  1         4  
214 1         7 die "$msg\n";
215             }
216              
217             sub _pem_to_der {
218 0     0     my $cert = shift;
219 0           $cert =~ s/^-{5}.*$//mg;
220 0           return decode_base64(Mojo::Util::trim($cert));
221             }
222              
223             sub _der_to_cert {
224 0     0     my $der = shift;
225 0           my $pem = encode_base64($der, '');
226 0           $pem =~ s!(.{1,64})!$1\n!g; # stolen from Convert::PEM
227 0           return sprintf "-----BEGIN CERTIFICATE-----\n%s-----END CERTIFICATE-----\n", $pem;
228             }
229              
230             1;
231              
232             =head1 NAME
233              
234             Mojo::ACME - Mojo-based ACME-protocol client
235              
236             =head1 SYNOPSIS
237              
238             # myapp.pl
239             use Mojolicious::Lite;
240             plugin 'ACME';
241             get '/' => {text => 'Hello World'};
242             app->start;
243              
244             # then on the command line, while the app is available on port 80
245             # NOTE! you should use -t when testing on following command
246              
247             # register an account key if necessary
248             $ ./myapp.pl acme account register
249             Writing account.key
250              
251             # generate your domain cert
252             $ ./myapp.pl acme cert generate mydomain.com
253             Writing myapp.key
254             Writing myapp.crt
255              
256             # install your cert and restart your server per server instructions
257              
258             =head1 DESCRIPTION
259              
260             L (also known as letsencrypt) is a service that provices free SSL certificates via an automated system.
261             The service uses (and indeed defines) a protocol called ACME to securely communicate authentication, verification, and certificate issuance.
262             If you aren't familiar with ACME or at least certificate issuance, you might want to see L first.
263             While many clients already exist, web framework plugins have the unique ability to handle the challenge response internally and therefore make for the easiest possible letsencrypt (or other ACME service) experience.
264              
265             =head1 DEVELOPMENT STATUS
266              
267             The plugin and command level apis should be fairly standardized; the author expects few changes to this level of the system.
268             That said, the lower level modules, like L are to be considered unstable and should not be relied upon.
269             Use of these classes directly is highly discouraged for the time being.
270              
271             =head1 ARCHITECTURE
272              
273             The system consists of three major component classes, the plugin L, the commands, and the lower level classes which they rely on.
274              
275             =head2 Plugin
276              
277             The plugin is the glue that holds the system together.
278             It adds the C command (and its subcommands) to your app's command system.
279             It also establishes a route which handles the challenge request from the ACME service.
280             During your certificate issuance, you must prove that you control the requested domain by serving specified content at a specific location.
281             This route makes that possible.
282              
283             The plugin itself reads configuration out of the application's L method.
284             This can be set directly in the application or loaded from a file via say L in the usual way.
285             It looks for a config key C containing a hash of configuration options.
286             Those options can be seen in the L documentation.
287              
288             The most important of these is C.
289             In order to know how to respond to the challenge request, your server will make a signed HTTP request to your ACME client which will be listening.
290             This url is used both as the listen value of the ACME client's built-in server, as well as the base of your server's request.
291             It is advised that you use a url which isn't publically available if possible, though the requests are HMAC signed in any event.
292              
293             =head2 Commands
294              
295             The system provides several commands, including those for creating and verifying an account, as well as certificate issuance (and soon, revoking).
296             The commands are made available simply by using the plugin in your application.
297             They are then available in the same manner as built-in commands
298              
299             $ ./myapp.pl acme ...
300              
301             While some options are sub-command specific, all sub-commands take a few options.
302             Important among those is the C<--ca> option and more conveniently the C<--test> (or C<-t>) flag.
303             Let's Encrypt has severe rate limiting for issuance of certicates on its production hosts.
304             Using the test flag uses the staging server which has greatly relaxed rate limits, though doesn't issue signed certs or create real accounts.
305             It does however use exactly the same process as the production service and issue valid (if not signed) certs.
306             The author highly recommends trying the process on the staging server first.
307              
308             =head2 Modules (Low Level Usage)
309              
310             As mentioned before, the author hopes to stabilize the low-level interface to be reusable/accessible, however for the time being that is not so and things WILL CHANGE UNEXPECTEDLY!
311              
312             =head1 SEE ALSO
313              
314             =over
315              
316             =item *
317              
318             L - L
319              
320             =item *
321              
322             Let's Encrypt - L
323              
324             =item *
325              
326             ACME Protocol - L
327              
328             =item *
329              
330             acme-tiny client from which I took a lot of inspiration/direction - L
331              
332             =back
333              
334              
335             =head1 SOURCE REPOSITORY
336              
337             L
338              
339             =head1 AUTHOR
340              
341             Joel Berger, Ejoel.a.berger@gmail.comE
342              
343             =head1 CONTRIBUTORS
344              
345             =over
346              
347             =item *
348              
349             Mario Domgoergen (mdom)
350              
351             =back
352              
353             =head1 COPYRIGHT AND LICENSE
354              
355             Copyright (C) 2016 by Joel Berger and L
356              
357             This library is free software; you can redistribute it and/or modify
358             it under the same terms as Perl itself.
359