From 8b5d9243219f4cdd232d141243041909d6da4df3 Mon Sep 17 00:00:00 2001 From: "Piotr F. Mieszkowski" Date: Fri, 12 Jul 2024 10:40:38 +0200 Subject: [PATCH] Implement LazyMessage, a wrapper for original contents We want to avoid deserialising message contents, because Python's email module might produce different representation than the MUA sending original message. The result would be a transformed message, which could mean broken message in certain conditions. --- lacre.py | 21 ++++++++++++++------- lacre/core.py | 36 +++++++++++++++++++++++++++--------- lacre/daemon.py | 12 +++++++----- lacre/lazymessage.py | 33 +++++++++++++++++++++++++++++++++ lacre/mailop.py | 21 ++++++++++----------- lacre/repositories.py | 8 ++++++++ 6 files changed, 99 insertions(+), 32 deletions(-) create mode 100644 lacre/lazymessage.py diff --git a/lacre.py b/lacre.py index 31b4b66..d30b1b6 100755 --- a/lacre.py +++ b/lacre.py @@ -32,6 +32,7 @@ lacre.init_logging(conf.get_item('logging', 'config')) # This has to be executed *after* logging initialisation. import lacre.core as core +from lacre.lazymessage import LazyMessage LOG = logging.getLogger('lacre.py') @@ -45,14 +46,19 @@ def main(): sys.exit(lacre.EX_CONFIG) delivered = False - try: - # Read e-mail from stdin, parse it - raw = sys.stdin.read() - raw_message = email.message_from_string(raw, policy=SMTPUTF8) - from_addr = raw_message['From'] - # Read recipients from the command-line - to_addrs = sys.argv[1:] + raw_message = None + # Read recipients from the command-line + to_addrs = sys.argv[1:] + + # Read e-mail from stdin, parse it + raw = sys.stdin.read() + raw_message = email.message_from_string(raw, policy=SMTPUTF8) + from_addr = raw_message['From'] + + lmessage = LazyMessage(to_addrs, lambda: raw_message) + + try: # Let's start core.deliver_message(raw_message, from_addr, to_addrs) delivered = True @@ -64,6 +70,7 @@ def main(): # some silly message-encoding issue that shouldn't bounce the # message, we just try recoding the message body and delivering it. try: + from_addr = raw_message['From'] core.failover_delivery(raw_message, to_addrs, from_addr) except: LOG.exception('Failover delivery failed too') diff --git a/lacre/core.py b/lacre/core.py index 7ae040a..1239eb3 100644 --- a/lacre/core.py +++ b/lacre/core.py @@ -41,6 +41,7 @@ import lacre.recipients as recpt import lacre.smime as smime from lacre.transport import send_msg, register_sender, SendFrom from lacre.mailop import KeepIntact, InlineOpenPGPEncrypt, MimeOpenPGPEncrypt, MailSerialisationException +from lacre.lazymessage import LazyMessage LOG = logging.getLogger(__name__) @@ -117,7 +118,10 @@ def _sort_gpg_recipients(gpg_to) -> Tuple[recpt.RecipientList, recpt.RecipientLi return mime, inline -def _gpg_encrypt_copy(message: EmailMessage, keys, recipients, encrypt_f): +def _gpg_encrypt_copy(message: EmailMessage, keys, recipients, encrypt_f, lmessage: LazyMessage = None) -> EmailMessage: + if lmessage: + message = lmessage.get_message() + msg_copy = copy.deepcopy(message) _customise_headers(msg_copy) encrypted_payloads = encrypt_f(msg_copy, keys) @@ -125,8 +129,8 @@ def _gpg_encrypt_copy(message: EmailMessage, keys, recipients, encrypt_f): return msg_copy -def _gpg_encrypt_to_bytes(message: EmailMessage, keys, recipients, encrypt_f) -> bytes: - msg_copy = _gpg_encrypt_copy(message, keys, recipients, encrypt_f) +def _gpg_encrypt_to_bytes(message: EmailMessage, keys, recipients, encrypt_f, lmessage) -> bytes: + msg_copy = _gpg_encrypt_copy(message, keys, recipients, encrypt_f, lmessage) try: return msg_copy.as_bytes(policy=SMTPUTF8) except IndexError: @@ -148,7 +152,9 @@ def _customise_headers(message: EmailMessage): message['X-Lacre'] = 'Encrypted by Lacre' -def _encrypt_all_payloads_inline(message: EmailMessage, gpg_to_cmdline): +def _encrypt_all_payloads_inline(message: EmailMessage, gpg_to_cmdline, lmessage: LazyMessage = None): + if lmessage: + message = lmessage.get_message() # This breaks cascaded MIME messages. Blame PGP/INLINE. encrypted_payloads = list() @@ -164,7 +170,7 @@ def _encrypt_all_payloads_inline(message: EmailMessage, gpg_to_cmdline): return encrypted_payloads -def _encrypt_all_payloads_mime(message: EmailMessage, gpg_to_cmdline): +def _encrypt_all_payloads_mime(message: EmailMessage, gpg_to_cmdline, lmessage: LazyMessage = None): # Convert a plain text email into PGP/MIME attachment style. Modeled after enigmail. pgp_ver_part = MIMEPart() pgp_ver_part.set_content('Version: 1' + text.EOL_S) @@ -178,6 +184,9 @@ def _encrypt_all_payloads_mime(message: EmailMessage, gpg_to_cmdline): encrypted_part.set_param('inline', "", 'Content-Disposition') encrypted_part.set_param('filename', "encrypted.asc", 'Content-Disposition') + if lmessage: + message = lmessage.get_message() + message.preamble = "This is an OpenPGP/MIME encrypted message (RFC 2440 and 3156)" boundary = _make_boundary() @@ -201,12 +210,14 @@ def _encrypt_all_payloads_mime(message: EmailMessage, gpg_to_cmdline): return [pgp_ver_part, _encrypt_payload(encrypted_part, gpg_to_cmdline, check_nested)] -def _rewrap_payload(message: EmailMessage) -> MIMEPart: +def _rewrap_payload(message: EmailMessage, lmessage: LazyMessage = None) -> MIMEPart: # In PGP/MIME (RFC 3156), the payload has to be a valid MIME entity. In # other words, we need to wrap text/* message's payload in a new MIME # entity. wrapper = MIMEPart(policy=SMTPUTF8) + if lmessage: + message = lmessage.get_message() content = message.get_content() wrapper.set_content(content) @@ -234,7 +245,9 @@ def _set_type_and_boundary(message: EmailMessage, boundary): message.set_param('boundary', boundary) -def _encrypt_payload(payload: EmailMessage, recipients, check_nested=True, **kwargs): +def _encrypt_payload(payload: EmailMessage, recipients, check_nested=True, lmessage: LazyMessage = None, **kwargs): + if lmessage: + payload = lmessage.get_message() raw_payload = payload.get_payload(decode=True) LOG.debug('About to encrypt raw payload: %s', raw_payload) LOG.debug('Original message: %s', payload) @@ -331,7 +344,9 @@ def failover_delivery(message: EmailMessage, recipients, from_address): LOG.warning('No failover strategy, giving up') -def _is_encrypted(raw_message: EmailMessage): +def _is_encrypted(raw_message: EmailMessage, lmessage: LazyMessage = None): + if lmessage: + raw_message = lmessage.get_message() if raw_message.get_content_type() == 'multipart/encrypted': return True @@ -342,8 +357,11 @@ def _is_encrypted(raw_message: EmailMessage): return text.is_message_pgp_inline(first_part) -def delivery_plan(recipients, message: EmailMessage, key_cache: kcache.KeyCache): +def delivery_plan(recipients, message: EmailMessage, key_cache: kcache.KeyCache, lmessage: LazyMessage = None): """Generate a sequence of delivery strategies.""" + if lmessage: + message = lmessage.get_message() + if _is_encrypted(message): LOG.debug('Message is already encrypted: %s', message) return [KeepIntact(recipients)] diff --git a/lacre/daemon.py b/lacre/daemon.py index d549de5..137b890 100644 --- a/lacre/daemon.py +++ b/lacre/daemon.py @@ -23,6 +23,7 @@ import lacre.core as gate import lacre.keyring as kcache import lacre.transport as xport from lacre.mailop import KeepIntact, MailSerialisationException +from lacre.lazymessage import LazyMessage class MailEncryptionProxy: @@ -37,16 +38,17 @@ class MailEncryptionProxy: with time_logger('Message delivery', LOG): try: keys = self._keyring.freeze_identities() + lmessage = LazyMessage(envelope.rcpt_tos, lambda: envelope.original_content) message = email.message_from_bytes(envelope.original_content, policy=SMTPUTF8) if message.defects: LOG.warning("Issues found: %s", repr(message.defects)) send = xport.SendFrom(envelope.mail_from) - for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys): + for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys, lmessage): LOG.debug(f"Sending mail via {operation!r}") try: - new_message = operation.perform(message) + new_message = operation.perform(message, lmessage) send(new_message, operation.recipients()) except (EncryptionException, MailSerialisationException) as e: # If the message can't be encrypted or serialised to a @@ -57,7 +59,7 @@ class MailEncryptionProxy: except xport.TransientFailure: LOG.info('Bouncing message') - return xport.RESULT_ABORT + return xport.RESULT_TRANS_FAIL except xport.PermanentFailure: LOG.exception('Permanent failure') @@ -69,11 +71,11 @@ class MailEncryptionProxy: else: LOG.exception('Unexpected exception caught, bouncing message') - return xport.RESULT_ABORT + return xport.RESULT_TRANS_FAIL return xport.RESULT_OK - def _send_unencrypted(self, operation, envelope, send: xport.SendFrom): + def _send_unencrypted(self, operation, envelope: Envelope, send: xport.SendFrom): # Do not parse and re-generate the message, just send it as it is. send(envelope.original_content, operation.recipients()) diff --git a/lacre/lazymessage.py b/lacre/lazymessage.py new file mode 100644 index 0000000..4770b54 --- /dev/null +++ b/lacre/lazymessage.py @@ -0,0 +1,33 @@ +from aiosmtpd.smtp import Envelope +from email import message_from_bytes +from email.message import EmailMessage +from email.parser import BytesHeaderParser +from email.policy import SMTPUTF8 + +class LazyMessage: + def __init__(self, recipients, content_provider): + self._content_provider = content_provider + self._recipients = recipients + self._headers = None + self._message = None + + def get_original_content(self) -> bytes: + return self._content_provider() + + def get_recipients(self): + return self._recipients + + def get_headers(self) -> EmailMessage: + if self._message: + return self._message + + if not self._headers: + self._headers = BytesHeaderParser(policy=SMTPUTF8).parsebytes(self.get_original_content()) + + return self._headers + + def get_message(self) -> EmailMessage: + if not self._message: + self._message = message_from_bytes(self.get_original_content(), policy=SMTPUTF8) + + return self._message diff --git a/lacre/mailop.py b/lacre/mailop.py index c3abae4..9187335 100644 --- a/lacre/mailop.py +++ b/lacre/mailop.py @@ -15,6 +15,7 @@ There are 3 operations available: import logging import lacre.core as core +from lacre.lazymessage import LazyMessage from email.message import Message, EmailMessage from email.parser import BytesHeaderParser from email.policy import SMTP, SMTPUTF8 @@ -23,10 +24,6 @@ from email.policy import SMTP, SMTPUTF8 LOG = logging.getLogger(__name__) -def parse_headers(message: bytes) -> EmailMessage: - return BytesHeaderParser(policy=SMTPUTF8).parsebytes(message) - - class MailSerialisationException(BaseException): """We can't turn an EmailMessage into sequence of bytes.""" pass @@ -39,7 +36,7 @@ class MailOperation: """Initialise the operation with a recipient.""" self._recipients = recipients - def perform(self, message: Message) -> bytes: + def perform(self, message: Message, lmessage: LazyMessage) -> bytes: """Perform this operation on MESSAGE. Return target message. @@ -80,12 +77,13 @@ class InlineOpenPGPEncrypt(OpenPGPEncrypt): """Initialise strategy object.""" super().__init__(recipients, keys, keyhome) - def perform(self, msg: Message) -> bytes: + def perform(self, msg: Message, lmessage: LazyMessage) -> bytes: """Encrypt with PGP Inline.""" LOG.debug('Sending PGP/Inline...') return core._gpg_encrypt_to_bytes(msg, self._keys, self._recipients, - core._encrypt_all_payloads_inline) + core._encrypt_all_payloads_inline, + lmessage) class MimeOpenPGPEncrypt(OpenPGPEncrypt): @@ -95,12 +93,13 @@ class MimeOpenPGPEncrypt(OpenPGPEncrypt): """Initialise strategy object.""" super().__init__(recipients, keys, keyhome) - def perform(self, msg: Message) -> bytes: + def perform(self, msg: Message, lmessage: LazyMessage) -> bytes: """Encrypt with PGP MIME.""" LOG.debug('Sending PGP/MIME...') return core._gpg_encrypt_to_bytes(msg, self._keys, self._recipients, - core._encrypt_all_payloads_mime) + core._encrypt_all_payloads_mime, + lmessage) class SMimeEncrypt(MailOperation): @@ -112,7 +111,7 @@ class SMimeEncrypt(MailOperation): self._email = email self._cert = certificate - def perform(self, message: Message) -> bytes: + def perform(self, message: Message, lmessage: LazyMessage) -> bytes: """Encrypt with a certificate.""" LOG.warning(f"Delivering clear-text to {self._recipients}") return message.as_bytes(policy=SMTP) @@ -132,7 +131,7 @@ class KeepIntact(MailOperation): """Initialise pass-through operation for a given recipient.""" super().__init__(recipients) - def perform(self, message: Message) -> bytes: + def perform(self, message: Message, lmessage: LazyMessage) -> bytes: """Return MESSAGE unmodified.""" try: return message.as_bytes(policy=SMTPUTF8) diff --git a/lacre/repositories.py b/lacre/repositories.py index c9c9bce..ae11ea4 100644 --- a/lacre/repositories.py +++ b/lacre/repositories.py @@ -80,6 +80,7 @@ class IdentityRepository(KeyRing): LOG.debug('Registering identity: %s -- %s', insq, insq.compile().params) with self._engine.connect() as conn: conn.execute(insq) + conn.commit() def _update(self, email, fprint): upq = self._identities.update() \ @@ -89,6 +90,7 @@ class IdentityRepository(KeyRing): LOG.debug('Updating identity: %s -- %s', upq, upq.compile().params) with self._engine.connect() as conn: conn.execute(upq) + conn.commit() def delete(self, email): delq = delete(self._identities).where(self._identities.c.email == email) @@ -96,6 +98,7 @@ class IdentityRepository(KeyRing): with self._engine.connect() as conn: conn.execute(delq) + conn.commit() def delete_all(self): LOG.warn('Deleting all identities from the database') @@ -103,6 +106,7 @@ class IdentityRepository(KeyRing): delq = delete(self._identities) with self._engine.connect() as conn: conn.execute(delq) + conn.commit() def freeze_identities(self) -> KeyCache: """Return a static, async-safe copy of the identity map. @@ -187,6 +191,7 @@ class KeyConfirmationQueue: with self._engine.connect() as conn: conn.execute(delq) + conn.commit() def delete_keys(self, row_id, /, email=None): """Remove key from the database.""" @@ -200,6 +205,7 @@ class KeyConfirmationQueue: with self._engine.connect() as conn: LOG.debug('Deleting public keys associated with confirmed email: %s', delq) conn.execute(delq) + conn.commit() def delete_key_by_email(self, email): """Remove keys linked to the given email from the database.""" @@ -208,6 +214,7 @@ class KeyConfirmationQueue: LOG.debug('Deleting email for: %s', email) with self._engine.connect() as conn: conn.execute(delq) + conn.commit() def mark_accepted(self, row_id): modq = self._keys.update().where(self._keys.c.id == row_id).values(status=db.ST_IMPORTED) @@ -215,3 +222,4 @@ class KeyConfirmationQueue: with self._engine.connect() as conn: conn.execute(modq) + conn.commit()