File Coverage

blib/lib/Mail/Milter/Authentication/Handler/ARC.pm
Criterion Covered Total %
statement 51 527 9.6
branch 0 188 0.0
condition 0 37 0.0
subroutine 17 40 42.5
pod 1 21 4.7
total 69 813 8.4


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::ARC;
2 1     1   101294 use strict;
  1         3  
  1         29  
3 1     1   6 use warnings;
  1         3  
  1         30  
4 1     1   588 use Mail::Milter::Authentication 2.20191205;
  1         783015  
  1         84  
5 1     1   19 use base 'Mail::Milter::Authentication::Handler';
  1         5  
  1         155  
6             our $VERSION = '2.20191205'; # VERSION
7             # ABSTRACT: Authentication Milter Module for validation of ARC signatures
8 1     1   729 use Data::Dumper;
  1         6665  
  1         72  
9 1     1   10 use Clone qw{ clone };
  1         3  
  1         50  
10 1     1   15 use English qw{ -no_match_vars };
  1         3  
  1         8  
11 1     1   425 use Sys::Syslog qw{:standard :macros};
  1         3  
  1         287  
12              
13 1     1   556 use Mail::DKIM 0.50;
  1         144  
  1         28  
14 1     1   497 use Mail::DKIM::DNS;
  1         4092  
  1         32  
15 1     1   490 use Mail::DKIM::TextWrap;
  1         1067  
  1         32  
16 1     1   580 use Mail::DKIM::ARC::Signer;
  1         43130  
  1         39  
17 1     1   596 use Mail::DKIM::ARC::Verifier;
  1         3532  
  1         45  
18 1     1   7 use Mail::AuthenticationResults 1.20180518;
  1         26  
  1         25  
19 1     1   7 use Mail::AuthenticationResults::Header::Entry;
  1         3  
  1         27  
20 1     1   9 use Mail::AuthenticationResults::Header::SubEntry;
  1         3  
  1         26  
21 1     1   7 use Mail::AuthenticationResults::Header::Comment;
  1         2  
  1         5581  
22              
23             sub default_config {
24             return {
25 0     0 0   'hide_none' => 0,
26             'arcseal_domain' => undef,
27             'arcseal_selector' => undef,
28             'arcseal_algorithm' => 'rsa-sha256',
29             'arcseal_key' => undef,
30             'arcseal_keyfile' => undef,
31             'arcseal_headers' => undef,
32             'trusted_domains' => [],
33             'rbl_whitelist' => '',
34             'no_strict' => 0,
35             };
36             }
37              
38             sub grafana_rows {
39 0     0 0   my ( $self ) = @_;
40 0           my @rows;
41 0           push @rows, $self->get_json( 'ARC_metrics' );
42 0           return \@rows;
43             }
44              
45             sub register_metrics {
46             return {
47 0     0 1   'arc_total' => 'The number of emails processed for ARC',
48             'arc_signatures' => 'The number of signatures processed for ARC',
49             'arcseal_total' => 'The number of ARC seals added',
50             };
51             }
52              
53             sub is_domain_trusted {
54 0     0 0   my ( $self, $domain ) = @_;
55 0 0         return 0 if ! defined $domain;
56 0           $domain = lc $domain;
57 0           my $config = $self->handler_config();
58              
59 0           my $trusted_domains = $config->{ 'trusted_domains' };
60 0 0         if ( $trusted_domains ) {
61 0           foreach my $trusted_domain ( @$trusted_domains ) {
62 0 0         if ( $domain eq lc $trusted_domain ) {
63             #$self->dbgout( 'ARCResult', 'ARC domain trusted by static list', LOG_INFO );
64 0           return 1;
65             }
66             }
67             }
68              
69 0           my $rbl_whitelist = $config->{ 'rbl_whitelist' };
70 0 0         if ( $rbl_whitelist ) {
71 0 0         if ( $self->rbl_check_domain( $domain, $rbl_whitelist ) ) {
72             #$self->dbgout( 'ARCResult', 'ARC domain trusted by dns list', LOG_INFO );
73 0           return 1;
74             }
75             }
76              
77 0           return 0;
78             }
79              
80             sub get_trusted_spf_results {
81 0     0 0   my ( $self ) = @_;
82              
83 0           my $aar = $self->get_trusted_arc_authentication_results();
84 0 0         return if ! $aar;
85              
86 0           my @trusted_results;
87              
88 0           foreach my $instance ( sort keys %$aar ) {
89 0           eval {
90 0           my $results = $aar->{$instance}->search({ 'isa' => 'entry', 'key' => 'spf' })->children();
91             RESULT:
92 0           foreach my $result ( @$results ) {
93 0           my $smtp_mailfrom = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'smtp.mailfrom' })->children()->[0]->value() };
  0            
94 0           $self->handle_exception( $@ );
95 0 0         next RESULT if ! $smtp_mailfrom;
96 0           my $result_domain = $self->get_domain_from( $smtp_mailfrom );
97 0           push @trusted_results, {
98             'domain' => $result_domain,
99             'scope' => 'mfrom',
100             'result' => $result->value(),
101             };
102             }
103             };
104 0 0         if ( my $error = $@ ) {
105 0           $self->handle_exception( $error );
106 0           $self->log_error( 'ARC Inherit Error ' . $error );
107             }
108             }
109 0           return \@trusted_results;
110             }
111              
112             sub get_trusted_dkim_results {
113 0     0 0   my ( $self ) = @_;
114              
115 0           my $aar = $self->get_trusted_arc_authentication_results();
116 0 0         return if ! $aar;
117              
118 0           my @trusted_results;
119              
120 0           foreach my $instance ( sort keys %$aar ) {
121 0           eval {
122 0           my $results = $aar->{$instance}->search({ 'isa' => 'entry', 'key' => 'dkim' })->children();
123             RESULT:
124 0           foreach my $result ( @$results ) {
125 0           my $entry_domain = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'header.d' })->children()->[0]->value() };
  0            
126 0           $self->handle_exception( $@ );
127 0 0         if ( ! $entry_domain ) {
128             # No domain, check for an identifier instead
129 0           my $entry_domain = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'header.i' })->children()->[0]->value() };
  0            
