DomainKeys.pm   [plain text]


# <@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::DomainKeys - perform DomainKeys verification tests

=head1 SYNOPSIS

 loadplugin Mail::SpamAssassin::Plugin::DomainKeys [/path/to/DomainKeys.pm]

Signature:
 header DK_SIGNED                eval:check_domainkeys_signed()
 header DK_VERIFIED              eval:check_domainkeys_verified()

Policy:
   Note that DK policy record is only fetched if DK_VERIFIED is
   false to save a signing domain from unnecessary DNS queries,
   as recommended (SHOULD) by draft-delany-domainkeys-base.
   Rules DK_POLICY_* should preferably not be relied upon when
   DK_VERIFIED is true, although they will return false in current
   implementation when a policy record is not fetched, except for
   DK_POLICY_TESTING, which is true if t=y appears in a public key
   record OR in a policy record (when available).
 header DK_POLICY_TESTING        eval:check_domainkeys_testing()
 header DK_POLICY_SIGNSOME       eval:check_domainkeys_signsome()
 header DK_POLICY_SIGNALL        eval:check_domainkeys_signall()

Whitelisting based on verified signature:
 header USER_IN_DK_WHITELIST     eval:check_for_dk_whitelist_from()
 header USER_IN_DEF_DK_WL        eval:check_for_def_dk_whitelist_from()

=head1 DESCRIPTION

This is the DomainKeys plugin and it needs lots more documentation.

Note that if the C<Mail::SpamAssassin::Plugin::DKIM> plugin is installed with
C<Mail::DKIM> version 0.20 or later, that plugin will also perform Domain Key
lookups on DomainKey-Signature headers, in which case this plugin is redundant.


Here is author's note from module C<Mail::DomainKeys> version 1.0:

  THIS MODULE IS OFFICIALLY UNSUPPORTED.

  Please move on to DKIM like a responsible Internet user.  I have.

  I will leave this module here on CPAN for a while, just in case someone
  has grown to depend on it.  It is apparent that DK will not be the way
  of the future. Thus, it is time to put this module to ground before it
  causes any further harm.

  Thanks for your support,
  Anthony

=cut

package Mail::SpamAssassin::Plugin::DomainKeys;

use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::Logger;
use Mail::SpamAssassin::Timeout;

use strict;
use warnings;
use bytes;

# Have to do this so that RPM doesn't find these as required perl modules
BEGIN { require Mail::DomainKeys::Message; require Mail::DomainKeys::Policy; }

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_domainkeys_signed");
  $self->register_eval_rule ("check_domainkeys_verified");
  $self->register_eval_rule ("check_domainkeys_signsome");
  $self->register_eval_rule ("check_domainkeys_testing");
  $self->register_eval_rule ("check_domainkeys_signall");
  $self->register_eval_rule ("check_for_dk_whitelist_from");
  $self->register_eval_rule ("check_for_def_dk_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_dk add@ress.com [signing domain name]

Use this to supplement the whitelist_from addresses with a check to make sure
the message has been signed by a DomainKeys signature that can be verified
against the From: domain's DomainKeys public key.

In order to support signing domain names that differ from the address domain
name, only one whitelist entry is allowed per line, exactly like
C<whitelist_from_rcvd>.  Multiple C<whitelist_from_dk> lines are allowed.  
File-glob style meta characters are allowed for the From: address, just like
with C<whitelist_from_rcvd>.  The optional signing domain name parameter must
match from the right-most side, also like in C<whitelist_from_rcvd>.

If no signing domain name parameter is specified the domain of the address
parameter specified will be used instead.

The From: address is obtained from a signed part of the message (ie. the
"From:" header), not from envelope data that is possible to forge.

Since this whitelist requires a DomainKeys check to be made, network tests must
be enabled.

Examples:

  whitelist_from_dk joe@example.com
  whitelist_from_dk *@corp.example.com

  whitelist_from_dk bob@it.example.net  example.net
  whitelist_from_dk *@eng.example.net   example.net

=item def_whitelist_from_dk add@ress.com [signing domain name]

Same as C<whitelist_from_dk>, but used for the default whitelist entries
in the SpamAssassin distribution.  The whitelist score is lower, because
these are often targets for spammer spoofing.

=cut

  push (@cmds, {
    setting => 'whitelist_from_dk',
    code => sub {
      my ($self, $key, $value, $line) = @_;
      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 $signer = (defined $2 ? $2 : $1);

      unless (defined $2) {
	$signer =~ s/^.*@(.*)$/$1/;
      }
      $self->{parser}->add_to_addrlist_rcvd ('whitelist_from_dk',
						$address, $signer);
    }
  });

  push (@cmds, {
    setting => 'def_whitelist_from_dk',
    code => sub {
      my ($self, $key, $value, $line) = @_;
      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 $signer = (defined $2 ? $2 : $1);

      unless (defined $2) {
	$signer =~ s/^.*@(.*)$/$1/;
      }
      $self->{parser}->add_to_addrlist_rcvd ('def_whitelist_from_dk',
						$address, $signer);
    }
  });

=back

=head1 ADMINISTRATOR SETTINGS

=over 4

=item domainkeys_timeout n             (default: 5)

How many seconds to wait for a DomainKeys query to complete, before
scanning continues without the DomainKeys result.

=cut

  push (@cmds, {
    setting => 'domainkeys_timeout',
    is_admin => 1,
    default => 5,
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
  });

  $conf->{parser}->register_commands(\@cmds);
}


