blob: d78934f1256534a84793e4fea1f1477ed29d7255 [file] [log] [blame]
# <@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::DKIM - perform DKIM verification tests
=head1 SYNOPSIS
loadplugin Mail::SpamAssassin::Plugin::DKIM [/path/to/DKIM.pm]
full DKIM_VALID eval:check_dkim_valid()
full DKIM_VALID_AU eval:check_dkim_valid_author_sig()
(for compatibility, a check_dkim_verified is a synonym for check_dkim_valid)
=head1 DESCRIPTION
This SpamAssassin plugin implements DKIM lookups as described by the RFC 4871,
as well as historical DomainKeys lookups, as described by RFC 4870, thanks
to the support for both types of signatures by newer versions of module
Mail::DKIM (0.22 or later).
It requires the C<Mail::DKIM> CPAN module to operate. Many thanks to Jason Long
for that module.
=head1 TAGS
The following tags are added to the set, available for use in reports,
header fields, other plugins, etc.:
_DKIMIDENTITY_ signing identities (the 'i' tag) from valid signatures;
_DKIMDOMAIN_ signing domains (the 'd' tag) from valid signatures;
Identities and domains from signatures which failed verification are not
included in these tags. Duplicates are eliminated (e.g. when there are two or
more valid signatures from the same signer, only one copy makes it into a tag).
Note that there may be more than one signature in a message - currently they
are provided as a space-separated list, although this behaviour may change.
=head1 SEE ALSO
C<Mail::DKIM>, C<Mail::SpamAssassin::Plugin>
http://jason.long.name/dkimproxy/
http://tools.ietf.org/rfc/rfc4871.txt
http://tools.ietf.org/rfc/rfc4870.txt
http://ietf.org/html.charters/dkim-charter.html
=cut
package Mail::SpamAssassin::Plugin::DKIM;
use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::Logger;
use Mail::SpamAssassin::Timeout;
use strict;
use warnings;
use bytes;
use re 'taint';
# Have to do this so that RPM doesn't find these as required perl modules.
BEGIN { require Mail::DKIM; require Mail::DKIM::Verifier; }
use vars qw(@ISA);
@ISA = qw(Mail::SpamAssassin::Plugin);
# constructor: register the eval rule
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_dkim_signed");
$self->register_eval_rule ("check_dkim_verified"); # old synonym for _valid
$self->register_eval_rule ("check_dkim_valid");
$self->register_eval_rule ("check_dkim_valid_author_sig");
$self->register_eval_rule ("check_dkim_signsome");
$self->register_eval_rule ("check_dkim_testing");
$self->register_eval_rule ("check_dkim_signall");
$self->register_eval_rule ("check_for_dkim_whitelist_from");
$self->register_eval_rule ("check_for_def_dkim_whitelist_from");
$self->set_config($mailsaobject->{conf});
return $self;
}
###########################################################################
sub set_config {
my($self, $conf) = @_;
my @cmds;
=head1 USER SETTINGS
=over 4
=item whitelist_from_dkim author@example.com [signing-identity]
Use this to supplement the whitelist_from addresses with a check to make
sure the message with a given From address (the author's address) carries a
valid Domain Keys Identified Mail (DKIM) signature by a verifier-acceptable
signing-identity (the i= tag).
Only one whitelist entry is allowed per line, as in C<whitelist_from_rcvd>.
Multiple C<whitelist_from_dkim> lines are allowed. File-glob style characters
are allowed for the From address (the first parameter), just like with
C<whitelist_from_rcvd>. The second parameter does not accept wildcards.
If no signing identity parameter is specified, the only acceptable signature
will be a first-party signature, i.e. the so called author signature, which
is a signature where the signing identity of a signature matches the author
address (i.e. the address in a From header field).
Since this whitelist requires a DKIM check to be made, network tests must
be enabled.
Examples of whitelisting based on an author signature (first-party):
whitelist_from_dkim joe@example.com
whitelist_from_dkim *@corp.example.com
whitelist_from_dkim *@*.example.com
Examples of whitelisting based on third-party signatures:
whitelist_from_dkim rick@example.net richard@example.net
whitelist_from_dkim rick@sub.example.net example.net
whitelist_from_dkim jane@example.net example.org
whitelist_from_dkim *@info.example.com example.com
whitelist_from_dkim *@* remailer.example.com
=item def_whitelist_from_dkim author@example.com [signing-identity]
Same as C<whitelist_from_dkim>, but used for the default whitelist entries
in the SpamAssassin distribution. The whitelist score is lower, because
these are often targets for abuse of public mailers which sign their mail.
=cut
push (@cmds, {
setting => 'whitelist_from_dkim',
code => sub {
my ($self, $key, $value, $line) = @_;
local ($1,$2);
unless (defined $value && $value !~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
unless ($value =~ /^(\S+)(?:\s+(\S+))?$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
my $address = $1;
my $identity = defined $2 ? $2 : ''; # empty implies author signature
$self->{parser}->add_to_addrlist_rcvd('whitelist_from_dkim',
$address, $identity);
}
});
push (@cmds, {
setting => 'def_whitelist_from_dkim',
code => sub {
my ($self, $key, $value, $line) = @_;
local ($1,$2);
unless (defined $value && $value !~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
unless ($value =~ /^(\S+)(?:\s+(\S+))?$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
my $address = $1;
my $identity = defined $2 ? $2 : ''; # empty implies author signature
$self->{parser}->add_to_addrlist_rcvd('def_whitelist_from_dkim',
$address, $identity);
}
});
=back
=head1 ADMINISTRATOR SETTINGS
=over 4
=item dkim_timeout n (default: 5)
How many seconds to wait for a DKIM query to complete, before
scanning continues without the DKIM result.
=cut
push (@cmds, {
setting => 'dkim_timeout',
is_admin => 1,
default => 5,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
});
$conf->{parser}->register_commands(\@cmds);
}
# ---------------------------------------------------------------------------
sub check_dkim_signed {
my ($self, $scan) = @_;
$self->_check_dkim_signature($scan) unless $scan->{dkim_checked_signature};
return $scan->{dkim_signed};
}
sub check_dkim_valid_author_sig {
my ($self, $scan) = @_;
$self->_check_dkim_signature($scan) unless $scan->{dkim_checked_signature};
return $scan->{dkim_valid_author_sig};
}
sub check_dkim_valid {
my ($self, $scan) = @_;
$self->_check_dkim_signature($scan) unless $scan->{dkim_checked_signature};
return $scan->{dkim_valid};
}
# mosnomer, old synonym for check_dkim_valid, kept for compatibility
sub check_dkim_verified {
my ($self, $scan) = @_;
$self->_check_dkim_signature($scan) unless $scan->{dkim_checked_signature};
return $scan->{dkim_valid};
}
# useless, semantically always true according to the current SSP draft
sub check_dkim_signsome {
my ($self, $scan) = @_;
# $self->_check_dkim_policy($scan) unless $scan->{dkim_checked_policy};
# return $scan->{dkim_signsome};
# just return false to avoid rule DKIM_POLICY_SIGNSOME always firing
return 0;
}
sub check_dkim_signall {
my ($self, $scan) = @_;
$self->_check_dkim_policy($scan) unless $scan->{dkim_checked_policy};
return $scan->{dkim_signall};
}
# public key carries a testing flag, or fetched policy carries a testing flag
sub check_dkim_testing {
my ($self, $scan) = @_;
my $result = 0;
$self->_check_dkim_signature($scan) unless $scan->{dkim_checked_signature};
if ($scan->{dkim_key_testing}) {
$result = 1;
} else {
$self->_check_dkim_policy($scan) unless $scan->{dkim_checked_policy};
$result = 1 if $scan->{dkim_policy_testing};
}
return $result;
}
sub check_for_dkim_whitelist_from {
my ($self, $scan) = @_;
$self->_check_dkim_whitelist($scan) unless $scan->{whitelist_checked};
return $scan->{dkim_match_in_whitelist_from_dkim} ||
$scan->{dkim_match_in_whitelist_auth};
}
sub check_for_def_dkim_whitelist_from {
my ($self, $scan) = @_;
$self->_check_dkim_whitelist($scan) unless $scan->{whitelist_checked};
return $scan->{dkim_match_in_def_whitelist_from_dkim} ||
$scan->{dkim_match_in_def_whitelist_auth};
}
# ---------------------------------------------------------------------------
sub _check_dkim_signature {
my ($self, $scan) = @_;
$scan->{dkim_checked_signature} = 1;
$scan->{dkim_signed} = 0;
$scan->{dkim_valid} = 0;
$scan->{dkim_valid_author_sig} = 0;
$scan->{dkim_key_testing} = 0;
$scan->{dkim_author_address} =
$scan->get('from:addr') if !defined $scan->{dkim_author_address};
my $timemethod = $self->{main}->time_method("check_dkim_signature");
# my $verifier = Mail::DKIM::Verifier->new(); # per new docs
my $verifier = Mail::DKIM::Verifier->new_object(); # old style???
if (!$verifier) {
dbg("dkim: cannot create Mail::DKIM::Verifier");
return;
}
$scan->{dkim_object} = $verifier;
# feed content of message into verifier, using \r\n endings,
# required by Mail::DKIM API (see bug 5300)
# note: bug 5179 comment 28: perl does silly things on non-Unix platforms
# unless we use \015\012 instead of \r\n
eval {
my $str = $scan->{msg}->get_pristine;
$str =~ s/\r?\n/\015\012/sg; # ensure \015\012 ending
# feeding large chunks to Mail::DKIM is much faster than line-by-line feed
$verifier->PRINT($str);
1;
} or do { # intercept die() exceptions and render safe
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
dbg("dkim: verification failed, intercepted error: $eval_stat");
return 0; # cannot verify message
};
my $timeout = $scan->{conf}->{dkim_timeout};
my $timer = Mail::SpamAssassin::Timeout->new({ secs => $timeout });
my $err = $timer->run_and_catch(sub {
dbg("dkim: performing public key lookup and signature verification");
$verifier->CLOSE(); # the action happens here
my $author = $verifier->message_originator;
$author = $author->address() if $author;
$author = '' if !defined $author; # when a From header field is missing
# Mail::DKIM sometimes leaves leading or trailing whitespace in address
$author =~ s/^[ \t]+//s; $author =~ s/[ \t]+\z//s; # trim
if ($author ne $scan->{dkim_author_address}) {
dbg("dkim: author parsing inconsistency, SA: <%s>, DKIM: <%s>",
$author, $scan->{dkim_author_address});
# currently SpamAssassin's parsing is better than Mail::Address parsing
# $scan->{dkim_author_address} = $author;
}
$scan->{dkim_signatures} = [];
# versions before 0.29 only provided a public interface to fetch one
# signature, new versions allow access to all signatures of a message
my @signatures = Mail::DKIM->VERSION >= 0.29 ? $verifier->signatures
: $verifier->signature;
@signatures = grep { defined } @signatures; # just in case
my $has_author_sig = 0;
foreach my $signature (@signatures) {
# i= Identity of the user or agent (e.g., a mailing list manager) on
# behalf of which this message is signed (dkim-quoted-printable;
# OPTIONAL, default is an empty local-part followed by an "@"
# followed by the domain from the "d=" tag).
my $identity = $signature->identity;
dbg("dkim: signing identity: %s, d=%s, a=%s, c=%s",
$identity, $signature->domain,
$signature->algorithm, scalar($signature->canonicalization));
if (!defined $identity || $identity eq '') { # just in case
$identity = '@' . $signature->domain;
$signature->identity($identity);
} elsif ($identity !~ /\@/) { # just in case
$identity = '@' . $identity;
$signature->identity($identity);
}
if ($signature->result eq 'pass') {
local ($1); # check if we have a valid first-party signature
if ($identity =~ /.\@[^@]*\z/s) { # identity has a localpart
$has_author_sig = 1 if lc($author) eq lc($identity);
} elsif ($author =~ /^.*?(\@[^\@]*)?\z/s && lc($1) eq lc($identity)) {
# ignoring localpart if identity doesn't have a localpart
$has_author_sig = 1;
}
}
}
$scan->{dkim_signatures} = \@signatures;
{ my (%seen1,%seen2);
my @valid_s = grep { $_->result eq 'pass' } @signatures;
$scan->set_tag('DKIMIDENTITY',
join(" ", grep { !$seen1{$_}++ } map { $_->identity } @valid_s));
$scan->set_tag('DKIMDOMAIN',
join(" ", grep { !$seen2{$_}++ } map { $_->domain } @valid_s));
}
# corresponds to 'best' result in case of multiple signatures
my $result = $verifier->result();
my $detail = $verifier->result_detail();
# let the result stand out more clearly in the log, use uppercase
dbg("dkim: signature verification result: ".
($detail eq 'none' ? $detail : uc $detail));
# check and remember verification results
if ($result eq 'pass') {
$scan->{dkim_signed} = 1;
$scan->{dkim_valid} = 1;
$scan->{dkim_valid_author_sig} = $has_author_sig;
}
elsif ($result eq 'fail') {
$scan->{dkim_signed} = 1;
# Returned if a valid DKIM-Signature header was found, but the
# signature does not contain a correct value for the message.
}
elsif ($result eq 'invalid') {
$scan->{dkim_signed} = 1;
# Returned if no valid DKIM-Signature headers were found,
# but there is at least one invalid DKIM-Signature header.
}
elsif ($result eq 'none') {
# no signatures, this is a default state
}
});
if ($timer->timed_out()) {
dbg("dkim: public key lookup or verification timed out after $timeout s");
} elsif ($err) {
chomp $err;
dbg("dkim: public key lookup or verification failed: $err");
}
}
sub _check_dkim_policy {
my ($self, $scan) = @_;
$scan->{dkim_checked_policy} = 1;
$scan->{dkim_signsome} = 0;
$scan->{dkim_signall} = 0;
$scan->{dkim_policy_testing} = 0;
$scan->{dkim_author_address} =
$scan->get('from:addr') if !defined $scan->{dkim_author_address};
# must check the message first to obtain signer, domain, and verif. status
$self->_check_dkim_signature($scan) unless $scan->{dkim_checked_signature};
my $verifier = $scan->{dkim_object};
my $timemethod = $self->{main}->time_method("check_dkim_policy");
if (!$verifier) {
dbg("dkim: policy: dkim object not available (programming error?)");
} elsif (!$scan->is_dns_available()) {
dbg("dkim: policy: not retrieved, no DNS resolving available");
} elsif ($scan->{dkim_valid_author_sig}) { # don't fetch policy when valid
# draft-allman-dkim-ssp: If the message contains a valid Author
# Signature, no Sender Signing Practices check need be performed:
# the Verifier SHOULD NOT look up the Sender Signing Practices
# and the message SHOULD be considered non-Suspicious.
dbg("dkim: policy: not retrieved, author signature is valid");
} else {
my $timeout = $scan->{conf}->{dkim_timeout};
my $timer = Mail::SpamAssassin::Timeout->new({ secs => $timeout });
my $err = $timer->run_and_catch(sub {
dbg("dkim: policy: performing lookup");
my $policy;
eval {
$policy = $verifier->fetch_author_policy; 1;
} or do {
# fetching or parsing a policy may throw an error, ignore such policy
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
dbg("dkim: policy: fetch or parse failed: $eval_stat");
undef $policy;
};
if (!$policy) {
dbg("dkim: policy: none");
} else {
my $policy_result = $policy->apply($verifier);
dbg("dkim: policy result $policy_result: ".$policy->as_string());
# extract the flags we expose, from the policy
my $pol_o = $policy->policy();
if ($pol_o eq '~') {
$scan->{dkim_signsome} = 1;
}
elsif ($pol_o eq '-') {
$scan->{dkim_signall} = 1;
}
if ($policy->testing()) {
$scan->{dkim_policy_testing} = 1;
}
}
});
if ($timer->timed_out()) {
dbg("dkim: lookup timed out after $timeout seconds");
} elsif ($err) {
chomp $err;
dbg("dkim: lookup failed: $err");
}
}
}
sub _check_dkim_whitelist {
my ($self, $scan) = @_;
$scan->{whitelist_checked} = 1;
return unless $scan->is_dns_available();
my $author = $scan->{dkim_author_address};
if (!defined $author) {
$scan->{dkim_author_address} = $author = $scan->get('from:addr');
}
if (!defined $author || $author eq '') {
dbg("dkim: check_dkim_whitelist: could not find author address");
return;
}
# collect whitelist entries matching the author from all lists
my @acceptable_identity_tuples;
$self->_wlcheck_acceptable_signature($scan, \@acceptable_identity_tuples,
'def_whitelist_from_dkim');
$self->_wlcheck_author_signature($scan, \@acceptable_identity_tuples,
'def_whitelist_auth');
$self->_wlcheck_acceptable_signature($scan, \@acceptable_identity_tuples,
'whitelist_from_dkim');
$self->_wlcheck_author_signature($scan, \@acceptable_identity_tuples,
'whitelist_auth');
if (!@acceptable_identity_tuples) {
dbg("dkim: no wl entries match author $author, no need to verify sigs");
return;
}
# if the message doesn't pass DKIM validation, it can't pass DKIM whitelist
# trigger a DKIM check so we can get address/identity info;
# continue if one or more signatures are valid or we want the debug info
return unless $self->check_dkim_valid($scan) || would_log("dbg","dkim");
# now do all the matching in one go, against all signatures in a message
my($any_match_at_all, $any_match_by_wl_ref) =
_wlcheck_list($self, $scan, \@acceptable_identity_tuples);
my(@valid,@fail);
foreach my $wl (keys %$any_match_by_wl_ref) {
my $match = $any_match_by_wl_ref->{$wl};
if (defined $match) {
$scan->{"dkim_match_in_$wl"} = 1 if $match;
if ($match) { push(@valid,$wl) } else { push(@fail,$wl) }
}
}
if (@valid) {
dbg("dkim: author %s, WHITELISTED by %s", $author, join(", ",@valid));
} elsif (@fail) {
dbg("dkim: author %s, found in %s BUT IGNORED", $author, join(", ",@fail));
} else {
dbg("dkim: author %s, not in any dkim whitelist", $author);
}
}
# check for verifier-acceptable signatures; an empty (or undefined) signing
# identity in a whitelist implies checking for an author signature
#
sub _wlcheck_acceptable_signature {
my ($self, $scan, $acceptable_identity_tuples_ref, $wl) = @_;
my $author = $scan->{dkim_author_address};
foreach my $white_addr (keys %{$scan->{conf}->{$wl}}) {
my $re = qr/$scan->{conf}->{$wl}->{$white_addr}{re}/i;
if ($author =~ $re) {
foreach my $acc_id (@{$scan->{conf}->{$wl}->{$white_addr}{domain}}) {
push(@$acceptable_identity_tuples_ref, [$acc_id,$wl,$re] );
}
}
}
}
# use a traditional whitelist_from -style addrlist, the only acceptable DKIM
# signature is an Author Signature. Note: don't pre-parse and store the
# domains; that's inefficient memory-wise and only saves one m//
#
sub _wlcheck_author_signature {
my ($self, $scan, $acceptable_identity_tuples_ref, $wl) = @_;
my $author = $scan->{dkim_author_address};
foreach my $white_addr (keys %{$scan->{conf}->{$wl}}) {
my $re = $scan->{conf}->{$wl}->{$white_addr};
if ($author =~ $re) {
push(@$acceptable_identity_tuples_ref, [undef,$wl,$re] );
}
}
}
sub _wlcheck_list {
my ($self, $scan, $acceptable_identity_tuples_ref) = @_;
my %any_match_by_wl;
my $any_match_at_all = 0;
my $expiration_supported = Mail::DKIM->VERSION >= 0.29 ? 1 : 0;
my $author = $scan->{dkim_author_address}; # address in a From header field
# walk through all signatures present in a message
foreach my $signature (@{$scan->{dkim_signatures}}) {
local ($1,$2);
my $valid = $signature->result eq 'pass';
my $expiration_time;
$expiration_time = $signature->expiration if $expiration_supported;
my $expired = defined $expiration_time &&
$expiration_time =~ /^\d{1,12}\z/ && time > $expiration_time;
my $identity = $signature->identity;
# split identity into local part and domain
$identity =~ /^ (.*?) \@ ([^\@]*) $/xs;
my($identity_mbx, $identity_dom) = ($1,$2);
my $author_matching_part = $author;
if ($identity =~ /^\@/) { # empty localpart in signing identity
$author_matching_part =~ s/^.*?(\@[^\@]*)?$/$1/s; # strip localpart
}
my $info = ''; # summary info string to be used for logging
$info .= ($valid ? 'VALID' : 'FAILED') . ($expired ? ' EXPIRED' : '');
$info .= lc $identity eq lc $author_matching_part ? ' author'
: ' third-party';
$info .= " signature by id " . $identity;
foreach my $entry (@$acceptable_identity_tuples_ref) {
my($acceptable_identity, $wl, $re) = @$entry;
# $re and $wl are here for logging purposes only, $re already checked.
# The $acceptable_identity is a verifier-acceptable signing identity.
# When $acceptable_identity is undef or an empty string it implies an
# author signature check.
my $matches = 0;
if (!defined $acceptable_identity || $acceptable_identity eq '') {
# An "Author Signature" (also called a first-party signature) is
# any Valid Signature where the signing identity matches the Author
# Address. If the signing identity does not include a localpart,
# then only the domains must match; otherwise, the two addresses
# must be identical.
# checking for author signature
$matches = 1 if lc $identity eq lc $author_matching_part;
}
else { # checking for verifier-acceptable signature
if ($acceptable_identity !~ /\@/) {
$acceptable_identity = '@' . $acceptable_identity;
}
# split into local part and domain
$acceptable_identity =~ /^ (.*?) \@ ([^\@]*) $/xs;
my($accept_id_mbx, $accept_id_dom) = ($1,$2);
# let's take a liberty and compare local parts case-insensitively
if ($accept_id_mbx ne '') { # local part exists, full id must match
$matches = 1 if lc $identity eq lc $acceptable_identity;
} else { # any local part in signing identity is acceptable
# as long as domain matches or is a subdomain
$matches = 1 if $identity_dom =~ /(^|\.)\Q$accept_id_dom\E\z/i;
}
}
if ($matches) {
dbg("dkim: $info, author $author, MATCHES $wl $re");
# a defined value indicates at least a match, not necessarily valid
$any_match_by_wl{$wl} = 0 if !exists $any_match_by_wl{$wl};
}
# only valid signature can cause whitelisting
$matches = 0 if !$valid || $expired;
$any_match_by_wl{$wl} = $any_match_at_all = 1 if $matches;
}
dbg("dkim: $info, author $author, no valid matches") if !$any_match_at_all;
}
return ($any_match_at_all, \%any_match_by_wl);
}
1;