Files

279 lines
10 KiB
Python
Raw Permalink Normal View History

"""An in-process SMTP server with STARTTLS, and a throwaway CA made at test time. Never sends mail anywhere.
`make_ca(dir)` writes a CA and a server certificate for 127.0.0.1 and localhost (with `cryptography` when it is
installed, else the `openssl` command). `FakeSmtp(certs)` listens on 127.0.0.1 and keeps every message it takes.
"""
from __future__ import annotations
import contextlib
import datetime as dt
import shutil
import socket
import ssl
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CRLF = b"\r\n"
@dataclass
class Certs:
ca: Path
cert: Path
key: Path
def can_make_ca() -> bool:
try:
import cryptography # noqa: F401
except ImportError:
return shutil.which("openssl") is not None
return True
def make_ca(where: Path, name: str = "ca") -> Certs:
where.mkdir(parents=True, exist_ok=True)
try:
return _with_cryptography(where, name)
except ImportError:
return _with_openssl(where, name)
def _with_cryptography(where: Path, name: str) -> Certs:
import ipaddress
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
now = dt.datetime.now(dt.UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Packet Station test {name}")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, True, True, False, False), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]))
.issuer_name(ca_name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
pem = serialization.Encoding.PEM
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
certs.ca.write_bytes(ca_cert.public_bytes(pem))
certs.cert.write_bytes(cert.public_bytes(pem))
certs.key.write_bytes(
key.private_bytes(pem, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption())
)
return certs
def _with_openssl(where: Path, name: str) -> Certs:
exe = shutil.which("openssl")
if exe is None:
raise RuntimeError("neither cryptography nor openssl is available")
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
ca_key = where / f"{name}.key"
csr = where / f"{name}-server.csr"
ext = where / f"{name}-ext.cnf"
ext.write_text(
"basicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n"
"subjectAltName=DNS:localhost,IP:127.0.0.1\nauthorityKeyIdentifier=keyid\n"
)
ca_cfg = where / f"{name}-ca.cnf"
ca_cfg.write_text(
"[req]\ndistinguished_name=dn\nx509_extensions=v3_ca\nprompt=no\n[dn]\nCN=Packet Station test "
f"{name}\n[v3_ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n"
"subjectKeyIdentifier=hash\n"
)
def run(*args: str) -> None:
subprocess.run([exe, *args], check=True, capture_output=True, timeout=60)
run("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", "-days", "1", "-keyout", str(ca_key),
"-out", str(certs.ca), "-config", str(ca_cfg)) # fmt: skip
run("req", "-newkey", "rsa:2048", "-nodes", "-sha256", "-keyout", str(certs.key), "-out", str(csr),
"-subj", "/CN=localhost") # fmt: skip
run("x509", "-req", "-in", str(csr), "-CA", str(certs.ca), "-CAkey", str(ca_key), "-CAcreateserial",
"-days", "1", "-sha256", "-extfile", str(ext), "-out", str(certs.cert)) # fmt: skip
return certs
@dataclass
class Message:
mail_from: str
rcpts: list[str]
data: bytes
tls: bool
@property
def text(self) -> str:
return self.data.decode("utf-8", "replace")
def header(self, name: str) -> str | None:
import email
msg = email.message_from_bytes(self.data)
value: Any = msg.get(name)
return None if value is None else str(value)
@dataclass
class FakeSmtp:
"""A threaded SMTP server. offer_tls=False leaves STARTTLS out of EHLO. refuse: addresses RCPT refuses."""
certs: Certs | None = None
offer_tls: bool = True
refuse: set[str] = field(default_factory=set)
messages: list[Message] = field(default_factory=list)
connections: int = 0
tls_failures: int = 0
def __post_init__(self) -> None:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(8)
self._sock.settimeout(0.2)
self.host, self.port = self._sock.getsockname()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
self._lock = threading.Lock()
def __enter__(self) -> FakeSmtp:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join(5)
self._sock.close()
def _serve(self) -> None:
while not self._stop.is_set():
try:
conn, _ = self._sock.accept()
except (TimeoutError, OSError):
continue
threading.Thread(target=self._session, args=(conn,), daemon=True).start()
def _session(self, conn: socket.socket) -> None:
with self._lock:
self.connections += 1
conn.settimeout(10)
try:
self._talk(conn)
finally:
with contextlib.suppress(OSError):
conn.close()
def _talk(self, conn: socket.socket) -> None:
sock: Any = conn
buf = b""
tls = False
mail_from, rcpts = "", [] # type: str, list[str]
def send(line: str) -> None:
sock.sendall(line.encode() + CRLF)
def readline() -> bytes | None:
nonlocal buf
while CRLF not in buf:
chunk = sock.recv(65536)
if not chunk:
return None
buf += chunk
line, buf = buf.split(CRLF, 1)
return line
with contextlib.suppress(OSError, ssl.SSLError, ValueError):
send("220 fake.test ESMTP")
while True:
raw = readline()
if raw is None:
return
cmd = raw.decode("utf-8", "replace")
verb = cmd.split(" ", 1)[0].upper()
if verb == "EHLO":
caps = ["250-fake.test", "250-8BITMIME"]
if self.offer_tls and not tls and self.certs is not None:
caps.append("250-STARTTLS")
caps.append("250 SMTPUTF8")
sock.sendall("\r\n".join(caps).encode() + b"\r\n")
elif verb == "HELO":
send("250 fake.test")
elif verb == "STARTTLS":
if not self.offer_tls or self.certs is None or tls:
send("454 TLS not available")
continue
send("220 Ready to start TLS")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(self.certs.cert), str(self.certs.key))
try:
sock = ctx.wrap_socket(conn, server_side=True)
except (ssl.SSLError, OSError):
with self._lock:
self.tls_failures += 1
return
tls, buf, mail_from, rcpts = True, b"", "", []
elif verb == "MAIL":
mail_from = cmd.split(":", 1)[1].split()[0].strip("<>")
rcpts = []
send("250 OK")
elif verb == "RCPT":
addr = cmd.split(":", 1)[1].split()[0].strip("<>")
if addr.lower() in self.refuse:
send("550 No such user")
else:
rcpts.append(addr)
send("250 OK")
elif verb == "DATA":
if not rcpts:
send("554 No valid recipients")
continue
send("354 End data with <CR><LF>.<CR><LF>")
while b"\r\n.\r\n" not in b"\r\n" + buf:
chunk = sock.recv(65536)
if not chunk:
return
buf += chunk
full = b"\r\n" + buf
data, rest = full.split(b"\r\n.\r\n", 1)
buf = rest
with self._lock:
self.messages.append(Message(mail_from, list(rcpts), data[2:], tls))
send("250 OK queued")
elif verb in ("RSET", "NOOP"):
send("250 OK")
elif verb == "QUIT":
send("221 Bye")
return
else:
send("502 Command not implemented")