From 6e04f2fef1368463cc51bbdeb3c071889bbc9749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sara=20Aim=C3=A9e=20Smiseth?= <51710585+SaraSmiseth@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:21:19 +0100 Subject: [PATCH] Update Zeyple to upstream 3.0.0 Use the unmodified upstream script, keep it root-owned, and configure Postfix to invoke Zeyple once per recipient. Update encryption tests for +subaddresses: all four fixture messages should be encrypted, with none sent unencrypted. --- README.md | 4 +- rootfs/etc/postfix/main.cf | 1 + rootfs/usr/local/bin/setup.sh | 5 +- rootfs/usr/local/bin/zeyple.py | 359 ++++++++++++++++++++++++--------- test/reverse.bats | 26 ++- 5 files changed, 290 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 4a9e9ba..699d6d6 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,9 @@ When using LDAP authentication the following additional variables become availab #### How does it work ? -[Zeyple](https://infertux.com/labs/zeyple/) catches email from the postfix queue, then encrypts it if a corresponding recipient's GPG public key is found. Finally, it puts it back into the queue. +[Zeyple 3.0.0](https://github.com/infertux/zeyple/releases/tag/v3.0.0) catches email from the postfix queue, then encrypts it if a corresponding recipient's GPG public key is found. Finally, it puts it back into the queue. + +For subaddresses such as `user+tag@example.com`, Zeyple also looks up the key for `user@example.com`. ![zeyple](https://i.imgur.com/gGQZL4V.png) diff --git a/rootfs/etc/postfix/main.cf b/rootfs/etc/postfix/main.cf index 905152a..c1b3768 100644 --- a/rootfs/etc/postfix/main.cf +++ b/rootfs/etc/postfix/main.cf @@ -252,6 +252,7 @@ non_smtpd_milters = inet:localhost:11332 ############ content_filter = zeyple +zeyple_destination_recipient_limit = 1 ####################### ## YOUR CUSTOM RULES ## diff --git a/rootfs/usr/local/bin/setup.sh b/rootfs/usr/local/bin/setup.sh index e466428..f797768 100644 --- a/rootfs/usr/local/bin/setup.sh +++ b/rootfs/usr/local/bin/setup.sh @@ -581,8 +581,9 @@ if [ "$ENABLE_ENCRYPTION" = true ]; then # Create all files and directories needed by Zeyple mkdir -p /var/mail/zeyple/keys chmod 700 /var/mail/zeyple/keys - chmod 744 /usr/local/bin/zeyple.py - chown -R zeyple:zeyple /var/mail/zeyple /usr/local/bin/zeyple.py + chmod 755 /usr/local/bin/zeyple.py + chown root:root /usr/local/bin/zeyple.py + chown -R zeyple:zeyple /var/mail/zeyple if [ "$TESTING" = true ]; then diff --git a/rootfs/usr/local/bin/zeyple.py b/rootfs/usr/local/bin/zeyple.py index 1a12416..995d364 100644 --- a/rootfs/usr/local/bin/zeyple.py +++ b/rootfs/usr/local/bin/zeyple.py @@ -1,45 +1,23 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import sys -import os -import logging +from configparser import ConfigParser +import base64 +import binascii +import copy import email -import email.mime.multipart -import email.mime.application import email.encoders +import email.mime.application +import email.mime.multipart +import email.mime.nonmultipart +import gpg +import logging +import os +import re import smtplib -import copy -from io import BytesIO - -# SafeConfigParser was removed in Python 3.12 (deprecated since 3.2, and an -# alias of ConfigParser since Python 3). Debian 13 ships Python 3.13. -from configparser import ConfigParser - -legacy_gpg = False -try: - import gpg -except ImportError: - import gpgme - legacy_gpg = True - -# Boiler plate to avoid dependency on six -# BBB: Python 2.7 support -PY3K = sys.version_info > (3, 0) - - -def message_from_binary(message): - if PY3K: - return email.message_from_bytes(message) - else: - return email.message_from_string(message) - - -def as_binary_string(email): - if PY3K: - return email.as_bytes() - else: - return email.as_string() +import subprocess +import sys +import tempfile def encode_string(string): @@ -50,10 +28,75 @@ def encode_string(string): __title__ = 'Zeyple' -__version__ = '1.2.2' +__version__ = '3.0.0' __author__ = 'Cédric Félizard' __license__ = 'AGPLv3+' -__copyright__ = 'Copyright 2012-2018 Cédric Félizard' +__copyright__ = 'Copyright 2012-2026 Cédric Félizard' +PGP_PREAMBLE = 'This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)' + + +def encrypted_packet_sequence(data): + """Check complete OpenPGP packet framing; GnuPG validates the packet bodies.""" + offset = 0 + tags = [] + + def read_integer(size): + nonlocal offset + if offset + size > len(data): + raise ValueError('Truncated packet header') + value = int.from_bytes(data[offset:offset + size], 'big') + offset += size + return value + + try: + while offset < len(data): + header = read_integer(1) + if not header & 0x80: + return False + size = 0 + if header & 0x40: + tag = header & 0x3f + # New-format packets can have several partial-length chunks, + # followed by a final chunk with a definite length (RFC 4880). + while True: + length = read_integer(1) + partial = 224 <= length < 255 + if length == 255: + length = read_integer(4) + elif partial: + length = 1 << (length & 0x1f) + elif length >= 192: + length = ((length - 192) << 8) + read_integer(1) + 192 + if offset + length > len(data): + return False + offset += length + size += length + if not partial: + break + else: + tag = (header >> 2) & 0x0f + length_type = header & 3 + size = len(data) - offset if length_type == 3 else read_integer(1 << length_type) + if offset + size > len(data): + return False + offset += size + if not size: + return False + tags.append(tag) + except ValueError: + return False + + # Only session keys followed by one encrypted data packet are eligible. + # Literal, compressed, signature and trailing packets must not bypass encryption. + return len(tags) >= 2 and all(tag in (1, 3) for tag in tags[:-1]) and \ + tags[-1] in (9, 18, 20) + + +def strip_armor_headers(data): + """Returns an ASCII-armored message without its armor headers, such as + Comment, which are outside the ciphertext so they can hold plaintext""" + lines = [line.strip() for line in data.strip().splitlines()] + return b'\n'.join(lines[:1] + lines[lines.index(b''):]).decode('ascii') + '\n' class Zeyple: @@ -67,6 +110,10 @@ def __init__(self, config_fname='zeyple.conf'): filename=log_file, level=logging.DEBUG, format='%(asctime)s %(process)s %(levelname)s %(message)s' ) + + # raise now rather than when handling an error if the value is invalid + self.force_encrypt + logging.info("Zeyple ready to encrypt outgoing emails") def load_configuration(self, filename): @@ -83,11 +130,7 @@ def load_configuration(self, filename): @property def gpg(self): - global legacy_gpg - if legacy_gpg: - protocol = gpgme.PROTOCOL_OpenPGP - else: - protocol = gpg.constants.PROTOCOL_OpenPGP + protocol = gpg.constants.PROTOCOL_OpenPGP if self.config.has_option('gpg', 'executable'): executable = self.config.get('gpg', 'executable') @@ -96,10 +139,7 @@ def gpg(self): home_dir = self.config.get('gpg', 'home') - if legacy_gpg: - ctx = gpgme.Context() - else: - ctx = gpg.Context() + ctx = gpg.Context() ctx.set_engine_info(protocol, executable, home_dir) ctx.armor = True @@ -109,31 +149,41 @@ def process_message(self, message_data, recipients): """Encrypts the message with recipient keys""" message_data = encode_string(message_data) - in_message = message_from_binary(message_data) + in_message = email.message_from_bytes(message_data) logging.info( "Processing outgoing message %s", in_message['Message-id']) if not recipients: - logging.warn("Cannot find any recipients, ignoring") + logging.warning("Cannot find any recipients, ignoring") + + already_encrypted = self._is_encrypted(in_message) + if already_encrypted: + in_message = self._strip_plaintext(in_message) sent_messages = [] + # copies of in_message must be deep: shallow ones share its headers, so changing + # them for one recipient would also change them for the next one for recipient in recipients: logging.info("Recipient: %s", recipient) - key_id = self._user_key(recipient) - logging.info("Key ID: %s", key_id) + if already_encrypted: + logging.info("Message is already encrypted, sending its ciphertext as is") + out_message = copy.deepcopy(in_message) - if key_id: - out_message = self._encrypt_message(in_message, key_id) + else: + key_id = self._user_key(recipient) + logging.info("Key ID: %s", key_id) - elif self.config.has_option('zeyple', 'force_encrypt') and \ - self.config.getboolean('zeyple', 'force_encrypt'): - logging.error("No keys found, message will not be sent!") - continue + if key_id: + out_message = self._encrypt_message(in_message, key_id) - else: - logging.warn("No keys found, message will be sent unencrypted") - out_message = copy.copy(in_message) + elif self.force_encrypt: + logging.error("No keys found, message will not be sent!") + continue + + else: + logging.warning("No keys found, message will be sent unencrypted") + out_message = copy.deepcopy(in_message) self._add_zeyple_header(out_message) self._send_message(out_message, recipient) @@ -141,6 +191,99 @@ def process_message(self, message_data, recipients): return sent_messages + @property + def force_encrypt(self): + return self.config.getboolean('zeyple', 'force_encrypt', fallback=False) + + def _is_encrypted(self, message): + """Returns whether the whole message is already encrypted with PGP""" + if message.get_content_type() == 'multipart/encrypted': + # PGP/MIME (RFC 3156): a version part then the encrypted data + parts = message.get_payload() if message.is_multipart() else [] + protocol = str(message.get_param('protocol', '')).lower() + if protocol != 'application/pgp-encrypted' or len(parts) != 2 or message.defects: + return False + if parts[0].get_content_type() != 'application/pgp-encrypted' or \ + parts[1].get_content_type() != 'application/octet-stream': + return False + # _strip_plaintext() replaces the preamble, epilogue and version part + version = parts[0].get_payload(decode=True) + payload = parts[1].get_payload(decode=True) + # Decoding can record defects, such as ignored invalid base64 text. + return not any(part.defects for part in parts) and \ + (version or b'').strip().splitlines()[:1] == [b'Version: 1'] and \ + self._is_pgp_message(payload) + + if message.is_multipart(): + return False + + # inline PGP: the body must be nothing but an encrypted block + payload = message.get_payload(decode=True) + return not message.defects and self._is_pgp_message(payload) + + def _is_pgp_message(self, data): + """Returns whether data is an ASCII-armored OpenPGP encrypted message""" + lines = [line.strip() for line in (data or b'').strip().splitlines()] + if len(lines) < 3 or b'' not in lines or \ + lines[0] != b'-----BEGIN PGP MESSAGE-----' or \ + lines[-1] != b'-----END PGP MESSAGE-----': + return False + + # skip the armor headers, which end with a blank line and which + # _strip_plaintext() removes, and the checksum, which can't hold anything else + body = lines[lines.index(b'') + 1:-1] + if body and body[-1].startswith(b'='): + if not re.fullmatch(rb'=[A-Za-z0-9+/]{4}', body[-1]): + return False + body = body[:-1] + + try: + packets = base64.b64decode(b''.join(body), validate=True) + except binascii.Error: + return False + + if not encrypted_packet_sequence(packets): + return False + + # Neither the first packet tag nor GPGME's identify() validates a + # complete message. Use GnuPG's parser, without decrypting or accessing + # the recipient keyring. Framing above also catches truncated ciphertext + # that --list-only deliberately skips without reporting an error. + # the same gpg as GPGME uses, also when PATH doesn't lead to it + executable = self.config.get('gpg', 'executable', fallback=None) or \ + self.gpg.engine_info.file_name + try: + with tempfile.TemporaryDirectory(prefix='zeyple-pgp-') as home: + result = subprocess.run( + [executable, '--no-options', '--batch', '--no-tty', '--no-autostart', + '--no-auto-key-retrieve', '--homedir', home, '--list-only', '--list-packets'], + input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=30, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + logging.warning('Cannot validate existing PGP encryption; applying recipient policy') + return False + + def _strip_plaintext(self, message): + """Returns a copy of an already encrypted message with only its ciphertext: + armor headers, MIME preamble, epilogue and part headers are outside it, so + they could hold plaintext, and the recipient doesn't need them""" + out_message = copy.deepcopy(message) + if message.get_content_type() == 'multipart/encrypted': + payload = message.get_payload()[1].get_payload(decode=True) + out_message.preamble = PGP_PREAMBLE + out_message.epilogue = None + out_message.set_payload([ + self._get_version_part(), + self._get_encrypted_part(strip_armor_headers(payload)), + ]) + else: + # armor is plain ASCII, so it needs no transfer encoding + del out_message['Content-Transfer-Encoding'] + out_message.set_payload(strip_armor_headers(message.get_payload(decode=True))) + return out_message + def _get_version_part(self): ret = email.mime.application.MIMEApplication( 'Version: 1\n', @@ -215,16 +358,18 @@ def _encrypt_message(self, in_message, key_id): # remove superfluous header del mixed['MIME-Version'] - payload = as_binary_string(mixed) + payload = mixed.as_bytes() encrypted_payload = self._encrypt_payload(payload, [key_id]) version = self._get_version_part() encrypted = self._get_encrypted_part(encrypted_payload) - out_message = copy.copy(in_message) - out_message.preamble = "This is an OpenPGP/MIME encrypted " \ - "message (RFC 4880 and 3156)" + out_message = copy.deepcopy(in_message) + out_message.preamble = PGP_PREAMBLE + # The original epilogue is already inside encrypted_payload. Keeping it + # on the copied outer message would also send it in plaintext. + out_message.epilogue = None if 'Content-Type' not in out_message: out_message['Content-Type'] = 'multipart/encrypted' @@ -241,41 +386,24 @@ def _encrypt_message(self, in_message, key_id): def _encrypt_payload(self, payload, key_ids): """Encrypts the payload with the given keys""" - global legacy_gpg payload = encode_string(payload) - self.gpg.armor = True - recipient = [self.gpg.get_key(key_id) for key_id in key_ids] for key in recipient: if key.expired: - if legacy_gpg: - raise gpgme.GpgmeError( - "Key with user email %s " - "is expired!".format(key.uids[0].email)) - else: - raise gpg.errors.GPGMEError( - "Key with user email %s " - "is expired!".format(key.uids[0].email)) - - if legacy_gpg: - plaintext = BytesIO(payload) - ciphertext = BytesIO() - - self.gpg.encrypt(recipient, gpgme.ENCRYPT_ALWAYS_TRUST, - plaintext, ciphertext) - - return ciphertext.getvalue() - else: - (ciphertext, encresult, signresult) = self.gpg.encrypt( - gpg.Data(string=payload), - recipients=recipient, - sign=False, - always_trust=True - ) + raise gpg.errors.GPGMEError( + "Key with user email {0} " + "is expired!".format(key.uids[0].email)) + + (ciphertext, encresult, signresult) = self.gpg.encrypt( + gpg.Data(string=payload), + recipients=recipient, + sign=False, + always_trust=True + ) - return ciphertext + return ciphertext def _user_key(self, email): """Returns the GPG key for the given email address""" @@ -286,9 +414,14 @@ def _user_key(self, email): # for searches like "n" for key in self.gpg.keylist(email): for uid in key.uids: - if uid.email == email: + if uid.email.lower() == email.lower(): return key.subkeys[0].keyid + # Strip sub addressing tag + submail = re.sub('(\\+[^@]+)', '', email) + if submail != email: + return self._user_key(submail) + return None def _add_zeyple_header(self, message): @@ -312,12 +445,42 @@ def _send_message(self, message, recipient): logging.info("Message %s sent", message['Message-id']) -if __name__ == '__main__': +def is_temporary(error): + """Returns whether the relay failed in a way that retrying later could fix""" + if isinstance(error, smtplib.SMTPRecipientsRefused): + codes = [code for code, _ in error.recipients.values()] + elif isinstance(error, smtplib.SMTPResponseException): + codes = [error.smtp_code] + else: + # the relay can't be reached (smtplib errors are OSErrors too) + return isinstance(error, OSError) + + return all(400 <= code < 500 for code in codes) + + +def main(): recipients = sys.argv[1:] - # BBB: Python 2.7 support - binary_stdin = sys.stdin.buffer if PY3K else sys.stdin + binary_stdin = sys.stdin.buffer message = binary_stdin.read() - zeyple = Zeyple() - zeyple.process_message(message, recipients) + zeyple = None + try: + zeyple = Zeyple() + zeyple.process_message(message, recipients) + except Exception as error: + # Never let Postfix bounce the message as the bounce would contain it unencrypted. + # Retrying could still end in a bounce, so with force_encrypt only retry when the + # relay is temporarily unavailable. + if zeyple and zeyple.force_encrypt and not is_temporary(error): + logging.exception("Cannot process message, dropping it as force_encrypt is set") + return 0 + + logging.exception("Cannot process message, Postfix will retry later") + return os.EX_TEMPFAIL + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/test/reverse.bats b/test/reverse.bats index b62a046..94c43ad 100644 --- a/test/reverse.bats +++ b/test/reverse.bats @@ -480,6 +480,12 @@ load 'test_helper/bats-assert/load' assert_success } +@test "checking zeyple: zeyple.py is owned by root and not writable by zeyple" { + run docker exec mailserver_reverse stat -c '%U:%G %a' /usr/local/bin/zeyple.py + assert_success + assert_output "root:root 755" +} + @test "checking zeyple: zeyple.log exist (reverse configuration)" { run docker exec mailserver_reverse [ -f /var/log/zeyple.log ] assert_success @@ -501,6 +507,12 @@ load 'test_helper/bats-assert/load' assert_output "zeyple" } +@test "checking zeyple: one recipient per invocation" { + run docker exec mailserver_reverse postconf -h zeyple_destination_recipient_limit + assert_success + assert_output 1 +} + @test "checking zeyple: user zeyple exist (reverse configuration)" { run docker exec mailserver_reverse /bin/sh -c "id -u zeyple" assert_success @@ -518,16 +530,22 @@ load 'test_helper/bats-assert/load' assert_output 1 } -@test "checking zeyple: 3 emails encrypted in john.doe folder" { +@test "checking zeyple: 4 emails encrypted in john.doe folder (including +subaddress)" { run docker exec mailserver_reverse /bin/sh -c "gzip -cd /var/mail/vhosts/domain.tld/john.doe/subdir/new/* | grep -i 'multipart/encrypted' | wc -l" assert_success - assert_output 3 + assert_output 4 run docker exec mailserver_reverse /bin/sh -c "gzip -cd /var/mail/vhosts/domain.tld/john.doe/subdir/new/* | grep -i 'BEGIN PGP MESSAGE' | wc -l" assert_success - assert_output 3 + assert_output 4 run docker exec mailserver_reverse /bin/sh -c "gzip -cd /var/mail/vhosts/domain.tld/john.doe/subdir/new/* | grep -i 'END PGP MESSAGE' | wc -l" assert_success - assert_output 3 + assert_output 4 +} + +@test "checking zeyple: no emails sent unencrypted" { + run docker exec mailserver_reverse /bin/sh -c "grep -i 'message will be sent unencrypted' /var/log/zeyple.log | wc -l" + assert_success + assert_output 0 } #