2012-06-27 09:26:38 +02:00
#!perl -w
=head1 NAME
dkim: validate DomainKeys and (DKIM) Domain Keys Indentified Messages
=head1 SYNOPSIS
Validate the DKIM and Domainkeys signatures of a message, and enforce DKIM
sending policies.
=head1 CONFIGURATION
=head2 reject [ 0 | 1 ]
dkim reject 1
Reject is a boolean that toggles message rejection on or off. Messages failing
validation are rejected by default.
Default: 1
=head2 reject_type
dkim reject_type [ temp | perm ]
Default: perm
=head1 SEE ALSO
http://www.dkim.org/
http://tools.ietf.org/html/rfc6376 - DKIM Signatures
http://tools.ietf.org/html/rfc5863 - DKIM Development, Deployment, & Operations
http://tools.ietf.org/html/rfc5617 - DKIM ADSP (Author Domain Signing Practices)
http://tools.ietf.org/html/rfc5585 - DKIM Service Overview
http://tools.ietf.org/html/rfc5016 - DKIM Signing Practices Protocol
http://tools.ietf.org/html/rfc4871 - DKIM Signatures
http://tools.ietf.org/html/rfc4870 - DomainKeys
=head1 AUTHORS
2012 - Matt Simerson - initial plugin
=head1 ACKNOWLEDGEMENTS
David Summers - http://www.nntp.perl.org/group/perl.qpsmtpd/2010/08/msg9417.html
Matthew Harrell - http://alecto.bittwiddlers.com/files/qpsmtpd/dkimcheck
2012-11-16 20:35:19 +01:00
I first attempted to fix the dkimcheck plugin, but soon scrapped that effort and wrote this one. Why?
=over 4
2012-06-27 09:26:38 +02:00
The nine 'if' brackets with 19 conditionals, and my inability to easily determine which of the 15 possible permutations (5 signature validation results x 3 possible policy results) were covered.
The use of $dkim->fetch_author_policy, which is deprecated by Mail::DKIM.
The paradim of a single policy, when DKIM supports 0 or many. Although I may yet implement the 'local' policy idea, so long as I'm confident it will never result in a false positive.
The OBF programming style, which is nigh impossible to test.
2012-11-16 20:35:19 +01:00
=back
2012-06-27 09:26:38 +02:00
=cut
use strict;
use warnings;
use Qpsmtpd::Constants;
# use Mail::DKIM::Verifier; # eval'ed in register()
use Socket qw(:DEFAULT :crlf);
sub init {
my ($self, $qp) = (shift, shift);
$self->{_args} = { @_ };
$self->{_args}{reject} = 1 if ! defined $self->{_args}{reject};
$self->{_args}{reject_type} ||= 'perm';
}
sub register {
my $self = shift;
eval "use Mail::DKIM::Verifier";
if ( $@ ) {
warn "skip, plugin disabled, could not load Mail::DKIM::Verifier\n";
$self->log(LOGERROR, "skip, plugin disabled, is Mail::DKIM installed?");
return;
};
$self->register_hook('data_post', 'data_post_handler');
};
sub data_post_handler {
my ($self, $transaction) = @_;
return DECLINED if $self->is_immune();
my $dkim = Mail::DKIM::Verifier->new() or do {
$self->log(LOGERROR, "error, could not instantiate a new Mail::DKIM::Verifier");
return DECLINED;
};
my $result = $self->get_dkim_result( $dkim, $transaction );
my $mess = $self->get_details( $dkim );
foreach my $r ( qw/ pass fail invalid temperror none / ) {
my $handler = 'handle_sig_' . $r;
if ( $result eq $r && $self->can( $handler ) ) {
#$self->log(LOGINFO, "dispatching $result to $handler");
return $self->$handler( $dkim, $mess );
};
};
$self->log( LOGERROR, "unknown result: $result, $mess" );
return DECLINED;
}
sub get_details {
my ($self, $dkim ) = @_;
my @data;
my $string;
push @data, "domain: " . $dkim->signature->domain if $dkim->signature;
push @data, "selector: " . $dkim->signature->selector if $dkim->signature;
push @data, "result: " . $dkim->result_detail if $dkim->result_detail;
foreach my $policy ( $dkim->policies ) {
next if ! $policy;
push @data, "policy: " . $policy->as_string;
push @data, "name: " . $policy->name;
push @data, "policy_location: " . $policy->location if $policy->location;
my $policy_result;
$policy_result = $policy->apply($dkim);
$policy_result or next;
push @data, "policy_result: " . $policy_result if $policy_result;
};
return join(', ', @data);
};
sub handle_sig_fail {
my ( $self, $dkim, $mess ) = @_;
2013-03-27 03:15:13 +01:00
$self->adjust_karma( -1 );
2012-06-27 09:26:38 +02:00
return $self->get_reject( "DKIM signature invalid: " . $dkim->result_detail, $mess );
};
sub handle_sig_temperror {
my ( $self, $dkim, $mess ) = @_;
$self->log(LOGINFO, "error, $mess" );
return ( DENYSOFT, "Please try again later - $dkim->result_detail" );
};
sub handle_sig_invalid {
my ( $self, $dkim, $mess ) = @_;
my ( $prs, $policies) = $self->get_policy_results( $dkim );
if ( ! $self->qp->connection->relay_client() ) {
foreach my $policy ( @$policies ) {
if ( $policy->signall && ! $policy->is_implied_default_policy ) {
$self->log(LOGINFO, $mess );
return $self->get_reject(
"invalid DKIM signature with sign-all policy",
"invalid signature, sign-all policy"
);
}
};
};
2013-03-27 03:15:13 +01:00
$self->adjust_karma( -1 );
2012-06-27 09:26:38 +02:00
$self->log(LOGINFO, $mess );
if ( $prs->{accept} ) {
$self->add_header( $mess );
$self->log( LOGERROR, "error, invalid signature but accept policy!?" );
return DECLINED;
}
elsif ( $prs->{neutral} ) {
$self->add_header( $mess );
$self->log( LOGERROR, "error, invalid signature but neutral policy?!" );
return DECLINED;
}
elsif ( $prs->{reject} ) {
return $self->get_reject(
"invalid DKIM signature: " . $dkim->result_detail,
2013-03-28 01:09:42 +01:00
"invalid signature, reject policy"
2012-06-27 09:26:38 +02:00
);
}
# this should never happen
$self->log( LOGINFO, "error, invalid signature, unhandled" );
$self->add_header( $mess );
return DECLINED;
};
sub handle_sig_pass {
my ( $self, $dkim, $mess ) = @_;
my ($prs) = $self->get_policy_results( $dkim );
if ( $prs->{accept} ) {
$self->add_header( $mess );
$self->log(LOGINFO, "pass, valid signature, accept policy");
2013-03-27 03:15:13 +01:00
$self->adjust_karma( 1 );
2012-06-27 09:26:38 +02:00
return DECLINED;
}
elsif ( $prs->{neutral} ) {
$self->add_header( $mess );
$self->log(LOGINFO, "pass, valid signature, neutral policy");
$self->log(LOGINFO, $mess );
return DECLINED;
}
elsif ( $prs->{reject} ) {
$self->log(LOGINFO, $mess );
2013-03-27 03:15:13 +01:00
$self->adjust_karma( -1 );
2012-06-27 09:26:38 +02:00
return $self->get_reject(
"DKIM signature valid but fails policy, $mess",
"fail, valid sig, reject policy"
);
};
# this should never happen
$self->add_header( $mess );
$self->log(LOGERROR, "pass, valid sig, no policy results" );
$self->log(LOGINFO, $mess );
return DECLINED;
};
sub handle_sig_none {
my ( $self, $dkim, $mess ) = @_;
my ( $prs, $policies) = $self->get_policy_results( $dkim );
if ( ! $self->qp->connection->relay_client() ) {
foreach my $policy ( @$policies ) {
if ( $policy->signall && ! $policy->is_implied_default_policy ) {
$self->log(LOGINFO, $mess );
return $self->get_reject(
"no DKIM signature with sign-all policy",
"no signature, sign-all policy"
);
}
};
};
if ( $prs->{accept} ) {
$self->log( LOGINFO, "pass, no signature, accept policy" );
return DECLINED;
}
elsif ( $prs->{neutral} ) {
$self->log( LOGINFO, "pass, no signature, neutral policy" );
return DECLINED;
}
elsif ( $prs->{reject} ) {
$self->log(LOGINFO, $mess );
$self->get_reject(
"no DKIM signature, policy says reject: " . $dkim->result_detail,
"no signature, reject policy"
);
};
# should never happen
$self->log( LOGINFO, "error, no signature, no policy" );
$self->log(LOGINFO, $mess );
return DECLINED;
};
sub get_dkim_result {
my $self = shift;
my ($dkim, $transaction) = @_;
foreach ( split ( /\n/s, $transaction->header->as_string ) ) {
$_ =~ s/\r?$//s;
eval { $dkim->PRINT ( $_ . CRLF ); };
$self->log(LOGERROR, $@ ) if $@;
}
$transaction->body_resetpos;
while (my $line = $transaction->body_getline) {
chomp $line;
s/\015$//;
eval { $dkim->PRINT($line . CRLF ); };
$self->log(LOGERROR, $@ ) if $@;
};
$dkim->CLOSE;
return $dkim->result;
};
sub get_policies {
my ($self, $dkim) = @_;
my @policies;
eval { @policies = $dkim->policies };
$self->log(LOGERROR, $@ ) if $@;
return @policies;
};
sub get_policy_results {
my ( $self, $dkim ) = @_;
my %prs;
my @policies = $self->get_policies( $dkim );
foreach my $policy ( @policies ) {
my $policy_result;
eval { $policy_result = $policy->apply($dkim); }; # accept, reject, neutral
if ( $@ ) {
$self->log(LOGERROR, $@ );
};
$prs{$policy_result}++ if $policy_result;
};
return \%prs, \@policies;
};
sub add_header {
my $self = shift;
my $header = shift or return;
2013-03-28 01:09:42 +01:00
# consider adding Authentication-Results header here as well
2012-06-27 09:26:38 +02:00
$self->qp->transaction->header->add( 'X-DKIM-Authentication', $header, 0 );
}