dkim: added message signing feature
This commit is contained in:
parent
eed4d5e791
commit
b8df80d398
218
plugins/dkim
218
plugins/dkim
@ -6,17 +6,17 @@ dkim: validate DomainKeys and (DKIM) Domain Keys Indentified Messages
|
|||||||
|
|
||||||
=head1 SYNOPSIS
|
=head1 SYNOPSIS
|
||||||
|
|
||||||
Validate the DKIM and Domainkeys signatures of a message, and enforce DKIM
|
Validate the DKIM and Domainkeys signatures of a message, enforce DKIM
|
||||||
sending policies.
|
sending policies, and DKIM sign outgoing messages.
|
||||||
|
|
||||||
=head1 CONFIGURATION
|
=head1 CONFIGURATION
|
||||||
|
|
||||||
=head2 reject [ 0 | 1 ]
|
=head2 reject [ 0 | 1 | naughty ]
|
||||||
|
|
||||||
dkim reject 1
|
dkim reject 0
|
||||||
|
|
||||||
Reject is a boolean that toggles message rejection on or off. Messages failing
|
Reject is a boolean that toggles message rejection on or off, or naughty,
|
||||||
validation are rejected by default.
|
which offloads a deferred rejection to the B<naughty> plugin.
|
||||||
|
|
||||||
Default: 1
|
Default: 1
|
||||||
|
|
||||||
@ -26,6 +26,67 @@ Default: 1
|
|||||||
|
|
||||||
Default: perm
|
Default: perm
|
||||||
|
|
||||||
|
=head1 HOW TO SIGN
|
||||||
|
|
||||||
|
=head2 generate DKIM key(s)
|
||||||
|
|
||||||
|
mkdir -p ~smtpd/config/dkim/example.org
|
||||||
|
cd ~smtpd/config/dkim/example.org
|
||||||
|
echo 'mar2013' > selector
|
||||||
|
openssl genrsa -out private 2048
|
||||||
|
chmod 400 private
|
||||||
|
openssl rsa -in private -out public -pubout
|
||||||
|
chown -R smtpd:smtpd ~smtpd/config/dkim/example.org
|
||||||
|
|
||||||
|
After running the commands, you'll have a directory with three files:
|
||||||
|
|
||||||
|
example.org
|
||||||
|
example.org/selector
|
||||||
|
example.org/private
|
||||||
|
example.org/public
|
||||||
|
|
||||||
|
=head3 selector
|
||||||
|
|
||||||
|
The selector can be any value that is a valid DNS label.
|
||||||
|
|
||||||
|
=head3 key length
|
||||||
|
|
||||||
|
The minimum recommended key length for short duration keys (ones that will be replaced within a few months) is 1024. If you are unlikely to rotate your keys frequently, go with 2048, at the expense of more CPU.
|
||||||
|
|
||||||
|
=head2 publish public key in DNS
|
||||||
|
|
||||||
|
mar2013._domainkey TXT "v=DKIM1;p=[public key stripped of whitespace];"
|
||||||
|
|
||||||
|
hash: h=[ sha1 | sha256 ]
|
||||||
|
test; t=[ s | s:y ]
|
||||||
|
granularity: g=[ ]
|
||||||
|
notes: n=[ ]
|
||||||
|
services: s=[email]
|
||||||
|
keytypes: [ rsa ]
|
||||||
|
|
||||||
|
Prepare the DNS record with these commands:
|
||||||
|
|
||||||
|
cd ~smtpd/config/dkim/example.org
|
||||||
|
cat selector | tr -d "\n" > dns
|
||||||
|
echo -n '._domainkey TXT "v=DKIM1;p=' >> dns
|
||||||
|
grep -v -e '^-' public | tr -d "\n" >> dns
|
||||||
|
echo '"' >> dns
|
||||||
|
|
||||||
|
The contents of I<dns> are ready to be copy/pasted into a BIND zone file, or better yet, NicTool, and published to most any DNS server.
|
||||||
|
|
||||||
|
=head2 testing
|
||||||
|
|
||||||
|
After confirming that the DKIM public key can be fetched with DNS, send test messages via QP to a Gmail box and check the Authentication-Results header. There are also DKIM relays (check-auth@verifier.port25.com, checkmyauth@auth.returnpath.net) that provide more debugging information in a nice email report.
|
||||||
|
|
||||||
|
=head2 Sign for others
|
||||||
|
|
||||||
|
Following the directions above will configure QP to DKIM sign messages from authenticated senders from example.org. Suppose you host client.com and would like to DKIM sign their messages too? Do that as follows:
|
||||||
|
|
||||||
|
cd ~smtpd/config/dkim/example.org
|
||||||
|
ln -s example.org client.com
|
||||||
|
|
||||||
|
QP will follow the symlink target and sign client.com emails with the example.org DKIM key.
|
||||||
|
|
||||||
=head1 SEE ALSO
|
=head1 SEE ALSO
|
||||||
|
|
||||||
http://www.dkim.org/
|
http://www.dkim.org/
|
||||||
@ -44,6 +105,10 @@ http://tools.ietf.org/html/rfc4871 - DKIM Signatures
|
|||||||
|
|
||||||
http://tools.ietf.org/html/rfc4870 - DomainKeys
|
http://tools.ietf.org/html/rfc4870 - DomainKeys
|
||||||
|
|
||||||
|
http://dkimcore.org/tools/
|
||||||
|
|
||||||
|
http://www.protodave.com/tools/dkim-key-checker/
|
||||||
|
|
||||||
=head1 AUTHORS
|
=head1 AUTHORS
|
||||||
|
|
||||||
2012 - Matt Simerson - initial plugin
|
2012 - Matt Simerson - initial plugin
|
||||||
@ -88,11 +153,13 @@ sub init {
|
|||||||
sub register {
|
sub register {
|
||||||
my $self = shift;
|
my $self = shift;
|
||||||
|
|
||||||
eval "use Mail::DKIM::Verifier";
|
foreach my $mod ( qw/ Mail::DKIM::Verifier Mail::DKIM::Signer Mail::DKIM::TextWrap / ) {
|
||||||
if ( $@ ) {
|
eval "use $mod";
|
||||||
warn "skip, plugin disabled, could not load Mail::DKIM::Verifier\n";
|
if ( $@ ) {
|
||||||
$self->log(LOGERROR, "skip, plugin disabled, is Mail::DKIM installed?");
|
warn "error, plugin disabled, could not load $mod\n";
|
||||||
return;
|
$self->log(LOGERROR, "skip, plugin disabled, is Mail::DKIM installed?");
|
||||||
|
return;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
$self->register_hook('data_post', 'data_post_handler');
|
$self->register_hook('data_post', 'data_post_handler');
|
||||||
@ -101,14 +168,27 @@ sub register {
|
|||||||
sub data_post_handler {
|
sub data_post_handler {
|
||||||
my ($self, $transaction) = @_;
|
my ($self, $transaction) = @_;
|
||||||
|
|
||||||
|
if ( $self->qp->connection->relay_client() ) {
|
||||||
|
# this is one of our authenticated users sending a message.
|
||||||
|
return $self->sign_it( $transaction );
|
||||||
|
};
|
||||||
|
|
||||||
return DECLINED if $self->is_immune();
|
return DECLINED if $self->is_immune();
|
||||||
|
|
||||||
|
return $self->validate_it( $transaction );
|
||||||
|
};
|
||||||
|
|
||||||
|
sub validate_it {
|
||||||
|
my ($self, $transaction) = @_;
|
||||||
|
|
||||||
|
# Incoming message, perform DKIM validation
|
||||||
my $dkim = Mail::DKIM::Verifier->new() or do {
|
my $dkim = Mail::DKIM::Verifier->new() or do {
|
||||||
$self->log(LOGERROR, "error, could not instantiate a new Mail::DKIM::Verifier");
|
$self->log(LOGERROR, "error, could not instantiate a new Mail::DKIM::Verifier");
|
||||||
return DECLINED;
|
return DECLINED;
|
||||||
};
|
};
|
||||||
|
|
||||||
my $result = $self->get_dkim_result( $dkim, $transaction );
|
$self->send_message_to_dkim( $dkim, $transaction );
|
||||||
|
my $result = $dkim->result;
|
||||||
my $mess = $self->get_details( $dkim );
|
my $mess = $self->get_details( $dkim );
|
||||||
|
|
||||||
foreach my $r ( qw/ pass fail invalid temperror none / ) {
|
foreach my $r ( qw/ pass fail invalid temperror none / ) {
|
||||||
@ -123,6 +203,30 @@ sub data_post_handler {
|
|||||||
return DECLINED;
|
return DECLINED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sub sign_it {
|
||||||
|
my ($self, $transaction) = @_;
|
||||||
|
|
||||||
|
my ($domain, $keydir) = $self->get_keydir($transaction) or return DECLINED;
|
||||||
|
my $selector = $self->get_selector($keydir);
|
||||||
|
|
||||||
|
my $dkim = Mail::DKIM::Signer->new(
|
||||||
|
Algorithm => "rsa-sha256",
|
||||||
|
Method => "relaxed",
|
||||||
|
Domain => $domain,
|
||||||
|
Selector => $selector,
|
||||||
|
KeyFile => "$keydir/private",
|
||||||
|
);
|
||||||
|
|
||||||
|
$self->send_message_to_dkim( $dkim, $transaction );
|
||||||
|
|
||||||
|
my $signature = $dkim->signature; # what is the signature result?
|
||||||
|
$self->qp->transaction->header->add(
|
||||||
|
'DKIM-Signature', $signature->as_string, 0 );
|
||||||
|
|
||||||
|
$self->log(LOGINFO, "pass, signed message, ", $signature->as_string );
|
||||||
|
return DECLINED;
|
||||||
|
};
|
||||||
|
|
||||||
sub get_details {
|
sub get_details {
|
||||||
my ($self, $dkim ) = @_;
|
my ($self, $dkim ) = @_;
|
||||||
|
|
||||||
@ -166,16 +270,14 @@ sub handle_sig_invalid {
|
|||||||
|
|
||||||
my ( $prs, $policies) = $self->get_policy_results( $dkim );
|
my ( $prs, $policies) = $self->get_policy_results( $dkim );
|
||||||
|
|
||||||
if ( ! $self->qp->connection->relay_client() ) {
|
foreach my $policy ( @$policies ) {
|
||||||
foreach my $policy ( @$policies ) {
|
if ( $policy->signall && ! $policy->is_implied_default_policy ) {
|
||||||
if ( $policy->signall && ! $policy->is_implied_default_policy ) {
|
$self->log(LOGINFO, $mess );
|
||||||
$self->log(LOGINFO, $mess );
|
return $self->get_reject(
|
||||||
return $self->get_reject(
|
"invalid DKIM signature with sign-all policy",
|
||||||
"invalid DKIM signature with sign-all policy",
|
"invalid signature, sign-all policy"
|
||||||
"invalid signature, sign-all policy"
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$self->adjust_karma( -1 );
|
$self->adjust_karma( -1 );
|
||||||
@ -194,7 +296,7 @@ sub handle_sig_invalid {
|
|||||||
elsif ( $prs->{reject} ) {
|
elsif ( $prs->{reject} ) {
|
||||||
return $self->get_reject(
|
return $self->get_reject(
|
||||||
"invalid DKIM signature: " . $dkim->result_detail,
|
"invalid DKIM signature: " . $dkim->result_detail,
|
||||||
"invalid signature, reject policy"
|
"fail, invalid signature, reject policy"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,16 +344,14 @@ sub handle_sig_none {
|
|||||||
|
|
||||||
my ( $prs, $policies) = $self->get_policy_results( $dkim );
|
my ( $prs, $policies) = $self->get_policy_results( $dkim );
|
||||||
|
|
||||||
if ( ! $self->qp->connection->relay_client() ) {
|
foreach my $policy ( @$policies ) {
|
||||||
foreach my $policy ( @$policies ) {
|
if ( $policy->signall && ! $policy->is_implied_default_policy ) {
|
||||||
if ( $policy->signall && ! $policy->is_implied_default_policy ) {
|
$self->log(LOGINFO, $mess );
|
||||||
$self->log(LOGINFO, $mess );
|
return $self->get_reject(
|
||||||
return $self->get_reject(
|
"no DKIM signature with sign-all policy",
|
||||||
"no DKIM signature with sign-all policy",
|
"no signature, sign-all policy"
|
||||||
"no signature, sign-all policy"
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if ( $prs->{accept} ) {
|
if ( $prs->{accept} ) {
|
||||||
@ -276,9 +376,35 @@ sub handle_sig_none {
|
|||||||
return DECLINED;
|
return DECLINED;
|
||||||
};
|
};
|
||||||
|
|
||||||
sub get_dkim_result {
|
sub get_keydir {
|
||||||
my $self = shift;
|
my ($self, $transaction) = @_;
|
||||||
my ($dkim, $transaction) = @_;
|
|
||||||
|
my $domain = $transaction->sender->host;
|
||||||
|
my $dir = "config/dkim/$domain";
|
||||||
|
|
||||||
|
if ( -l $dir ) {
|
||||||
|
$dir = readlink($dir);
|
||||||
|
$dir = "config/dkim/$dir" if $dir !~ /\//; # no /, relative path
|
||||||
|
($domain) = (split /\//, $dir)[-1];
|
||||||
|
};
|
||||||
|
|
||||||
|
if ( ! -d $dir ) {
|
||||||
|
$self->log(LOGINFO, "skip, DKIM not configured for $domain");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if ( ! -r $dir ) {
|
||||||
|
$self->log(LOGINFO, "error, unable to read key from $dir");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if ( ! -r "$dir/private" ) {
|
||||||
|
$self->log(LOGINFO, "error, unable to read dkim key from $dir/private");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
return ($domain, $dir);
|
||||||
|
};
|
||||||
|
|
||||||
|
sub send_message_to_dkim {
|
||||||
|
my ($self, $dkim, $transaction) = @_;
|
||||||
|
|
||||||
foreach ( split ( /\n/s, $transaction->header->as_string ) ) {
|
foreach ( split ( /\n/s, $transaction->header->as_string ) ) {
|
||||||
$_ =~ s/\r?$//s;
|
$_ =~ s/\r?$//s;
|
||||||
@ -289,14 +415,12 @@ sub get_dkim_result {
|
|||||||
$transaction->body_resetpos;
|
$transaction->body_resetpos;
|
||||||
while (my $line = $transaction->body_getline) {
|
while (my $line = $transaction->body_getline) {
|
||||||
chomp $line;
|
chomp $line;
|
||||||
s/\015$//;
|
$line =~ s/\015$//;
|
||||||
eval { $dkim->PRINT($line . CRLF ); };
|
eval { $dkim->PRINT($line . CRLF ); };
|
||||||
$self->log(LOGERROR, $@ ) if $@;
|
$self->log(LOGERROR, $@ ) if $@;
|
||||||
};
|
};
|
||||||
|
|
||||||
$dkim->CLOSE;
|
$dkim->CLOSE;
|
||||||
|
|
||||||
return $dkim->result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sub get_policies {
|
sub get_policies {
|
||||||
@ -326,11 +450,25 @@ sub get_policy_results {
|
|||||||
return \%prs, \@policies;
|
return \%prs, \@policies;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sub get_selector {
|
||||||
|
my ($self, $keydir) = @_;
|
||||||
|
|
||||||
|
open my $SFH, '<', "$keydir/selector" or do {
|
||||||
|
$self->log(LOGINFO, "error, unable to read selector from $keydir/selector");
|
||||||
|
return DECLINED;
|
||||||
|
};
|
||||||
|
my $selector = <$SFH>;
|
||||||
|
chomp $selector;
|
||||||
|
close $SFH;
|
||||||
|
$self->log(LOGINFO, "info, selector: $selector");
|
||||||
|
return $selector;
|
||||||
|
};
|
||||||
|
|
||||||
sub add_header {
|
sub add_header {
|
||||||
my $self = shift;
|
my $self = shift;
|
||||||
my $header = shift or return;
|
my $header = shift or return;
|
||||||
|
|
||||||
# consider adding Authentication-Results header here as well
|
# consider adding Authentication-Results header, (RFC 5451)
|
||||||
$self->qp->transaction->header->add( 'X-DKIM-Authentication', $header, 0 );
|
$self->qp->transaction->header->add( 'X-DKIM-Authentication', $header, 0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user