Some PGP advancement

This commit is contained in:
Mattia Mascarello 2025-03-16 11:45:49 +01:00
parent 4900374cbd
commit ef4556f69e
3 changed files with 226 additions and 28 deletions

View File

@ -17,3 +17,7 @@ apps:
password: "pw1" password: "pw1"
app2@localhost: app2@localhost:
password: "pw2" password: "pw2"
gpg:
home: "/home/user/.gnupg"
passphrase: "phrase"

242
proxy.py
View File

@ -4,13 +4,22 @@ import base64
import socket import socket
import yaml import yaml
import signal import signal
from email import message_from_bytes
from email.parser import BytesParser from email.parser import BytesParser
from email.policy import default 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.controller import Controller
from aiosmtpd.smtp import AuthResult from aiosmtpd.smtp import AuthResult, Envelope, Session
import gnupg
import sys import sys
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
import requests
# --------------------------- # ---------------------------
# Load Configuration # Load Configuration
@ -66,6 +75,7 @@ def setup_logging():
# Setup global logger # Setup global logger
logger = setup_logging() logger = setup_logging()
# --------------------------- # ---------------------------
# Watchdog for Configuration Reload # Watchdog for Configuration Reload
# --------------------------- # ---------------------------
@ -100,7 +110,14 @@ SMTP_PASSWORD = config["smtp_server"]["password"]
SMTP_FROM_NAME = config["smtp_server"]["from_name"] SMTP_FROM_NAME = config["smtp_server"]["from_name"]
SMTP_FROM_EMAIL = config["smtp_server"]["from_email"] 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): def __init__(self):
self.app_credentials = app_credentials self.APP_CREDENTIALS = APP_CREDENTIALS
async def __call__(self, server, session, envelope, mechanism, auth_data): async def __call__(self, server, session, envelope, mechanism, auth_data):
fail_nothandled = AuthResult(success=False, handled=False) fail_nothandled = AuthResult(success=False, handled=False)
@ -137,11 +154,16 @@ class EmailAuthenticator:
return fail_nothandled return fail_nothandled
username, password = decoded.split("\x00") 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}") logger.info(f"✅ Authentication successful for {username}")
return AuthResult(success=True) return AuthResult(success=True)
else: else:
logger.error(f"❌ Authentication failed for {username}: Incorrect password") logger.error(
f"❌ Authentication failed for {username}: Incorrect password"
)
return fail_nothandled return fail_nothandled
except Exception as e: 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, Handler for incoming emails that logs the data, performs authentication,
rewrites the 'From' header, and forwards the email via the specified SMTP server. 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.""" """Handle the EHLO command and advertise AUTH support."""
session.host_name = hostname session.host_name = hostname
return ( return (
@ -170,7 +192,7 @@ class EmailLoggingProxy:
"250 HELP" "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, Handle the DATA command, log the email, modify the From header,
and forward the email via the specified SMTP server. and forward the email via the specified SMTP server.
@ -184,29 +206,193 @@ class EmailLoggingProxy:
# Log the email content # Log the email content
logger.debug(f"Mail data:\n{data.decode('utf-8', errors='replace')}") 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 # Parse the email
msg = BytesParser(policy=default).parsebytes(data) msg = BytesParser(policy=default).parsebytes(data)
# Modify the "From" header with the configuration details # Modify the "From" header with the configuration details
msg.replace_header("From", f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>") msg.replace_header("From", f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>")
# Forward the email via the specified SMTP server encrypted_recipients = []
try: unencrypted_recipients = []
logger.info(f"📤 Forwarding email to {rcpttos} via SMTP server...")
with smtplib.SMTP(SMTP_SERVER_HOST, SMTP_SERVER_PORT) as smtp_server: for recipient in rcpttos:
smtp_server.starttls() # Upgrade connection to TLS if self.fetch_pgp_key(recipient):
smtp_server.login(SMTP_USER, SMTP_PASSWORD) encrypted_recipients.append(recipient)
smtp_server.sendmail(SMTP_FROM_EMAIL, rcpttos, msg.as_bytes()) else:
logger.info(f"✅ Email successfully forwarded to {rcpttos}") 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" 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:
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"<p><strong>⚠️ This email was sent without end-to-end encryption.</strong><br>"
f"This mail server supports automatic PGP encryption.<br>"
f"Consider setting up a PGP key and publishing it to a keyserver "
f"(e.g., keys.openpgp.org).</p>"
)
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"<p><strong>⚠️ This email was sent without end-to-end encryption.</strong><br>"
f"This mail server supports automatic PGP encryption.<br>"
f"Consider setting up a PGP key and publishing it to a keyserver "
f"(e.g., keys.openpgp.org).</p>"
)
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()
smtp_server.login(SMTP_USER, SMTP_PASSWORD)
smtp_server.sendmail(SMTP_FROM_EMAIL, recipients, msg.as_bytes())
logger.info(f"✅ Email successfully sent to {recipients}")
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to send email: {e}") logger.error(f"❌ Failed to send email: {e}")
return "550 Internal server error"
# --------------------------- # ---------------------------
@ -223,8 +409,14 @@ def run_proxy():
authenticator = EmailAuthenticator() authenticator = EmailAuthenticator()
# Create the SMTP instance and pass it as handler to the Controller # Create the SMTP instance and pass it as handler to the Controller
smtp_handler = EmailLoggingProxy() smtp_handler = EmailProxy()
controller = Controller(smtp_handler, hostname=PROXY_HOST, port=PROXY_PORT, authenticator=authenticator, auth_require_tls=False) 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 # Start the config file watcher in a separate thread
config_watcher = Observer() config_watcher = Observer()

View File

@ -1,3 +1,5 @@
aiosmtpd==1.4.6 aiosmtpd==1.4.6
PyYAML==6.0.2 PyYAML==6.0.2
watchdog==6.0.0 watchdog==6.0.0
python-gnupg==0.5.4
requests==2.32.3