File Coverage

blib/lib/Net/ACME2/HTTP.pm
Criterion Covered Total %
statement 80 119 67.2
branch 12 30 40.0
condition 4 9 44.4
subroutine 24 29 82.7
pod 0 7 0.0
total 120 194 61.8


line stmt bran cond sub pod time code
1             package Net::ACME2::HTTP;
2              
3             =encoding utf-8
4              
5             =head1 NAME
6              
7             Net::ACME2::HTTP - transport logic for C.
8              
9             =head1 DESCRIPTION
10              
11             This module handles communication with an ACME server at the HTTP level.
12             It wraps POSTs in JWSes (JSON Web Signatures) as needed.
13              
14             There should be no reason to interact with this class in production.
15              
16             =cut
17              
18 3     3   20 use strict;
  3         9  
  3         105  
19 3     3   18 use warnings;
  3         5  
  3         80  
20              
21 3     3   787 use JSON ();
  3         13456  
  3         84  
22              
23 3     3   1314 use Net::ACME2::Error ();
  3         13  
  3         65  
24 3     3   1294 use Net::ACME2::HTTP::Response ();
  3         8  
  3         60  
25 3     3   1294 use Net::ACME2::PromiseUtil ();
  3         8  
  3         60  
26 3     3   18 use Net::ACME2::X ();
  3         6  
  3         57  
27              
28 3     3   13 use constant _CONTENT_TYPE => 'application/jose+json';
  3         8  
  3         4976  
