From 4a45a9360b6af3edf3509c5533f7662ba9321e15 Mon Sep 17 00:00:00 2001 From: Kumi Date: Wed, 30 Mar 2022 13:04:59 +0200 Subject: [PATCH] Initial commit --- .gitignore | 5 ++++ classes/authenticator.py | 8 +++++ classes/config.py | 50 +++++++++++++++++++++++++++++++ classes/smtpdhandler.py | 39 ++++++++++++++++++++++++ classes/ssl.py | 64 ++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 ++ settings.ini | 6 ++++ worker.py | 33 +++++++++++++++++++++ 8 files changed, 207 insertions(+) create mode 100644 .gitignore create mode 100644 classes/authenticator.py create mode 100644 classes/config.py create mode 100644 classes/smtpdhandler.py create mode 100644 classes/ssl.py create mode 100644 requirements.txt create mode 100644 settings.ini create mode 100644 worker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0deb7f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +*.pyc +__pycache__/ +maildir/ +*.pem \ No newline at end of file diff --git a/classes/authenticator.py b/classes/authenticator.py new file mode 100644 index 0000000..76a3807 --- /dev/null +++ b/classes/authenticator.py @@ -0,0 +1,8 @@ +from aiosmtpd.smtp import AuthResult + +class Authenticator: + def __init__(self, config): + self.config = config + + def __call__(self, server, session, envelope, mechanism, auth_data): + return AuthResult(success=self.config.verify_password(auth_data.login.decode(), auth_data.password.decode()), handled=True) diff --git a/classes/config.py b/classes/config.py new file mode 100644 index 0000000..6fa15f7 --- /dev/null +++ b/classes/config.py @@ -0,0 +1,50 @@ +from configparser import ConfigParser +from socket import gethostname +from pathlib import Path + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHash + + +class Config: + def __init__(self, path): + self.config = ConfigParser() + self.path = path + self.config.read(path) + + self.hash_passwords() + + def hash_passwords(self): + hasher = PasswordHasher() + + for user, password in self.config.items("USERS"): + try: + hasher.check_needs_rehash(password) + except InvalidHash: + self.config["USERS"][user] = hasher.hash(password) + + with open(self.path, "w") as configfile: + self.config.write(configfile) + + def verify_password(self, user, password): + hasher = PasswordHasher() + + try: + hasher.verify(self.config["USERS"][user], password) + return True + except: + return False + + @property + def hostname(self): + return self.config.get("SERVER", "hostname", fallback=gethostname()) + + @property + def port(self): + return self.config.getint("SERVER", "port", fallback=8025) + + @property + def maildir(self): + path = self.config.get("SERVER", "maildir", fallback="maildir") + Path(path).mkdir(parents=True, exist_ok=True) + return path \ No newline at end of file diff --git a/classes/smtpdhandler.py b/classes/smtpdhandler.py new file mode 100644 index 0000000..1e6971d --- /dev/null +++ b/classes/smtpdhandler.py @@ -0,0 +1,39 @@ +import uuid +import json + +from datetime import datetime +from pathlib import Path + + +class SmtpdHandler: + def __init__(self, config): + self.config = config + + async def handle_MAIL(self, server, session, envelope, address, mail_options): + if session.authenticated: + envelope.mail_from = address + return('250 OK') + return('530 5.7.0 Authentication required') + + async def handle_DATA(self, server, session, envelope): + eid = uuid.uuid4() + + try: + with open(Path(self.config.maildir) / f"{eid}.eml", "wb") as mailfile: + mailfile.write( + f"From {envelope.mail_from} {datetime.now().isoformat()}\n".encode()) + mailfile.write( + f"Received: from {session.host_name} ({session.peer[0]}) by {server.hostname} (Kumi Systems FileMailer) id {eid}; {datetime.now().isoformat()}\n".encode()) + mailfile.write(envelope.original_content) + with open(Path(self.config.maildir) / f"{eid}.json", "w") as jsonfile: + data = { + "sender": envelope.mail_from, + "recipients": envelope.rcpt_tos + } + + json.dump(data, jsonfile) + + return('250 Message accepted for delivery') + + except: + return('451 Requested action aborted: local error in processing') diff --git a/classes/ssl.py b/classes/ssl.py new file mode 100644 index 0000000..5ff40b7 --- /dev/null +++ b/classes/ssl.py @@ -0,0 +1,64 @@ +from OpenSSL import crypto + +import ssl +import tempfile + +from datetime import datetime + + +class SSL: + def __init__(self, hostname=None, email=None, country=None, locality=None, state=None, org=None, orgunit=None, validity=10*365*60*60*24, bits=4096): + self.cn = hostname or "localhost" + self.email = email or ("filemailer@%s" % (hostname or "localhost")) + self.country = country or "AT" + self.locality = locality or "Graz" + self.state = state or "Steiermark" + self.org = org or "Kumi Systems e.U." + self.orgunit = orgunit or "FileMailer" + self.validity = validity + self.bits = bits + + def makeCert(self): + k = crypto.PKey() + k.generate_key(crypto.TYPE_RSA, self.bits) + + cert = crypto.X509() + cert.get_subject().C = self.country + cert.get_subject().ST = self.state + cert.get_subject().L = self.locality + cert.get_subject().O = self.org + cert.get_subject().OU = self.orgunit + cert.get_subject().CN = self.cn + cert.get_subject().emailAddress = self.email + cert.set_serial_number(int(datetime.now().timestamp())) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(self.validity) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(k) + cert.sign(k, 'sha512') + + return cert, k + + def makeContext(self): + cert, k = self.makeCert() + + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + + with tempfile.NamedTemporaryFile() as certfile, tempfile.NamedTemporaryFile() as keyfile: + certdump = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) + certfile.write(certdump) + certfile.flush() + + keydump = crypto.dump_privatekey(crypto.FILETYPE_PEM, k) + keyfile.write(keydump) + keyfile.flush() + + context.load_cert_chain(certfile.name, keyfile.name) + + return context + + @staticmethod + def makeContextFromFiles(certfile="cert.pem", keyfile="key.pem"): + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile, keyfile) + return context \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2014cb0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiosmtpd +argon2-cffi \ No newline at end of file diff --git a/settings.ini b/settings.ini new file mode 100644 index 0000000..a14bb1d --- /dev/null +++ b/settings.ini @@ -0,0 +1,6 @@ +[SERVER] +maildir = maildir + +[USERS] +test = $argon2id$v=19$m=65536,t=3,p=4$0ZfYHQjV5IlHxtPqKP5O7A$LZ/vfXP1QoymVaPwwhH/0+FOK+Ek5fwr7YC98/E402A + diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..e883d82 --- /dev/null +++ b/worker.py @@ -0,0 +1,33 @@ +from aiosmtpd.controller import Controller as SmtpdController +from aiosmtpd.smtp import AuthResult + +import asyncio +import logging + +from classes.smtpdhandler import SmtpdHandler +from classes.config import Config +from classes.authenticator import Authenticator +from classes.ssl import SSL + +if __name__ == "__main__": + log = logging.basicConfig() + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + config = Config("settings.ini") + authenticator = Authenticator(config) + handler = SmtpdHandler(config) + + smtpd = SmtpdController(handler, hostname=config.hostname, + port=config.port, authenticator=authenticator, + ident="Kumi Systems FileMailer", + auth_require_tls=False) + + smtpd.start() + + try: + loop.run_forever() + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close()