first working signing version

Signed-off-by: Florian Brandes <florian.brandes@posteo.de>
This commit is contained in:
2024-07-05 11:41:02 +02:00
parent 3144db3613
commit f4e6356792

View File

@@ -18,13 +18,22 @@ from email import message_from_bytes, message_from_string
from email.message import Message from email.message import Message
from email.policy import default as EmailMessagePolicy from email.policy import default as EmailMessagePolicy
from locale import LC_ALL, setlocale from locale import LC_ALL, setlocale
from pathlib import Path
from typing import List, Optional, Set, Tuple, Union from typing import List, Optional, Set, Tuple, Union
import envelope
from aiosmtpd.controller import SMTP as Server from aiosmtpd.controller import SMTP as Server
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
from aiosmtpd.smtp import Envelope, Session from aiosmtpd.smtp import Envelope, Session
from aiosmtplib import SMTP, SMTPException, SMTPRecipientRefused, SMTPRecipientsRefused 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) @dataclass(frozen=True)
@@ -44,6 +53,9 @@ class ClientConfig:
set_reply_to: bool set_reply_to: bool
use_tls: bool use_tls: bool
start_tls: bool start_tls: bool
smime_cert: str
smime_cert_private: str
smime_to_cert: str
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -52,7 +64,7 @@ class Config:
client: ClientConfig client: ClientConfig
@classmethod @classmethod
def _from_config(cls, config: configparser.ConfigParser) -> "Config": def _from_config(cls, config: configparser.RawConfigParser) -> "Config":
return cls( return cls(
server=ServerConfig( server=ServerConfig(
hostname=config.get("server", "hostname", fallback="localhost"), hostname=config.get("server", "hostname", fallback="localhost"),
@@ -72,6 +84,11 @@ class Config:
), ),
use_tls=config.getboolean("client", "use_tls", fallback=True), use_tls=config.getboolean("client", "use_tls", fallback=True),
start_tls=config.getboolean("client", "start_tls", fallback=False), 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, 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( async def _send_message(
self, message: Message, sender: str, recipients: List[str] self, message: bytes, sender: str, recipients: List[str]
) -> None: ) -> 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( failed_recipients: Set[str] = set(
(await self.send_message(message, sender=sender, recipients=recipients))[ (
0 await self.sendmail(
].keys() sender,
recipients,
message,
)
)[0].keys()
) )
if len(failed_recipients): # raise also when not all have been refused if len(failed_recipients): # raise also when not all have been refused
raise SMTPRecipientsRefused( raise SMTPRecipientsRefused(
@@ -120,21 +192,14 @@ class SMTPClient(SMTP):
del message["Reply-To"] del message["Reply-To"]
message["Original-Sender"] = sender if sender is not None else "" message["Original-Sender"] = sender if sender is not None else ""
message["Original-Recipient"] = ", ".join(recipients) 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( print(
f"{message['From']} ({message['Original-Sender']}) -> " f"{self._config.sender} ({message['Original-Sender']}) -> "
f"{message['To']} ({message['Original-Recipient']}): " f"{', '.join(self._config.recipients)} ({message['Original-Recipient']}): "
f"'{message.get('Subject', '')}'" 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 async with self._lock: # TODO: consumer task from spool queue, reusing connections
try: try:
await self.connect() await self.connect()