blob: 67cf02cec707c0e81667853a9f7725af9db2bba9 [file]
# <@LICENSE>
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to you under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at:
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# </@LICENSE>
=head1 NAME
Mail::SpamAssassin::Plugin::ARC - perform ARC verification tests
=head1 SYNOPSIS
loadplugin Mail::SpamAssassin::Plugin::ARC [/path/to/ARC.pm]
full ARC_SIGNED eval:check_arc_signed()
full ARC_VALID eval:check_arc_valid()
full ARC_TRUSTED eval:check_arc_trusted()
=head1 DESCRIPTION
This SpamAssassin plugin implements ARC (Authenticated Received Chain)
verification as described by RFC 8617.
If the C<AuthRes> plugin is loaded and has parsed C<arc=> results from
Authentication-Results headers, those results will be re-used and native
verification is skipped. Otherwise, the plugin performs its own
cryptographic verification using the C<Mail::DKIM::ARC::Verifier> module
(version 0.50 or later required).
=head1 SEE ALSO
C<Mail::DKIM> Mail::SpamAssassin::Plugin(3)
https://www.rfc-editor.org/rfc/rfc8617
=cut
package Mail::SpamAssassin::Plugin::ARC;
use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::Logger;
use Mail::SpamAssassin::Timeout;
use Mail::SpamAssassin::Header::ArcAuthenticationResults;
use version;
use strict;
use warnings;
use re 'taint';
our @ISA = qw(Mail::SpamAssassin::Plugin);
sub new {
my $class = shift;
my $mailsaobject = shift;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsaobject);
bless ($self, $class);
$self->register_eval_rule("check_arc_signed", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS);
$self->register_eval_rule("check_arc_valid", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS);
$self->register_eval_rule("check_arc_trusted", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS);
# run after AuthRes (-20) so we can re-use arc= results,
# but before DKIM, SPF, DMARC (0) so arc_auth_results is available
$self->register_method_priority("parsed_metadata", -10);
$self->set_config($mailsaobject->{conf});
return $self;
}
###########################################################################
sub set_config {
my($self, $conf) = @_;
my @cmds;
=head1 USER SETTINGS
=over 4
=item arc_timeout n (default: 5)
Timeout in seconds for ARC signature verification. If Mail::DKIM cannot
complete verification within this time, the ARC check will be aborted.
=item arc_trusted_sealers domain1 domain2 ...
Specify domains that are trusted as ARC sealers. When the ARC chain is
cryptographically valid, the plugin will parse ARC-Authentication-Results
headers from trusted sealers and make them available to other plugins
(such as DMARC) via C<$pms-E<gt>{arc_auth_results}>. ARC instances sealed
by untrusted domains are ignored.
If no trusted sealers are configured, ARC-Authentication-Results headers
will not be parsed.
Can be specified multiple times, additional entries are appended.
arc_trusted_sealers google.com microsoft.com
arc_trusted_sealers yahoo.com
=back
=cut
push(@cmds, {
setting => 'arc_timeout',
default => 5,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
});
push(@cmds, {
setting => 'arc_trusted_sealers',
default => {},
type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
code => sub {
my ($self, $key, $value, $line) = @_;
if (!defined $value || $value =~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
foreach my $domain (split(/\s+/, lc $value)) {
$self->{arc_trusted_sealers}->{$domain} = 1;
}
}
});
$conf->{parser}->register_commands(\@cmds);
}
###########################################################################
sub parsed_metadata {
my ($self, $opts) = @_;
my $pms = $opts->{permsgstatus};
my(@arc_signatures, @arc_valid_signatures);
$pms->{arc_signatures_ready} = 0;
$pms->{arc_signatures_dependable} = 0;
$pms->{arc_signatures} = \@arc_signatures;
$pms->{arc_valid_signatures} = \@arc_valid_signatures;
$pms->{arc_signed} = 0;
$pms->{arc_valid} = 0;
my $suppl_attrib = $pms->{msg}->{suppl_attrib};
if (defined $suppl_attrib && exists $suppl_attrib->{arc_signatures}) {
my $provided_arc_signatures = $suppl_attrib->{arc_signatures};
if (ref $provided_arc_signatures) {
@arc_signatures = @$provided_arc_signatures;
$pms->{arc_signatures_ready} = 1;
$pms->{arc_signatures_dependable} = 1;
dbg("arc: ARC signatures provided by the caller, %d signatures",
scalar(@arc_signatures));
}
}
if ($pms->{arc_signatures_ready}) {
$self->_check_arc_valid_signature($pms, \@arc_signatures);
} elsif ($self->_check_arc_from_authres($pms)) {
# re-used ARC results from AuthRes plugin
} elsif (!$pms->is_dns_available()) {
dbg("arc: signature verification disabled, DNS resolving not available");
} elsif (!$self->_arc_load_modules()) {
# Mail::DKIM::ARC module not available
} else {
my $timemethod = $self->{main}->time_method("check_arc_signature");
my $arc_verifier = Mail::DKIM::ARC::Verifier->new;
$self->_check_signature($pms, $arc_verifier, \@arc_signatures);
}
}
sub check_arc_signed {
my ($self, $pms, $full_ref, @acceptable_domains) = @_;
my $result = 0;
if (!$pms->{arc_signed}) {
# don't bother
} elsif (!@acceptable_domains) {
$result = 1; # no additional constraints, any signing domain will do
}
return $result;
}
sub check_arc_valid {
my ($self, $pms, $full_ref, @acceptable_domains) = @_;
my $result = 0;
if (!$pms->{arc_valid}) {
# don't bother
} elsif (!@acceptable_domains) {
$result = 1; # no additional constraints, any signing domain will do,
# also any signing key size will do
}
return $result;
}
sub check_arc_trusted {
my ($self, $pms) = @_;
return $pms->{arc_auth_results} ? 1 : 0;
}
sub _check_arc_from_authres {
my ($self, $pms) = @_;
return 0 if !$pms->{authres_parsed} || !$pms->{authres_parsed}{arc}
|| !@{$pms->{authres_parsed}{arc}};
dbg("arc: checking for AuthRes plugin ARC results");
foreach my $arc_result (@{$pms->{authres_parsed}{arc}}) {
my $result = lc($arc_result->{result} || '');
next unless $result =~ /^(pass|fail|none)$/;
dbg("arc: re-using result from AuthRes plugin: %s", $result);
if ($result eq 'pass') {
$pms->{arc_signed} = 1;
$pms->{arc_valid} = 1;
$self->_parse_trusted_aar_from_headers($pms);
} elsif ($result eq 'fail') {
$pms->{arc_signed} = 1;
}
return 1;
}
return 0;
}
# ---------------------------------------------------------------------------
sub _arc_load_modules {
my ($self) = @_;
if (!$self->{tried_loading}) {
$self->{service_available} = 0;
my $timemethod = $self->{main}->time_method("arc_load_modules");
my $eval_stat;
eval {
{ require Mail::DKIM::ARC::Verifier }
1;
} or do {
$eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
};
$self->{tried_loading} = 1;
if (defined $eval_stat) {
dbg("arc: cannot load Mail::DKIM::ARC::Verifier module, ARC checks disabled: %s",
$eval_stat);
} else {
my $version = Mail::DKIM::ARC::Verifier->VERSION;
dbg("arc: using Mail::DKIM::ARC::Verifier version $version");
if (version->parse($version) >= version->parse(0.40)) {
# Let Mail::DKIM use our interface to Net::DNS::Resolver.
my $res = $self->{main}->{resolver};
dbg("arc: providing our own resolver: %s", ref $res);
Mail::DKIM::DNS::resolver($res);
}
$self->{service_available} = 1;
}
}
return $self->{service_available};
}
sub _check_signature {
my($self, $pms, $verifier, $signatures) = @_;
my $conf = $pms->{conf};
if (!$verifier) {
dbg("arc: cannot create Mail::DKIM::ARC::Verifier object");
return;
}
$pms->{arc_verifier} = $verifier;
eval {
my $str = $pms->{msg}->get_pristine();
if ($pms->{msg}->{line_ending} eq "\015\012") {
$verifier->PRINT($str);
} else {
$str =~ s/\012/\015\012/gs;
$verifier->PRINT($str);
undef $str;
}
1;
} or do {
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
dbg("arc: verification failed, intercepted error: $eval_stat");
return 0;
};
my $timeout = $conf->{arc_timeout};
my $timer = Mail::SpamAssassin::Timeout->new(
{ secs => $timeout, deadline => $pms->{master_deadline} });
my $err = $timer->run_and_catch(sub {
dbg("arc: performing public ARC key lookup and signature verification");
$verifier->CLOSE();
@$signatures = $verifier->UNIVERSAL::can("signatures") ?
$verifier->signatures : $verifier->signature;
});
if ($timer->timed_out()) {
dbg("arc: public key lookup or verification timed out after %s s",
$timeout);
} elsif ($err) {
chomp $err;
dbg("arc: ARC public key lookup or verification failed: $err");
}
$pms->{arc_signatures_ready} = 1;
if (!@$signatures || !$pms->{tests_already_hit}->{'__TRUNCATED'}) {
$pms->{arc_signatures_dependable} = 1;
}
$self->_check_arc_valid_signature($pms, \@$signatures);
}
sub _check_arc_valid_signature {
my($self, $pms, $signatures) = @_;
my(@valid_signatures);
my $conf = $pms->{conf};
my @arc_sig;
if ($pms->{arc_signatures_ready}) {
foreach my $signature (@$signatures) {
next if !defined $signature;
next if !defined $signature->selector || $signature->selector eq "";
my($info, $valid, $expired);
$valid = $signature->result eq 'pass';
$info = $valid ? 'VALID' : 'FAILED';
if ($valid && $signature->UNIVERSAL::can("check_expiration")) {
$expired = !$signature->check_expiration;
$info .= ' EXPIRED' if $expired;
}
my %arc;
$arc{prefix} = $signature->prefix;
$arc{valid} = $valid;
push(@arc_sig, \%arc);
push(@valid_signatures, $signature) if $valid && !$expired;
if (would_log("dbg","arc")) {
my $d = $signature->domain;
dbg("arc: %s i=%s %s d=%s, s=%s, a=%s, c=%s, %s",
$info, $signature->instance, $signature->prefix,
map(!defined $_ ? '(undef)' : $_,
$d, $signature->selector,
$signature->algorithm, scalar($signature->canonicalization),
$signature->result),
);
}
}
if (@valid_signatures) {
$pms->{arc_signed} = 1;
my $arc_seal_valid = 0;
my $arc_message_valid = 0;
my $arc_message_found = 0;
# All ARC-Seals signatures and the most recent ARC-Message-Signature must be valid
foreach my $arc ( @arc_sig ) {
if ($arc->{prefix} eq 'ARC-Message-Signature:') {
next if $arc_message_found;
$arc_message_valid = 1 if $arc->{valid};
$arc_message_found = 1;
}
if ($arc->{prefix} eq 'ARC-Seal:') {
if ($arc->{valid}) {
$arc_seal_valid = 1;
} else {
$arc_seal_valid = 0;
last;
}
}
}
if ($arc_message_valid and $arc_seal_valid) {
$pms->{arc_valid} = 1;
$self->_parse_trusted_aar($pms);
}
my $sig = $valid_signatures[0];
my $sig_res = $sig->result_detail;
dbg("arc: ARC signature verification result: %s", uc($sig_res));
} elsif (@$signatures) {
$pms->{arc_signed} = 1;
my $sig = @$signatures[0];
my $sig_res = $sig->result_detail;
dbg("arc: ARC signature verification result: %s", uc($sig_res));
} else {
dbg("arc: ARC signature verification result: none");
}
}
}
sub _parse_trusted_aar {
my ($self, $pms) = @_;
my $trusted = $pms->{conf}->{arc_trusted_sealers};
return if !$trusted || !%$trusted;
my $verifier = $pms->{arc_verifier};
return if !ref($verifier) || !defined $verifier->{seals};
# Walk the chain from newest to oldest. Trust the contiguous run of
# trusted sealers at the top of the chain; as soon as we hit an
# untrusted hop, anything older than it cannot be trusted either.
# $verifier->{seals} is indexed by ARC instance number.
my $seals = $verifier->{seals};
my %trusted_indices;
for (my $i = $#$seals; $i >= 1; $i--) {
my $seal = $seals->[$i];
next if !defined $seal;
my $d = $seal->{tags_by_name}{d}{value};
next if !defined $d;
$d = lc $d;
if ($trusted->{$d}) {
$trusted_indices{$i} = 1;
dbg("arc: seal i=%s d=%s is trusted", $i, $d);
} else {
dbg("arc: seal i=%s d=%s is not trusted, stopping chain walk", $i, $d);
last;
}
}
return if !%trusted_indices;
$self->_parse_aar_by_trusted_indices($pms, \%trusted_indices);
}
sub _parse_trusted_aar_from_headers {
my ($self, $pms) = @_;
my $trusted = $pms->{conf}->{arc_trusted_sealers};
return if !$trusted || !%$trusted;
# Collect ARC-Seal domains by instance index
my %seal_domain;
my @seals = $pms->{msg}->get_pristine_header('ARC-Seal');
foreach my $seal (@seals) {
chomp $seal;
my ($i) = $seal =~ /\bi\s*=\s*(\d+)/;
my ($d) = $seal =~ /\bd\s*=\s*([^\s;]+)/;
next if !defined $d || !defined $i;
$seal_domain{$i} = lc $d;
}
# Walk the chain from newest to oldest. Trust the contiguous run of
# trusted sealers at the top of the chain; as soon as we hit an
# untrusted hop, anything older than it cannot be trusted either.
my %trusted_indices;
foreach my $i (sort { $b <=> $a } keys %seal_domain) {
my $d = $seal_domain{$i};
if ($trusted->{$d}) {
$trusted_indices{$i} = 1;
dbg("arc: seal i=%s d=%s is trusted", $i, $d);
} else {
dbg("arc: seal i=%s d=%s is not trusted, stopping chain walk", $i, $d);
last;
}
}
return if !%trusted_indices;
$self->_parse_aar_by_trusted_indices($pms, \%trusted_indices);
}
sub _parse_aar_by_trusted_indices {
my ($self, $pms, $trusted_indices) = @_;
# Parse ARC-Authentication-Results headers from trusted sealers
my @aar = $pms->{msg}->get_pristine_header('ARC-Authentication-Results');
return if !@aar;
my @arc_auth_results;
foreach my $hdr (@aar) {
chomp $hdr;
my $ar = eval {
Mail::SpamAssassin::Header::ArcAuthenticationResults->new($hdr);
};
if ($@ || !$ar) {
dbg("arc: failed to parse AAR header: %s", $@ || 'unknown error');
next;
}
my $arc_index = $ar->arc_index();
if (!defined $arc_index) {
dbg("arc: AAR header missing i= tag, skipping");
next;
}
if (!$trusted_indices->{$arc_index}) {
dbg("arc: AAR i=%s not from trusted sealer, skipping", $arc_index);
next;
}
my $authserv = $ar->authserv_id();
my %results;
foreach my $method ($ar->methods()) {
foreach my $m ($ar->method($method)) {
$results{$method} = {
result => $m->{result},
reason => $m->{reason},
properties => $m->{properties},
};
}
}
push @arc_auth_results, {
arc_index => $arc_index,
authserv => $authserv,
results => \%results,
};
dbg("arc: parsed trusted AAR i=%s authserv=%s methods=%s",
$arc_index, $authserv, join(' ', sort keys %results));
}
if (@arc_auth_results) {
@arc_auth_results = sort { $a->{arc_index} <=> $b->{arc_index} }
@arc_auth_results;
$pms->{arc_auth_results} = \@arc_auth_results;
}
}
1;