diff --git a/lacre/daemon.py b/lacre/daemon.py index 1981caf..dce748c 100644 --- a/lacre/daemon.py +++ b/lacre/daemon.py @@ -30,10 +30,10 @@ class MailEncryptionProxy: """Accept a message and either encrypt it or forward as-is.""" # for now, just return an error because we're not ready to handle mail - for recipient, operation in gate.delivery_plan(envelope.rcpt_tos): - LOG.debug(f"Sending mail to {recipient} via {operation}") + for operation in gate.delivery_plan(envelope.rcpt_tos): + LOG.debug(f"Sending mail via {operation}") new_message = operation.perform(envelope.content) - gate.send_msg(new_message, [recipient], envelope.mail_from) + gate.send_msg(new_message, operation.recipients(), envelope.mail_from) return RESULT_NOT_IMPLEMENTED @@ -48,11 +48,15 @@ def _init_controller(): def _validate_config(): missing = conf.validate_config() if missing: - params = ", ".join([f"[{tup[0]}]{tup[1]}" for tup in missing]) + params = ", ".join([_full_param_name(tup) for tup in missing]) LOG.error(f"Following mandatory parameters are missing: {params}") sys.exit(lacre.EX_CONFIG) +def _full_param_name(tup): + return f"[{tup[0]}]{tup[1]}" + + async def _sleep(): while True: await asyncio.sleep(5) diff --git a/lacre/mailgate.py b/lacre/mailgate.py index 3403895..2fa727a 100644 --- a/lacre/mailgate.py +++ b/lacre/mailgate.py @@ -47,6 +47,8 @@ def _gpg_encrypt(raw_message, recipients): gpg_to, ungpg_to = _sort_gpg_recipients(recipients) + LOG.info(f"Got addresses: gpg_to={gpg_to!r}, ungpg_to={ungpg_to!r}") + if gpg_to: LOG.info("Encrypting email to: %s" % ' '.join(x[0] for x in gpg_to)) @@ -82,13 +84,7 @@ def _gpg_encrypt(raw_message, recipients): # Encrypt mail with PGP/MIME raw_message_mime = copy.deepcopy(raw_message) - if conf.config_item_equals('default', 'add_header', 'yes'): - raw_message_mime['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate' - - if 'Content-Transfer-Encoding' in raw_message_mime: - raw_message_mime.replace_header('Content-Transfer-Encoding', '8BIT') - else: - raw_message_mime['Content-Transfer-Encoding'] = '8BIT' + _customise_headers(raw_message_mime) encrypted_payloads = _encrypt_all_payloads_mime(raw_message_mime, gpg_to_cmdline_mime) raw_message_mime.set_payload(encrypted_payloads) @@ -99,22 +95,30 @@ def _gpg_encrypt(raw_message, recipients): # Encrypt mail with PGP/INLINE raw_message_inline = copy.deepcopy(raw_message) - if conf.config_item_equals('default', 'add_header', 'yes'): - raw_message_inline['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate' - - if 'Content-Transfer-Encoding' in raw_message_inline: - raw_message_inline.replace_header('Content-Transfer-Encoding', '8BIT') - else: - raw_message_inline['Content-Transfer-Encoding'] = '8BIT' + _customise_headers(raw_message_inline) encrypted_payloads = _encrypt_all_payloads_inline(raw_message_inline, gpg_to_cmdline_inline) raw_message_inline.set_payload(encrypted_payloads) send_msg(raw_message_inline.as_string(), gpg_to_smtp_inline) + LOG.info(f"Not processed emails: {ungpg_to}") return ungpg_to +def _customise_headers(message): + msg_copy = copy.deepcopy(message) + if conf.config_item_equals('default', 'add_header', 'yes'): + msg_copy['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate' + + if 'Content-Transfer-Encoding' in msg_copy: + msg_copy.replace_header('Content-Transfer-Encoding', '8BIT') + else: + msg_copy['Content-Transfer-Encoding'] = '8BIT' + + return + + def _load_keys(): """Return a map from a key's fingerprint to email address.""" keys = GnuPG.public_keys(conf.get_item('gpg', 'keyhome')) @@ -124,6 +128,28 @@ def _load_keys(): return keys +class GpgRecipient: + """A tuple-like object that contains GPG recipient data.""" + + def __init__(self, left, right): + """Initialise a tuple-like object that contains GPG recipient data.""" + self._left = left + self._right = right + + def __getitem__(self, index): + """Pretend this object is a tuple by returning an indexed tuple element.""" + if index == 0: + return self._left + elif index == 1: + return self._right + else: + raise IndexError() + + def __repr__(self): + """Return textual representation of this GPG Recipient.""" + return f"GpgRecipient({self._left!r}, {self._right!r})" + + def _sort_gpg_recipients(recipients): # This list will be filled with pairs (M, N), where M is the destination # address we're going to deliver the message to and N is the identity we're @@ -141,12 +167,25 @@ def _sort_gpg_recipients(recipients): # GnuPG keys found in our keyring. keys = _load_keys() + LOG.info(f'Processisng recipients: {recipients!r}') for to in recipients: - key = _find_key(to, keys, strict_mode) - if key is not None: - gpg_to.append(key) - else: - ungpg_to.append(to) + LOG.info(f"At to={to!r}") + own_key = _try_configured_key(to, keys) + if own_key is not None: + gpg_to.append(GpgRecipient(own_key[0], own_key[1])) + continue + + direct_key = _try_direct_key_lookup(to, keys, strict_mode) + if direct_key is not None: + gpg_to.append(GpgRecipient(direct_key[0], direct_key[1])) + continue + + domain_key = _try_configured_domain_key(to, keys) + if domain_key is not None: + gpg_to.append(GpgRecipient(domain_key[0], domain_key[1])) + continue + + ungpg_to.append((to, to)) return gpg_to, ungpg_to @@ -321,7 +360,7 @@ def _smime_encrypt(raw_message, recipients): unsmime_to = list() for addr in recipients: - cert_and_email = _get_cert_for_email(addr, cert_path) + cert_and_email = _get_cert_for_email(addr[0], cert_path) if not (cert_and_email is None): (to_cert, normal_email) = cert_and_email @@ -364,6 +403,8 @@ def _smime_encrypt(raw_message, recipients): 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) @@ -378,12 +419,15 @@ def _get_cert_for_email(to_addr, cert_path): 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.debug(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}") + LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}") return _get_cert_for_email(fixed_up_email, cert_path) @@ -452,7 +496,7 @@ def _is_encrypted(raw_message): def delivery_plan(recipients): """Generate a sequence of pairs: a recipient and their delivery strategy.""" for recipient in recipients: - yield recipient, KeepIntact(recipient) + yield KeepIntact(recipient) def deliver_message(raw_message, from_address, to_addrs): @@ -465,22 +509,26 @@ def deliver_message(raw_message, from_address, to_addrs): recipients_left = [_sanitize_case_sense(recipient) for recipient in to_addrs] # 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) return # Encrypt mails for recipients with known public PGP keys + LOG.debug("Encrypting with OpenPGP") recipients_left = _gpg_encrypt(raw_message, recipients_left) if not recipients_left: return # Encrypt mails for recipients with known S/MIME certificate + LOG.debug("Encrypting with S/MIME") recipients_left = _smime_encrypt(raw_message, recipients_left) 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) diff --git a/lacre/mailop.py b/lacre/mailop.py index b7b08bb..51e3c33 100644 --- a/lacre/mailop.py +++ b/lacre/mailop.py @@ -23,9 +23,9 @@ LOG = logging.getLogger(__name__) class MailOperation: """Contract for an operation to be performed on a message.""" - def __init__(self, recipient): + def __init__(self, recipients=[]): """Initialise the operation with a recipient.""" - self._recipient = recipient + self._recipients = recipients def perform(self, message): """Perform this operation on MESSAGE. @@ -34,9 +34,13 @@ class MailOperation: """ raise NotImplementedError(self.__class__()) - def recipient(self): - """Return recipient of the message.""" - return self._recipient + def recipients(self): + """Return list of recipients of the message.""" + return self._recipients + + def add_recipient(self, recipient): + """Register another message recipient.""" + self._recipients.append(recipient) class OpenPGPEncrypt(MailOperation): diff --git a/lacre/text.py b/lacre/text.py index 1314f74..6980b3a 100644 --- a/lacre/text.py +++ b/lacre/text.py @@ -1,5 +1,6 @@ import sys import re +import logging # The standard way to encode line-ending in email: EOL = "\r\n" @@ -7,7 +8,15 @@ EOL = "\r\n" PGP_INLINE_BEGIN = b"-----BEGIN PGP MESSAGE-----" PGP_INLINE_END = b"-----END PGP MESSAGE-----" + +LOG = logging.getLogger(__name__) + + def parse_content_type(content_type): + """Analyse Content-Type email header. + + Return a pair: type and sub-type. + """ parts = [p.strip() for p in content_type.split(';')] if len(parts) == 1: # No additional attributes provided. Use default encoding. @@ -16,20 +25,28 @@ def parse_content_type(content_type): # At least one attribute provided. Find out if any of them is named # 'charset' and if so, use it. ctype = parts[0] - encoding = [p for p in parts[1:] if p.startswith('charset=') ] + encoding = [p for p in parts[1:] if p.startswith('charset=')] if encoding: eq_idx = encoding[0].index('=') return (ctype, encoding[0][eq_idx+1:]) else: return (ctype, sys.getdefaultencoding()) -def parse_delimiter(address): - withdelim = re.match('^([^\+]+)\+([^@]+)@(.*)$', address) + +def parse_delimiter(address: str): + """Parse an email with delimiter and topic. + + Return destination emaili and topic as a tuple. + """ + withdelim = re.match('^([^\\+]+)\\+([^@]+)@(.*)$', address) + LOG.debug(f'Parsed email: {withdelim!r}') + if withdelim: return (withdelim.group(1) + '@' + withdelim.group(3), withdelim.group(2)) else: return (address, None) -def is_pgp_inline(payload): - """Finds out if the payload (bytes) contains PGP/INLINE markers.""" + +def is_pgp_inline(payload) -> bool: + """Find out if the payload (bytes) contains PGP/INLINE markers.""" return PGP_INLINE_BEGIN in payload and PGP_INLINE_END in payload