130 0           $self->handle_exception( $@ );
131 0 0         if ( $entry_domain ) {
132 0           $entry_domain =~ s/^.*\@//;
133             }
134             }
135 0 0         next RESULT if ! $entry_domain;
136 0           $entry_domain = lc $entry_domain;
137              
138 0           my $entry_selector = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'x-selector' })->children()->[0]->value() };
  0            
139 0           $self->handle_exception( $@ );
140 0 0         if ( ! $entry_selector ) {
141             # Google are using header.s
142 0           $entry_selector = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'header.s' })->children()->[0]->value() };
  0            
143 0           $self->handle_exception( $@ );
144             }
145             # If we don't have a selector then we fake it.
146 0 0         $entry_selector = 'x-arc-chain' if ! $entry_selector;
147             ## TODO If we can't find this in the ar header then we could
148             ## try looking for the Signature and pull it from there.
149             ## But let's not do that right now.
150 0 0         next RESULT if ! $entry_selector;
151              
152             #my $result_domain = $self->get_domain_from( $smtp_mailfrom );
153 0           push @trusted_results, {
154             'domain' => $entry_domain,
155             'selector' => $entry_selector,,
156             'result' => $result->value(),
157             'human_result' => 'Trusted ARC entry',
158             };
159             }
160             };
161 0 0         if ( my $error = $@ ) {
162 0           $self->handle_exception( $error );
163 0           $self->log_error( 'ARC Inherit Error ' . $error );
164             }
165             }
166 0           return \@trusted_results;
167             }
168              
169             sub inherit_trusted_spf_results {
170 0     0 0   my ( $self ) = @_;
171              
172 0 0         return if ( ! $self->is_handler_loaded( 'SPF' ) );
173              
174 0           my $aar = $self->get_trusted_arc_authentication_results();
175 0 0         return if ! $aar;
176              
177 0           foreach my $instance ( sort keys %$aar ) {
178 0           eval {
179             # Find all ARC SPF results which passed
180 0           my $results = $aar->{$instance}->search({ 'isa' => 'entry', 'key' => 'spf', 'value' => 'pass' })->children();
181             RESULT:
182 0           foreach my $result ( @$results ) {
183              
184             # Does the entry have an x-arc-domain entry? if do then leave it alone.
185 0 0         next RESULT if ( scalar @{ $result->search({ 'isa' => 'subentry', 'key' => 'x-arc-domain' })->children() }> 0 );
  0            
186              
187             # Does the entry have a smtp.mailfrom entry we can match on?
188 0           my $smtp_mailfrom = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'smtp.mailfrom' })->children()->[0]->value() };
  0            
189 0           $self->handle_exception( $@ );
190 0 0         next RESULT if ! $smtp_mailfrom;
191 0           $smtp_mailfrom = lc $smtp_mailfrom;
192              
193             # Do we have an existing entry for this spf record with the same smtp.mailfrom?
194 0           my $top_handler = $self->get_top_handler();
195 0           my $existing_auth_headers = $top_handler->{'auth_headers'};
196 0           my $found_passing = 0;
197              
198             HEADER:
199 0           foreach my $header ( @$existing_auth_headers ) {
200 0 0         next if $header->key() ne 'spf';
201              
202 0           my $quoted = quotemeta($smtp_mailfrom);
203 0           my $regex = qr{$quoted}i;
204 0           my $this_mailfrom = eval{ $header->search({ 'isa' => 'subentry', 'key' => 'smtp.mailfrom', 'value' => $regex })->children()->[0]->value() };
  0            
205 0           $self->handle_exception( $@ );
206 0 0         next HEADER if ! $this_mailfrom;
207              
208             # We already have a pass, leave it alone
209 0 0         $found_passing = 1 if $header->value() eq 'pass';
210              
211             }
212              
213             # We found a passing result for this mailfrom, leave it alone
214 0 0         next RESULT if $found_passing;
215              
216             # We didn't find a passing result, so rename the existing ones.....
217             HEADER:
218 0           foreach my $header ( @$existing_auth_headers ) {
219 0 0         next if $header->key() ne 'spf';
220              
221 0           my $quoted = quotemeta($smtp_mailfrom);
222 0           my $regex = qr{$quoted}i;
223 0           my $this_mailfrom = eval{ $header->search({ 'isa' => 'subentry', 'key' => 'smtp.mailfrom', 'value' => $regex })->children()->[0]->value() };
  0            
224 0           $self->handle_exception( $@ );
225 0 0         next HEADER if ! $this_mailfrom;
226              
227             # Rename the existing header
228 0           $header->set_key( 'x-local-spf' );
229             }
230              
231             # And add the new one
232 0           $result->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-arc-instance' )->safe_set_value( $instance ) );
233 0           $result->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-arc-domain' )->safe_set_value( $self->{ 'arc_domain'}->{ $instance } ) );
234 0           $result->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'Trusted from aar.' . $instance . '.' . $self->{ 'arc_domain' }->{ $instance } ) );
235 0           $result->orphan();
236 0           $self->add_auth_header( $result );
237              
238             }
239             };
240 0 0         if ( my $error = $@ ) {
241 0           $self->handle_exception( $error );
242 0           $self->log_error( 'ARC Inherit Error ' . $error );
243             }
244             }
245 0           return;
246             }
247              
248             sub inherit_trusted_dkim_results {
249 0     0 0   my ( $self ) = @_;
250              
251 0 0         return if ( ! $self->is_handler_loaded( 'DKIM' ) );
252              
253 0           my $aar = $self->get_trusted_arc_authentication_results();
254 0 0         return if ! $aar;
255              
256 0           foreach my $instance ( sort keys %$aar ) {
257 0           eval {
258             # Find all ARC DKIM results which passed
259 0           my $results = $aar->{$instance}->search({ 'isa' => 'entry', 'key' => 'dkim', 'value' => 'pass' })->children();
260             RESULT:
261 0           foreach my $result ( @$results ) {
262              
263             # Does the entry have an x-arc-domain entry? if do then leave it alone.
264 0 0         next RESULT if ( scalar @{ $result->search({ 'isa' => 'subentry', 'key' => 'x-arc-domain' })->children() }> 0 );
  0            
265              
266             # Does the entry have a domain identifier we can match on?
267 0           my $entry_domain = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'header.d' })->children()->[0]->value() };
  0            
268 0           $self->handle_exception( $@ );
269 0 0         if ( ! $entry_domain ) {
270             # No domain, check for an identifier instead
271 0           my $entry_domain = eval{ $result->search({ 'isa' => 'subentry', 'key' => 'header.i' })->children()->[0]->value() };
  0            
272 0           $self->handle_exception( $@ );
273 0 0         if ( $entry_domain ) {
274 0           $entry_domain =~ s/^.*\@//;
275             }
276             }
277 0 0         next RESULT if ! $entry_domain;
278 0           $entry_domain = lc $entry_domain;
279              
280             # Do we have an existing entry for this spf record with the same domain?
281 0           my $top_handler = $self->get_top_handler();
282 0           my $existing_auth_headers = $top_handler->{'auth_headers'};
283 0           my $found_passing = 0;
284              
285             HEADER:
286 0           foreach my $header ( @$existing_auth_headers ) {
287 0 0         next if $header->key() ne 'dkim';
288              
289 0           my $quoted = quotemeta($entry_domain);
290 0           my $regex = qr{$quoted}i;
291 0           my $this_domain = eval{ $header->search({ 'isa' => 'subentry', 'key' => 'header.d', 'value' => $regex })->children()->[0]->value() };
  0            
292 0           $self->handle_exception( $@ );
293 0 0         next HEADER if ! $this_domain;
294              
295             # We already have a pass, leave it alone
296 0 0         $found_passing = 1 if $header->value() eq 'pass';
297              
298             }
299              
300             # We found a passing result for this mailfrom, leave it alone
301 0 0         next RESULT if $found_passing;
302              
303             # We didn't find a passing result, so rename the existing ones.....
304             HEADER:
305 0           foreach my $header ( @$existing_auth_headers ) {
306 0 0         next if $header->key() ne 'dkim';
307              
308 0           my $quoted = quotemeta($entry_domain);
309 0           my $regex = qr{$quoted}i;
310 0           my $this_domain = eval{ $header->search({ 'isa' => 'subentry', 'key' => 'header.d', 'value' => $regex })->children()->[0]->value() };
  0            
311 0           $self->handle_exception( $@ );
312 0 0         next HEADER if ! $this_domain;
313              
314             # Rename the existing header
315 0           $header->set_key( 'x-local-dkim' );
316             }
317              
318             # And add the new one
319 0           $result->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-arc-instance' )->safe_set_value( $instance ) );
320 0           $result->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-arc-domain' )->safe_set_value( $self->{ 'arc_domain'}->{ $instance } ) );
321 0           $result->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'Trusted from aar.' . $instance . '.' . $self->{ 'arc_domain' }->{ $instance } ) );
322 0           $result->orphan();
323 0           $self->add_auth_header( $result );
324              
325             }
326             };
327 0 0         if ( my $error = $@ ) {
328 0           $self->handle_exception( $error );
329 0           $self->log_error( 'ARC Inherit Error ' . $error );
330             }
331             }
332 0           return;
333             }
334              
335             sub inherit_trusted_ip_results {
336 0     0 0   my ( $self ) = @_;
337              
338 0           my $aar = $self->get_trusted_arc_authentication_results();
339 0 0         return if ! $aar;
340              
341             # Add result from first trusted ingress hop
342 0           my ( $instance ) = sort keys %$aar;
343 0           foreach my $thing ( sort qw { iprev x-ptr } ) {
344 0           eval {
345 0           my $results = $aar->{$instance}->search({ 'isa' => 'entry', 'key' => $thing })->children();
346             RESULT:
347 0           foreach my $result ( @$results ) {
348 0 0         next RESULT if ( scalar @{ $result->search({ 'isa' => 'subentry', 'key' => 'x-arc-domain' })->children() }> 0 );
  0            
349 0           $result->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-arc-instance' )->safe_set_value( $instance ) );
350 0           $result->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'x-arc-domain' )->safe_set_value( $self->{ 'arc_domain'}->{ $instance } ) );
351 0           $result->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'Trusted from aar.' . $instance . '.' . $self->{ 'arc_domain' }->{ $instance } ) );
352 0           $result->orphan();
353 0           $self->add_auth_header( $result );
354             }
355             };
356 0 0         if ( my $error = $@ ) {
357 0           $self->handle_exception( $error );
358 0           $self->log_error( 'ARC Inherit Error ' . $error );
359             }
360             }
361              
362 0           return;
363             }
364              
365             sub get_trusted_arc_authentication_results {
366 0     0 0   my ( $self ) = @_;
367              
368             # First, we need an arc pass or we trust nothing!
369 0 0         return if $self->{ 'arc_result' } ne 'pass';
370              
371 0           my $trusted_aar = {};
372             INSTANCE:
373 0           foreach my $instance ( reverse sort keys %{$self->{ 'arc_auth_results' } } ) {
  0            
374 0   0       my $signature_domain = $self->{'arc_domain'}->{ $instance } // q{};
375 0 0         if ( $self->is_domain_trusted( $signature_domain ) ) {
376             # Clone this, so we can safely modify entries later
377 0           $trusted_aar->{ $instance } = clone $self->{ 'arc_auth_results' }->{ $instance };
378             }
379             else {
380             # We don't trust this host, we can't trust anything before it!
381 0           last INSTANCE;
382             }
383             }
384              
385 0 0         if ( scalar keys %$trusted_aar == 0 ) {
386 0           return;
387             }
388 0           return $trusted_aar;
389             }
390              
391             # Do we trust the entire chain
392             sub is_chain_trusted {
393 0     0 0   my ( $self ) = @_;
394 0 0         return 0 if $self->{ 'arc_result' } ne 'pass';
395 0           foreach my $instance ( reverse sort keys %{$self->{ 'arc_auth_results' } } ) {
  0            
396 0   0       my $signature_domain = $self->{'arc_domain'}->{ $instance } // q{};
397 0 0         return 0 if ! $self->is_domain_trusted( $signature_domain );
398             }
399 0           return 1;
400             }
401              
402             # Get the trusted ingress IP
403             sub get_arc_trusted_ingress_ip {
404 0     0 0   my ( $self ) = @_;
405 0           my $aar = $self->get_trusted_arc_authentication_results();
406 0 0         return if ! $aar;
407 0           my ( $first_instance ) = sort keys %$aar;
408 0 0         return if ! $first_instance;
409              
410 0           my $ip;
411              
412 0           $ip = eval{ $aar->{$first_instance}->search({ 'isa' => 'entry', 'key' => 'iprev' })->children()->[0]->search({ 'isa' => 'subentry', 'key' => 'smtp.remote-ip'})->children()->[0]->value(); };
  0            
413 0 0         if ( my $error = $@ ) {
414 0           $self->handle_exception( $error );
415 0           $self->log_error( 'ARC Inherit Error ' . $error );
416             }
417 0 0         return $ip if $ip;
418              
419 0           $ip = eval{ $aar->{$first_instance}->search({ 'isa' => 'entry', 'key' => 'iprev' })->children()->[0]->search({ 'isa' => 'subentry', 'key' => 'policy.iprev'})->children()->[0]->value(); };
  0            
420 0 0         if ( my $error = $@ ) {
421 0           $self->handle_exception( $error );
422 0           $self->log_error( 'ARC Inherit Error ' . $error );
423             }
424 0           return $ip;
425             }
426              
427             # Find the earliest instance in the trusted chain
428             sub search_trusted_aar {
429 0     0 0   my ( $self, $search ) = @_;
430 0           my $trusted_aar = $self->get_trusted_arc_authentication_results();
431 0 0         return if ! $trusted_aar;
432 0           foreach my $instance ( sort keys %{$trusted_aar} ) {
  0            
433 0           my $found = $trusted_aar->{ $instance }->search( $search );
434 0 0         if ( scalar @{ $found->children() } ) {
  0            
435 0           return $found;
436             }
437             }
438 0           return;
439             }
440              
441             sub envfrom_callback {
442 0     0 0   my ( $self, $env_from ) = @_;
443 0           $self->{'failmode'} = 0;
444 0           $self->{'headers'} = [];
445 0           $self->{'body'} = [];
446 0           $self->{'has_arc'} = 0;
447 0           $self->{'valid_domains'} = {};
448 0           $self->{'carry'} = q{};
449 0           $self->{'arc_auth_results'} = {};
450 0           $self->{'arc_domain'} = {};
451 0           $self->{'arc_result'} = '';
452 0           $self->destroy_object('arc');
453 0           return;
454             }
455              
456             sub header_callback {
457 0     0 0   my ( $self, $header, $value, $original ) = @_;
458 0           my $EOL = "\015\012";
459 0           my $arc_chunk = $original . $EOL;
460 0           $arc_chunk =~ s/\015?\012/$EOL/g;
461 0           push @{$self->{'headers'}} , $arc_chunk;
  0            
462              
463 0 0         if ( lc($header) eq 'arc-authentication-results' ) {
464 0           $self->{'has_arc'} = 1;
465 0           my ( $instance, $aar ) = split( ';', $value, 2 );
466 0           $instance =~ s/.*i=(\d+).*$/$1/;
467 0           my $parsed = eval{ Mail::AuthenticationResults->parser()->parse( $aar ) };
  0            
468 0           $self->handle_exception( $@ );
469 0           $self->{'arc_auth_results'}->{ $instance } = $parsed;
470             }
471              
472 0 0         if ( lc($header) eq 'arc-seal' ) {
473 0           $self->{'has_arc'} = 1;
474             }
475              
476 0 0         if ( lc($header) eq 'arc-message-signature' ) {
477 0           $self->{'has_arc'} = 1;
478             }
479              
480 0           return;
481             }
482              
483             sub eoh_callback {
484 0     0 0   my ($self) = @_;
485 0           my $config = $self->handler_config();
486              
487 0           $self->{'carry'} = q{};
488              
489 0 0 0       if ($config->{arcseal_domain} and
      0        
      0        
490             $config->{arcseal_selector} and
491             ($config->{arcseal_key} || $config->{arcseal_keyfile}))
492             {
493 0           $self->{has_arcseal} = 1;
494             }
495              
496 0 0         unless ($self->{'has_arc'}) {
497 0           $self->metric_count( 'arc_total', { 'result' => 'none' } );
498 0           $self->dbgout( 'ARCResult', 'No ARC headers', LOG_INFO );
499 0 0         unless ($config->{'hide_none'}) {
500 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'arc' )->safe_set_value( 'none' );
501 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'no signatures found' ) );
502 0           $self->add_auth_header( $header );
503             }
504 0           $self->{arc_result} = 'none';
505 0 0         delete $self->{headers} unless $self->{has_arcseal};
506 0           return;
507             }
508              
509 0           my $arc;
510 0           eval {
511 0           my $UseStrict = 1;
512 0 0         if ( $config->{ 'no_strict' } ) {
513 0           $UseStrict = 0;
514             }
515 0           $arc = Mail::DKIM::ARC::Verifier->new( 'Strict' => $UseStrict );
516             # The following requires Mail::DKIM > 0.4
517 0           my $resolver = $self->get_object('resolver');
518 0           Mail::DKIM::DNS::resolver($resolver);
519 0           $self->set_object('arc', $arc, 1);
520             };
521 0 0         if ( my $error = $@ ) {
522 0           $self->handle_exception( $error );
523 0           $self->log_error( 'ARC Setup Error ' . $error );
524 0           $self->_check_error( $error );
525 0           $self->metric_count( 'arc_total', { 'result' => 'error' } );
526 0           $self->{'failmode'} = 1;
527 0           $self->{arc_result} = 'fail'; # XXX - handle tempfail better
528 0 0         delete $self->{headers} unless $self->{has_arcseal};
529 0           return;
530             }
531              
532 0           eval {
533             $arc->PRINT( join q{},
534 0           @{ $self->{'headers'} },
  0            
535             "\015\012",
536             );
537             };
538 0 0         if ( my $error = $@ ) {
539 0           $self->handle_exception( $error );
540 0           $self->log_error( 'ARC Headers Error ' . $error );
541 0           $self->_check_error( $error );
542 0           $self->metric_count( 'arc_total', { 'result' => 'error' } );
543 0           $self->{'failmode'} = 1;
544 0           $self->{arc_result} = 'fail'; # XXX - handle tempfail better
545 0 0         delete $self->{headers} unless $self->{has_arcseal};
546 0           return;
547             }
548             }
549              
550             sub body_callback {
551 0     0 0   my ( $self, $body_chunk ) = @_;
552 0           my $EOL = "\015\012";
553              
554 0           my $arc_chunk;
555 0 0         if ( $self->{'carry'} ne q{} ) {
556 0           $arc_chunk = $self->{'carry'} . $body_chunk;
557 0           $self->{'carry'} = q{};
558             }
559             else {
560 0           $arc_chunk = $body_chunk;
561             }
562              
563 0 0         if ( substr( $arc_chunk, -1 ) eq "\015" ) {
564 0           $self->{'carry'} = "\015";
565 0           $arc_chunk = substr( $arc_chunk, 0, -1 );
566             }
567              
568 0           $arc_chunk =~ s/\015?\012/$EOL/g;
569 0 0         push @{$self->{body}}, $arc_chunk if $self->{has_arcseal};
  0            
570              
571 0 0 0       if ($self->{has_arc} and not $self->{failmode}) {
572 0           my $arc = $self->get_object('arc');
573 0           eval {
574 0           $arc->PRINT( $arc_chunk );
575             };
576 0 0         if ( my $error = $@ ) {
577 0           $self->handle_exception( $error );
578 0           $self->log_error( 'ARC Body Error ' . $error );
579 0           $self->_check_error( $error );
580 0           $self->metric_count( 'arc_total', { 'result' => 'error' } );
581 0           $self->{'failmode'} = 1;
582 0           $self->{arc_result} = 'fail'; # XXX - handle tempfail better
583 0 0         delete $self->{headers} unless $self->{has_arcseal};
584             }
585             }
586             }
587              
588             sub eom_requires {
589 0     0 0   my ( $self ) = @_;
590 0           my @requires;
591              
592 0 0         if ( $self->is_handler_loaded( 'DKIM' ) ) {
593 0           push @requires, 'DKIM';
594             }
595              
596 0           return \@requires;
597             }
598              
599             sub eom_callback {
600 0     0 0   my ($self) = @_;
601              
602 0 0 0       push @{$self->{body}}, $self->{carry} if ($self->{carry} and $self->{has_arcseal});
  0            
603              
604             # the rest of eom is only used for arc, not arcseal
605 0 0         return unless $self->{'has_arc'};
606 0 0         return if $self->{'failmode'};
607              
608 0           my $config = $self->handler_config();
609              
610 0           my $arc = $self->get_object('arc');
611              
612 0           eval {
613 0           $arc->PRINT( $self->{'carry'} );
614 0           $arc->CLOSE();
615 0           $self->check_timeout();
616              
617 0           my $arc_result = $arc->result;
618 0           my $arc_result_detail = $arc->result_detail;
619              
620 0           $self->metric_count( 'arc_total', { 'result' => $arc_result } );
621              
622 0           $self->dbgout( 'ARCResult', $arc_result_detail, LOG_INFO );
623              
624 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'arc' )->safe_set_value( $arc_result );
625              
626 0           my @items;
627 0           foreach my $signature ( @{ $arc->{signatures} } ) {
  0            
628 0 0         my $type =
    0          
629             ref($signature) eq 'Mail::DKIM::ARC::Seal' ? 'as'
630             : ref($signature) eq 'Mail::DKIM::ARC::MessageSignature' ? 'ams'
631             : ref($signature);
632 0   0       push @items,
      0        
      0        
633             "$type."
634             . ( $signature->instance() || '' ) . '.'
635             . ( $signature->domain() || '(none)' ) . '='
636             . ( $signature->result_detail() || '?' );
637 0           $self->{ 'arc_domain' }->{ $signature->instance() } = $signature->domain();
638             }
639              
640 0 0         if ( @items ) {
641 0           my $header_comment = Mail::AuthenticationResults::Header::Comment->new();
642 0           my $header_comment_text = join( ', ', @items );
643             # Try set_value first (required for potential nested comment), if this fails then
644             # set using safe_set_value
645 0           eval { $header_comment->set_value( $header_comment_text ); };
  0            
646 0 0         if ( my $error = $@ ) {
647 0           $self->handle_exception( $error );
648 0           $header_comment->safe_set_value( $header_comment_text );
649             }
650 0           $header->add_child( $header_comment );
651             }
652              
653 0           my $ip_address = $self->ip_address();
654 0           $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) );
655              
656 0           $self->add_auth_header( $header );
657              
658 0           $self->{arc_result} = $arc_result;
659             };
660 0 0         if ( my $error = $@ ) {
661 0           $self->handle_exception( $error );
662 0           $self->log_error( 'ARC EOM Error ' . $error );
663 0           $self->_check_error( $error );
664 0           $self->metric_count( 'arc_total', { 'result' => 'error' } );
665 0           $self->{'failmode'} = 1;
666 0           $self->{arc_result} = 'fail';
667             }
668              
669 0           $self->inherit_trusted_spf_results();
670 0           $self->inherit_trusted_dkim_results();
671 0           $self->inherit_trusted_ip_results();
672              
673 0           return;
674              
675             }
676              
677             sub close_callback {
678 0     0 0   my ( $self ) = @_;
679 0           delete $self->{'failmode'};
680 0           delete $self->{'headers'};
681 0           delete $self->{'body'};
682 0           delete $self->{'carry'};
683 0           delete $self->{'has_arc'};
684 0           delete $self->{'valid_domains'};
685 0           delete $self->{'arc_domain'};
686 0           delete $self->{'arc_result'};
687 0           delete $self->{'arc_auth_results'};
688 0           $self->destroy_object('arc');
689 0           return;
690             }
691              
692             sub _check_error {
693 0     0     my ( $self, $error ) = @_;
694 0 0 0       if ( $error =~ /^DNS error: query timed out/
    0 0        
    0 0        
      0        
695             or $error =~ /^DNS query timeout/
696             ){
697 0           $self->log_error( 'Temp ARC Error - ' . $error );
698 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'arc' )->safe_set_value( 'temperror' );
699 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'dns timeout' ) );
700 0           $self->add_auth_header( $header );
701             }
702             elsif ( $error =~ /^DNS error: SERVFAIL/ ){
703 0           $self->log_error( 'Temp ARC Error - ' . $error );
704 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'arc' )->safe_set_value( 'temperror' );
705 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'dns servfail' ) );
706 0           $self->add_auth_header( $header );
707             }
708             elsif ( $error =~ /^no domain to fetch policy for$/
709             or $error =~ /^policy syntax error$/
710             or $error =~ /^empty domain label/
711             or $error =~ /^invalid name /
712             ){
713 0           $self->log_error( 'Perm ARC Error - ' . $error );
714 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'arc' )->safe_set_value( 'permerror' );
715 0           $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'syntax or domain error' ) );
716 0           $self->add_auth_header( $header );
717             }
718             else {
719 0           $self->log_error( 'Unexpected ARC Error - ' . $error );
720 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'arc' )->safe_set_value( 'temperror' );
721 0           $self->add_auth_header( $header );
722             # Fill these in as they occur, but for unknowns err on the side of caution
723             # and tempfail/exit
724 0           $self->exit_on_close();
725 0           $self->tempfail_on_error();
726             }
727 0           return;
728             }
729              
730             sub _fmtheader {
731 0     0     my $header = shift;
732 0           my $value = $header->{value};
733 0           $value =~ s/\015?\012/\015\012/gs; # make sure line endings are right
734 0           return "$header->{field}: $value\015\012";
735             }
736              
737             sub addheader_callback {
738 0     0 0   my $self = shift;
739 0           my $handler = shift;
740              
741 0 0         return unless $self->{has_arcseal};
742              
743 0           my $config = $self->handler_config();
744              
745 0           eval {
746 0           my %KeyOpts;
747 0 0         if ($config->{arcseal_keyfile}) {
748 0           $KeyOpts{KeyFile} = $config->{arcseal_keyfile};
749             }
750             else {
751             $KeyOpts{Key} = Mail::DKIM::PrivateKey->load(
752 0           Data => $config->{arcseal_key});
753             }
754             my $arcseal = Mail::DKIM::ARC::Signer->new(
755             Algorithm => $config->{arcseal_algorithm},
756             Domain => $config->{arcseal_domain},
757             SrvId => $self->get_my_hostname(),
758             Selector => $config->{arcseal_selector},
759             Headers => $config->{arcseal_headers},
760             # chain value is arc_result from previous seal validation
761             Chain => $self->{arc_result},
762 0           Timestamp => time(),
763             %KeyOpts,
764             );
765              
766             # pre-headers from handler (reversed as they will add in reverse)
767 0 0         foreach my $header (reverse @{$handler->{pre_headers} || []}) {
  0            
768 0           $arcseal->PRINT(_fmtheader($header));
769             }
770              
771             # then all the original headers: XXX - this doesn't deal with
772             # the change_header command, but only sanitize uses that.
773             # It would be a massive pain to make that work consistently,
774             # as it would need to modify the already cached headers in
775             # each handler with the current architecture
776 0 0         foreach my $chunk (@{$self->{headers} || []}) {
  0            
777 0           $arcseal->PRINT($chunk);
778             }
779              
780             # post-headers from handler (these are in order)
781 0 0         foreach my $header (@{$handler->{add_headers} || []}) {
  0            
782 0           $arcseal->PRINT(_fmtheader($header));
783 0           $self->check_timeout();
784             }
785              
786             # finish header block with a blank line
787 0           $arcseal->PRINT("\015\012");
788              
789             # all the body chunks
790 0           foreach my $chunk (@{$self->{body}}) {
  0            
791 0           $arcseal->PRINT($chunk);
792             }
793              
794             # and we're done
795 0           $arcseal->CLOSE;
796 0           $self->check_timeout();
797              
798 0           my $arcseal_result = $arcseal->result();
799 0           my $arcseal_result_detail = $arcseal->result_detail();
800              
801 0           $self->metric_count( 'arcseal_total', { 'result' => $arcseal_result } );
802              
803 0           $self->dbgout( 'ARCSealResult', $arcseal_result_detail, LOG_INFO );
804              
805             # we need to extract the headers from ARCSeal and re-format them
806             # back to the format that pre_headers expects
807 0           my $headers = $arcseal->as_string();
808 0           my @list;
809              
810 0           my $current_header = q{};
811 0           my $current_value = q{};
812 0           foreach my $header_line ( (split ( /\015?\012/, $headers ) ) ) {
813 0 0         if ( $header_line =~ /^\s/ ) {
814             # Line begins with whitespace, add to previous header
815 0           $header_line =~ s/^\s+/ /; # for consistency
816 0           $current_value .= "\n" . $header_line;
817             }
818             else {
819             # This is a brand new header!
820 0 0         if ( $current_header ne q{} ) {
821             # We have a cached header, add it now.
822 0           push @list, { 'field' => $current_header, 'value' => $current_value };
823 0           $current_value = q{};
824             }
825 0           ( $current_header, $current_value ) = split ( ':', $header_line, 2 );
826 0           $current_value =~ s/^ +//;
827             }
828             }
829 0 0         if ( $current_header ne q{} ) {
830             # We have a cached header, add it now.
831 0           push @list, { 'field' => $current_header, 'value' => $current_value };
832 0           $current_value = q{};
833             }
834              
835             # these will prepend in reverse
836 0           push @{$handler->{pre_headers}}, reverse @list;
  0            
837             };
838              
839 0 0         if ( my $error = $@ ) {
840 0           $self->handle_exception( $error );
841 0           $self->log_error( 'ARCSeal Error ' . $error );
842 0           $self->metric_count( 'arcseal_total', { 'result' => 'error' } );
843 0           return;
844             }
845             }
846              
847             1;
848              
849             __END__
850              
851             =pod
852              
853             =encoding UTF-8
854              
855             =head1 NAME
856              
857             Mail::Milter::Authentication::Handler::ARC - Authentication Milter Module for validation of ARC signatures
858              
859             =head1 VERSION
860              
861             version 2.20191205
862              
863             =head1 DESCRIPTION
864              
865             Module for validation of ARC signatures
866              
867             =head1 CONFIGURATION
868              
869             "ARC" : { | Config for the ARC Module
870             "hide_none" : 0, | Hide auth line if the result is 'none'
871             "arcseal_domain" : "example.com", | Domain to sign ARC Seal with (not sealed if blank)
872             "arcseal_selector" : undef, | Selector to use for ARC Seal (not sealed if blank)
873             "arcseal_algorithm" : 'rsa-sha256', | Algorithm to use on ARC Seal (default rsa-sha256)
874             "arcseal_key" : undef, | Key (base64) string to sign ARC Seal with; or
875             "arcseal_keyfile" : undef, | File containing ARC Seal key
876             "arcseal_headers" : undef, | Additional headers to cover in ARC-Message-Signature
877             "trusted_domains" : [], | Trust these domains when traversing ARC chains
878             "rbl_whitelist" : undef, | rhs list for looking up trusted signing domains
879             "no_strict" : 0, | Ignore rfc 8301 security considerations (not recommended)
880             },
881              
882             =head1 AUTHORS
883              
884             =over 4
885              
886             =item *
887              
888             Bron Gondwana <brong@fastmailteam.com>
889              
890             =item *
891              
892             Marc Bradshaw <marc@marcbradshaw.net>
893              
894             =back
895              
896             =head1 COPYRIGHT AND LICENSE
897              
898             This software is copyright (c) 2018 by FastMail Pty Ltd.
899              
900             This is free software; you can redistribute it and/or modify it under
901             the same terms as the Perl 5 programming language system itself.
902              
903             =cut