29              
30             my $_MAX_RETRIES = 5;
31              
32             #accessed from tests
33             our $_NONCE_HEADER = 'replay-nonce';
34              
35             #Used in testing
36             our $verify_SSL = 1;
37              
38             #NB: “key” isn’t needed if we’re just doing GETs.
39             sub new {
40 19     19 0 107 my ( $class, %opts ) = @_;
41              
42 19         38 my $is_sync;
43              
44 19   33     81 $opts{'ua'} ||= do {
45 19         39 $is_sync = 1;
46              
47 19         694 require Net::ACME2::HTTP_Tiny;
48 19         132 Net::ACME2::HTTP_Tiny->new( verify_SSL => $verify_SSL );
49             };
50              
51             my $self = bless {
52             _ua => $opts{'ua'},
53             _acme_key => $opts{'key'},
54 19         127 _key_id => $opts{'key_id'},
55             _retries_left => $_MAX_RETRIES,
56             _sync_io => $is_sync,
57             }, $class;
58              
59 19         91 return bless $self, $class;
60             }
61              
62             sub timeout {
63 0     0 0 0 my $self = shift;
64              
65 0         0 return $self->{'_ua'}->timeout(@_);
66             }
67              
68             sub set_key_id {
69 24     24 0 87 my ($self, $key_id) = @_;
70              
71 24         68 $self->{'_key_id'} = $key_id;
72              
73 24         60 return $self;
74             }
75              
76             sub set_new_nonce_url {
77 18     18 0 49 my ($self, $url) = @_;
78              
79 18         44 $self->{'_nonce_url'} = $url;
80              
81 18         38 return $self;
82             }
83              
84             # promise
85             #GETs submit no data and thus are not signed.
86             sub get {
87 18     18 0 47 my ( $self, $url ) = @_;
88              
89 18         83 return $self->_request( 'GET', $url );
90             }
91              
92             # promise
93             # ACME spec 6.2: for all requests not signed using an existing account,
94             # e.g., newAccount
95             sub post_full_jwt {
96 24     24 0 51 my $self = shift;
97              
98 24         90 return $self->_post( 'create_full_jws', @_ );
99             }
100              
101             # promise
102             # ACME spec 6.2: for all requests signed using an existing account
103             sub post_key_id {
104 0     0 0 0 my $self = shift;
105              
106 0         0 return $self->_post(
107             'create_key_id_jws',
108             @_,
109             );
110             }
111              
112             #----------------------------------------------------------------------
113              
114             # promise
115             # POSTs are signed.
116             sub _post {
117 24     24   135 my ( $self, $jwt_method, $url, $data, $opts_hr ) = @_;
118              
119 24 50       131 die "Need JWT method!" if !$jwt_method;
120              
121             # Needed now that the constructor allows instantiation
122             # without “key”.
123 24 50       78 die "Constructor needed “key” to do POST! ($url)" if !$self->{'_acme_key'};
124              
125             return Net::ACME2::PromiseUtil::then(
126             $self->_create_jwt( $jwt_method, $url, $data ),
127             sub {
128 24     24   73 my $jws = shift;
129              
130             # local $opts_hr->{'headers'}{'Content-Type'} = 'application/jose+json';
131              
132             return Net::ACME2::PromiseUtil::do_then_catch(
133             sub {
134 24   33     354 return $self->_request_and_set_last_nonce(
135             'POST',
136             $url,
137             {
138             content => $jws,
139             headers => {
140             'content-type' => _CONTENT_TYPE,
141             },
142             },
143             $opts_hr || (),
144             ),
145             },
146             sub {
147 24         94 $self->{'_retries_left'} = $_MAX_RETRIES;
148              
149 24         1024 return shift;
150             },
151             sub {
152 0         0 my ($err) = @_;
153              
154 0         0 my $resp;
155              
156 0 0       0 if ( eval { $err->get('acme')->type() =~ m<:badNonce\z> } ) {
  0         0  
157 0 0       0 if (!$self->{'_retries_left'}) {
    0          
158 0         0 warn( "$url: Received “badNonce” error, and no retries left!\n" );
159             }
160             elsif ($self->{'_last_nonce'}) {
161              
162             # This scenario seems worth a warn() because even if the
163             # retry succeeds, something probably went awry somewhere.
164              
165 0         0 warn( "$url: Received “badNonce” error! Retrying ($self->{'_retries_left'} left) …\n" );
166              
167 0         0 $self->{'_retries_left'}--;
168              
169             # NB: The success of this depends on our having recorded
170             # the Replay-Nonce from the last response.
171 0         0 return $self->_post( $jwt_method, $url, $data, $opts_hr );
172             }
173             else {
174 0         0 warn( "$url: Received “badNonce” without a Replay-Nonce! (Server violates RFC 8555/6.5!) Cannot retry …" );
175             }
176             }
177              
178 0         0 $self->{'_retries_left'} = $_MAX_RETRIES;
179              
180 0         0 local $@ = $err;
181 0         0 die;
182             },
183 24         288 );
184             },
185 24         100 );
186             }
187              
188             # promise
189             sub _ua_request {
190 54     54   177 my ( $self, $type, @args ) = @_;
191              
192 54         317 return $self->{'_ua'}->request( $type, @args );
193             }
194              
195             sub _consume_nonce_in_headers {
196 0     0   0 my ($self, $headers_hr) = @_;
197              
198 0         0 my $_nonce_header_lc = $_NONCE_HEADER;
199 0         0 $_nonce_header_lc =~ tr;
200              
201 0         0 my $nonce = $headers_hr->{$_nonce_header_lc};
202              
203 0 0       0 $self->{'_last_nonce'} = $nonce if $nonce;
204              
205 0         0 return;
206             }
207              
208             sub _xform_http_error {
209 0     0   0 my ($self, $exc) = @_;
210              
211 0 0       0 if ( eval { $exc->isa('Net::ACME2::X::HTTP::Protocol') } ) {
  0         0  
212              
213 0         0 $self->_consume_nonce_in_headers( $exc->get('headers') );
214              
215             #If the exception is able to be made into a Net::ACME2::Error,
216             #then do so to get a nicer error message.
217 0         0 my $acme_error = eval {
218             Net::ACME2::Error->new(
219 0         0 %{ JSON::decode_json( $exc->get('content') ) },
  0         0  
220             );
221             };
222              
223 0 0       0 if ($acme_error) {
224 0         0 die Net::ACME2::X->create(
225             'ACME',
226             {
227             http => $exc,
228             acme => $acme_error,
229             },
230             );
231             }
232             }
233              
234 0         0 $@ = $exc;
235 0         0 die;
236             }
237              
238             # promise
239             # overridden in tests
240             sub _request {
241 54     54   249 my ( $self, $type, @args ) = @_;
242              
243             return Net::ACME2::PromiseUtil::do_then_catch(
244 54     54   168 sub { $self->_ua_request( $type, @args ) },
245             sub {
246 54     54   304 return Net::ACME2::HTTP::Response->new($_[0]);
247             },
248 0     0   0 sub { $self->_xform_http_error(@_) },
249 54         475 );
250             }
251              
252             # promise
253             sub _request_and_set_last_nonce {
254 36     36   146 my ( $self, $type, $url, @args ) = @_;
255              
256             return Net::ACME2::PromiseUtil::then(
257             $self->_request( $type, $url, @args ),
258             sub {
259 36     36   102 my ($resp) = @_;
260              
261             # NB: ACME’s replay protection works thus:
262             # - each server response includes a nonce
263             # - each request must include ONE of the nonces that have been sent
264             # - once used, a nonce can’t be reused
265             #
266             # This is subtly different from what was originally in mind (i.e., that
267             # each request must use the most recently sent nonce). It implies that
268             # GETs do not need to send nonces, though each GET will *receive* a
269             # nonce that may be used.
270 36 50       270 $self->{'_last_nonce'} = $resp->header($_NONCE_HEADER) or do {
271 0         0 die Net::ACME2::X->create('Generic', "Received no $_NONCE_HEADER from $url!");
272             };
273              
274 36         1008 return $resp;
275             },
276 36         142 );
277             }
278              
279             # promise
280             sub _get_first_nonce {
281 12     12   33 my ($self) = @_;
282              
283 12 50       54 my $url = $self->{'_nonce_url'} or do {
284              
285             # Shouldn’t happen unless there’s an errant refactor.
286 0         0 die Net::ACME2::X->create('Generic', 'Set newNonce URL first!');
287             };
288              
289 12         63 return $self->_request_and_set_last_nonce( 'HEAD', $url );
290             }
291              
292             # promise OR JWS itself.
293             sub _create_jwt {
294 24     24   84 my ( $self, $jwt_method, $url, $data ) = @_;
295              
296 24   66     106 $self->{'_jwt_maker'} ||= do {
297 12         19 my $class;
298              
299 12         69 my $key_type = $self->{'_acme_key'}->get_type();
300              
301 12 100       67 if ($key_type eq 'rsa') {
    50          
302 4         10 $class = 'Net::ACME2::JWTMaker::RSA';
303             }
304             elsif ($key_type eq 'ecdsa') {
305 8         20 $class = 'Net::ACME2::JWTMaker::ECC';
306             }
307             else {
308              
309             # As of this writing, Crypt::Perl only does RSA and ECDSA keys.
310             # If we get here, it’s possible that Crypt::Perl now supports
311             # an additional key type that this library doesn’t recognize.
312 0         0 die Net::ACME2::X->create('Generic', "Unrecognized key type: “$key_type”");
313             }
314              
315 12 100       135 if (!$class->can('new')) {
316 4         23 require Module::Runtime;
317 4         23 Module::Runtime::use_module($class);
318             }
319              
320             $class->new(
321 12         115 key => $self->{'_acme_key'},
322             );
323             };
324              
325             # In sync mode, we just throw away this value.
326             # In async mode the undef is ignored, and a promise is honored.
327 24 100       116 my $maybe_promise = $self->{'_last_nonce'} ? undef : $self->_get_first_nonce();
328              
329             return Net::ACME2::PromiseUtil::then(
330             $maybe_promise,
331             sub {
332              
333             # Ideally we’d wait until we’ve confirmed that this JWT reached
334             # the server to delete the local nonce, but at this point a
335             # failure to reach the server seems pretty edge-case-y. Even if
336             # that happens, we’ll just request another nonce next time,
337             # so no big deal.
338 24 50   24   110 my $nonce = delete $self->{'_last_nonce'} or do {
339 0         0 die "No nonce even after _get_first_nonce()!";
340             };
341              
342             # For testing badNonce retry …
343             # $nonce = reverse($nonce) if $self->{'_retries_left'};
344             # $nonce = reverse($nonce);
345              
346             return $self->{'_jwt_maker'}->$jwt_method(
347 24         231 key_id => $self->{'_key_id'},
348             payload => $data,
349             extra_headers => {
350             nonce => $nonce,
351             url => $url,
352             },
353             );
354             },
355 24         121 );
356             }
357              
358             1;