File Coverage

blib/lib/Mail/DKIM/ARC/Signer.pm
Criterion Covered Total %
statement 166 268 61.9
branch 67 122 54.9
condition 9 33 27.2
subroutine 16 25 64.0
pod 11 16 68.7
total 269 464 57.9


line stmt bran cond sub pod time code
1             package Mail::DKIM::ARC::Signer;
2 3     3   66518 use strict;
  3         15  
  3         87  
3 3     3   15 use warnings;
  3         6  
  3         134  
4             our $VERSION = '1.20230911'; # VERSION
5             # ABSTRACT: generates a DKIM signature for a 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 3     3   1298 use Mail::DKIM::PrivateKey;
  3         11  
  3         118  
14 3     3   1470 use Mail::DKIM::ARC::MessageSignature;
  3         20  
  3         147  
15 3     3   1416 use Mail::DKIM::ARC::Seal;
  3         8  
  3         89  
16 3     3   1543 use Mail::AuthenticationResults::Parser;
  3         70399  
  3         100  
17 3     3   22 use Mail::AuthenticationResults::Header::AuthServID;
  3         6  
  3         67  
18              
19              
20 3     3   15 use base 'Mail::DKIM::Common';
  3         8  
  3         1406  
21 3     3   25 use Carp;
  3         9  
  3         8840  