sub check_domainkeys_signed {
  my ($self, $scan) = @_;

  $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked};
  
  return $scan->{domainkeys_signed};
}

sub check_domainkeys_verified {
  my ($self, $scan) = @_;

  $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked};
  
  return $scan->{domainkeys_verified};
}

sub check_domainkeys_signsome {
  my ($self, $scan) = @_;

  $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked};
  return $scan->{domainkeys_signsome};
}

sub check_domainkeys_testing {
  my ($self, $scan) = @_;

  $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked};
  
  return $scan->{domainkeys_testing};
}

sub check_domainkeys_signall {
  my ($self, $scan) = @_;

  $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked};
  
  return $scan->{domainkeys_signall};
}

sub check_for_dk_whitelist_from {
  my ($self, $scan) = @_;
  $self->_check_dk_whitelist($scan, 0) unless $scan->{dk_whitelist_from_checked};
  $scan->{dk_whitelist_from};
}

sub check_for_def_dk_whitelist_from {
  my ($self, $scan) = @_;
  $self->_check_dk_whitelist($scan, 1) unless $scan->{def_dk_whitelist_from_checked};
  $scan->{def_dk_whitelist_from};
}

# ---------------------------------------------------------------------------

sub _check_domainkeys {
  my ($self, $scan) = @_;

  $scan->{domainkeys_checked} = 0;
  $scan->{domainkeys_signed} = 0;
  $scan->{domainkeys_verified} = 0;
  $scan->{domainkeys_signsome} = 0;
  $scan->{domainkeys_testing} = 0;
  $scan->{domainkeys_signall} = 0;

  my $header = $scan->{msg}->get_pristine_header();
  my $body = $scan->{msg}->get_body();
  my $dksighdr = $scan->{msg}->get_header("DomainKey-Signature");
  dbg("dk: signature: $dksighdr")  if defined $dksighdr;

  $self->sanitize_header_for_dk(\$header)
    if defined $dksighdr && $dksighdr !~ /(?:^|;)[ \t]*h=/;  # case sensitive

  my $message = Mail::DomainKeys::Message->load(HeadString => $header,
						 BodyReference => $body);

  if (!$message) {
    dbg("dk: cannot load message using Mail::DomainKeys::Message");
    return;
  }

  $scan->{domainkeys_checked} = 1;

  # does a sender domain header exist?
  my $domain = $message->senderdomain();
  if (!$domain) {
    dbg("dk: no sender domain");
    return;
  }

  # get the sender address for whitelist checks
  if (defined $message->sender()) {
    $scan->{dk_address} = @{$message->sender()}[1];
    dbg("dk: sender: $scan->{dk_address}");
  } elsif (defined $message->from()) {
    $scan->{dk_address} ||= @{$message->from()}[1];
    dbg("dk: from: $scan->{dk_address}");
  } else {
    dbg("dk: could not determine sender: or from: identity");
  }

  # get the signing domain name for whitelist checks
  $scan->{dk_signing_domain} = $self->_dkmsg_signing_domain($scan, $message);
  dbg("dk: signing domain name: ".
    ($scan->{dk_signing_domain} ? $scan->{dk_signing_domain} : "not found"));

  my $timeout = $scan->{conf}->{domainkeys_timeout};

  my $timer = Mail::SpamAssassin::Timeout->new({ secs => $timeout });
  my $err = $timer->run_and_catch(sub {

    $self->_dk_lookup_trapped($scan, $message, $domain);

  });

  if ($timer->timed_out()) {
    dbg("dk: lookup timed out after $timeout seconds");
    return 0;
  }

  if ($err) {
    chomp $err;
    warn("dk: lookup failed: $err\n");
    return 0;
  }

  my $comment = $self->_dkmsg_hdr($message);
  $comment ||= '';
  $comment =~ s/\s+/ /gs;       # no newlines please

  $scan->{dk_comment} = "DomainKeys status: $comment";
}

