File Coverage

blib/lib/Mail/Milter/Authentication/Handler/DKIM.pm
Criterion Covered Total %
statement 205 271 75.6
branch 65 118 55.0
condition 27 57 47.3
subroutine 19 20 95.0
pod 1 10 10.0
total 317 476 66.6


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::DKIM;
2 38     38   19842 use 5.20.0;
  38         209  
3 38     38   446 use strict;
  38         185  
  38         1085  
4 38     38   270 use warnings;
  38         197  
  38         1263  
5 38     38   281 use Mail::Milter::Authentication::Pragmas;
  38         187  
  38         318  
6             # ABSTRACT: Handler class for DKIM
7             our $VERSION = '3.20230629'; # VERSION
8 38     38   9355 use base 'Mail::Milter::Authentication::Handler';
  38         130  
  38         4364  
9 38     38   15080 use Mail::DKIM 1.20200824;
  38         4270  
  38         1526  
10 38     38   12079 use Mail::DKIM::DNS;
  38         86527  
  38         1217  
11 38     38   12402 use Mail::DKIM::KeyValueList;
  38         39173  
  38         1452  
12 38     38   15189 use Mail::DKIM::Verifier;
  38         1165192  
  38         150422  
13              
14             sub default_config {
15             return {
16 1     1 0 1863 'hide_none' => 0,
17             'hide_domainkeys' => 0,
18             'check_adsp' => 1,
19             'show_default_adsp' => 0,
20             'adsp_hide_none' => 0,
21             'extra_properties' => 0,
22             'no_strict' => 0,
23             };
24             }
25              
26             sub grafana_rows {
27 1     1 0 4325 my ( $self ) = @_;
28 1         3 my @rows;
29 1         13 push @rows, $self->get_json( 'DKIM_metrics' );
30 1         7 return \@rows;
31             }
32              
33             sub register_metrics {
34             return {
35 40     40 1 447 'dkim_total' => 'The number of emails processed for DKIM',
36             'dkim_signatures' => 'The number of signatures processed for DKIM',
37             };
38             }
39              
40             sub envfrom_callback {
41 196     196 0 859 my ( $self, $env_from ) = @_;
42 196         886 $self->{'failmode'} = 0;
43 196         924 $self->{'headers'} = [];
44 196         789 $self->{'has_dkim'} = 0;
45 196         891 $self->{'valid_domains'} = {};
46 196         940 $self->{'carry'} = q{};
47 196         1395 $self->destroy_object('dkim');
48             }
49              
50             sub show_domainkeys {
51 36     36 0 170 my ( $self ) = @_;
52 36         156 my $config = $self->handler_config();
53 36 50       480 return 1 if ! exists $config->{'hide_domainkeys'};
54 0 0       0 return 0 if $config->{'hide_domainkeys'};
55 0         0 return 1;
56             }
57              
58             sub header_callback {
59 1371     1371 0 4065 my ( $self, $header, $value, $original ) = @_;
60 1371 50       4267 return if ( $self->{'failmode'} );
61 1371         2863 my $EOL = "\015\012";
62 1371         3911 my $dkim_chunk = $original . $EOL;
63 1371         12565 $dkim_chunk =~ s/\015?\012/$EOL/g;
64 1371         3406 push @{$self->{'headers'}} , $dkim_chunk;
  1371         4142  
65              
66 1371 100       4731 if ( lc($header) eq 'dkim-signature' ) {
67 163         678 $self->{'has_dkim'} = 1;
68              
69 163         356 my $parsed = eval{ Mail::DKIM::KeyValueList->parse( $value ) };
  163         2239  
70 163         41927 $self->handle_exception( $@ );
71 163 50       825 if ( $parsed ) {
72 163         984 my $domain = $parsed->get_tag('d');
73 163         2079 my $selector = $parsed->get_tag('s');
74 163 50 33     2306 if ( defined $selector && defined $domain ) {
75 163         838 my $resolver = $self->get_object('resolver');
76 163         814 my $lookup = $selector.'._domainkey.'.$domain;
77 163         404 eval{ $resolver->bgsend( $lookup, 'TXT' ) };
  163         1848  
78 163         282559 $self->handle_exception( $@ );
79 163         1129 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
80 163         597 $lookup = '_adsp._domainkey.'.$domain;
81 163         357 eval{ $resolver->bgsend( $lookup, 'TXT' ) };
  163         698  
82 163         181526 $self->handle_exception( $@ );
83 163         964 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
84             }
85             }
86             }
87 1371 50       5566 if ( lc($header) eq 'domainkey-signature' ) {
88 0 0       0 $self->{'has_dkim'} = 1 if $self->show_domainkeys();
89             }
90             }
91              
92             sub eoh_callback {
93 196     196 0 832 my ($self) = @_;
94 196 50       957 return if ( $self->{'failmode'} );
95 196         1145 my $config = $self->handler_config();
96              
97 196 100       1337 if ( $self->{'has_dkim'} == 0 ) {
98 65         703 $self->metric_count( 'dkim_total', { 'result' => 'none' } );
99 65         707 $self->dbgout( 'DKIMResult', 'No DKIM headers', LOG_DEBUG );
100 65 100       710 if ( !( $config->{'hide_none'} ) ) {
101 58         773 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'none' );
102 58         5116 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'no signatures found' ) );
103 58         21383 $self->add_auth_header( $header );
104             }
105 65         440 delete $self->{'headers'};
106             }
107             else {
108              
109 131         378 my $dkim;
110 131         336 eval {
111 131         384 my $UseStrict = 1;
112 131 100       565 if ( $config->{ 'no_strict' } ) {
113 2         6 $UseStrict = 0;
114             }
115 131         2436 $dkim = Mail::DKIM::Verifier->new( 'Strict' => $UseStrict );
116 131         11795 my $resolver = $self->get_object('resolver');
117 131         1382 Mail::DKIM::DNS::resolver($resolver);
118 131         1809 $self->set_object('dkim', $dkim, 1);
119             };
120 131 50       901 if ( my $error = $@ ) {
121 0         0 $self->handle_exception( $error );
122 0         0 $self->log_error( 'DKIM Setup Error ' . $error );
123 0         0 $self->{'failmode'} = 1;
124 0         0 $self->_check_error( $error );
125 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
126 0         0 delete $self->{'headers'};
127 0         0 return;
128             }
129              
130 131         364 eval {
131             $dkim->PRINT( join q{},
132 131         404 @{ $self->{'headers'} },
  131         1775  
133             "\015\012",
134             );
135             };
136 131 50       347654 if ( my $error = $@ ) {
137 0         0 $self->handle_exception( $error );
138 0         0 $self->log_error( 'DKIM Headers Error ' . $error );
139 0         0 $self->{'failmode'} = 1;
140 0         0 $self->_check_error( $error );
141 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
142             }
143              
144 131         798 delete $self->{'headers'};
145             }
146              
147 196         1141 $self->{'carry'} = q{};
148             }
149              
150             sub body_callback {
151 194     194 0 787 my ( $self, $body_chunk ) = @_;
152 194 50       852 return if ( $self->{'failmode'} );
153 194 100       899 return if ( $self->{'has_dkim'} == 0 );
154 131         395 my $EOL = "\015\012";
155              
156 131         346 my $dkim_chunk;
157 131 50       653 if ( $self->{'carry'} ne q{} ) {
158 0         0 $dkim_chunk = $self->{'carry'} . $body_chunk;
159 0         0 $self->{'carry'} = q{};
160             }
161             else {
162 131         421 $dkim_chunk = $body_chunk;
163             }
164              
165 131 50       694 if ( substr( $dkim_chunk, -1 ) eq "\015" ) {
166 0         0 $self->{'carry'} = "\015";
167 0         0 $dkim_chunk = substr( $dkim_chunk, 0, -1 );
168             }
169              
170 131         2092 $dkim_chunk =~ s/\015?\012/$EOL/g;
171              
172 131         782 my $dkim = $self->get_object('dkim');
173 131         376 eval {
174 131         846 $dkim->PRINT( $dkim_chunk );
175             };
176 131 50       20395 if ( my $error = $@ ) {
177 0         0 $self->handle_exception( $error );
178 0         0 $self->log_error( 'DKIM Body Error ' . $error );
179 0         0 $self->{'failmode'} = 1;
180 0         0 $self->_check_error( $error );
181 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
182             }
183             }
184              
185             sub eom_callback {
186 196     196 0 849 my ($self) = @_;
187              
188 196 100       1033 return if ( $self->{'has_dkim'} == 0 );
189 131 50       651 return if ( $self->{'failmode'} );
190              
191 131         571 my $config = $self->handler_config();
192              
193 131         751 my $dkim = $self->get_object('dkim');
194              
195 131         526 eval {
196 131         933 $dkim->PRINT( $self->{'carry'} );
197 131         3255 $dkim->CLOSE();
198 131         374338 $self->check_timeout();
199              
200 131         1632 my $dkim_result = $dkim->result;
201 131         1975 my $dkim_result_detail = $dkim->result_detail;
202              
203 131         2117 $self->metric_count( 'dkim_total', { 'result' => $dkim_result } );
204              
205 131         1154 $self->dbgout( 'DKIMResult', $dkim_result_detail, LOG_DEBUG );
206              
207 131 50       1540 if ( !$dkim->signatures() ) {
208 0 0 0     0 if ( !( $config->{'hide_none'} && $dkim_result eq 'none' ) ) {
209 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( $dkim_result );
210 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'no signatures found' ) );
211 0         0 $self->add_auth_header( $header );
212             }
213             }
214 131         2100 foreach my $signature ( $dkim->signatures() ) {
215              
216 163         1587 my $otype = ref $signature;
217 163 50       962 my $type =
    50          
218             $otype eq 'Mail::DKIM::DkSignature' ? 'domainkeys'
219             : $otype eq 'Mail::DKIM::Signature' ? 'dkim'
220             : 'dkim';
221 163         727 $self->dbgout( 'DKIMSignatureType', $type, LOG_DEBUG );
222              
223 163         1246 $self->dbgout( 'DKIMSignatureDomain', $signature->domain, LOG_DEBUG );
224 163         1462 $self->dbgout( 'DKIMSignatureIdentity', $signature->identity, LOG_DEBUG );
225 163         1526 $self->dbgout( 'DKIMSignatureResult', $signature->result_detail, LOG_DEBUG );
226 163         1411 my $signature_result = $signature->result();
227 163         1912 my $signature_result_detail = $signature->result_detail();
228              
229 163 100       2106 if ( $signature_result eq 'pass' ) {
230 101         499 $self->{'valid_domains'}->{ lc $signature->domain } = 1;
231             }
232              
233 163 100       2588 if ( $signature_result eq 'invalid' ) {
234 24 50       140 if ( $signature_result_detail =~ /DNS query timeout for (.*) at / ) {
235 0         0 my $timeout_domain = $1;
236 0         0 $self->log_error( "TIMEOUT DETECTED: in DKIM result: $timeout_domain" );
237 0         0 $signature_result_detail = "DNS query timeout for $timeout_domain";
238             }
239             }
240              
241 163         445 my $result_comment = q{};
242 163 100 66     1054 if ( $signature_result ne 'pass' and $signature_result ne 'none' ) {
243 62         1011 $signature_result_detail =~ /$signature_result \((.*)\)/;
244 62 50       358 if ( $1 ) {
245 62         259 $result_comment = $1 . ', ';
246             }
247             }
248 163 50 66     1016 if (
249             !(
250             $config->{'hide_none'} && $signature_result eq 'none'
251             )
252             )
253             {
254              
255 163   50     384 my $selector = eval{ $signature->selector } // q{};
  163         696  
256 163         2828 my $key = eval {$signature->get_public_key()};
  163         705  
257 163   50     35608 my $key_type = eval{ $key->type() } // 'unknown';
  163         918  
258             # Key size for ed25519 does not make sense
259 163 50 50     2064 my $key_size = $key_type eq 'rsa' ? ( eval{ $key->size() } // 0 ) : 0;
  163         740  
260              
261 163   50     2826 my $hash_algorithm = eval { $signature->hash_algorithm(); } // '';
  163         600  
262 163   50     4171 my $canonicalization = eval { $signature->canonicalization(); } // '';
  163         591  
263              
264 163         4875 my @key_data_parts;
265 163 50       822 push @key_data_parts, $key_size . '-bit' if $key_size;
266 163         566 push @key_data_parts, "$key_type key";
267 163 50       658 push @key_data_parts, $hash_algorithm if $hash_algorithm;
268 163         722 my $key_data = join ' ', @key_data_parts;
269              
270 163         1941 $self->metric_count( 'dkim_signatures', {
271             'type' => $type,
272             'result' => $signature_result,
273             'key_size' => $key_size,
274             'key_type' => $key_type,
275             'hash_algorithm' => $hash_algorithm,
276             'canonicalization' => $canonicalization,
277             } );
278              
279 163 50       1345 if ( $type eq 'domainkeys' ) {
280 0 0       0 if ( $self->show_domainkeys() ) {
281 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( $type )->safe_set_value( $signature_result );
282 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $result_comment . $key_data ) );
283 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.d' )->safe_set_value( $signature->domain() ) );
284 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.b' )->safe_set_value( substr( $signature->data(), 0, 8 ) ) );
285 0 0       0 if ( $config->{'extra_properties'} ) {
286 0 0       0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-bits' )->safe_set_value( $key_size ) ) if $key_size;
287 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-keytype' )->safe_set_value( $key_type ) );
288 0 0       0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-algorithm' )->safe_set_value( $hash_algorithm ) ) if $hash_algorithm;
289 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-selector' )->safe_set_value( $selector ) );
290             }
291 0         0 $self->add_auth_header($header);
292             }
293             }
294             else {
295 163         2337 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( $type )->safe_set_value( $signature_result );
296 163         15403 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $result_comment . $key_data ) );
297 163         89770 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.d' )->safe_set_value( $signature->domain() ) );
298 163         19713 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.i' )->safe_set_value( $signature->identity() ) );
299 163         20202 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.b' )->safe_set_value( substr( $signature->data(), 0, 8 ) ) );
300 163 50 33     20325 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.a' )->safe_set_value( $key_type . '-' . $hash_algorithm ) ) if ($key_type && $hash_algorithm);
301 163         15824 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.s' )->safe_set_value( $selector ) );
302 163 100       15074 if ( $config->{'extra_properties'} ) {
303 12 50       97 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-bits' )->safe_set_value( $key_size ) ) if $key_size;
304             }
305 163         2273 $self->add_auth_header($header);
306             }
307             }
308 163         690 $self->check_timeout();
309             }
310              
311             # the alleged author of the email may specify how to handle email
312 131 100 100     1660 if ( $config->{'check_adsp'}
      100        
      66        
313             && ( $self->is_local_ip_address() == 0 )
314             && ( $self->is_trusted_ip_address() == 0 )
315             && ( $self->is_authenticated() == 0 ) )
316             {
317             POLICY:
318 36         384 foreach my $policy ( $dkim->policies() ) {
319 108         108122 my $apply = $policy->apply($dkim);
320 108         29508 my $string = $policy->as_string();
321 108   100     2151 my $location = $policy->location() || q{};
322 108         1336 my $name = $policy->name();
323 108         833 my $default = $policy->is_implied_default_policy();
324              
325 108         1553 my $otype = ref $policy;
326 108 50       619 my $type =
    100          
    100          
327             $otype eq 'Mail::DKIM::AuthorDomainPolicy' ? 'dkim-adsp'
328             : $otype eq 'Mail::DKIM::DkimPolicy' ? 'x-dkim-ssp'
329             : $otype eq 'Mail::DKIM::DkPolicy' ? 'x-dkim-dkssp'
330             : 'x-dkim-policy';
331              
332 108         537 $self->dbgout( 'DKIMPolicy', $apply, LOG_DEBUG );
333 108         523 $self->dbgout( 'DKIMPolicyString', $string, LOG_DEBUG );
334 108         603 $self->dbgout( 'DKIMPolicyLocation', $location, LOG_DEBUG );
335 108         560 $self->dbgout( 'DKIMPolicyName', $name, LOG_DEBUG );
336 108 100       753 $self->dbgout( 'DKIMPolicyDefault', $default ? 'yes' : 'no', LOG_DEBUG );
337              
338 108 50 66     839 next POLICY if ( ( $type eq 'x-dkim-dkssp' ) && ( ! $self->show_domainkeys() ) );
339              
340 108 50       506 my $result =
    50          
    100          
341             $apply eq 'accept' ? 'pass'
342             : $apply eq 'reject' ? 'discard'
343             : $apply eq 'neutral' ? 'unknown'
344             : 'unknown';
345              
346 108 50 66     508 if ( ! ( $config->{'adsp_hide_none'} && $result eq 'none' ) ) {
347 108 100 66     602 if ( ( ! $default ) or $config->{'show_default_adsp'} ) {
348 36         344 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( $type )->safe_set_value( $result );
349 36 50       2731 my $comment = ( $default ? 'default ' : q{} )
    50          
350             . "$name policy"
351             . ( $location ? " from $location" : q{} )
352             # . ( $string ? ", $string" : q{} )
353             ;
354 36         188 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) );
355 36         18649 $self->add_auth_header( $header );
356             }
357             }
358 108         420 $self->check_timeout();
359             }
360             }
361             };
362 131 50       1109 if ( my $error = $@ ) {
363 0         0 $self->handle_exception( $error );
364              
365             # Also in DMARC module
366 0         0 $self->log_error( 'DKIM EOM Error ' . $error );
367 0         0 $self->{'failmode'} = 1;
368 0         0 $self->_check_error( $error );
369 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
370 0         0 return;
371             }
372             }
373              
374             sub close_callback {
375 118     118 0 423 my ( $self ) = @_;
376 118         435 delete $self->{'failmode'};
377 118         387 delete $self->{'headers'};
378 118         298 delete $self->{'body'};
379 118         390 delete $self->{'carry'};
380 118         292 delete $self->{'has_dkim'};
381 118         418 delete $self->{'valid_domains'};
382 118         579 $self->destroy_object('dkim');
383             }
384              
385             sub _check_error {
386 0     0     my ( $self, $error ) = @_;
387 0 0 0       if ( $error =~ /^DNS error: query timed out/
    0 0        
    0 0        
      0        
388             or $error =~ /^DNS query timeout/
389             ){
390 0           $self->log_error( 'Temp DKIM Error - ' . $error );
391 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'temperror' );
392 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'dns timeout' ) );
393 0           $self->add_auth_header( $header );
394             }
395             elsif ( $error =~ /^DNS error: SERVFAIL/ ){
396 0           $self->log_error( 'Temp DKIM Error - ' . $error );
397 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'temperror' );
398 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'dns servfail' ) );
399 0           $self->add_auth_header( $header );
400             }
401             elsif ( $error =~ /^no domain to fetch policy for$/
402             or $error =~ /^policy syntax error$/
403             or $error =~ /^empty domain label/
404             or $error =~ /^invalid name /
405             ){
406 0           $self->log_error( 'Perm DKIM Error - ' . $error );
407 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'permerror' );
408 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'syntax or domain error' ) );
409 0           $self->add_auth_header( $header );
410             }
411             else {
412 0           $self->exit_on_close( 'Unexpected DKIM Error - ' . $error );
413 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'temperror' );
414 0           $self->add_auth_header( $header );
415             # Fill these in as they occur, but for unknowns err on the side of caution
416             # and tempfail/exit
417 0           $self->tempfail_on_error();
418             }
419             }
420              
421             1;
422              
423             __END__
424              
425             =pod
426              
427             =encoding UTF-8
428              
429             =head1 NAME
430              
431             Mail::Milter::Authentication::Handler::DKIM - Handler class for DKIM
432              
433             =head1 VERSION
434              
435             version 3.20230629
436              
437             =head1 DESCRIPTION
438              
439             Module for validation of DKIM and DomainKeys signatures, and application of ADSP policies.
440              
441             =head1 CONFIGURATION
442              
443             "DKIM" : { | Config for the DKIM Module
444             "hide_none" : 0, | Hide auth line if the result is 'none'
445             "hide_domainkeys" : 0, | Hide any DomainKeys results
446             "check_adsp" : 1, | Also check for ADSP
447             "show_default_adsp" : 0, | Show the default ADSP result
448             "adsp_hide_none" : 0, | Hide auth ADSP if the result is 'none'
449             "extra_properties" : 0 | Add extra properties (not to rfc) relating to key and selector
450             "no_strict" : 0, | Ignore rfc 8301 security considerations (not recommended
451             },
452              
453             =head1 AUTHOR
454              
455             Marc Bradshaw <marc@marcbradshaw.net>
456              
457             =head1 COPYRIGHT AND LICENSE
458              
459             This software is copyright (c) 2020 by Marc Bradshaw.
460              
461             This is free software; you can redistribute it and/or modify it under
462             the same terms as the Perl 5 programming language system itself.
463              
464             =cut