337 lines
9.1 KiB
Perl
337 lines
9.1 KiB
Perl
#!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
|
|
|
|
I first attempted to fix the dkimcheck plugin, but soon scrapped that effort and wrote this one. Why?
|
|
|
|
=over 4
|
|
|
|
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.
|
|
|
|
=back
|
|
|
|
=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 ) = @_;
|
|
|
|
$self->adjust_karma( -1 );
|
|
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"
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
$self->adjust_karma( -1 );
|
|
$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,
|
|
"invalid signature, reject policy"
|
|
);
|
|
}
|
|
|
|
# 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");
|
|
$self->adjust_karma( 1 );
|
|
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 );
|
|
$self->adjust_karma( -1 );
|
|
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;
|
|
|
|
# consider adding Authentication-Results header here as well
|
|
$self->qp->transaction->header->add( 'X-DKIM-Authentication', $header, 0 );
|
|
}
|
|
|