mirror of
https://github.com/MatMasIt/smtp_app_proxy.git
synced 2025-04-21 03:45:25 +02:00
Some PGP advancement
This commit is contained in:
parent
4900374cbd
commit
ef4556f69e
@ -17,3 +17,7 @@ apps:
|
|||||||
password: "pw1"
|
password: "pw1"
|
||||||
app2@localhost:
|
app2@localhost:
|
||||||
password: "pw2"
|
password: "pw2"
|
||||||
|
|
||||||
|
gpg:
|
||||||
|
home: "/home/user/.gnupg"
|
||||||
|
passphrase: "phrase"
|
238
proxy.py
238
proxy.py
@ -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 = []
|
||||||
|
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:
|
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"<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:
|
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.login(SMTP_USER, SMTP_PASSWORD)
|
||||||
smtp_server.sendmail(SMTP_FROM_EMAIL, rcpttos, msg.as_bytes())
|
smtp_server.sendmail(SMTP_FROM_EMAIL, recipients, msg.as_bytes())
|
||||||
logger.info(f"✅ Email successfully forwarded to {rcpttos}")
|
logger.info(f"✅ Email successfully sent to {recipients}")
|
||||||
return "250 OK"
|
|
||||||
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()
|
||||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user