diff --git a/gpg-mailgate.py b/gpg-mailgate.py index 1e72854..087c9c9 100755 --- a/gpg-mailgate.py +++ b/gpg-mailgate.py @@ -64,6 +64,6 @@ if not delivered: # silly message-encoding issue that shouldn't bounce the message, we just # try recoding the message body and delivering it. try: - core.failover_delivery(raw_message, to_addrs) + 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 7442f13..36ebd43 100644 --- a/lacre/core.py +++ b/lacre/core.py @@ -30,19 +30,16 @@ from email.message import EmailMessage, MIMEPart import email.utils from email.policy import SMTPUTF8 import GnuPG -import os -import smtplib import asyncio -from typing import Tuple, List, AnyStr - -# imports for S/MIME -from M2Crypto import BIO, SMIME, X509 +from typing import Tuple import logging import lacre.text as text import lacre.config as conf import lacre.keyring as kcache 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 @@ -289,92 +286,6 @@ def _append_gpg_extension(attachment): attachment.set_param('name', pgpFilename) -def _smime_encrypt(raw_message, recipients): - global LOG - global from_addr - - if not conf.config_item_set('smime', 'cert_path'): - LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.") - return recipients - - cert_path = conf.get_item('smime', 'cert_path')+"/" - s = SMIME.SMIME() - sk = X509.X509_Stack() - smime_to = list() - unsmime_to = list() - - for addr in recipients: - cert_and_email = _get_cert_for_email(addr, cert_path) - - if not (cert_and_email is None): - (to_cert, normal_email) = cert_and_email - LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email) - smime_to.append(addr) - x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM) - sk.push(x509) - else: - unsmime_to.append(addr) - - if smime_to: - s.set_x509_stack(sk) - s.set_cipher(SMIME.Cipher('aes_192_cbc')) - p7 = s.encrypt(BIO.MemoryBuffer(raw_message.as_string())) - # Output p7 in mail-friendly format. - out = BIO.MemoryBuffer() - out.write('From: ' + from_addr + text.EOL_S) - out.write('To: ' + raw_message['To'] + text.EOL_S) - if raw_message['Cc']: - out.write('Cc: ' + raw_message['Cc'] + text.EOL_S) - if raw_message['Bcc']: - out.write('Bcc: ' + raw_message['Bcc'] + text.EOL_S) - if raw_message['Subject']: - out.write('Subject: ' + raw_message['Subject'] + text.EOL_S) - - if conf.config_item_equals('default', 'add_header', 'yes'): - out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate' + text.EOL_S) - - s.write(out, p7) - - LOG.debug(f"Sending message from {from_addr} to {smime_to}") - - send_msg(out.read(), smime_to) - if unsmime_to: - LOG.debug(f"Unable to find valid S/MIME certificates for {unsmime_to}") - - return unsmime_to - - -def _get_cert_for_email(to_addr, cert_path): - insensitive = conf.config_item_equals('default', 'mail_case_insensitive', 'yes') - - LOG.info(f'Retrieving certificate for {to_addr!r} from {cert_path!r}, sensitivity={insensitive!r}') - - files_in_directory = os.listdir(cert_path) - for filename in files_in_directory: - file_path = os.path.join(cert_path, filename) - if not os.path.isfile(file_path): - continue - - if insensitive: - if filename.casefold() == to_addr: - return (file_path, to_addr) - else: - if filename == to_addr: - return (file_path, to_addr) - - # support foo+ignore@bar.com -> foo@bar.com - LOG.info(f"An email with topic? {to_addr}") - (fixed_up_email, topic) = text.parse_delimiter(to_addr) - LOG.info(f'Got {fixed_up_email!r} and {topic!r}') - if topic is None: - # delimiter not used - LOG.info('Topic not found') - return None - else: - LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}") - return _get_cert_for_email(fixed_up_email, cert_path) - - def _generate_message_from_payloads(payloads, message=None): if message is None: message = email.mime.multipart.MIMEMultipart(payloads.get_content_subtype()) @@ -395,46 +306,28 @@ def _get_first_payload(payloads): return payloads -def send_msg(message: AnyStr, recipients: List[str], fromaddr=None): - """Send MESSAGE to RECIPIENTS to the mail relay.""" - global from_addr - - if fromaddr is not None: - from_addr = fromaddr - - recipients = [_f for _f in recipients if _f] - if recipients: - LOG.info(f"Sending email to: {recipients!r}") - relay = conf.relay_params() - smtp = smtplib.SMTP(relay[0], relay[1]) - if conf.flag_enabled('relay', 'starttls'): - smtp.starttls() - smtp.sendmail(from_addr, recipients, message) - else: - LOG.info("No recipient found") - - def _recode(m: EmailMessage): payload = m.get_payload() m.set_content(payload) -def failover_delivery(message: EmailMessage, recipients): +def failover_delivery(message: EmailMessage, recipients, from_address): """Try delivering message just one last time.""" LOG.debug('Failover delivery') + send = SendFrom(from_address) if message.get_content_maintype() == 'text': LOG.debug('Flat text message, adjusting coding') _recode(message) b = message.as_bytes(policy=SMTPUTF8) - send_msg(b, recipients) + send(b, recipients) elif message.get_content_maintype() == 'multipart': LOG.debug('Multipart message, adjusting coding of text entities') for part in message.iter_parts(): if part.get_content_maintype() == 'text': _recode(part) b = message.as_bytes(policy=SMTPUTF8) - send_msg(b, recipients) + send(b, recipients) else: LOG.warning('No failover strategy, giving up') @@ -475,19 +368,19 @@ def delivery_plan(recipients, message: EmailMessage, key_cache: kcache.KeyCache) def deliver_message(raw_message: EmailMessage, from_address, to_addrs): """Send RAW_MESSAGE to all TO_ADDRS using the best encryption method available.""" - global from_addr - # Ugly workaround to keep the code working without too many changes. - from_addr = from_address + register_sender(from_address) sanitize = text.choose_sanitizer(conf.get_item('default', 'mail_case_insensitive')) recipients_left = [sanitize(recipient) for recipient in to_addrs] + send = SendFrom(from_address) + # There is no need for nested encryption LOG.debug("Seeing if it's already encrypted") if _is_encrypted(raw_message): LOG.debug("Message is already encrypted. Encryption aborted.") - send_msg(raw_message.as_string(), recipients_left) + send(raw_message.as_string(), recipients_left) return # Encrypt mails for recipients with known public PGP keys @@ -498,10 +391,10 @@ def deliver_message(raw_message: EmailMessage, from_address, to_addrs): # Encrypt mails for recipients with known S/MIME certificate LOG.debug("Encrypting with S/MIME") - recipients_left = _smime_encrypt(raw_message, recipients_left) + recipients_left = smime.encrypt(raw_message, recipients_left, from_address) if not recipients_left: return # Send out mail to recipients which are left LOG.debug("Sending the rest as text/plain") - send_msg(raw_message.as_string(), recipients_left) + send(raw_message.as_string(), recipients_left) diff --git a/lacre/daemon.py b/lacre/daemon.py index 36d1212..10fcf50 100644 --- a/lacre/daemon.py +++ b/lacre/daemon.py @@ -13,12 +13,6 @@ from email.policy import SMTPUTF8 import time from watchdog.observers import Observer -# Mail status constants. -# -# These are the only values that our mail handler is allowed to return. -RESULT_OK = '250 OK' -RESULT_ERROR = '500 Could not process your message' - # Load configuration and init logging, in this order. Only then can we load # the last Lacre module, i.e. lacre.mailgate. conf.load_config() @@ -28,6 +22,7 @@ LOG = logging.getLogger('lacre.daemon') from GnuPG import EncryptionException import lacre.core as gate import lacre.keyring as kcache +import lacre.transport as xport from lacre.mailop import KeepIntact @@ -57,32 +52,33 @@ class MailEncryptionProxy: if conf.flag_enabled('daemon', 'log_headers'): LOG.info('Message headers: %s', self._extract_headers(message)) + send = xport.SendFrom(envelope.mail_from) for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys): LOG.debug(f"Sending mail via {operation!r}") try: new_message = operation.perform(message) - gate.send_msg(new_message, operation.recipients(), envelope.mail_from) + send(new_message, operation.recipients()) except EncryptionException: # If the message can't be encrypted, deliver cleartext. LOG.exception('Unable to encrypt message, delivering in cleartext') if not isinstance(operation, KeepIntact): - self._send_unencrypted(operation, message, envelope) + self._send_unencrypted(operation, message, envelope, send) else: LOG.error(f'Cannot perform {operation}') except: LOG.exception('Unexpected exception caught, bouncing message') - return RESULT_ERROR + return xport.RESULT_ERROR ellapsed = (time.process_time() - start) * 1000 LOG.info(f'Message delivered in {ellapsed:.2f} ms') - return RESULT_OK + return xport.RESULT_OK - def _send_unencrypted(self, operation, message, envelope): + def _send_unencrypted(self, operation, message, envelope, send: xport.SendFrom): keep = KeepIntact(operation.recipients()) new_message = keep.perform(message) - gate.send_msg(new_message, operation.recipients(), envelope.mail_from) + send(new_message, operation.recipients(), envelope.mail_from) def _beginning(self, e: Envelope) -> bytes: double_eol_pos = e.original_content.find(DOUBLE_EOL_BYTES) diff --git a/lacre/recipients.py b/lacre/recipients.py index 02692e4..7ce2739 100644 --- a/lacre/recipients.py +++ b/lacre/recipients.py @@ -32,7 +32,23 @@ import lacre.text as text LOG = logging.getLogger(__name__) -class GpgRecipient: +class Recipient: + """Wraps recipient's email.""" + + def __init__(self, email): + """Initialise the recipient.""" + self._email = email + + def email(self) -> str: + """Return email address of this recipient.""" + return self._email + + def __str__(self): + """Return string representation of this recipient: the email address.""" + return self._email + + +class GpgRecipient(Recipient): """A tuple-like object that contains GPG recipient data.""" def __init__(self, left, right): @@ -53,7 +69,7 @@ class GpgRecipient: """Return textual representation of this GPG Recipient.""" return f"GpgRecipient({self._left!r}, {self._right!r})" - def email(self): + def email(self) -> str: """Return this recipient's email address.""" return self._left diff --git a/lacre/smime.py b/lacre/smime.py new file mode 100644 index 0000000..55b935f --- /dev/null +++ b/lacre/smime.py @@ -0,0 +1,126 @@ +# +# gpg-mailgate +# +# This file is part of the gpg-mailgate source code. +# +# gpg-mailgate is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# gpg-mailgate source code is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with gpg-mailgate source code. If not, see . +# + +"""S/MIME handling module.""" + +import os + +from M2Crypto import BIO, SMIME, X509 + +import logging +import lacre.text as text +import lacre.config as conf +import lacre.transport as xport + +LOG = logging.getLogger(__name__) + + +# +# WARNING: This file is not covered with E2E tests. +# + +def encrypt(raw_message, recipients, from_addr): + """Encrypt with S/MIME.""" + if not conf.config_item_set('smime', 'cert_path'): + LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.") + return recipients + + cert_path = conf.get_item('smime', 'cert_path')+"/" + s = SMIME.SMIME() + sk = X509.X509_Stack() + smime_to = list() + cleartext_to = list() + + for addr in recipients: + cert_and_email = _get_cert_for_email(addr, cert_path) + + if not (cert_and_email is None): + (to_cert, normal_email) = cert_and_email + LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email) + smime_to.append(addr) + x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM) + sk.push(x509) + else: + cleartext_to.append(addr) + + if smime_to: + s.set_x509_stack(sk) + s.set_cipher(SMIME.Cipher('aes_192_cbc')) + p7 = s.encrypt(BIO.MemoryBuffer(raw_message.as_string())) + # Output p7 in mail-friendly format. + out = BIO.MemoryBuffer() + out.write('From: ' + from_addr + text.EOL_S) + out.write('To: ' + raw_message['To'] + text.EOL_S) + if raw_message['Cc']: + out.write('Cc: ' + raw_message['Cc'] + text.EOL_S) + if raw_message['Bcc']: + out.write('Bcc: ' + raw_message['Bcc'] + text.EOL_S) + if raw_message['Subject']: + out.write('Subject: ' + raw_message['Subject'] + text.EOL_S) + + if conf.config_item_equals('default', 'add_header', 'yes'): + out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate' + text.EOL_S) + + s.write(out, p7) + + LOG.debug(f"Sending message from {from_addr} to {smime_to}") + + send_msg = xport.SendFrom(from_addr) + send_msg(out.read(), smime_to) + + if cleartext_to: + LOG.debug(f"Unable to find valid S/MIME certificates for {cleartext_to}") + + return cleartext_to + + +def _path_comparator(insensitive: bool): + if insensitive: + return lambda filename, recipient: filename.casefold() == recipient + else: + return lambda filename, recipient: filename == recipient + + +def _get_cert_for_email(to_addr, cert_path): + insensitive = conf.config_item_equals('default', 'mail_case_insensitive', 'yes') + paths_equal = _path_comparator(insensitive) + + LOG.info('Retrieving certificate for %s from %s, insensitive=%s', + to_addr, cert_path, insensitive) + + files_in_directory = os.listdir(cert_path) + for filename in files_in_directory: + file_path = os.path.join(cert_path, filename) + if not os.path.isfile(file_path): + continue + + if paths_equal(file_path, to_addr): + return (file_path, to_addr) + + # support foo+ignore@bar.com -> foo@bar.com + LOG.info(f"An email with topic? {to_addr}") + (fixed_up_email, topic) = text.parse_delimiter(to_addr) + LOG.info(f'Got {fixed_up_email!r} and {topic!r}') + if topic is None: + # delimiter not used + LOG.info('Topic not found') + return None + else: + LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}") + return _get_cert_for_email(fixed_up_email, cert_path) diff --git a/lacre/transport.py b/lacre/transport.py new file mode 100644 index 0000000..3d3bf1f --- /dev/null +++ b/lacre/transport.py @@ -0,0 +1,71 @@ +"""SMTP transport module.""" + +import smtplib +import logging +from typing import AnyStr, List + +import lacre.config as conf + +# Mail status constants. +# +# These are the only values that our mail handler is allowed to return. +RESULT_OK = '250 OK' +RESULT_ERROR = '500 Could not process your message' + +LOG = logging.getLogger(__name__) + +# This is a left-over from old architecture. +from_addr = None + + +def register_sender(fromaddr): + """Set module state: message sender address.""" + global from_addr + LOG.warning('Setting global recipient: %s', fromaddr) + from_addr = fromaddr + + +def send_msg(message: AnyStr, recipients: List[str]): + """Send MESSAGE to RECIPIENTS to the mail relay.""" + global from_addr + LOG.debug('Delivery from %s to %s', from_addr, recipients) + + recipients = [_f for _f in recipients if _f] + if recipients: + LOG.info(f"Sending email to: {recipients!r}") + relay = conf.relay_params() + smtp = smtplib.SMTP(relay[0], relay[1]) + if conf.flag_enabled('relay', 'starttls'): + smtp.starttls() + smtp.sendmail(from_addr, recipients, message) + else: + LOG.info("No recipient found") + + +class SendFrom: + """A class wrapping the transport process.""" + + def __init__(self, from_addr): + """Initialise the transport.""" + self._from_addr = from_addr + + def __call__(self, message: AnyStr, recipients: List[str]): + """Send the given message to all recipients from the list. + + - Message is the email object serialised to str or bytes. + - Empty recipients are filtered out before communication. + """ + recipients = [_f for _f in recipients if _f] + + if not recipients: + LOG.warning("No recipient found") + return + + LOG.info("Sending email to: %s", recipients) + relay = conf.relay_params() + smtp = smtplib.SMTP(relay[0], relay[1]) + + if conf.flag_enabled('relay', 'starttls'): + smtp.starttls() + + smtp.sendmail(self._from_addr, recipients, message)