File Coverage

blib/lib/Mail/DKIM/ARC/Verifier.pm
Criterion Covered Total %
statement 269 303 88.7
branch 102 134 76.1
condition 48 71 67.6
subroutine 16 17 94.1
pod 2 9 22.2
total 437 534 81.8


line stmt bran cond sub pod time code
1             package Mail::DKIM::ARC::Verifier;
2 2     2   1094 use strict;
  2         5  
  2         61  
3 2     2   11 use warnings;
  2         3  
  2         90  
4             our $VERSION = '1.20230630'; # VERSION
5             # ABSTRACT: verifies an ARC-Sealed message
6              
7             # Copyright 2017 FastMail Pty Ltd. All Rights Reserved.
8             # Bron Gondwana
9              
10             # This program is free software; you can redistribute it and/or
11             # modify it under the same terms as Perl itself.
12              
13              
14              
15 2     2   14 use base 'Mail::DKIM::Common';
  2         7  
  2         186  
16 2     2   15 use Mail::DKIM::ARC::MessageSignature;
  2         3  
  2         39  
17 2     2   11 use Mail::DKIM::ARC::Seal;
  2         5  
  2         61  
18 2     2   11 use Mail::Address;
  2         5  
  2         71  
19 2     2   12 use Carp;
  2         4  
  2         6357  