# perform DK lookups.  This method is trapped within a timeout alarm() scope
sub _dk_lookup_trapped {
  my ($self, $scan, $message, $domain) = @_;

  # verified
  if ($message->signed()) {
    $scan->{domainkeys_signed} = 1;
    if ($message->verify()) {
      $scan->{domainkeys_verified} = 1;
    }
  }
  # testing flag in signature
  if ($message->testing()) {
    $scan->{domainkeys_testing} = 1;
  }
  my $policy;
  if (!$scan->{domainkeys_verified}) {
    # Recipient systems SHOULD not retrieve a policy TXT record
    # for email that successfully verifies.
    $policy = Mail::DomainKeys::Policy->fetch(Protocol => 'dns',
					      Domain => $domain);
    my($fetched_policy) = $policy ? $policy->as_string : 'NONE';
    $fetched_policy = ''  if !defined $fetched_policy;
    dbg ("dk: fetched policy for domain $domain: $fetched_policy");
  }
  return unless $policy;

  # not signed and domain doesn't sign all
  if ($policy->signsome()) {
    $scan->{domainkeys_signsome} = 1;
  }

  # testing flag in policy
  if ($policy->testing()) {
    $scan->{domainkeys_testing} = 1;
  }

  # does policy require all mail to be signed
  if ($policy->signall()) {
    $scan->{domainkeys_signall} = 1;
  }

  my $comment = $self->_dkmsg_hdr($message);
  dbg("dk: comment is '$comment'");
}

# get the DK status "header" from the Mail::DomainKeys::Message object
sub _dkmsg_hdr {
  my ($self, $message) = @_;
  # try to use the signature() API if it exists (post-0.80)
  if ($message->can("signature")) {
    my($sts,$msg);
    if (!$message->signed) {
      $sts = "no signature";
    } else {
      $sts = $message->signature->status;
      $msg = $message->signature->errorstr;
    }
    dbg("dk: $sts" . (defined $msg ? " ($msg)" : ''));
    return $sts;
  } else {
    return $message->header->value;
  }
}

# get the DK signing domain name from the Mail::DomainKeys::Message object
sub _dkmsg_signing_domain {
  my ($self, $scan, $message) = @_;
  # try to use the signature() API if it exists (post-0.80)
  if ($message->can("signature")) {
    if (!$message->signed) {
      return undef;
    }
    return $message->signature->domain;
  } else {
    # otherwise parse it ourself
    if ($scan->{msg}->get_header("DomainKey-Signature") =~
        /(?: ^|; ) [ \t]* d= [ \t]* ([^;]*?) [ \t]* (?: ;|$ )/x) {
      return $1;
    }
    return undef;
  }
}

sub sanitize_header_for_dk {
  my ($self, $ref) = @_;

  dbg("dk: sanitizing header, no \"h\" tag in signature");
  # remove folding, in a HTML-escape data-preserving style, so we can
  # strip headers easily
  $$ref =~ s/!/!ex;/gs;
  $$ref =~ s/\n([ \t])/!nl;$1/gs;
  my @hdrs = split(/^/m, $$ref);

  while (scalar @hdrs > 0) {
    my $last = pop @hdrs;
    next if ($last =~ /^\r?$/);

    # List all the known appended headers that may break a DK signature. Things
    # to note:
    # 
    # 1. only *appended* headers should be listed; prepended additions are fine.
    # 2. some virus-scanner headers may be better left out, since there are ISPs
    # who scan for viruses before the message leaves their SMTP relay; this is
    # not quite decided.
    #
    # TODO: there's probably loads more, and this should be user-configurable

    if ($last =~ /^ (?:
            # SpamAssassin additions, remove these so that mass-check works
            X-Spam-\S+

            # other spam filters
            |X-MailScanner(?:-SpamCheck)?
            |X-Pyzor |X-DCC-\S{2,25}-Metrics
            |X-Bogosity

            # post-delivery MUA additions
            |X-Evolution
            |X-MH-Thread-Markup

            # IMAP or POP additions
            |X-Keywords
            |(?:X-)?Status |X-Flags |Replied |Forwarded
            |Lines |Content-Length
            |X-UIDL? |X-IMAPbase

            # MTA delivery control headers
            |X-MDaemon-Deliver-To

            # other MUAs: VM and Gnus
            |X-VM-(?:Bookmark|(?:POP|IMAP)-Retrieved|Labels|Last-Modified
            |Summary-Format|VHeader|v\d-Data|Message-Order)
            |X-Gnus-Mail-Source
            |Xref
          ):/ix)
    {
      $last =~ /^([^:]+):/; dbg("dk: ignoring header '$1'");
      next;
    }

    push (@hdrs, $last); last;
  }

  $$ref = join("", @hdrs);

  # and return the remaining headers to pristine condition
  # $$ref =~ s/^\n//gs; $$ref =~ s/\n$//gs;
  $$ref =~ s/!nl;/\n/gs;
  $$ref =~ s/!ex;/!/gs;
}

