From 6c9acca8ab54cc1e34812c542ed3a23666383275 Mon Sep 17 00:00:00 2001 From: gpatel-fr <44170243+gpatel-fr@users.noreply.github.com> Date: Mon, 14 Oct 2019 00:03:02 +0200 Subject: [PATCH] add dkim client capability --- setup.py | 3 ++ slimta/policy/headers.py | 72 +++++++++++++++++++++++++++++- test/requirements.txt | 1 + test/test_slimta_policy_headers.py | 59 +++++++++++++++++++++++- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index a96468ba..65091fc3 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,9 @@ 'pysasl >= 0.4.0', 'pycares < 3.0.0; python_version < "3.0"', 'pycares >= 1; python_version >= "3.0"'], + extras_require={ + 'dkim': ['dkimpy >= 0.9.0'], + }, classifiers=['Development Status :: 3 - Alpha', 'Topic :: Communications :: Email :: Mail Transport Agents', 'Intended Audience :: Developers', diff --git a/slimta/policy/headers.py b/slimta/policy/headers.py index fe89911c..d038f735 100644 --- a/slimta/policy/headers.py +++ b/slimta/policy/headers.py @@ -30,11 +30,19 @@ from time import strftime, gmtime, localtime from math import floor from socket import getfqdn +import logging +import slimta.logging from slimta.core import __version__ as VERSION from . import QueuePolicy +try: + from dkim import dkim_sign, DKIMException +except ImportError: + dkim_sign = None -__all__ = ['AddDateHeader', 'AddMessageIdHeader', 'AddReceivedHeader'] + +__all__ = ['AddDateHeader', 'AddMessageIdHeader', 'AddReceivedHeader', + 'AddDKIMHeader'] class AddDateHeader(QueuePolicy): @@ -132,4 +140,66 @@ def apply(self, envelope): envelope.prepend_header('Received', data) +class AddDKIMHeader(QueuePolicy): + """Adds a Domain Key Identified Mail header + will by default sign all the headers (except the ones marked + as SHOULD NOT SIGN as stated in dkimpy doc) + if this is not the last header added, the following ones + will not be signed. + :param dkim: Dict of dicts indexed by domain (example.com) + - privkey: private key: PEM file loaded in a string + - selector: selector setup in DNS for the domain + - signature_algorithm: (default rsa-sha256) + - include-headers: headers to sign (by default, all + except the ones marked as SHOULD NOT SIGN see + dkimpy doc) + Refs: + https://www.dkim.org + https://gathman.org/pydkim/ + https://launchpad.net/dkimpy + """ + + def __init__(self, dkim): + if not dkim_sign: + raise ImportError('dkimpy is not installed') + self.dkim = dkim + self.logger = logging.getLogger(__name__) + + def apply(self, envelope): + dom = envelope.sender + if not dom: + return # no warn if sending deliv. fail notif. + if '@' not in dom: + slimta.logging.logline(self.logger.error, __name__, id(self), + 'DKIM: invalid sender', **dict(sender=dom)) + return + dom = dom.split('@')[1] + if dom not in self.dkim: + slimta.logging.logline(self.logger.debug, __name__, id(self), + "DKIM: domain :'" + dom + + "' is not setup, ignore") + return + domk = self.dkim[dom] + pk = domk['privkey'].encode('utf-8') + sel = domk['selector'].encode('utf-8') + algo = domk['signature_algorithm'] or 'rsa-sha256' + algo = algo.encode('utf-8') + flds = domk['include_headers'] + if flds: + flds = [x.encode('utf-8') for x in flds] + dom = dom.encode('utf-8') + try: + h, m = envelope.flatten() + wm = h + m + hd = dkim_sign(message=wm, selector=sel, domain=dom, privkey=pk, + signature_algorithm=algo, include_headers=flds) + except DKIMException as e: + slimta.logging.logline(self.logger.error, __name__, id(self), + 'DKIM: exception:' + str(e), + **dict(sender=envelope.sender, domain=dom)) + return + data = hd.replace(b'\r\n', b'').decode('utf-8').split(':', 1)[1] + envelope.prepend_header('DKIM-Signature', data) # RFC 6376 par. 5.6 + + # vim:et:fdm=marker:sts=4:sw=4:ts=4 diff --git a/test/requirements.txt b/test/requirements.txt index 32baf50a..36a604a9 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -4,3 +4,4 @@ mox3 testfixtures flake8 twine +dkimpy >= 0.9.0 diff --git a/test/test_slimta_policy_headers.py b/test/test_slimta_policy_headers.py index 93005c63..38819dbc 100644 --- a/test/test_slimta_policy_headers.py +++ b/test/test_slimta_policy_headers.py @@ -1,11 +1,14 @@ +from mox3.mox import MoxTestBase, IsA, IgnoreArg import unittest +import logging +import slimta.logging from slimta.policy.headers import AddDateHeader, AddMessageIdHeader, \ - AddReceivedHeader + AddReceivedHeader, AddDKIMHeader from slimta.envelope import Envelope -class TestPolicyHeaders(unittest.TestCase): +class TestPolicyHeaders(MoxTestBase, unittest.TestCase): def test_add_date_header(self): env = Envelope() @@ -63,5 +66,57 @@ def test_add_received_header_prepended(self): AddReceivedHeader().apply(env) self.assertEqual(['Received', 'From'], env.headers.keys()) + def test_add_dkim_header(self): + log = [] + def set_log(a,b,c,d, **kwargs): + log.append(d) + self.mox.StubOutWithMock(slimta.logging, 'logline') + slimta.logging.logline(IgnoreArg(), IsA(str), IsA(int), IsA(str), + sender='sender').WithSideEffects(set_log) + slimta.logging.logline(IgnoreArg(), IsA(str), IsA(int), + IsA(str)).WithSideEffects(set_log) + slimta.logging.logline(IgnoreArg(), IsA(str), IsA(int), + IsA(str), domain=b'example.com', + sender='sender@example.com').WithSideEffects(set_log) + self.mox.ReplayAll() + env = Envelope('sender', ['rcpt@example.com']) + env.parse(b'From: test@example.com\r\n') + dkim = {} + AddDKIMHeader(dkim).apply(env) + self.assertFalse(env.headers['DKIM-Signature']) + self.assertTrue(log[0] == "DKIM: invalid sender") + env = Envelope('sender@example.com', ['rcpt@example.com']) + env.parse(b'From: test@example.com\r\n') + AddDKIMHeader(dkim).apply(env) + self.assertFalse(env.headers['DKIM-Signature']) + self.assertTrue(log[1] == + "DKIM: domain :'example.com' is not setup, ignore") + dkim['example.com'] = {'privkey': 'bad', 'selector': 'sel', + 'signature_algorithm': 'rsa-sha1', + 'include_headers': ['from','subject'] } + AddDKIMHeader(dkim).apply(env) + self.assertFalse(env.headers['DKIM-Signature']) + self.assertTrue(log[2] == "DKIM: exception:Private key not found") + pk = """ +-----BEGIN RSA PRIVATE KEY----- +MIIBOwIBAAJBAMhmwtECnKod9ywj8KcK308anyuS2iglAAoaAsibaduk0TTZX/sG +wOAwwh71jsrdMIMDKGAnOn7ikYSVfxvFUQsCAwEAAQJAVWVsyRIa3mcsh9O83gHF +DPlkMHZAnnC95pAU9ZU8c8qzGolDz2h3g+3py09L2dNN1KrmHgjs706OuKznTK3C +KQIhAPocPlKGOQvM5t1Iv7kU2dkMsDso5iMLWJ7si5zTAM7/AiEAzR7aoUhJiFaD +gm35ak2QzAk99H6uZXL5pPCvQJ+HyfUCIQCxTXJU2Df6iJAk0JyxXPmuJ5OK7Mxw +jWuOhgvW6bIKCwIhAJ7RT+hmpwCYM7TuX5puOjmwwjanS3KjRiXucVMw8httAiBV +LB94QlyDoRo6NOTbRIU1quGV/G3jufSl5hgqwuibQw== +-----END RSA PRIVATE KEY----- +""" + dkim['example.com'] = { 'privkey': pk, 'selector': 'sel', + 'signature_algorithm': 'rsa-sha1', + 'include_headers': ['from','subject'] } + AddDKIMHeader(dkim).apply(env) + self.assertTrue(env.headers['DKIM-Signature']) + sighd = env.headers['DKIM-Signature'] + self.assertIn('a=rsa-sha1', sighd) + self.assertIn('d=example.com', sighd) + self.assertIn('s=sel', sighd) + # vim:et:fdm=marker:sts=4:sw=4:ts=4