20             our $MAX_SIGNATURES_TO_PROCESS = 50;
21              
22             sub init {
23 168     168 0 259 my $self = shift;
24 168         538 $self->SUPER::init;
25 168         322 $self->{signatures} = [];
26 168         510 $self->{result} = undef; # we're done once this is set
27             }
28              
29             # @{$arc->{signatures}}
30             # array of L objects, representing all
31             # parseable message signatures and seals found in the header,
32             # ordered from the top of the header to the bottom.
33             #
34             # $arc->{signature_reject_reason}
35             # simple string listing a reason, if any, for not using a signature.
36             # This may be a helpful diagnostic if there is a signature in the header,
37             # but was found not to be valid. It will be ambiguous if there are more
38             # than one signatures that could not be used.
39             #
40             # @{$arc->{headers}}
41             # array of strings, each member is one header, in its original format.
42             #
43             # $arc->{algorithms}
44             # array of algorithms, one for each signature being verified.
45             #
46             # $arc->{result}
47             # string; the result of the verification (see the result() method)
48             #
49              
50             sub handle_header {
51 2408     2408 0 3459 my $self = shift;
52 2408         4304 my ( $field_name, $contents, $line ) = @_;
53              
54 2408         6040 $self->SUPER::handle_header( $field_name, $contents );
55              
56 2408 100       5357 if ( lc($field_name) eq 'arc-message-signature' ) {
57             eval {
58 189         555 local $SIG{__DIE__};
59 189         591 my $signature = Mail::DKIM::ARC::MessageSignature->parse($line);
60 188         561 $self->add_signature($signature);
61 187         850 1
62 189 100       326 } || do {
63              
64             # the only reason an error should be thrown is if the
65             # signature really is unparse-able
66              
67             # otherwise, invalid signatures are caught in finish_header()
68              
69 2         9 chomp( my $E = $@ );
70 2         10 $self->{signature_reject_reason} = $E;
71             };
72             }
73              
74 2408 100       5874 if ( lc($field_name) eq 'arc-seal' ) {
75             eval {
76 188         612 local $SIG{__DIE__};
77 188         645 my $signature = Mail::DKIM::ARC::Seal->parse($line);
78 188         572 $self->add_signature($signature);
79 187         1126 1
80 188 100       331 } || do {
81              
82             # the only reason an error should be thrown is if the
83             # signature really is unparse-able
84              
85             # otherwise, invalid signatures are caught in finish_header()
86              
87 1         3 chomp( my $E = $@ );
88 1         9 $self->{signature_reject_reason} = $E;
89             };
90             }
91              
92             }
93              
94             sub add_signature {
95 376     376 0 680 my ( $self, $signature ) = @_;
96 376 50       845 croak 'wrong number of arguments' unless ( @_ == 2 );
97              
98 376 100       775 return if $self->{result}; # already failed
99              
100 364         495 push @{ $self->{signatures} }, $signature;
  364         708  
101              
102 364 100       859 unless ( $self->check_signature($signature) ) {
103 14         51 $signature->result( 'invalid', $self->{signature_reject_reason} );
104 14         30 return;
105             }
106              
107             # signature looks ok, go ahead and query for the public key
108 350         989 $signature->fetch_public_key;
109              
110             # create a canonicalization filter and algorithm
111 348         788 my $algorithm_class =
112             $signature->get_algorithm_class( $signature->algorithm );
113             my $algorithm = $algorithm_class->new(
114             Signature => $signature,
115             Debug_Canonicalization => $signature->isa('Mail::DKIM::ARC::Seal')
116             ? $self->{AS_Canonicalization}
117             : $self->{AMS_Canonicalization},
118 348 100       2433 );
119              
120             # push through the headers parsed prior to the signature header
121 348 50       1121 if ( $algorithm->wants_pre_signature_headers ) {
122              
123             # Note: this will include the signature header that led to this
124             # "algorithm"...
125 348         514 foreach my $head ( @{ $self->{headers} } ) {
  348         819  
126 1036         2020 $algorithm->add_header($head);
127             }
128             }
129              
130             # save the algorithm
131 348   50     836 $self->{algorithms} ||= [];
132 348         482 push @{ $self->{algorithms} }, $algorithm;
  348         657  
133              
134             # check for bogus tags (should be done much earlier but better late than never)
135             # tagkeys is uniq'd via a hash, rawtaglen counts all the tags
136 348         516 my @tagkeys = keys %{ $signature->{tags_by_name} };
  348         1436  
137 348         555 my $rawtaglen = $#{ $signature->{tags} };
  348         595  
138              
139             # crock: ignore empty clause after trailing semicolon
140             $rawtaglen--
141 348 100       567 if $signature->{tags}->[ $#{ $signature->{tags} } ]->{raw} =~ /^\s*$/;
  348         1541  
142              
143             # duplicate tags
144 348 100       815 if ( $rawtaglen != $#tagkeys ) {
145 4         12 $self->{result} = 'fail'; # bogus
146 4         10 $self->{details} = 'Duplicate tag in signature';
147 4         11 return;
148             }
149              
150             # invalid tag name
151 344 100       714 if ( grep { !m{[a-z][a-z0-9_]*}i } @tagkeys ) {
  2735         6722  
152 2         13 $self->{result} = 'fail'; # bogus
153 2         6 $self->{details} = 'Invalid tag in signature';
154 2         6 return;
155             }
156              
157 342 100       1598 if ( $signature->isa('Mail::DKIM::ARC::Seal') ) {
    50          
158 176         237 my ($instance);
159 176   100     472 $instance = $signature->instance() || '';
160              
161 176 50 66     1189 if ( $instance !~ m{^\d+$} or $instance < 1 or $instance > 1024 ) {
      66        
162 5         11 $self->{result} = 'fail'; # bogus
163 5         23 $self->{details} = sprintf "Invalid ARC-Seal instance '%s'",
164             $instance;
165 5         15 return;
166             }
167              
168 171 100       508 if ( $self->{seals}[$instance] ) {
169 3         9 $self->{result} = 'fail'; # dup
170 3 50       15 if ( $signature eq $self->{seals}[$instance] ) {
171 0         0 $self->{details} = sprintf 'Duplicate ARC-Seal %d', $instance;
172             }
173             else {
174 3         18 $self->{details} = sprintf 'Redundant ARC-Seal %d', $instance;
175             }
176 3         10 return;
177             }
178              
179 168         551 $self->{seals}[$instance] = $signature;
180             }
181             elsif ( $signature->isa('Mail::DKIM::ARC::MessageSignature') ) {
182 166   100     426 my $instance = $signature->instance() || '';
183              
184 166 50 66     1243 if ( $instance !~ m{^\d+$} or $instance < 1 or $instance > 1024 ) {
      66        
185 4         7 $self->{result} = 'fail'; # bogus
186             $self->{details} =
187 4         18 sprintf "Invalid ARC-Message-Signature instance '%s'", $instance;
188 4         13 return;
189             }
190              
191 162 100       462 if ( $self->{messages}[$instance] ) {
192 3         16 $self->{result} = 'fail'; # dup
193 3 50       14 if ( $signature->as_string() eq
194             $self->{messages}[$instance]->as_string() )
195             {
196 0         0 $self->{details} = sprintf 'Duplicate ARC-Message-Signature %d',
197             $instance;
198             }
199             else {
200 3         18 $self->{details} = sprintf 'Redundant ARC-Message-Signature %d',
201             $instance;
202             }
203 3         10 return;
204             }
205 159         512 $self->{messages}[$instance] = $signature;
206             }
207             }
208              
209             sub check_signature {
210 364     364 0 644 my $self = shift;
211 364 50       709 croak 'wrong number of arguments' unless ( @_ == 1 );
212 364         710 my ($signature) = @_;
213              
214 364 50       849 unless ( $signature->check_version ) {
215              
216             # unsupported version
217 0 0       0 if ( defined $signature->version ) {
218             $self->{signature_reject_reason} =
219 0         0 'unsupported version ' . $signature->version;
220             }
221             else {
222 0         0 $self->{signature_reject_reason} = 'missing v tag';
223             }
224 0         0 return 0;
225             }
226              
227 364 100 100     804 unless ( $signature->algorithm
      33        
      66        
228             && $signature->get_algorithm_class( $signature->algorithm )
229             && ( !$self->{Strict} || $signature->algorithm ne 'rsa-sha1' )
230             ) # no more SHA1 for us in strict mode
231             {
232             # unsupported algorithm
233 6         16 $self->{signature_reject_reason} = 'unsupported algorithm';
234 6 100       17 if ( defined $signature->algorithm ) {
235 4         17 $self->{signature_reject_reason} .= ' ' . $signature->algorithm;
236             }
237 6         20 return 0;
238             }
239              
240 358 100       1014 unless ( $signature->check_canonicalization ) {
241              
242             # unsupported canonicalization method
243 1         4 $self->{signature_reject_reason} = 'unsupported canonicalization';
244 1 50       5 if ( defined $signature->canonicalization ) {
245             $self->{signature_reject_reason} .=
246 1         5 ' ' . $signature->canonicalization;
247             }
248 1         7 return 0;
249             }
250              
251 357 50       856 unless ( $signature->check_protocol ) {
252              
253             # unsupported query protocol
254             $self->{signature_reject_reason} =
255 0 0       0 !defined( $signature->protocol )
256             ? 'missing q tag'
257             : 'unsupported query protocol, q=' . $signature->protocol;
258 0         0 return 0;
259             }
260              
261 357 50       839 unless ( $signature->check_expiration ) {
262              
263             # signature has expired
264 0         0 $self->{signature_reject_reason} = 'signature is expired';
265 0         0 return 0;
266             }
267              
268 357 100       744 unless ( defined $signature->domain ) {
269              
270             # no domain specified
271 2         5 $self->{signature_reject_reason} = 'missing d tag';
272 2         6 return 0;
273             }
274              
275 355 100       773 if ( $signature->domain eq '' ) {
276              
277             # blank domain
278 2         4 $self->{signature_reject_reason} = 'invalid domain in d tag';
279 2         7 return 0;
280             }
281              
282 353 100       830 unless ( defined $signature->selector ) {
283              
284             # no selector specified
285 3         11 $self->{signature_reject_reason} = 'missing s tag';
286 3         10 return 0;
287             }
288              
289 350         855 return 1;
290             }
291              
292             sub check_public_key {
293 295     295 0 485 my $self = shift;
294 295 50       685 croak 'wrong number of arguments' unless ( @_ == 2 );
295 295         549 my ( $signature, $public_key ) = @_;
296              
297 295         443 my $result = 0;
298             eval {
299 295         980 local $SIG{__DIE__};
300 295         463 $@ = undef;
301              
302             # HACK- I'm indecisive here about whether I want the
303             # check_foo functions to return false or to "die"
304             # on failure
305              
306             # check public key's allowed hash algorithms
307 295         751 $result =
308             $public_key->check_hash_algorithm( $signature->hash_algorithm );
309              
310             # HACK- DomainKeys signatures are allowed to have an empty g=
311             # tag in the public key
312             # my $empty_g_means_wildcard = $signature->isa('Mail::DKIM::DkSignature');
313              
314             # check public key's granularity
315 295   33     1239 $result &&= $public_key->check_granularity( $signature->domain, 0 );
316              
317             # $signature->instance, $empty_g_means_wildcard);
318              
319 295 50       721 die $@ if $@;
320 295         1030 1
321 295 50       457 } || do {
322 0         0 my $E = $@;
323 0         0 chomp $E;
324 0         0 $self->{signature_reject_reason} = "public key: $E";
325             };
326 295         845 return $result;
327             }
328              
329             #
330             # called when the verifier has received the last of the message headers
331             # (body is still to come)
332             #
333             sub finish_header {
334 168     168 0 265 my $self = shift;
335              
336             # Signatures we found and were successfully parsed are stored in
337             # $self->{signatures}. If none were found, our result is "none".
338              
339 168 100 66     566 if ( @{ $self->{signatures} } == 0
  168         541  
340             && !defined( $self->{signature_reject_reason} ) )
341             {
342 5         10 $self->{result} = 'none';
343 5         11 return;
344             }
345              
346             # check for duplicate AAR headers (dup AS and AMS checked in add_signature)
347 163         777 my @aars = [];
348 163         258 foreach my $hdr ( @{ $self->{headers} } ) {
  163         338  
349 2366 100       6589 if ( my ($i) = $hdr =~ m{ARC-Authentication-Results:\s*i=(\d+)\s*;}i ) {
350 180 100       497 if ( defined $aars[$i] ) {
351 2         8 $self->{result} = 'fail';
352             $self->{details} =
353 2         11 "Duplicate ARC-Authentication-Results header $1";
354 2         9 return;
355             }
356 178         393 $aars[$i] = $hdr;
357             }
358             }
359              
360 161         277 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  161         339  
361             $algorithm->finish_header(
362             Headers => $self->{headers},
363 344         1055 Chain => 'pass'
364             );
365             }
366              
367             # stop processing signatures that are already known to be invalid
368 161         407 @{ $self->{algorithms} } = grep {
369 344         798 my $sig = $_->signature;
370 344   33     738 !( $sig->result && $sig->result eq 'invalid' );
371 161         274 } @{ $self->{algorithms} };
  161         419  
372              
373 161 50 33     257 if ( @{ $self->{algorithms} } == 0
  161         590  
374 0         0 && @{ $self->{signatures} } > 0 )
375             {
376 0   0     0 $self->{result} = $self->{signatures}->[0]->result || 'invalid';
377             $self->{details} = $self->{signatures}->[0]->{verify_details}
378 0   0     0 || $self->{signature_reject_reason};
379 0         0 return;
380             }
381             }
382              
383             sub _check_and_verify_signature {
384 306     306   457 my $self = shift;
385 306         603 my ($algorithm) = @_;
386              
387             # check signature
388 306         699 my $signature = $algorithm->signature;
389              
390 306 50       699 if ( not $signature->get_tag('d') ) { # All sigs must have a D tag
391 0         0 $self->{signature_reject_reason} = 'missing D tag';
392 0         0 return ( 'fail', $self->{signature_reject_reason} );
393             }
394              
395 306 100       683 if ( not $signature->get_tag('b') ) { # All sigs must have a B tag
396 4         12 $self->{signature_reject_reason} = 'missing B tag';
397 4         20 return ( 'fail', $self->{signature_reject_reason} );
398             }
399              
400 302 100       1167 if ( not $signature->isa('Mail::DKIM::ARC::Seal') ) { # AMS tests
401 151 100       327 unless ( $signature->get_tag('bh') ) { # AMS must have a BH tag
402 2         7 $self->{signature_reject_reason} = 'missing BH tag';
403 2         15 return ( 'fail', $self->{signature_reject_reason} );
404             }
405 149 100 100     319 if ( ( $signature->get_tag('h') || '' ) =~ /arc-seal/i )
406             { # cannot cover AS
407             $self->{signature_reject_reason} =
408 1         4 'Arc-Message-Signature covers Arc-Seal';
409 1         12 return ( 'fail', $self->{signature_reject_reason} );
410             }
411             }
412              
413             # AMS signature must not
414              
415             # get public key
416 299         521 my $pkey;
417             eval {
418 299         977 local $SIG{__DIE__};
419 299         858 $pkey = $signature->get_public_key;
420 295         1231 1
421 299 100       509 } || do {
422 4         13 my $E = $@;
423 4         14 chomp $E;
424 4         15 $self->{signature_reject_reason} = "public key: $E";
425 4         16 return ( 'invalid', $self->{signature_reject_reason} );
426             };
427              
428 295 50       844 unless ( $self->check_public_key( $signature, $pkey ) ) {
429 0         0 return ( 'invalid', $self->{signature_reject_reason} );
430             }
431              
432             # make sure key is big enough
433 295         802 my $keysize = $pkey->cork->size * 8; # in bits
434 295 50 66     698 if ( $keysize < 1024 && $self->{Strict} ) {
435 2         8 $self->{signature_reject_reason} = "Key length $keysize too short";
436 2         7 return ( 'fail', $self->{signature_reject_reason} );
437             }
438              
439             # verify signature
440 293         456 my $result;
441             my $details;
442 293         460 local $@ = undef;
443             eval {
444 293         793 local $SIG{__DIE__};
445 293 100       916 $result = $algorithm->verify() ? 'pass' : 'fail';
446 287   100     987 $details = $algorithm->{verification_details} || $@;
447 287         1108 1
448 293 100       527 } || do {
449              
450             # see also add_signature
451 6         31 chomp( my $E = $@ );
452 6 50       61 if ( $E =~ /(OpenSSL error: .*?) at / ) {
    0          
453 6         23 $E = $1;
454             }
455             elsif ( $E =~ /^(panic:.*?) at / ) {
456 0         0 $E = "OpenSSL $1";
457             }
458 6         17 $result = 'fail';
459 6         18 $details = $E;
460             };
461 293         979 return ( $result, $details );
462             }
463              
464             sub finish_body {
465 168     168 0 296 my $self = shift;
466              
467 168 100       387 return if $self->{result}; # already failed
468              
469 140         212 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  140         311  
470              
471             # finish canonicalizing
472 306         803 $algorithm->finish_body;
473              
474 306         850 my ( $result, $details ) =
475             $self->_check_and_verify_signature($algorithm);
476              
477             # save the results of this signature verification
478 306         734 $algorithm->{result} = $result;
479 306         613 $algorithm->{details} = $details;
480 306   66     930 $self->{signature} ||= $algorithm->signature; # something if we fail
481 306         726 $algorithm->signature->result( $result, $details );
482             }
483              
484 140   100     437 my $seals = $self->{seals} || [];
485 140   100     334 my $messages = $self->{messages} || [];
486 140 50 66     354 unless ( @$seals or @$messages ) {
487 0         0 $self->{result} = 'none';
488 0         0 $self->{details} = 'no ARC headers found';
489 0         0 return;
490             }
491              
492             # determine if it's valid:
493             # 5.1.1.5. Determining the 'cv' Tag Value for ARC-Seal
494              
495             # In order for a series of ARC sets to be considered valid, the
496             # following statements MUST be satisfied:
497              
498             # 1. The chain of ARC sets must have structural integrity (no sets or
499             # set component header fields missing, no duplicates, excessive
500             # hops (cf. Section 5.1.1.1.1), etc.);
501              
502 140 50       332 if ( $#$seals == 0 ) {
503 0         0 $self->{result} = 'fail';
504 0         0 $self->{details} = 'missing ARC-Seal 1';
505 0         0 return;
506             }
507 140 50       295 if ( $#$messages == 0 ) {
508 0         0 $self->{result} = 'fail';
509 0         0 $self->{details} = 'missing ARC-Message-Signature 1';
510 0         0 return;
511             }
512              
513 140 100       296 if ( $#$messages > $#$seals ) {
514 11         23 $self->{result} = 'fail';
515 11         34 $self->{details} = 'missing Arc-Seal ' . $#$messages;
516 11         35 return;
517             }
518              
519 129         377 foreach my $i ( 1 .. $#$seals ) {
520              
521             # XXX - we should error if it's already present, but that's done above if at all
522 152 100       356 if ( !$seals->[$i] ) {
523 1         6 $self->{result} = 'fail';
524 1         10 $self->{details} = "missing ARC-Seal $i";
525 1         3 return;
526             }
527 151 100       333 if ( !$messages->[$i] ) {
528 12         32 $self->{result} = 'fail';
529 12         38 $self->{details} = "missing ARC-Message-Signature $i";
530 12         37 return;
531             }
532             }
533              
534             # 2. All ARC-Seal header fields MUST validate;
535 116         243 foreach my $i ( 1 .. $#$seals ) {
536 137         293 my $result = $seals->[$i]->result();
537 137 100       342 if ( $result ne 'pass' ) {
538 38         109 $self->{signature} = $seals->[$i]->signature;
539 38         75 $self->{result} = $result;
540 38         91 $self->{details} = $seals->[$i]->result_detail();
541 38         122 return;
542             }
543             }
544              
545             # 3. All ARC-Seal header fields MUST have a chain value (cv=) status
546             # of "pass" (except the first which MUST be "none"); and
547 78         221 my $cv = $seals->[1]->get_tag('cv');
548 78 100 100     355 if ( !defined $cv or $cv ne 'none' ) {
549 7         20 $self->{signature} = $seals->[1]->signature;
550 7         14 $self->{result} = 'fail';
551 7         13 $self->{details} = 'first ARC-Seal must be cv=none';
552 7         19 return;
553             }
554 71         171 foreach my $i ( 2 .. $#$seals ) {
555 16         42 my $cv = $seals->[$i]->get_tag('cv');
556 16 100       42 if ( $cv ne 'pass' ) {
557 2         7 $self->{signature} = $seals->[$i]->signature;
558 2         5 $self->{result} = 'fail';
559 2         11 $self->{details} = "wrong cv for ARC-Seal i=$i";
560 2         6 return;
561             }
562             }
563              
564             # 4. The newest (highest instance number (i=)) AMS header field MUST
565             # validate.
566 69         204 my $result = $messages->[$#$seals]->result();
567 69 100       180 if ( $result ne 'pass' ) {
568 16         56 $self->{signature} = $messages->[$#$seals]->signature;
569 16         39 $self->{result} = $result;
570 16         78 $self->{details} = $messages->[$#$seals]->result_detail();
571 16         41 return;
572             }
573              
574             # Success!
575 53         159 $self->{signature} = $seals->[$#$seals]->signature();
576 53         142 $self->{result} = 'pass';
577 53         181 $self->{details} = $seals->[$#$seals]->result_detail();
578             }
579              
580             sub result_detail {
581 168     168 1 548 my $self = shift;
582              
583 168 100       389 return 'none' if $self->{result} eq 'none';
584              
585 163         259 my @items;
586 163         227 foreach my $signature ( @{ $self->{signatures} } ) {
  163         360  
587 364 50       975 my $type =
    100          
588             ref($signature) eq 'Mail::DKIM::ARC::Seal' ? 'as'
589             : ref($signature) eq 'Mail::DKIM::ARC::MessageSignature' ? 'ams'
590             : ref($signature);
591 364   100     1047 push @items,
      100        
      100        
592             "$type."
593             . ( $signature->instance() || '' ) . '.'
594             . ( $signature->domain() || '(none)' ) . '='
595             . ( $signature->result_detail() || '?' );
596             }
597              
598 163         785 return $self->{result} . ' (' . join( ', ', @items ) . ')';
599             }
600              
601              
602              
603             sub signatures {
604 0     0 1   my $self = shift;
605 0 0         croak 'unexpected argument' if @_;
606              
607 0           return @{ $self->{signatures} };
  0            
608             }
609              
610             1;
611              
612             __END__