forked from Disroot/gpg-lacre
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:
parent
b6155ade96
commit
8b5d924321
6 changed files with 99 additions and 32 deletions
21
lacre.py
21
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')
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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
33
lacre/lazymessage.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue