From f4e6356792f91f6daa6f8cd0918f0dc6af3074b8 Mon Sep 17 00:00:00 2001 From: Florian Brandes Date: Fri, 5 Jul 2024 11:41:02 +0200 Subject: [PATCH] first working signing version Signed-off-by: Florian Brandes --- smtprd.py | 99 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/smtprd.py b/smtprd.py index a6fc778..02f919c 100644 --- a/smtprd.py +++ b/smtprd.py @@ -18,13 +18,22 @@ from email import message_from_bytes, message_from_string from email.message import Message from email.policy import default as EmailMessagePolicy from locale import LC_ALL, setlocale +from pathlib import Path from typing import List, Optional, Set, Tuple, Union -import envelope from aiosmtpd.controller import SMTP as Server from aiosmtpd.controller import Controller from aiosmtpd.smtp import Envelope, Session from aiosmtplib import SMTP, SMTPException, SMTPRecipientRefused, SMTPRecipientsRefused +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.serialization import ( + Encoding, + load_pem_private_key, + pkcs7, +) +from cryptography.x509 import load_pem_x509_certificate +from envelope import Envelope @dataclass(frozen=True) @@ -44,6 +53,9 @@ class ClientConfig: set_reply_to: bool use_tls: bool start_tls: bool + smime_cert: str + smime_cert_private: str + smime_to_cert: str @dataclass(frozen=True) @@ -52,7 +64,7 @@ class Config: client: ClientConfig @classmethod - def _from_config(cls, config: configparser.ConfigParser) -> "Config": + def _from_config(cls, config: configparser.RawConfigParser) -> "Config": return cls( server=ServerConfig( hostname=config.get("server", "hostname", fallback="localhost"), @@ -72,6 +84,11 @@ class Config: ), use_tls=config.getboolean("client", "use_tls", fallback=True), start_tls=config.getboolean("client", "start_tls", fallback=False), + smime_cert=config.get("client", "smime_cert", fallback=""), + smime_cert_private=config.get( + "client", "smime_cert_private", fallback="" + ), + smime_to_cert=config.get("client", "smime_to_cert", fallback=""), ), ) @@ -99,13 +116,68 @@ class SMTPClient(SMTP): start_tls=self._config.start_tls, ) + def _encrypt_and_sign(self, message: Message, recipients: List[str]) -> Message: + # Currently does NOT work + new_message = ( + Envelope() + .smime() + .load(message) + .sign( + key=Path(self._config.smime_cert_private), + cert=Path(self._config.smime_cert), + ) + .as_message() + ) + new_message["Original-Sender"] = message["Original-Sender"] + new_message["Original-Recipient"] = message["Original-Recipient"] + new_message["From"] = message["From"] + new_message["To"] = message["To"] + return new_message + + def _sign(self, message: Message) -> bytes: + """Sign the message + + This function signs the message. All existing headers will be moved + inside the signature boundary. + Args: + message (Message): message object + + Returns: + bytes: The signed message including the From and To Header + """ + with open(Path(self._config.smime_cert_private), "rb") as key_data: + key = load_pem_private_key(key_data.read(), password=None) + with open(self._config.smime_cert, "rb") as cert_data: + cert = load_pem_x509_certificate(cert_data.read()) + + # sign + output = ( + pkcs7.PKCS7SignatureBuilder() + .set_data(message.as_bytes()) + .add_signer(cert, key, hashes.SHA512(), rsa_padding=padding.PKCS1v15()) + .sign(Encoding.SMIME, [pkcs7.PKCS7Options.DetachedSignature]) + ) + # Add correct headers + new = b"From: " + self._config.sender.encode() + b"\r\n" + output + new = b"To: " + ", ".join(self._config.recipients).encode() + b"\r\n" + new + new = b"Subject: " + message.get("Subject", "").encode() + b"\r\n" + new + return new + async def _send_message( - self, message: Message, sender: str, recipients: List[str] + self, message: bytes, sender: str, recipients: List[str] ) -> None: + # if we use send_message, it expects a message object + # This will add a newline after the signing process. The message object is fine + # but as soon as _as_bytes or _as_string is called, it will add a new line between the boundary of the + # signed message, which will ruin the signature failed_recipients: Set[str] = set( - (await self.send_message(message, sender=sender, recipients=recipients))[ - 0 - ].keys() + ( + await self.sendmail( + sender, + recipients, + message, + ) + )[0].keys() ) if len(failed_recipients): # raise also when not all have been refused raise SMTPRecipientsRefused( @@ -120,21 +192,14 @@ class SMTPClient(SMTP): del message["Reply-To"] message["Original-Sender"] = sender if sender is not None else "" message["Original-Recipient"] = ", ".join(recipients) - message["From"] = self._config.sender - message["To"] = ", ".join(self._config.recipients) - if ( - self._config.set_reply_to - and sender is not None - and sender != self._config.sender - ): - message["Reply-To"] = sender print( - f"{message['From']} ({message['Original-Sender']}) -> " - f"{message['To']} ({message['Original-Recipient']}): " + f"{self._config.sender} ({message['Original-Sender']}) -> " + f"{', '.join(self._config.recipients)} ({message['Original-Recipient']}): " f"'{message.get('Subject', '')}'" ) - + if self._config.smime_cert: + message = self._sign(message) async with self._lock: # TODO: consumer task from spool queue, reusing connections try: await self.connect()