File Coverage

blib/lib/Mail/Milter/Authentication/Handler/DKIM.pm
Criterion Covered Total %
statement 215 283 75.9
branch 61 106 57.5
condition 21 46 45.6
subroutine 23 24 95.8
pod 1 10 10.0
total 321 469 68.4


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::DKIM;
2 27     27   14251 use strict;
  27         89  
  27         815  
3 27     27   149 use warnings;
  27         61  
  27         751  
4 27     27   148 use base 'Mail::Milter::Authentication::Handler';
  27         63  
  27         3381  
5             our $VERSION = '20191206'; # VERSION
6              
7 27     27   303 use Data::Dumper;
  27         62  
  27         1642  
8 27     27   171 use English qw{ -no_match_vars };
  27         65  
  27         348  
9 27     27   13685 use Sys::Syslog qw{:standard :macros};
  27         200  
  27         9223  
10              
11 27     27   9284 use Mail::DKIM 0.39;
  27         3251  
  27         797  
12 27     27   10413 use Mail::DKIM::Verifier 0.39;
  27         971987  
  27         789  
13 27     27   222 use Mail::DKIM::DNS;
  27         67  
  27         597  
14 27     27   145 use Mail::DKIM::KeyValueList;
  27         71  
  27         743  
15 27     27   176 use Mail::AuthenticationResults::Header::Entry;
  27         70  
  27         1092  
16 27     27   162 use Mail::AuthenticationResults::Header::SubEntry;
  27         69  
  27         800  
17 27     27   176 use Mail::AuthenticationResults::Header::Comment;
  27         72  
  27         70474  
