File Coverage

blib/lib/Mail/DKIM/ARC/Verifier.pm
Criterion Covered Total %
statement 270 305 88.5
branch 103 136 75.7
condition 48 71 67.6
subroutine 16 17 94.1
pod 2 9 22.2
total 439 538 81.6


line stmt bran cond sub pod time code
1             package Mail::DKIM::ARC::Verifier;
2 2     2   1773 use strict;
  2         4  
  2         83  
3 2     2   12 use warnings;
  2         5  
  2         203  
4             our $VERSION = '1.20240923'; # 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   15 use base 'Mail::DKIM::Common';
  2         5  
  2         307  
16 2     2   18 use Mail::DKIM::ARC::MessageSignature;
  2         4  
  2         75  
17 2     2   11 use Mail::DKIM::ARC::Seal;
  2         4  
  2         46  
18 2     2   10 use Mail::Address;
  2         4  
  2         90  
19 2     2   11 use Carp;
  2         4  
  2         8535  
20             our $MAX_SIGNATURES_TO_PROCESS = 50;
21              
22             sub init {
23 168     168 0 356 my $self = shift;
24 168         1097 $self->SUPER::init;
25 168         728 $self->{signatures} = [];
26 168         537 $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 3799 my $self = shift;
52 2408         5797 my ( $field_name, $contents, $line ) = @_;
53              
54 2408         16503 $self->SUPER::handle_header( $field_name, $contents );
55              
56 2408 100       6257 if ( lc($field_name) eq 'arc-message-signature' ) {
57             eval {
58 189         820 local $SIG{__DIE__};
59 189         979 my $signature = Mail::DKIM::ARC::MessageSignature->parse($line);
60 188         997 $self->add_signature($signature);
61 187         1284 1
62 189 100       372 } || 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         11 chomp( my $E = $@ );
70 2         15 $self->{signature_reject_reason} = $E;
71             };
72             }
73              
74 2408 100       7040 if ( lc($field_name) eq 'arc-seal' ) {
75             eval {
76 188         985 local $SIG{__DIE__};
77 188         1096 my $signature = Mail::DKIM::ARC::Seal->parse($line);
78 188         921 $self->add_signature($signature);
79 187         1712 1
80 188 100       456 } || 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         5 chomp( my $E = $@ );
88 1         6 $self->{signature_reject_reason} = $E;
89             };
90             }
91              
92             }
93              
94             sub add_signature {
95 376     376 0 888 my ( $self, $signature ) = @_;
96 376 50       1032 croak 'wrong number of arguments' unless ( @_ == 2 );
97              
98 376 100       1132 return if $self->{result}; # already failed
99              
100             # Set verification time if we have one
101 364 50       919 if ($self->{verify_time}) {
102 0         0 $signature->set_verify_time($self->{verify_time});
103             }
104              
105 364         825 push @{ $self->{signatures} }, $signature;
  364         988  
106              
107 364 100       1259 unless ( $self->check_signature($signature) ) {
108 14         78 $signature->result( 'invalid', $self->{signature_reject_reason} );
109 14         37 return;
110             }
111              
112             # signature looks ok, go ahead and query for the public key
113 350         1376 $signature->fetch_public_key;
114              
115             # create a canonicalization filter and algorithm
116 348         1166 my $algorithm_class =
117             $signature->get_algorithm_class( $signature->algorithm );
118             my $algorithm = $algorithm_class->new(
119             Signature => $signature,
120             Debug_Canonicalization => $signature->isa('Mail::DKIM::ARC::Seal')
121             ? $self->{AS_Canonicalization}
122             : $self->{AMS_Canonicalization},
123 348 100       4083 );
124              
125             # push through the headers parsed prior to the signature header
126 348 50       1660 if ( $algorithm->wants_pre_signature_headers ) {
127              
128             # Note: this will include the signature header that led to this
129             # "algorithm"...
130 348         571 foreach my $head ( @{ $self->{headers} } ) {
  348         1161  
131 1036         2404 $algorithm->add_header($head);
132             }
133             }
134              
135             # save the algorithm
136 348   50     1920 $self->{algorithms} ||= [];
137 348         593 push @{ $self->{algorithms} }, $algorithm;
  348         918  
138              
139             # check for bogus tags (should be done much earlier but better late than never)
140             # tagkeys is uniq'd via a hash, rawtaglen counts all the tags
141 348         559 my @tagkeys = keys %{ $signature->{tags_by_name} };
  348         2028  
142 348         688 my $rawtaglen = $#{ $signature->{tags} };
  348         895  
143              
144             # crock: ignore empty clause after trailing semicolon
145             $rawtaglen--
146 348 100       846 if $signature->{tags}->[ $#{ $signature->{tags} } ]->{raw} =~ /^\s*$/;
  348         2420  
147              
148             # duplicate tags
149 348 100       1022 if ( $rawtaglen != $#tagkeys ) {
150 4         11 $self->{result} = 'fail'; # bogus
151 4         16 $self->{details} = 'Duplicate tag in signature';
152 4         12 return;
153             }
154              
155             # invalid tag name
156 344 100       868 if ( grep { !m{[a-z][a-z0-9_]*}i } @tagkeys ) {
  2735         8766  
157 2         8 $self->{result} = 'fail'; # bogus
158 2         9 $self->{details} = 'Invalid tag in signature';
159 2         10 return;
160             }
161              
162 342 100       2326 if ( $signature->isa('Mail::DKIM::ARC::Seal') ) {
    50          
163 176         298 my ($instance);
164 176   100     789 $instance = $signature->instance() || '';
165              
166 176 50 66     1952 if ( $instance !~ m{^\d+$} or $instance < 1 or $instance > $MAX_SIGNATURES_TO_PROCESS ) {
      66        
167 5         14 $self->{result} = 'fail'; # bogus
168 5         17 $self->{details} = sprintf "Invalid ARC-Seal instance '%s'",
169             $instance;
170 5         22 return;
171             }
172              
173 171 100       728 if ( $self->{seals}[$instance] ) {
174 3         10 $self->{result} = 'fail'; # dup
175 3 50       18 if ( $signature eq $self->{seals}[$instance] ) {
176 0         0 $self->{details} = sprintf 'Duplicate ARC-Seal %d', $instance;
177             }
178             else {
179 3         27 $self->{details} = sprintf 'Redundant ARC-Seal %d', $instance;
180             }
181 3         15 return;
182             }
183              
184 168         853 $self->{seals}[$instance] = $signature;
185             }
186             elsif ( $signature->isa('Mail::DKIM::ARC::MessageSignature') ) {
187 166   100     667 my $instance = $signature->instance() || '';
188              
189 166 50 66     1747 if ( $instance !~ m{^\d+$} or $instance < 1 or $instance > $MAX_SIGNATURES_TO_PROCESS ) {
      66        
190 4         14 $self->{result} = 'fail'; # bogus
191             $self->{details} =
192 4         754 sprintf "Invalid ARC-Message-Signature instance '%s'", $instance;
193 4         27 return;
194             }
195              
196 162 100       632 if ( $self->{messages}[$instance] ) {
197 3         12 $self->{result} = 'fail'; # dup
198 3 50       15 if ( $signature->as_string() eq
199             $self->{messages}[$instance]->as_string() )
200             {
201 0         0 $self->{details} = sprintf 'Duplicate ARC-Message-Signature %d',
202             $instance;
203             }
204             else {
205 3         56 $self->{details} = sprintf 'Redundant ARC-Message-Signature %d',
206             $instance;
207             }
208 3         17 return;
209             }
210 159         820 $self->{messages}[$instance] = $signature;
211             }
212             }
213              
214             sub check_signature {
215 364     364 0 715 my $self = shift;
216 364 50       879 croak 'wrong number of arguments' unless ( @_ == 1 );
217 364         824 my ($signature) = @_;
218              
219 364 50       1469 unless ( $signature->check_version ) {
220              
221             # unsupported version
222 0 0       0 if ( defined $signature->version ) {
223             $self->{signature_reject_reason} =
224 0         0 'unsupported version ' . $signature->version;
225             }
226             else {
227 0         0 $self->{signature_reject_reason} = 'missing v tag';
228             }
229 0         0 return 0;
230             }
231              
232 364 100 100     1114 unless ( $signature->algorithm
      33        
      66        
233             && $signature->get_algorithm_class( $signature->algorithm )
234             && ( !$self->{Strict} || $signature->algorithm ne 'rsa-sha1' )
235             ) # no more SHA1 for us in strict mode
236             {
237             # unsupported algorithm
238 6         52 $self->{signature_reject_reason} = 'unsupported algorithm';
239 6 100       22 if ( defined $signature->algorithm ) {
240 4         18 $self->{signature_reject_reason} .= ' ' . $signature->algorithm;
241             }
242 6         27 return 0;
243             }
244              
245 358 100       1361 unless ( $signature->check_canonicalization ) {
246              
247             # unsupported canonicalization method
248 1         6 $self->{signature_reject_reason} = 'unsupported canonicalization';
249 1 50       6 if ( defined $signature->canonicalization ) {
250             $self->{signature_reject_reason} .=
251 1         6 ' ' . $signature->canonicalization;
252             }
253 1         6 return 0;
254             }
255              
256 357 50       1229 unless ( $signature->check_protocol ) {
257              
258             # unsupported query protocol
259             $self->{signature_reject_reason} =
260 0 0       0 !defined( $signature->protocol )
261             ? 'missing q tag'
262             : 'unsupported query protocol, q=' . $signature->protocol;
263 0         0 return 0;
264             }
265              
266 357 50       1351 unless ( $signature->check_expiration ) {
267              
268             # signature has expired
269 0         0 $self->{signature_reject_reason} = 'signature is expired';
270 0         0 return 0;
271             }
272              
273 357 100       1013 unless ( defined $signature->domain ) {
274              
275             # no domain specified
276 2         9 $self->{signature_reject_reason} = 'missing d tag';
277 2         10 return 0;
278             }
279              
280 355 100       971 if ( $signature->domain eq '' ) {
281              
282             # blank domain
283 2         100 $self->{signature_reject_reason} = 'invalid domain in d tag';
284 2         14 return 0;
285             }
286              
287 353 100       947 unless ( defined $signature->selector ) {
288              
289             # no selector specified
290 3         15 $self->{signature_reject_reason} = 'missing s tag';
291 3         12 return 0;
292             }
293              
294 350         1155 return 1;
295             }
296              
297             sub check_public_key {
298 295     295 0 535 my $self = shift;
299 295 50       927 croak 'wrong number of arguments' unless ( @_ == 2 );
300 295         779 my ( $signature, $public_key ) = @_;
301              
302 295         630 my $result = 0;
303             eval {
304 295         1415 local $SIG{__DIE__};
305 295         586 $@ = undef;
306              
307             # HACK- I'm indecisive here about whether I want the
308             # check_foo functions to return false or to "die"
309             # on failure
310              
311             # check public key's allowed hash algorithms
312 295         1301 $result =
313             $public_key->check_hash_algorithm( $signature->hash_algorithm );
314              
315             # HACK- DomainKeys signatures are allowed to have an empty g=
316             # tag in the public key
317             # my $empty_g_means_wildcard = $signature->isa('Mail::DKIM::DkSignature');
318              
319             # check public key's granularity
320 295   33     1777 $result &&= $public_key->check_granularity( $signature->domain, 0 );
321              
322             # $signature->instance, $empty_g_means_wildcard);
323              
324 295 50       823 die $@ if $@;
325 295         1454 1
326 295 50       676 } || do {
327 0         0 my $E = $@;
328 0         0 chomp $E;
329 0         0 $self->{signature_reject_reason} = "public key: $E";
330             };
331 295         925 return $result;
332             }
333              
334             #
335             # called when the verifier has received the last of the message headers
336             # (body is still to come)
337             #
338             sub finish_header {
339 168     168 0 423 my $self = shift;
340              
341             # Signatures we found and were successfully parsed are stored in
342             # $self->{signatures}. If none were found, our result is "none".
343              
344 168 100 66     305 if ( @{ $self->{signatures} } == 0
  168         917  
345             && !defined( $self->{signature_reject_reason} ) )
346             {
347 5         15 $self->{result} = 'none';
348 5         13 return;
349             }
350              
351             # check for duplicate AAR headers (dup AS and AMS checked in add_signature)
352 163         550 my @aars = [];
353 163         335 foreach my $hdr ( @{ $self->{headers} } ) {
  163         562  
354 2366 100       7947 if ( my ($i) = $hdr =~ m{ARC-Authentication-Results:\s*i=(\d+)\s*;}i ) {
355 180 100       673 if ( defined $aars[$i] ) {
356 2         7 $self->{result} = 'fail';
357             $self->{details} =
358 2         13 "Duplicate ARC-Authentication-Results header $1";
359 2         221 return;
360             }
361 178         550 $aars[$i] = $hdr;
362             }
363             }
364              
365 161         329 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  161         497  
366             $algorithm->finish_header(
367             Headers => $self->{headers},
368 344         1714 Chain => 'pass'
369             );
370             }
371              
372             # stop processing signatures that are already known to be invalid
373 161         562 @{ $self->{algorithms} } = grep {
374 344         1200 my $sig = $_->signature;
375 344   33     1232 !( $sig->result && $sig->result eq 'invalid' );
376 161         378 } @{ $self->{algorithms} };
  161         650  
377              
378 161 50 33     318 if ( @{ $self->{algorithms} } == 0
  161         1059  
379 0         0 && @{ $self->{signatures} } > 0 )
380             {
381 0   0     0 $self->{result} = $self->{signatures}->[0]->result || 'invalid';
382             $self->{details} = $self->{signatures}->[0]->{verify_details}
383 0   0     0 || $self->{signature_reject_reason};
384 0         0 return;
385             }
386             }
387              
388             sub _check_and_verify_signature {
389 306     306   641 my $self = shift;
390 306         768 my ($algorithm) = @_;
391              
392             # check signature
393 306         902 my $signature = $algorithm->signature;
394              
395 306 50       958 if ( not $signature->get_tag('d') ) { # All sigs must have a D tag
396 0         0 $self->{signature_reject_reason} = 'missing D tag';
397 0         0 return ( 'fail', $self->{signature_reject_reason} );
398             }
399              
400 306 100       777 if ( not $signature->get_tag('b') ) { # All sigs must have a B tag
401 4         16 $self->{signature_reject_reason} = 'missing B tag';
402 4         18 return ( 'fail', $self->{signature_reject_reason} );
403             }
404              
405 302 100       2117 if ( not $signature->isa('Mail::DKIM::ARC::Seal') ) { # AMS tests
406 151 100       499 unless ( $signature->get_tag('bh') ) { # AMS must have a BH tag
407 2         5 $self->{signature_reject_reason} = 'missing BH tag';
408 2         6 return ( 'fail', $self->{signature_reject_reason} );
409             }
410 149 100 100     455 if ( ( $signature->get_tag('h') || '' ) =~ /arc-seal/i )
411             { # cannot cover AS
412             $self->{signature_reject_reason} =
413 1         5 'Arc-Message-Signature covers Arc-Seal';
414 1         5 return ( 'fail', $self->{signature_reject_reason} );
415             }
416             }
417              
418             # AMS signature must not
419              
420             # get public key
421 299         595 my $pkey;
422             eval {
423 299         1605 local $SIG{__DIE__};
424 299         1210 $pkey = $signature->get_public_key;
425 295         1611 1
426 299 100       716 } || do {
427 4         13 my $E = $@;
428 4         14 chomp $E;
429 4         23 $self->{signature_reject_reason} = "public key: $E";
430 4         22 return ( 'invalid', $self->{signature_reject_reason} );
431             };
432              
433 295 50       1357 unless ( $self->check_public_key( $signature, $pkey ) ) {
434 0         0 return ( 'invalid', $self->{signature_reject_reason} );
435             }
436              
437             # make sure key is big enough
438 295         1072 my $keysize = $pkey->cork->size * 8; # in bits
439 295 50 66     980 if ( $keysize < 1024 && $self->{Strict} ) {
440 2         8 $self->{signature_reject_reason} = "Key length $keysize too short";
441 2         9 return ( 'fail', $self->{signature_reject_reason} );
442             }
443              
444             # verify signature
445 293         636 my $result;
446             my $details;
447 293         596 local $@ = undef;
448             eval {
449 293         1032 local $SIG{__DIE__};
450 293 100       1754 $result = $algorithm->verify() ? 'pass' : 'fail';
451 287   100     1399 $details = $algorithm->{verification_details} || $@;
452 287         1842 1
453 293 100       622 } || do {
454              
455             # see also add_signature
456 6         42 chomp( my $E = $@ );
457 6 50       78 if ( $E =~ /(OpenSSL error: .*?) at / ) {
    0          
458 6         24 $E = $1;
459             }
460             elsif ( $E =~ /^(panic:.*?) at / ) {
461 0         0 $E = "OpenSSL $1";
462             }
463 6         18 $result = 'fail';
464 6         16 $details = $E;
465             };
466 293         1476 return ( $result, $details );
467             }
468              
469             sub finish_body {
470 168     168 0 356 my $self = shift;
471              
472 168 100       619 return if $self->{result}; # already failed
473              
474 140         275 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  140         468  
475              
476             # finish canonicalizing
477 306         1222 $algorithm->finish_body;
478              
479 306         1381 my ( $result, $details ) =
480             $self->_check_and_verify_signature($algorithm);
481              
482             # save the results of this signature verification
483 306         1035 $algorithm->{result} = $result;
484 306         1083 $algorithm->{details} = $details;
485 306   66     2111 $self->{signature} ||= $algorithm->signature; # something if we fail
486 306         987 $algorithm->signature->result( $result, $details );
487             }
488              
489 140   100     743 my $seals = $self->{seals} || [];
490 140   100     474 my $messages = $self->{messages} || [];
491 140 50 66     606 unless ( @$seals or @$messages ) {
492 0         0 $self->{result} = 'none';
493 0         0 $self->{details} = 'no ARC headers found';
494 0         0 return;
495             }
496              
497             # determine if it's valid:
498             # 5.1.1.5. Determining the 'cv' Tag Value for ARC-Seal
499              
500             # In order for a series of ARC sets to be considered valid, the
501             # following statements MUST be satisfied:
502              
503             # 1. The chain of ARC sets must have structural integrity (no sets or
504             # set component header fields missing, no duplicates, excessive
505             # hops (cf. Section 5.1.1.1.1), etc.);
506              
507 140 50       513 if ( $#$seals == 0 ) {
508 0         0 $self->{result} = 'fail';
509 0         0 $self->{details} = 'missing ARC-Seal 1';
510 0         0 return;
511             }
512 140 50       538 if ( $#$messages == 0 ) {
513 0         0 $self->{result} = 'fail';
514 0         0 $self->{details} = 'missing ARC-Message-Signature 1';
515 0         0 return;
516             }
517              
518 140 100       566 if ( $#$messages > $#$seals ) {
519 11         32 $self->{result} = 'fail';
520 11         51 $self->{details} = 'missing Arc-Seal ' . $#$messages;
521 11         52 return;
522             }
523              
524 129         581 foreach my $i ( 1 .. $#$seals ) {
525              
526             # XXX - we should error if it's already present, but that's done above if at all
527 152 100       498 if ( !$seals->[$i] ) {
528 1         2 $self->{result} = 'fail';
529 1         3 $self->{details} = "missing ARC-Seal $i";
530 1         3 return;
531             }
532 151 100       575 if ( !$messages->[$i] ) {
533 12         43 $self->{result} = 'fail';
534 12         56 $self->{details} = "missing ARC-Message-Signature $i";
535 12         68 return;
536             }
537             }
538              
539             # 2. All ARC-Seal header fields MUST validate;
540 116         344 foreach my $i ( 1 .. $#$seals ) {
541 137         538 my $result = $seals->[$i]->result();
542 137 100       630 if ( $result ne 'pass' ) {
543 38         171 $self->{signature} = $seals->[$i]->signature;
544 38         144 $self->{result} = $result;
545 38         151 $self->{details} = $seals->[$i]->result_detail();
546 38         165 return;
547             }
548             }
549              
550             # 3. All ARC-Seal header fields MUST have a chain value (cv=) status
551             # of "pass" (except the first which MUST be "none"); and
552 78         319 my $cv = $seals->[1]->get_tag('cv');
553 78 100 100     507 if ( !defined $cv or $cv ne 'none' ) {
554 7         31 $self->{signature} = $seals->[1]->signature;
555 7         20 $self->{result} = 'fail';
556 7         15 $self->{details} = 'first ARC-Seal must be cv=none';
557 7         30 return;
558             }
559 71         238 foreach my $i ( 2 .. $#$seals ) {
560 16         60 my $cv = $seals->[$i]->get_tag('cv');
561 16 100       75 if ( $cv ne 'pass' ) {
562 2         10 $self->{signature} = $seals->[$i]->signature;
563 2         7 $self->{result} = 'fail';
564 2         8 $self->{details} = "wrong cv for ARC-Seal i=$i";
565 2         10 return;
566             }
567             }
568              
569             # 4. The newest (highest instance number (i=)) AMS header field MUST
570             # validate.
571 69         247 my $result = $messages->[$#$seals]->result();
572 69 100       234 if ( $result ne 'pass' ) {
573 16         61 $self->{signature} = $messages->[$#$seals]->signature;
574 16         45 $self->{result} = $result;
575 16         68 $self->{details} = $messages->[$#$seals]->result_detail();
576 16         64 return;
577             }
578              
579             # Success!
580 53         286 $self->{signature} = $seals->[$#$seals]->signature();
581 53         185 $self->{result} = 'pass';
582 53         265 $self->{details} = $seals->[$#$seals]->result_detail();
583             }
584              
585             sub result_detail {
586 168     168 1 948 my $self = shift;
587              
588 168 100       634 return 'none' if $self->{result} eq 'none';
589              
590 163         321 my @items;
591 163         319 foreach my $signature ( @{ $self->{signatures} } ) {
  163         629  
592 364 50       1402 my $type =
    100          
593             ref($signature) eq 'Mail::DKIM::ARC::Seal' ? 'as'
594             : ref($signature) eq 'Mail::DKIM::ARC::MessageSignature' ? 'ams'
595             : ref($signature);
596 364   100     1684 push @items,
      100        
      100        
597             "$type."
598             . ( $signature->instance() || '' ) . '.'
599             . ( $signature->domain() || '(none)' ) . '='
600             . ( $signature->result_detail() || '?' );
601             }
602              
603 163         1155 return $self->{result} . ' (' . join( ', ', @items ) . ')';
604             }
605              
606              
607              
608             sub signatures {
609 0     0 1   my $self = shift;
610 0 0         croak 'unexpected argument' if @_;
611              
612 0           return @{ $self->{signatures} };
  0            
613             }
614              
615             1;
616              
617             __END__