[pmg-devel] [PATCH pmg-api 05/12] add PMG::DKIMSign module

Stoiko Ivanov s.ivanov at proxmox.com
Mon Oct 7 21:28:49 CEST 2019


the module serves 2 purposes:
* it extends Mail::DKIM::Signer:
  * it provides a glue layer between MIME::Entity's output method (which
    expects print and uses \n as line terminator) and Mail::DKIM::Signer's
    PRINT method (which expects \r\n)
  * it infers the domain which should be used for signing based on the
    sender's e-mail address (and the DKIM-settings in PMG-configuration)

* it provides helper methods for generating a new private key, outputting a
  DKIM TXT record and for finding the domain which should be used for signing
  a mail

certain headers get oversigned (in order to prevent adding a previously
non-existing header (e.g. Reply-To) and retaining a valid signature).
the list of headers which to oversign is inspired by rspamd's choice [0].
for rationale see [1,2,3]

[0] https://rspamd.com/doc/modules/dkim_signing.html#sign-headers
[1] https://noxxi.de/research/breaking-dkim-on-purpose-and-by-chance.html
[2] https://github.com/rspamd/rspamd/issues/2136

Signed-off-by: Stoiko Ivanov <s.ivanov at proxmox.com>
---
 src/Makefile        |   1 +
 src/PMG/DKIMSign.pm | 150 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 151 insertions(+)
 create mode 100644 src/PMG/DKIMSign.pm

diff --git a/src/Makefile b/src/Makefile
index c864ae8..b03a27b 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -73,6 +73,7 @@ LIBSOURCES =				\
 	PMG/LDAPSet.pm			\
 	PMG/LDAPCache.pm		\
 	PMG/DBTools.pm			\
+	PMG/DKIMSign.pm			\
 	PMG/Quarantine.pm		\
 	PMG/Report.pm			\
 	PMG/RuleDB/Group.pm		\
diff --git a/src/PMG/DKIMSign.pm b/src/PMG/DKIMSign.pm
new file mode 100644
index 0000000..fa61101
--- /dev/null
+++ b/src/PMG/DKIMSign.pm
@@ -0,0 +1,150 @@
+package PMG::DKIMSign;
+
+use strict;
+use warnings;
+use Mail::DKIM::Signer;
+use Mail::DKIM::TextWrap;
+use Crypt::OpenSSL::RSA;
+
+use PVE::Tools;
+use PVE::INotify;
+use PVE::SafeSyslog;
+
+use PMG::Utils;
+use PMG::Config;
+use base qw(Mail::DKIM::Signer);
+
+sub new {
+    my ($class, $selector, $sign_all) = @_;
+
+    die "no selector provided\n" if ! $selector;
+
+    my %opts = (
+	Algorithm => 'rsa-sha256',
+	Method => 'relaxed/relaxed',
+	Selector => $selector,
+	KeyFile => "/etc/pmg/dkim/$selector.private",
+    );
+
+    my $self = $class->SUPER::new(%opts);
+
+    # oversign certain headers
+    $self->extended_headers({
+	From => '+',
+	To => '+',
+	CC => '+',
+	'Reply-To' => '+',
+	Subject => '+',
+    });
+
+    $self->{sign_all} = $sign_all;
+
+    return $self;
+}
+
+# MIME::Entity can output to all objects responding to 'read' (and does so in
+# chunks) Mail::DKIM::Signer has a 'READ' method and expects each line
+# terminated with "\r\n"
+
+
+sub print {
+    my ($self, $line) = @_;
+    $line =~ s/\012/\015\012/g;
+    $self->PRINT($line);
+}
+
+sub create_signature {
+    my ($self) = @_;
+
+    $self->CLOSE();
+    return $self->signature->as_string();
+}
+
+#determines which domain should be used for signing based on the e-mailaddress
+sub signing_domain {
+    my ($self, $sender_email) = @_;
+
+    my $input_domain;
+    if ( $sender_email =~ m/@([a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)*)$/ ) {
+	$input_domain = $1;
+    }
+
+    if ($self->{sign_all}) {
+	    $self->domain($input_domain) if $self->{sign_all};
+	    return;
+    }
+
+    # check that input_domain is in/a subdomain of in the
+    # dkimdomains, falling back to the relay domains.
+    my $dkimdomains = PVE::INotify::read_file('dkimdomains');
+    $dkimdomains = PVE::INotify::read_file('domains') if !scalar(%$dkimdomains);
+
+    foreach my $domain (sort keys %$dkimdomains) {
+	if ( $input_domain =~ /\Q$domain\E$/ ) {
+	    $self->domain($domain);
+	    return;
+	}
+    }
+
+    syslog('info', "not DKIM signing mail from $sender_email");
+
+    return;
+}
+
+# key-handling and utility methods
+sub get_selector_info {
+    my ($selector) = @_;
+
+    die "no selector provided\n" if !defined($selector);
+    my ($pubkey, $size);
+    eval {
+	my $privkeytext = PVE::Tools::file_get_contents("/etc/pmg/dkim/$selector.private");
+	my $privkey =  Crypt::OpenSSL::RSA->new_private_key($privkeytext);
+	$size = $privkey->size() * 8;
+
+	$pubkey = $privkey->get_public_key_x509_string();
+    };
+    die "$@\n" if $@;
+
+    $pubkey =~ s/-----(?:BEGIN|END) PUBLIC KEY-----//g;
+    $pubkey =~ s/\v//mg;
+
+    # split record into 250 byte chunks for DNS-server compatibility
+    # see opendkim-genkey
+    my $record = qq{$selector._domainkey\tIN\tTXT\t( "v=DKIM1; h=sha256; k=rsa; "\n\t  "p=};
+    my $len = length($pubkey);
+    my $cur = 0;
+    while ($len > 0) {
+	if ($len < 250) {
+	    $record .= substr($pubkey, $cur);
+	    $len = 0;
+	} else {
+	    $record .= substr($pubkey, $cur, 250) . qq{"\n\t  "};
+	    $cur += 250;
+	    $len -= 250;
+	}
+    }
+    $record .= qq{" )  ; ----- DKIM key $selector};
+
+    return ($record, $size);
+}
+
+sub set_selector {
+    my ($selector, $keysize) = @_;
+
+    die "no selector provided\n" if !defined($selector);
+    die "no keysize provided\n" if !defined($keysize);
+    die "invalid keysize\n" if ($keysize != 1024 && $keysize!= 2048 && $keysize != 4096);;
+    my $privkey_file = "/etc/pmg/dkim/$selector.private";
+
+    eval {
+	my $cmd = ['openssl', 'genrsa', '-out', $privkey_file, $keysize];
+	PMG::Utils::run_silent_cmd($cmd);
+	my $cfg = PMG::Config->new();
+	$cfg->set('admin', 'dkim_selector', $selector);
+	$cfg->write();
+	PMG::Utils::reload_smtp_filter();
+    };
+    die "unable to generate dkim key for $selector ($keysize bits): $@\n" if $@;
+}
+1;
-- 
2.20.1




More information about the pmg-devel mailing list