22              
23             # PROPERTIES
24             #
25             # public:
26             #
27             # $signer->{Algorithm}
28             # identifies what algorithm to use when signing the message
29             # default is "rsa-sha256"
30             #
31             # $signer->{Domain}
32             # identifies what domain the message is signed for
33             #
34             # $signer->{SrvId}
35             # identifies what authserv-id is in the A-R headers
36             #
37             # $signer->{KeyFile}
38             # name of the file containing the private key used to sign
39             #
40             # $signer->{Policy}
41             # a signing policy (of type Mail::DKIM::SigningPolicy)
42             #
43             # $signer->{Selector}
44             # identifies name of the selector identifying the key
45             #
46             # $signer->{Key}
47             # the loaded private key
48             #
49             # private:
50             #
51             # $signer->{algorithms} = []
52             # an array of algorithm objects... an algorithm object is created for
53             # each signature being added to the message
54             #
55             # $signer->{result}
56             # result of the signing policy: "signed" or "skipped"
57             #
58             # $signer->{details}
59             # why we skipped this signature
60             #
61             # $signer->{signature}
62             # the created signature (of type Mail::DKIM::Signature)
63              
64             sub init {
65 22     22 0 40 my $self = shift;
66 22         81 $self->SUPER::init;
67              
68 22 100       131 if ( defined $self->{KeyFile} ) {
69             $self->{Key} ||=
70 5   33     37 Mail::DKIM::PrivateKey->load( File => $self->{KeyFile} );
71             }
72              
73 22 50       60 unless ( $self->{'Algorithm'} ) {
74              
75             # use default algorithm
76 0         0 $self->{'Algorithm'} = 'rsa-sha256';
77             }
78 22 100       52 unless ( $self->{'Domain'} ) {
79              
80             # use default domain
81 17         69 $self->{'Domain'} = 'example.org';
82             }
83 22 100       51 unless ( $self->{'SrvId'} ) {
84              
85             # use default domain
86 5         12 $self->{'SrvId'} = $self->{'Domain'};
87             }
88 22 50       48 unless ( $self->{'Selector'} ) {
89              
90             # use default selector
91 0         0 $self->{'Selector'} = 'unknown';
92             }
93 22         45 $self->{result} = '?'; # better update this before we finish
94             die 'Invalid signing algorithm'
95 22 50       59 unless $self->{Algorithm} eq 'rsa-sha256'; # add ed25519 sometime
96             die 'Need a valid chain value'
97 22 50 33     180 unless $self->{Chain} and $self->{Chain} =~ m{^(pass|fail|none|ar)$};
98             }
99              
100             sub finish_header {
101 22     22 0 32 my $self = shift;
102              
103             # add the AAR header
104 22         83 my @aar;
105             my @ams;
106 22         0 my @as;
107              
108 22         0 my $ar;
109             HEADER:
110 22         32 foreach my $header ( @{ $self->{headers} } ) {
  22         48  
111 197         750 $header =~ s/[\r\n]+$//;
112 197 100       454 if ( $header =~ m/^Authentication-Results:/ ) {
113 29         134 my ( $arval ) = $header =~ m/^Authentication-Results:[^;]*;[\t ]*(.*)/is;
114 29         47 my $parsed;
115             eval {
116 29         112 $parsed= Mail::AuthenticationResults::Parser->new
117             ->parse( $header );
118 28         81376 1
119 29 100       50 } || do {
120 1         1416 my $error = $@;
121 1         58 warn "Authentication-Results Header parse error: $error\n$header";
122 1         11 next HEADER;
123             };
124 28         86 my $ardom = $parsed->value->value;
125              
126             next
127 28 100       588 unless "\L$ardom" eq $self->{SrvId}; # make sure it's our domain
128              
129 25         462 $arval =~ s/;?\s*$//; # ignore trailing semicolon and whitespace
130             # preserve leading fold if there is one, otherwise set one leading space
131 25 100       146 $arval =~ s/^\s*/ / unless ($arval =~ m/^\015\012/);
132 25 100       62 if ($ar) {
133 5         11 $ar .= ";$arval";
134             }
135             else {
136 20         49 $ar = "$ardom;$arval";
137             }
138              
139             # get chain value from A-R header
140             $self->{Chain} = $1
141 25 100 100     362 if $self->{Chain} eq 'ar' and $arval =~ m{\barc=(none|pass|fail)};
142              
143             }
144             else {
145             # parse ARC headers to make sure we have completeness
146              
147 168 100       309 if ( $header =~ m/^ARC-/ ) {
148 19 50       42 if ( !$ar ) {
149 0         0 $self->{result} = 'skipped';
150             $self->{details} =
151 0         0 'ARC header seen before Authentication-Results';
152 0         0 return;
153             }
154 19 100       46 if ( $self->{Chain} eq 'ar' ) {
155 1         2 $self->{result} = 'skipped';
156             $self->{details} =
157 1         4 'No ARC result found in Authentication-Results';
158 1         4 return;
159             }
160              
161             }
162              
163 167 100       450 if ( $header =~ m/^ARC-Seal:/ ) {
    100          
    100          
164 6         22 my $seal = Mail::DKIM::ARC::Seal->parse($header);
165 6         27 my $i = $seal->instance;
166 6 50       20 if ( $as[$i] ) {
167 0         0 $self->{result} = 'skipped';
168 0         0 $self->{details} = "Duplicate ARC-Seal $i";
169 0         0 return;
170             }
171 6         13 $as[$i] = $seal;
172             }
173             elsif ( $header =~ m/^ARC-Message-Signature:/ ) {
174 6         18 my $sig = Mail::DKIM::ARC::MessageSignature->parse($header);
175 6         17 my $i = $sig->instance;
176 6 50       33 if ( $ams[$i] ) {
177 0         0 $self->{result} = 'skipped';
178             $self->{details} =
179 0         0 "Duplicate ARC-Message-Signature $i";
180 0         0 return;
181             }
182 6         15 $ams[$i] = $sig;
183             }
184             elsif ( $header =~ m/^ARC-Authentication-Results:\s*i=(\d+)/ ) {
185 6         19 my $i = $1;
186 6 50       15 if ( $aar[$i] ) {
187 0         0 $self->{result} = 'skipped';
188             $self->{details} =
189 0         0 "Duplicate ARC-Authentication-Results $i";
190 0         0 return;
191             }
192              
193 6         13 $aar[$i] = $header;
194             }
195             }
196             }
197              
198 21 100       64 unless ($ar) {
199 2         5 $self->{result} = 'skipped';
200 2         6 $self->{details} = 'No authentication results seen';
201 2         6 return;
202             }
203              
204 19 100       57 $self->{Chain} = 'none' if ($self->{Chain} eq 'ar');
205              
206 19 50       46 if ( $#ams > $#as ) {
207 0         0 $self->{result} = 'skipped';
208 0         0 $self->{details} = 'More message signatures than seals';
209 0         0 return;
210             }
211 19 50       47 if ( $#aar > $#as ) {
212 0         0 $self->{result} = 'skipped';
213 0         0 $self->{details} = 'More authentication results than seals';
214 0         0 return;
215             }
216              
217 19         59 foreach my $i ( 1 .. $#as ) {
218 6 50       14 unless ( $as[$i] ) {
219 0         0 $self->{result} = 'skipped';
220 0         0 $self->{details} = "Missing ARC-Seal $i";
221 0         0 return;
222             }
223 6 50       19 unless ( $ams[$i] ) {
224 0         0 $self->{result} = 'skipped';
225 0         0 $self->{details} = "Missing Arc-Message-Signature $i";
226 0         0 return;
227             }
228              
229             # don't care about authentication results, they are compulsary
230             }
231              
232 19   100     70 $self->{_Instance} = @as || 1; # next instance value
233              
234             # first add the AAR header
235 19         75 $self->{_AAR} = "ARC-Authentication-Results: i=$self->{_Instance}; $ar";
236 19         34 unshift @{ $self->{headers} }, $self->{_AAR};
  19         68  
237              
238             # set up the signer for AMS
239             $self->add_signature(
240             Mail::DKIM::ARC::MessageSignature->new(
241             Algorithm => $self->{Algorithm},
242             Headers => $self->headers,
243             Instance => $self->{_Instance},
244             Method => 'relaxed/relaxed',
245             Domain => $self->{Domain},
246             Selector => $self->{Selector},
247             Key => $self->{Key},
248             KeyFile => $self->{KeyFile},
249             ( $self->{Timestamp} ? ( Timestamp => $self->{Timestamp} ) : () ),
250             ( $self->{Expiration} ? ( Expiration => $self->{Expiration} ) : () ),
251             (
252 19 100       71 $self->{'Tags'} ? ( Tags => $self->{'Tags'} )
    50          
    50          
253             : ()
254             ),
255             )
256             );
257              
258 19         33 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  19         46  
259              
260             # output header as received so far into canonicalization
261 19         36 foreach my $header ( @{ $self->{headers} } ) {
  19         36  
262 205         367 $algorithm->add_header($header);
263             }
264 19         58 $algorithm->finish_header( Headers => $self->{headers} );
265             }
266             }
267              
268             sub finish_body {
269 22     22 0 35 my $self = shift;
270              
271 22 100       53 if ( $self->{result} eq 'skipped' ) { # already failed
272 3         11 $self->{_AS} = undef;
273 3         6 return;
274             }
275              
276 19         32 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  19         41  
277              
278             # finished canonicalizing
279 19         59 $algorithm->finish_body;
280              
281             # load the private key file if necessary
282 19         58 my $signature = $algorithm->signature;
283             my $key =
284             $signature->{Key}
285             || $signature->{KeyFile}
286             || $self->{Key}
287 19   0     66 || $self->{KeyFile};
288 19 50 33     85 if ( defined($key) && !ref($key) ) {
289 0         0 $key = Mail::DKIM::PrivateKey->load( File => $key );
290             }
291             $key
292 19 50       56 or die "no key available to sign with\n";
293              
294             # compute signature value
295 19         56 my $signb64 = $algorithm->sign($key);
296 19         84 $signature->data($signb64);
297              
298             # insert linebreaks in signature data, if desired
299 19         86 $signature->prettify_safe();
300              
301 19         78 $self->{_AMS} = $signature->as_string();
302 19         38 unshift @{ $self->{headers} }, $self->{_AMS};
  19         75  
303             }
304              
305             # reset the internal state
306 19         49 $self->{signatures} = [];
307 19         306 $self->{algorithms} = [];
308              
309             $self->add_signature(
310             Mail::DKIM::ARC::Seal->new(
311             Algorithm => $self->{Algorithm},
312             Chain => $self->{Chain},
313             Headers => $self->headers,
314             Instance => $self->{_Instance},
315             Domain => $self->{Domain},
316             Selector => $self->{Selector},
317             Key => $self->{Key},
318             KeyFile => $self->{KeyFile},
319             ( $self->{Timestamp} ? ( Timestamp => $self->{Timestamp} ) : () ),
320             ( $self->{Expiration} ? ( Expiration => $self->{Expiration} ) : () ),
321             (
322 19 100       114 $self->{'SealTags'} ? ( Tags => $self->{'SealTags'} )
    50          
    50          
323             : ()
324             ),
325              
326             )
327             );
328              
329 19         34 foreach my $algorithm ( @{ $self->{algorithms} } ) {
  19         46  
330              
331             # output header as received so far into canonicalization
332 19         29 foreach my $header ( @{ $self->{headers} } ) {
  19         37  
333 224         374 $algorithm->add_header($header);
334             }
335              
336             # chain needed for seal canonicalization
337             $algorithm->finish_header(
338             Headers => $self->{headers},
339             Chain => $self->{Chain}
340 19         59 );
341              
342             # no body is required for ARC-Seal
343             # finished canonicalizing
344 19         64 $algorithm->finish_body;
345              
346             # load the private key file if necessary
347 19         48 my $signature = $algorithm->signature;
348             my $key =
349             $signature->{Key}
350             || $signature->{KeyFile}
351             || $self->{Key}
352 19   0     55 || $self->{KeyFile};
353 19 50 33     92 if ( defined($key) && !ref($key) ) {
354 0         0 $key = Mail::DKIM::PrivateKey->load( File => $key );
355             }
356             $key
357 19 50       47 or die "no key available to sign ARC-Seal\n";
358              
359             # compute signature value
360 19         119 my $signb64 = $algorithm->sign($key);
361 19         80 $signature->data($signb64);
362              
363             # insert linebreaks in signature data, if desired
364 19         64 $signature->prettify_safe();
365              
366 19         73 $self->{_AS} = $signature->as_string();
367             }
368              
369 19         67 $self->{result} = 'sealed';
370             }
371              
372              
373             sub add_signature {
374 38     38 1 60 my $self = shift;
375 38         54 my $signature = shift;
376              
377             # create a canonicalization filter and algorithm
378 38 50 0     125 my $algorithm_class =
379             $signature->get_algorithm_class( $signature->algorithm )
380             or die 'unsupported algorithm ' . ( $signature->algorithm || '' ) . "\n";
381             my $algorithm = $algorithm_class->new(
382             Signature => $signature,
383             Debug_Canonicalization => $self->{Debug_Canonicalization},
384 38         204 );
385 38         80 push @{ $self->{algorithms} }, $algorithm;
  38         102  
386 38         71 return;
387             }
388              
389              
390             sub algorithm {
391 0     0 1 0 my $self = shift;
392 0 0       0 if ( @_ == 1 ) {
393 0         0 $self->{Algorithm} = shift;
394             }
395 0         0 return $self->{Algorithm};
396             }
397              
398              
399             sub domain {
400 0     0 1 0 my $self = shift;
401 0 0       0 if ( @_ == 1 ) {
402 0         0 $self->{Domain} = shift;
403             }
404 0         0 return $self->{Domain};
405             }
406              
407              
408              
409             # these are headers that "should" be included in the signature,
410             # according to the DKIM spec.
411             my @DEFAULT_HEADERS = qw(From Sender Reply-To Subject Date
412             Message-ID To Cc MIME-Version
413             Content-Type Content-Transfer-Encoding Content-ID Content-Description
414             Resent-Date Resent-From Resent-Sender Resent-To Resent-cc
415             Resent-Message-ID
416             In-Reply-To References
417             List-Id List-Help List-Unsubscribe List-Subscribe
418             List-Post List-Owner List-Archive);
419              
420             sub process_headers_hash {
421 0     0 0 0 my $self = shift;
422 0         0 my @headers;
423              
424             # these are the header fields we found in the message we're signing
425 0         0 my @found_headers = @{ $self->{header_field_names} };
  0         0  
426              
427             # Convert all keys to lower case
428 0         0 foreach my $header ( keys %{ $self->{'ExtendedHeaders'} } ) {
  0         0  
429 0 0       0 next if $header eq lc $header;
430 0 0       0 if ( exists $self->{'ExtendedHeaders'}->{ lc $header } ) {
431              
432             # Merge
433 0         0 my $first = $self->{'ExtendedHeaders'}->{ lc $header };
434 0         0 my $second = $self->{'ExtendedHeaders'}->{$header};
435 0 0 0     0 if ( $first eq '+' || $second eq '+' ) {
    0 0        
436 0         0 $self->{'ExtendedHeaders'}->{ lc $header } = '+';
437             }
438             elsif ( $first eq '*' || $second eq '*' ) {
439 0         0 $self->{'ExtendedHeaders'}->{ lc $header } = '*';
440             }
441             else {
442 0         0 $self->{'ExtendedHeaders'}->{ lc $header } = $first + $second;
443             }
444             }
445             else {
446             # Rename
447             $self->{'ExtendedHeaders'}->{ lc $header } =
448 0         0 $self->{'ExtendedHeaders'}->{$header};
449             }
450 0         0 delete $self->{'ExtendedHeaders'}->{$header};
451             }
452              
453             # Add the default headers
454 0 0       0 if ( !$self->{'NoDefaultHeaders'} ) {
455 0         0 foreach my $default (@DEFAULT_HEADERS) {
456 0 0       0 if ( !exists $self->{'ExtendedHeaders'}->{ lc $default } ) {
457 0         0 $self->{'ExtendedHeaders'}->{ lc $default } = '*';
458             }
459             }
460             }
461              
462             # Build a count of found headers
463 0         0 my $header_counts = {};
464 0         0 foreach my $header (@found_headers) {
465 0 0       0 if ( !exists $header_counts->{ lc $header } ) {
466 0         0 $header_counts->{ lc $header } = 1;
467             }
468             else {
469 0         0 $header_counts->{ lc $header } = $header_counts->{ lc $header } + 1;
470             }
471             }
472              
473 0         0 foreach my $header ( sort keys %{ $self->{'ExtendedHeaders'} } ) {
  0         0  
474 0         0 my $want_count = $self->{'ExtendedHeaders'}->{$header};
475 0   0     0 my $have_count = $header_counts->{ lc $header } || 0;
476 0         0 my $add_count = 0;
477 0 0       0 if ( $want_count eq '+' ) {
    0          
478 0         0 $add_count = $have_count + 1;
479             }
480             elsif ( $want_count eq '*' ) {
481 0         0 $add_count = $have_count;
482             }
483             else {
484 0 0       0 if ( $want_count > $have_count ) {
485 0         0 $add_count = $have_count;
486             }
487             else {
488 0         0 $add_count = $want_count;
489             }
490             }
491 0         0 for ( 1 .. $add_count ) {
492 0         0 push @headers, $header;
493             }
494             }
495 0         0 return join( ':', @headers );
496             }
497              
498             sub extended_headers {
499 0     0 1 0 my $self = shift;
500 0         0 $self->{'ExtendedHeaders'} = shift;
501 0         0 return;
502             }
503              
504             sub headers {
505 38     38 1 66 my $self = shift;
506 38 50       84 croak 'unexpected argument' if @_;
507              
508 38 50       84 if ( exists $self->{'ExtendedHeaders'} ) {
509 0         0 return $self->process_headers_hash();
510             }
511              
512             # these are the header fields we found in the message we're signing
513 38         54 my @found_headers = @{ $self->{header_field_names} };
  38         138  
514              
515             # these are the headers we actually want to sign
516 38         55 my @wanted_headers;
517 38 100       84 if ( !$self->{'NoDefaultHeaders'} ) {
518 6         30 @wanted_headers = @DEFAULT_HEADERS;
519             }
520 38 100       81 if ( $self->{Headers} ) {
521 32         131 push @wanted_headers, split /:/, $self->{Headers};
522             }
523              
524             my @headers =
525             grep {
526 38         79 my $a = $_;
  372         491  
527 372         498 scalar grep { lc($a) eq lc($_) } @wanted_headers
  2412         4057  
528             } @found_headers;
529 38         473 return join( ':', @headers );
530             }
531              
532             # return nonzero if this is header we should sign
533             sub want_header {
534 0     0 0 0 my $self = shift;
535 0         0 my ($header_name) = @_;
536              
537             #TODO- provide a way for user to specify which headers to sign
538 0         0 return scalar grep { lc($_) eq lc($header_name) } @DEFAULT_HEADERS;
  0         0  
539             }
540              
541              
542             sub key {
543 0     0 1 0 my $self = shift;
544 0 0       0 if (@_) {
545 0         0 $self->{Key} = shift;
546 0         0 $self->{KeyFile} = undef;
547             }
548 0         0 return $self->{Key};
549             }
550              
551              
552             sub key_file {
553 0     0 1 0 my $self = shift;
554 0 0       0 if (@_) {
555 0         0 $self->{Key} = undef;
556 0         0 $self->{KeyFile} = shift;
557             }
558 0         0 return $self->{KeyFile};
559             }
560              
561              
562              
563             sub selector {
564 0     0 1 0 my $self = shift;
565 0 0       0 if ( @_ == 1 ) {
566 0         0 $self->{Selector} = shift;
567             }
568 0         0 return $self->{Selector};
569             }
570              
571              
572             sub signatures {
573 0     0 1 0 my $self = shift;
574 0 0       0 croak 'no arguments allowed' if @_;
575 0         0 return map { $_->signature } @{ $self->{algorithms} };
  0         0  
  0         0  
576             }
577              
578              
579             sub as_string {
580 17     17 1 66 my $self = shift;
581 17 100       40 return '' unless $self->{_AS}; # skipped, no signature
582              
583 16         68 return join( "\015\012", $self->{_AS}, $self->{_AMS}, $self->{_AAR}, '' );
584             }
585              
586              
587             sub as_strings {
588 3     3 1 7 my $self = shift;
589 3         20 return ( $self->{_AS}, $self->{_AMS}, $self->{_AAR} );
590             }
591              
592             1;
593              
594             __END__