sub _check_dk_whitelist {
  my ($self, $scan, $default) = @_;

  return unless $scan->is_dns_available();

  # trigger a DK check so we can get address/signer info
  # if verification failed only continue if we want the debug info
  unless ($self->check_domainkeys_verified($scan)) {
    unless (would_log("dbg", "dk")) {
      return;
    }
  }

  unless ($scan->{dk_address}) {
    dbg("dk: ". ($default ? "def_" : "") ."whitelist_from_dk: could not find sender or from address");
    return;
  }
  unless ($scan->{dk_signing_domain}) {
    dbg("dk: ". ($default ? "def_" : "") ."whitelist_from_dk: could not find signing domain name");
    return;
  }

  if ($default) {
    $scan->{def_dk_whitelist_from_checked} = 1;
    $scan->{def_dk_whitelist_from} =
                    $self->_wlcheck_domain($scan,'def_whitelist_from_dk');

    if (!$scan->{def_dk_whitelist_from}) {
      $scan->{def_dk_whitelist_from} =
                    $self->_wlcheck_no_domain($scan,'def_whitelist_auth');
    }
  } else {
    $scan->{dk_whitelist_from_checked} = 1;
    $scan->{dk_whitelist_from} =
                    $self->_wlcheck_domain($scan,'whitelist_from_dk');
    
    if (!$scan->{dk_whitelist_from}) {
      $scan->{dk_whitelist_from} =
                    $self->_wlcheck_no_domain($scan,'whitelist_auth');
    }
  }

  # if the message doesn't pass DK validation, it can't pass a DK whitelist
  if ($default) {
    if ($scan->{def_dk_whitelist_from}) {
      if ($self->check_domainkeys_verified($scan)) {
	dbg("dk: address: $scan->{dk_address} signing domain name: ".
	  "$scan->{dk_signing_domain} is in user's DEF_WHITELIST_FROM_DK and ".
	  "passed DK verification");
      } else {
	dbg("dk: address: $scan->{dk_address} signing domain name: ".
	  "$scan->{dk_signing_domain} is in user's DEF_WHITELIST_FROM_DK but ".
	  "failed DK verification");
	$scan->{def_dk_whitelist_from} = 0;
      }
    } else {
      dbg("dk: address: $scan->{dk_address} signing domain name: ".
	  "$scan->{dk_signing_domain} is not in user's DEF_WHITELIST_FROM_DK");
    }
  } else {
    if ($scan->{dk_whitelist_from}) {
      if ($self->check_domainkeys_verified($scan)) {
	dbg("dk: address: $scan->{dk_address} signing domain name: ".
	  "$scan->{dk_signing_domain} is in user's WHITELIST_FROM_DK and ".
	  "passed DK verification");
      } else {
	dbg("dk: address: $scan->{dk_address} signing domain name: ".
	  "$scan->{dk_signing_domain} is in user's WHITELIST_FROM_DK but ".
	  "failed DK verification");
	$scan->{dk_whitelist_from} = 0;
      }
    } else {
      dbg("dk: address: $scan->{dk_address} signing domain name: ".
	  "$scan->{dk_signing_domain} is not in user's WHITELIST_FROM_DK");
    }
  }
}

sub _wlcheck_domain {
  my ($self, $scan, $wl) = @_;

  foreach my $white_addr (keys %{$scan->{conf}->{$wl}}) {
    my $re = qr/$scan->{conf}->{$wl}->{$white_addr}{re}/i;
    foreach my $domain (@{$scan->{conf}->{$wl}->{$white_addr}{domain}}) {
      $self->_wlcheck_one_dom($scan, $wl, $white_addr, $domain, $re) and return 1;
    }
  }
  return 0;
}

sub _wlcheck_one_dom {
  my ($self, $scan, $wl, $white_addr, $domain, $re) = @_;

  if ($scan->{dk_address} =~ $re) {
    if ($scan->{dk_signing_domain} =~ /(?:^|\.)\Q${domain}\E$/i) {
      dbg("dk: address: $scan->{dk_address} matches $wl $re $domain");
      return 1;
    }
  }
  return 0;
}


# use a traditional whitelist_from-style addrlist, and infer the
# domain from each address on the fly.  Note: don't pre-parse and
# store the domains; that's inefficient memory-wise and only saves 1 m//
sub _wlcheck_no_domain {
  my ($self, $scan, $wl) = @_;

  foreach my $white_addr (keys %{$scan->{conf}->{$wl}}) {
    my $domain = ($white_addr =~ /\@(.*?)$/) ? $1 : $white_addr;
    my $re = $scan->{conf}->{$wl}->{$white_addr};
    $self->_wlcheck_one_dom($scan, $wl, $white_addr, $domain, $re) and return 1;
  }
  return 0;
}

1;