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.
This commit is contained in:
Piotr F. Mieszkowski 2024-07-12 10:40:38 +02:00
parent b6155ade96
commit 8b5d924321
Signed by untrusted user: pfm
GPG key ID: BDE5BC1FA5DC53D5
6 changed files with 99 additions and 32 deletions

View file

@ -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')

View file

@ -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)]

View file

@ -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())

33
lacre/lazymessage.py Normal file
View file

@ -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

View file

@ -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)

View file

@ -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()