diff --git a/config.example.yml b/config.example.yml index 346c3b4..0ec82c6 100644 --- a/config.example.yml +++ b/config.example.yml @@ -17,3 +17,7 @@ apps: password: "pw1" app2@localhost: password: "pw2" + +gpg: + home: "/home/user/.gnupg" + passphrase: "phrase" \ No newline at end of file diff --git a/proxy.py b/proxy.py index ba60e97..55bc1d0 100644 --- a/proxy.py +++ b/proxy.py @@ -4,13 +4,22 @@ import base64 import socket import yaml import signal +from email import message_from_bytes from email.parser import BytesParser from email.policy import default +from email.message import EmailMessage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email.encoders import encode_base64 from aiosmtpd.controller import Controller -from aiosmtpd.smtp import AuthResult +from aiosmtpd.smtp import AuthResult, Envelope, Session +import gnupg import sys from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler +import requests + # --------------------------- # Load Configuration @@ -46,17 +55,17 @@ def is_port_available(host: str, port: int) -> bool: def setup_logging(): """Set up logging configuration with a single stream handler.""" logger = logging.getLogger() # Get the root logger - + # Set the logging level to DEBUG logger.setLevel(logging.DEBUG) - + # Create a stream handler to output logs to stdout stream_handler = logging.StreamHandler(sys.stdout) - + # Set the log format formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") stream_handler.setFormatter(formatter) - + # Add the handler to the logger logger.addHandler(stream_handler) @@ -66,6 +75,7 @@ def setup_logging(): # Setup global logger logger = setup_logging() + # --------------------------- # Watchdog for Configuration Reload # --------------------------- @@ -100,7 +110,14 @@ SMTP_PASSWORD = config["smtp_server"]["password"] SMTP_FROM_NAME = config["smtp_server"]["from_name"] SMTP_FROM_EMAIL = config["smtp_server"]["from_email"] -app_credentials = config["apps"] # Allowed users' credentials + +APP_CREDENTIALS = config["apps"] # Allowed users' credentials +PASSPHRASE = config["gpg"]["passphrase"] + +gpg = gnupg.GPG(gnupghome=config["gpg"]["home"]) +gpg.encoding = "utf-8" + +KEYSERVER_URL = "https://keys.openpgp.org/vks/v1/by-email/" # --------------------------- @@ -112,7 +129,7 @@ class EmailAuthenticator: """ def __init__(self): - self.app_credentials = app_credentials + self.APP_CREDENTIALS = APP_CREDENTIALS async def __call__(self, server, session, envelope, mechanism, auth_data): fail_nothandled = AuthResult(success=False, handled=False) @@ -137,11 +154,16 @@ class EmailAuthenticator: return fail_nothandled username, password = decoded.split("\x00") - if username in self.app_credentials and self.app_credentials[username]["password"] == password: + if ( + username in self.APP_CREDENTIALS + and self.APP_CREDENTIALS[username]["password"] == password + ): logger.info(f"āœ… Authentication successful for {username}") return AuthResult(success=True) else: - logger.error(f"āŒ Authentication failed for {username}: Incorrect password") + logger.error( + f"āŒ Authentication failed for {username}: Incorrect password" + ) return fail_nothandled except Exception as e: @@ -150,15 +172,15 @@ class EmailAuthenticator: # --------------------------- -# EmailLoggingProxy Class +# EmailProxy Class # --------------------------- -class EmailLoggingProxy: +class EmailProxy: """ Handler for incoming emails that logs the data, performs authentication, rewrites the 'From' header, and forwards the email via the specified SMTP server. """ - async def handle_EHLO(self, server, session, envelope, hostname): + async def handle_EHLO(self, server, session: Session, envelope: Envelope, hostname): """Handle the EHLO command and advertise AUTH support.""" session.host_name = hostname return ( @@ -170,7 +192,7 @@ class EmailLoggingProxy: "250 HELP" ) - async def handle_DATA(self, server, session, envelope): + async def handle_DATA(self, server, session: Session, envelope: Envelope): """ Handle the DATA command, log the email, modify the From header, and forward the email via the specified SMTP server. @@ -184,29 +206,193 @@ class EmailLoggingProxy: # Log the email content logger.debug(f"Mail data:\n{data.decode('utf-8', errors='replace')}") - # Validate sender against allowed app credentials - if mailfrom not in app_credentials: - logger.error(f"🚫 Unauthorized sender: {mailfrom}") - return "550 Unauthorized sender" - # Parse the email msg = BytesParser(policy=default).parsebytes(data) # Modify the "From" header with the configuration details msg.replace_header("From", f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>") - # Forward the email via the specified SMTP server + encrypted_recipients = [] + unencrypted_recipients = [] + + for recipient in rcpttos: + if self.fetch_pgp_key(recipient): + encrypted_recipients.append(recipient) + else: + unencrypted_recipients.append(recipient) + + bcc_recipients = [] + if "Bcc" in msg: + bcc_recipients = msg["Bcc"].split(",") + del msg["Bcc"] + + if encrypted_recipients: + encrypted_msg = self.encrypt_mime_email(msg, encrypted_recipients) + if encrypted_msg: + self.send_email(encrypted_msg, encrypted_recipients) + + if unencrypted_recipients: + plaintext_msg = self.add_unencrypted_warning(msg) + self.send_email(plaintext_msg, unencrypted_recipients) + + # Send separate emails to each BCC recipient for privacy + for bcc in bcc_recipients: + if bcc in encrypted_recipients: + self.send_email(self.encrypt_mime_email(msg, [bcc]), [bcc]) + else: + self.send_email(self.add_unencrypted_warning(msg), [bcc]) + + return "250 OK" + + def fetch_pgp_key(self, email): + """Checks if a PGP key exists locally or fetches it from the keyserver.""" + + # Check if the key exists locally + keys = gpg.list_keys(keys=email) + if keys: + logger.info(f"āœ… PGP key for {email} found locally.") + return True # Key exists locally + + # Key does not exist locally, so try fetching from the keyserver + logger.info(f"šŸ” Looking up PGP key for {email} on keyserver...") + + # Request the key from the keyserver API + keyserver_url = f"{KEYSERVER_URL}{email}" try: - logger.info(f"šŸ“¤ Forwarding email to {rcpttos} via SMTP server...") + response = requests.get(keyserver_url) + + if response.status_code == 200: + key_data = response.text + logger.info( + f"āœ… Successfully retrieved PGP key for {email} from keyserver." + ) + + # Import the key into GPG + import_result = gpg.import_keys(key_data) + + if import_result.count > 0: + logger.info( + f"āœ… PGP key for {email} successfully imported into GPG." + ) + return True + else: + logger.warning(f"āŒ Failed to import PGP key for {email}.") + return False + else: + logger.warning( + f"āŒ No PGP key found for {email}. HTTP status code: {response.status_code}" + ) + return False + except requests.exceptions.RequestException as e: + logger.error(f"āŒ Error while contacting keyserver: {e}") + return False + + def encrypt_mime_email(self, msg, recipients): + """Encrypts a full email as PGP/MIME with multiple recipients and hides the subject.""" + original_subject = msg["Subject"] + msg.replace_header("Subject", "...") + + + # Convert email to MIME + mime_msg = MIMEMultipart() + mime_msg["Subject"] = "..." + mime_msg["From"] = msg["From"] + mime_msg["To"] = ", ".join(recipients) + + # Add original subject inside encrypted content + text_part = MIMEText(f"Subject: {original_subject}\n\n{msg.as_string()}") + mime_msg.attach(text_part) + + # Encrypt with all recipient keys + encrypted_data = gpg.encrypt( + mime_msg.as_string(), + recipients=recipients, + sign=SMTP_FROM_EMAIL, + always_trust=True, + passphrase=PASSPHRASE, + ) + + if not encrypted_data.ok: + logger.error(f"āŒ Encryption failed: {encrypted_data.stderr}") + return None + + # Create PGP/MIME encrypted email + encrypted_email = MIMEMultipart( + "encrypted", protocol="application/pgp-encrypted" + ) + encrypted_email["Subject"] = "Encrypted Message" + encrypted_email["From"] = msg["From"] + encrypted_email["To"] = ", ".join(recipients) + + # PGP version header + pgp_header = MIMEBase("application", "pgp-encrypted") + pgp_header.add_header("Content-Description", "PGP/MIME Versions Header") + pgp_header.set_payload("Version: 1\r\n") + encrypted_email.attach(pgp_header) + + # Encrypted email payload + encrypted_part = MIMEBase("application", "octet-stream") + encrypted_part.set_payload(str(encrypted_data)) + encrypted_part.add_header( + "Content-Disposition", "inline", filename="encrypted.asc" + ) + encrypted_email.attach(encrypted_part) + + return encrypted_email + + def add_unencrypted_warning(self, msg): + """Adds a warning footer to unencrypted emails for both plain-text and HTML bodies.""" + warning_text = ( + "\n\nāš ļø This email was sent without end-to-end encryption.\n" + "This mail server supports automatic PGP encryption.\n" + "Consider setting up a PGP key and publishing it to a keyserver (e.g., keys.openpgp.org)." + ) + + # For multipart emails (text/plain + text/html) + if msg.is_multipart(): + for part in msg.walk(): + # Check if the part is plain text + if part.get_content_type() == "text/plain": + part.set_payload(part.get_payload() + warning_text) + # Check if the part is HTML text + elif part.get_content_type() == "text/html": + html_warning = ( + f"