18              
19             sub default_config {
20             return {
21 1     1 0 1259 'hide_none' => 0,
22             'hide_domainkeys' => 0,
23             'check_adsp' => 1,
24             'show_default_adsp' => 0,
25             'adsp_hide_none' => 0,
26             'extra_properties' => 0,
27             'no_strict' => 0,
28             };
29             }
30              
31             sub grafana_rows {
32 1     1 0 3446 my ( $self ) = @_;
33 1         2 my @rows;
34 1         15 push @rows, $self->get_json( 'DKIM_metrics' );
35 1         31 return \@rows;
36             }
37              
38             sub register_metrics {
39             return {
40 28     28 1 289 'dkim_total' => 'The number of emails processed for DKIM',
41             'dkim_signatures' => 'The number of signatures processed for DKIM',
42             };
43             }
44              
45             sub envfrom_callback {
46 164     164 0 564 my ( $self, $env_from ) = @_;
47 164         531 $self->{'failmode'} = 0;
48 164         593 $self->{'headers'} = [];
49 164         537 $self->{'has_dkim'} = 0;
50 164         883 $self->{'valid_domains'} = {};
51 164         552 $self->{'carry'} = q{};
52 164         1061 $self->destroy_object('dkim');
53 164         433 return;
54             }
55              
56             sub show_domainkeys {
57 28     28 0 115 my ( $self ) = @_;
58 28         128 my $config = $self->handler_config();
59 28 50       208 return 1 if ! exists $config->{'hide_domainkeys'};
60 0 0       0 return 0 if $config->{'hide_domainkeys'};
61 0         0 return 1;
62             }
63              
64             sub header_callback {
65 1085     1085 0 2924 my ( $self, $header, $value, $original ) = @_;
66 1085 50       3166 return if ( $self->{'failmode'} );
67 1085         2125 my $EOL = "\015\012";
68 1085         2814 my $dkim_chunk = $original . $EOL;
69 1085         8948 $dkim_chunk =~ s/\015?\012/$EOL/g;
70 1085         2374 push @{$self->{'headers'}} , $dkim_chunk;
  1085         2986  
71              
72 1085 100       3371 if ( lc($header) eq 'dkim-signature' ) {
73 150         380 $self->{'has_dkim'} = 1;
74              
75 150         293 my $parsed = eval{ Mail::DKIM::KeyValueList->parse( $value ) };
  150         1576  
76 150         30446 $self->handle_exception( $@ );
77 150 50       433 if ( $parsed ) {
78 150         483 my $domain = $parsed->get_tag('d');
79 150         1438 my $selector = $parsed->get_tag('s');
80 150 50 33     1503 if ( $selector && $domain ) {
81 150         583 my $resolver = $self->get_object('resolver');
82 150         477 my $lookup = $selector.'._domainkey.'.$domain;
83 150         1405 $resolver->bgsend( $lookup, 'TXT' );
84 150         202153 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
85 150         439 $lookup = '_adsp._domainkey.'.$domain;
86 150         586 $resolver->bgsend( $lookup, 'TXT' );
87 150         130193 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
88             }
89             }
90             }
91 1085 50       2909 if ( lc($header) eq 'domainkey-signature' ) {
92 0 0       0 $self->{'has_dkim'} = 1 if $self->show_domainkeys();
93             }
94              
95 1085         2859 return;
96             }
97              
98             sub eoh_callback {
99 164     164 0 475 my ($self) = @_;
100 164 50       616 return if ( $self->{'failmode'} );
101 164         735 my $config = $self->handler_config();
102              
103 164 100       655 if ( $self->{'has_dkim'} == 0 ) {
104 46         446 $self->metric_count( 'dkim_total', { 'result' => 'none' } );
105 46         279 $self->dbgout( 'DKIMResult', 'No DKIM headers', LOG_INFO );
106 46 100       266 if ( !( $config->{'hide_none'} ) ) {
107 45         651 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'none' );
108 45         3908 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'no signatures found' ) );
109 45         15559 $self->add_auth_header( $header );
110             }
111 46         200 delete $self->{'headers'};
112             }
113             else {
114              
115 118         276 my $dkim;
116 118         244 eval {
117 118         261 my $UseStrict = 1;
118 118 100       401 if ( $config->{ 'no_strict' } ) {
119 2         7 $UseStrict = 0;
120             }
121 118         1803 $dkim = Mail::DKIM::Verifier->new( 'Strict' => $UseStrict );
122             # The following requires Mail::DKIM > 0.4
123 118 50       7764 if ( $Mail::DKIM::VERSION >= 0.4 ) {
124 118         426 my $resolver = $self->get_object('resolver');
125 118         805 Mail::DKIM::DNS::resolver($resolver);
126             }
127 118         1132 $self->set_object('dkim', $dkim, 1);
128             };
129 118 50       503 if ( my $error = $@ ) {
130 0         0 $self->handle_exception( $error );
131 0         0 $self->log_error( 'DKIM Setup Error ' . $error );
132 0         0 $self->{'failmode'} = 1;
133 0         0 $self->_check_error( $error );
134 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
135 0         0 delete $self->{'headers'};
136 0         0 return;
137             }
138              
139 118         221 eval {
140             $dkim->PRINT( join q{},
141 118         259 @{ $self->{'headers'} },
  118         1043  
142             "\015\012",
143             );
144             };
145 118 50       243492 if ( my $error = $@ ) {
146 0         0 $self->handle_exception( $error );
147 0         0 $self->log_error( 'DKIM Headers Error ' . $error );
148 0         0 $self->{'failmode'} = 1;
149 0         0 $self->_check_error( $error );
150 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
151             }
152              
153 118         556 delete $self->{'headers'};
154             }
155              
156 164         618 $self->{'carry'} = q{};
157              
158 164         534 return;
159             }
160              
161             sub body_callback {
162 164     164 0 507 my ( $self, $body_chunk ) = @_;
163 164 50       655 return if ( $self->{'failmode'} );
164 164 100       624 return if ( $self->{'has_dkim'} == 0 );
165 118         288 my $EOL = "\015\012";
166              
167 118         212 my $dkim_chunk;
168 118 50       415 if ( $self->{'carry'} ne q{} ) {
169 0         0 $dkim_chunk = $self->{'carry'} . $body_chunk;
170 0         0 $self->{'carry'} = q{};
171             }
172             else {
173 118         263 $dkim_chunk = $body_chunk;
174             }
175              
176 118 50       484 if ( substr( $dkim_chunk, -1 ) eq "\015" ) {
177 0         0 $self->{'carry'} = "\015";
178 0         0 $dkim_chunk = substr( $dkim_chunk, 0, -1 );
179             }
180              
181 118         1544 $dkim_chunk =~ s/\015?\012/$EOL/g;
182              
183 118         537 my $dkim = $self->get_object('dkim');
184 118         267 eval {
185 118         569 $dkim->PRINT( $dkim_chunk );
186             };
187 118 50       14977 if ( my $error = $@ ) {
188 0         0 $self->handle_exception( $error );
189 0         0 $self->log_error( 'DKIM Body Error ' . $error );
190 0         0 $self->{'failmode'} = 1;
191 0         0 $self->_check_error( $error );
192 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
193             }
194 118         358 return;
195             }
196              
197             sub eom_callback {
198 164     164 0 454 my ($self) = @_;
199              
200 164 100       711 return if ( $self->{'has_dkim'} == 0 );
201 118 50       462 return if ( $self->{'failmode'} );
202              
203 118         462 my $config = $self->handler_config();
204              
205 118         438 my $dkim = $self->get_object('dkim');
206              
207 118         264 eval {
208 118         540 $dkim->PRINT( $self->{'carry'} );
209 118         2120 $dkim->CLOSE();
210 118         336629 $self->check_timeout();
211              
212 118         739 my $dkim_result = $dkim->result;
213 118         1251 my $dkim_result_detail = $dkim->result_detail;
214              
215 118         1555 $self->metric_count( 'dkim_total', { 'result' => $dkim_result } );
216              
217 118         629 $self->dbgout( 'DKIMResult', $dkim_result_detail, LOG_INFO );
218              
219 118 50       576 if ( !$dkim->signatures() ) {
220 0 0 0     0 if ( !( $config->{'hide_none'} && $dkim_result eq 'none' ) ) {
221 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( $dkim_result );
222 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'no signatures found' ) );
223 0         0 $self->add_auth_header( $header );
224             }
225             }
226 118         1362 foreach my $signature ( $dkim->signatures() ) {
227              
228 150         982 my $otype = ref $signature;
229 150 50       628 my $type =
    50          
230             $otype eq 'Mail::DKIM::DkSignature' ? 'domainkeys'
231             : $otype eq 'Mail::DKIM::Signature' ? 'dkim'
232             : 'dkim';
233 150         510 $self->dbgout( 'DKIMSignatureType', $type, LOG_DEBUG );
234              
235 150         598 $self->dbgout( 'DKIMSignatureDomain', $signature->domain, LOG_DEBUG );
236 150         586 $self->dbgout( 'DKIMSignatureIdentity', $signature->identity, LOG_DEBUG );
237 150         690 $self->dbgout( 'DKIMSignatureResult', $signature->result_detail, LOG_DEBUG );
238 150         570 my $signature_result = $signature->result();
239 150         1188 my $signature_result_detail = $signature->result_detail();
240              
241 150 100       1365 if ( $signature_result eq 'pass' ) {
242 88         346 $self->{'valid_domains'}->{ lc $signature->domain } = 1;
243             }
244              
245 150 100       1663 if ( $signature_result eq 'invalid' ) {
246 24 50       95 if ( $signature_result_detail =~ /DNS query timeout for (.*) at / ) {
247 0         0 my $timeout_domain = $1;
248 0         0 $self->log_error( "TIMEOUT DETECTED: in DKIM result: $timeout_domain" );
249 0         0 $signature_result_detail = "DNS query timeout for $timeout_domain";
250             }
251             }
252              
253 150         365 my $result_comment = q{};
254 150 100 66     758 if ( $signature_result ne 'pass' and $signature_result ne 'none' ) {
255 62         829 $signature_result_detail =~ /$signature_result \((.*)\)/;
256 62 50       290 if ( $1 ) {
257 62         177 $result_comment = $1 . ', ';
258             }
259             }
260 150 50 66     617 if (
261             !(
262             $config->{'hide_none'} && $signature_result eq 'none'
263             )
264             )
265             {
266              
267 150         287 my $key_size = 0;
268 150         352 my $key_type = q{};
269 150   50     294 my $selector = eval{ $signature->selector } || q{};
270 150         1940 eval {
271 150         442 my $key = $signature->get_public_key();
272 150         32079 $key_size = $key->size();
273 150         2080 $key_type = $key->type();
274             };
275              
276 150         978 my $hash_algorithm = eval { $signature->hash_algorithm(); };
  150         465  
277 150         2928 my $canonicalization = eval { $signature->canonicalization(); };
  150         457  
278              
279 150         3565 my $key_data = $key_size . '-bit ' . $key_type . ' key ' . $hash_algorithm;
280              
281 150         1292 $self->metric_count( 'dkim_signatures', {
282             'type' => $type,
283             'result' => $signature_result,
284             'key_size' => $key_size,
285             'key_type' => $key_type,
286             'hash_algorithm' => $hash_algorithm,
287             'canonicalization' => $canonicalization,
288             } );
289              
290 150 50       606 if ( $type eq 'domainkeys' ) {
291 0 0       0 if ( $self->show_domainkeys() ) {
292 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( $type )->safe_set_value( $signature_result );
293 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $result_comment . $key_data ) );
294 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.d' )->safe_set_value( $signature->domain() ) );
295 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.b' )->safe_set_value( substr( $signature->data(), 0, 8 ) ) );
296 0 0       0 if ( $config->{'extra_properties'} ) {
297 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-bits' )->safe_set_value( $key_size ) );
298 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-keytype' )->safe_set_value( $key_type ) );
299 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-algorithm' )->safe_set_value( $hash_algorithm ) );
300 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-selector' )->safe_set_value( $selector ) );
301             }
302 0         0 $self->add_auth_header($header);
303             }
304             }
305             else {
306 150         1531 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( $type )->safe_set_value( $signature_result );
307 150         10819 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $result_comment . $key_data ) );
308 150         68402 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.d' )->safe_set_value( $signature->domain() ) );
309 150         14466 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.i' )->safe_set_value( $signature->identity() ) );
310 150         14639 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.b' )->safe_set_value( substr( $signature->data(), 0, 8 ) ) );
311 150         13896 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.a' )->safe_set_value( $key_type . '-' . $hash_algorithm ) );
312 150         11541 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.s' )->safe_set_value( $selector ) );
313 150 100       11013 if ( $config->{'extra_properties'} ) {
314 12         38 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-bits' )->safe_set_value( $key_size ) );
315             }
316 150         1557 $self->add_auth_header($header);
317             }
318             }
319 150         434 $self->check_timeout();
320             }
321              
322             # the alleged author of the email may specify how to handle email
323 118 100 100     791 if ( $config->{'check_adsp'}
      100        
      66        
324             && ( $self->is_local_ip_address() == 0 )
325             && ( $self->is_trusted_ip_address() == 0 )
326             && ( $self->is_authenticated() == 0 ) )
327             {
328             POLICY:
329 28         157 foreach my $policy ( $dkim->policies() ) {
330 84         169936 my $apply = $policy->apply($dkim);
331 84         19093 my $string = $policy->as_string();
332 84   100     1356 my $location = $policy->location() || q{};
333 84         893 my $name = $policy->name();
334 84         545 my $default = $policy->is_implied_default_policy();
335              
336 84         1128 my $otype = ref $policy;
337 84 50       378 my $type =
    100          
    100          
338             $otype eq 'Mail::DKIM::AuthorDomainPolicy' ? 'dkim-adsp'
339             : $otype eq 'Mail::DKIM::DkimPolicy' ? 'x-dkim-ssp'
340             : $otype eq 'Mail::DKIM::DkPolicy' ? 'x-dkim-dkssp'
341             : 'x-dkim-policy';
342              
343 84         375 $self->dbgout( 'DKIMPolicy', $apply, LOG_DEBUG );
344 84         299 $self->dbgout( 'DKIMPolicyString', $string, LOG_DEBUG );
345 84         318 $self->dbgout( 'DKIMPolicyLocation', $location, LOG_DEBUG );
346 84         340 $self->dbgout( 'DKIMPolicyName', $name, LOG_DEBUG );
347 84 100       402 $self->dbgout( 'DKIMPolicyDefault', $default ? 'yes' : 'no', LOG_DEBUG );
348              
349 84 50 66     495 next POLICY if ( ( $type eq 'x-dkim-dkssp' ) && ( ! $self->show_domainkeys() ) );
350              
351 84 50       353 my $result =
    50          
    100          
352             $apply eq 'accept' ? 'pass'
353             : $apply eq 'reject' ? 'discard'
354             : $apply eq 'neutral' ? 'unknown'
355             : 'unknown';
356              
357 84 50 33     324 if ( ! ( $config->{'adsp_hide_none'} && $result eq 'none' ) ) {
358 84 100 66     392 if ( ( ! $default ) or $config->{'show_default_adsp'} ) {
359 28         199 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( $type )->safe_set_value( $result );
360 28 50       2233 my $comment = ( $default ? 'default ' : q{} )
    50          
361             . "$name policy"
362             . ( $location ? " from $location" : q{} )
363             # . ( $string ? ", $string" : q{} )
364             ;
365 28         139 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) );
366 28         14079 $self->add_auth_header( $header );
367             }
368             }
369 84         289 $self->check_timeout();
370             }
371             }
372             };
373 118 50       661 if ( my $error = $@ ) {
374 0         0 $self->handle_exception( $error );
375              
376             # Also in DMARC module
377 0         0 $self->log_error( 'DKIM EOM Error ' . $error );
378 0         0 $self->{'failmode'} = 1;
379 0         0 $self->_check_error( $error );
380 0         0 $self->metric_count( 'dkim_total', { 'result' => 'error' } );
381 0         0 return;
382             }
383             }
384              
385             sub close_callback {
386 97     97 0 299 my ( $self ) = @_;
387 97         332 delete $self->{'failmode'};
388 97         287 delete $self->{'headers'};
389 97         213 delete $self->{'body'};
390 97         265 delete $self->{'carry'};
391 97         236 delete $self->{'has_dkim'};
392 97         288 delete $self->{'valid_domains'};
393 97         562 $self->destroy_object('dkim');
394 97         264 return;
395             }
396              
397             sub _check_error {
398 0     0     my ( $self, $error ) = @_;
399 0 0 0       if ( $error =~ /^DNS error: query timed out/
    0 0        
    0 0        
      0        
400             or $error =~ /^DNS query timeout/
401             ){
402 0           $self->log_error( 'Temp DKIM Error - ' . $error );
403 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'temperror' );
404 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'dns timeout' ) );
405 0           $self->add_auth_header( $header );
406             }
407             elsif ( $error =~ /^DNS error: SERVFAIL/ ){
408 0           $self->log_error( 'Temp DKIM Error - ' . $error );
409 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'temperror' );
410 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'dns servfail' ) );
411 0           $self->add_auth_header( $header );
412             }
413             elsif ( $error =~ /^no domain to fetch policy for$/
414             or $error =~ /^policy syntax error$/
415             or $error =~ /^empty domain label/
416             or $error =~ /^invalid name /
417             ){
418 0           $self->log_error( 'Perm DKIM Error - ' . $error );
419 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'permerror' );
420 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'syntax or domain error' ) );
421 0           $self->add_auth_header( $header );
422             }
423             else {
424 0           $self->log_error( 'Unexpected DKIM Error - ' . $error );
425 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dkim' )->safe_set_value( 'temperror' );
426 0           $self->add_auth_header( $header );
427             # Fill these in as they occur, but for unknowns err on the side of caution
428             # and tempfail/exit
429 0           $self->exit_on_close();
430 0           $self->tempfail_on_error();
431             }
432 0           return;
433             }
434              
435             1;
436              
437             __END__
438              
439             =pod
440              
441             =encoding UTF-8
442              
443             =head1 NAME
444              
445             Mail::Milter::Authentication::Handler::DKIM
446              
447             =head1 VERSION
448              
449             version 20191206
450              
451             =head1 DESCRIPTION
452              
453             Module for validation of DKIM and DomainKeys signatures, and application of ADSP policies.
454              
455             =head1 CONFIGURATION
456              
457             "DKIM" : { | Config for the DKIM Module
458             "hide_none" : 0, | Hide auth line if the result is 'none'
459             "hide_domainkeys" : 0, | Hide any DomainKeys results
460             "check_adsp" : 1, | Also check for ADSP
461             "show_default_adsp" : 0, | Show the default ADSP result
462             "adsp_hide_none" : 0, | Hide auth ADSP if the result is 'none'
463             "extra_properties" : 0 | Add extra properties (not to rfc) relating to key and selector
464             "no_strict" : 0, | Ignore rfc 8301 security considerations (not recommended
465             },
466              
467             =head1 AUTHOR
468              
469             Marc Bradshaw <marc@marcbradshaw.net>
470              
471             =head1 COPYRIGHT AND LICENSE
472              
473             This software is copyright (c) 2018 by Marc Bradshaw.
474              
475             This is free software; you can redistribute it and/or modify it under
476             the same terms as the Perl 5 programming language system itself.
477              
478             =cut