āš ļø This email was sent without end-to-end encryption.
" + f"This mail server supports automatic PGP encryption.
" + f"Consider setting up a PGP key and publishing it to a keyserver " + f"(e.g., keys.openpgp.org).

" + ) + part.set_payload(part.get_payload() + html_warning) + part.replace_header( + "Content-Transfer-Encoding", "quoted-printable" + ) # Ensure HTML part is encoded correctly + else: + # If it's a non-multipart message (either plain-text or HTML only) + if msg.get_content_type() == "text/plain": + msg.set_payload(msg.get_payload() + warning_text) + elif msg.get_content_type() == "text/html": + html_warning = ( + f"

āš ļø This email was sent without end-to-end encryption.
" + f"This mail server supports automatic PGP encryption.
" + f"Consider setting up a PGP key and publishing it to a keyserver " + f"(e.g., keys.openpgp.org).

" + ) + msg.set_payload(msg.get_payload() + html_warning) + msg.replace_header("Content-Transfer-Encoding", "quoted-printable") + + return msg + + def send_email(self, msg, recipients): + """Sends an email via SMTP, ensuring BCC recipients remain private.""" + try: + logger.info(f"šŸ“¤ Sending email to {recipients} via SMTP...") with smtplib.SMTP(SMTP_SERVER_HOST, SMTP_SERVER_PORT) as smtp_server: - smtp_server.starttls() # Upgrade connection to TLS + smtp_server.starttls() smtp_server.login(SMTP_USER, SMTP_PASSWORD) - smtp_server.sendmail(SMTP_FROM_EMAIL, rcpttos, msg.as_bytes()) - logger.info(f"āœ… Email successfully forwarded to {rcpttos}") - return "250 OK" + smtp_server.sendmail(SMTP_FROM_EMAIL, recipients, msg.as_bytes()) + logger.info(f"āœ… Email successfully sent to {recipients}") except Exception as e: logger.error(f"āŒ Failed to send email: {e}") - return "550 Internal server error" # --------------------------- @@ -223,9 +409,15 @@ def run_proxy(): authenticator = EmailAuthenticator() # Create the SMTP instance and pass it as handler to the Controller - smtp_handler = EmailLoggingProxy() - controller = Controller(smtp_handler, hostname=PROXY_HOST, port=PROXY_PORT, authenticator=authenticator, auth_require_tls=False) - + smtp_handler = EmailProxy() + controller = Controller( + smtp_handler, + hostname=PROXY_HOST, + port=PROXY_PORT, + authenticator=authenticator, + auth_require_tls=False, + ) + # Start the config file watcher in a separate thread config_watcher = Observer() config_watcher.schedule(ConfigReloadHandler(), ".", recursive=False) diff --git a/requirements.txt b/requirements.txt index 156ec6c..5f03202 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ aiosmtpd==1.4.6 PyYAML==6.0.2 watchdog==6.0.0 +python-gnupg==0.5.4 +requests==2.32.3 \ No newline at end of file