269 lines
9.1 KiB
Python
Executable File
269 lines
9.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# This program is free software, licensed under the terms of the GNU GPL.
|
|
# See the Bongo COPYING file for full details.
|
|
# Copyright (c) 2026 Bongo Project contributors
|
|
|
|
"""Verify authenticated SMTP submission over STARTTLS and implicit TLS."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
import re
|
|
import socket
|
|
import ssl
|
|
|
|
|
|
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
|
|
SUBMISSION_PORT = int(os.environ.get("BONGO_TEST_SUBMISSION_PORT", "587"))
|
|
SMTPS_PORT = int(os.environ.get("BONGO_TEST_SMTPS_PORT", "465"))
|
|
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
|
|
HELO_NAME = os.environ.get("BONGO_TEST_HELO", "client.bongo.test")
|
|
USERNAME = os.environ.get("BONGO_TEST_USER", "test1")
|
|
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
|
|
WRONG_PASSWORD = os.environ.get(
|
|
"BONGO_TEST_WRONG_PASSWORD", "definitely-not-the-test-password"
|
|
)
|
|
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
|
|
|
|
|
|
class SMTPCheckError(RuntimeError):
|
|
"""Raised when the live SMTP peer violates the tested contract."""
|
|
|
|
|
|
class SMTPConnection:
|
|
def __init__(self, port: int, implicit_tls: bool = False) -> None:
|
|
raw_socket = socket.create_connection((HOST, port), timeout=TIMEOUT)
|
|
raw_socket.settimeout(TIMEOUT)
|
|
self.socket: socket.socket | ssl.SSLSocket = raw_socket
|
|
self.reader = raw_socket.makefile("rb")
|
|
self.tls_version = ""
|
|
if implicit_tls:
|
|
self.start_tls()
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self.reader.close()
|
|
finally:
|
|
self.socket.close()
|
|
|
|
def start_tls(self) -> None:
|
|
self.reader.close()
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
self.socket = context.wrap_socket(
|
|
self.socket, server_hostname=HOST
|
|
)
|
|
self.socket.settimeout(TIMEOUT)
|
|
self.reader = self.socket.makefile("rb")
|
|
self.tls_version = self.socket.version() or ""
|
|
if self.tls_version not in {"TLSv1.2", "TLSv1.3"}:
|
|
raise SMTPCheckError(
|
|
f"negotiated unexpected TLS version {self.tls_version!r}"
|
|
)
|
|
|
|
def response(self, *expected: int) -> tuple[int, list[bytes]]:
|
|
lines: list[bytes] = []
|
|
response_code = -1
|
|
while True:
|
|
line = self.reader.readline(8194)
|
|
if not line:
|
|
raise SMTPCheckError(
|
|
f"connection closed while waiting for SMTP {expected}"
|
|
)
|
|
if len(line) > 8192:
|
|
raise SMTPCheckError("SMTP response line exceeded 8192 bytes")
|
|
match = RESPONSE.match(line)
|
|
if match is None:
|
|
raise SMTPCheckError(f"malformed SMTP response: {line!r}")
|
|
code = int(match.group(1))
|
|
if response_code == -1:
|
|
response_code = code
|
|
elif code != response_code:
|
|
raise SMTPCheckError(
|
|
f"multiline SMTP response changed code: {line!r}"
|
|
)
|
|
if code not in expected:
|
|
raise SMTPCheckError(
|
|
f"expected SMTP {expected}, received {line!r}"
|
|
)
|
|
lines.append(match.group(3))
|
|
if match.group(2) == b" ":
|
|
return code, lines
|
|
|
|
def command(self, command: str, *expected: int) -> tuple[int, list[bytes]]:
|
|
self.socket.sendall(command.encode("ascii") + b"\r\n")
|
|
return self.response(*expected)
|
|
|
|
|
|
def capabilities(lines: list[bytes]) -> dict[str, list[str]]:
|
|
result: dict[str, list[str]] = {}
|
|
for line in lines[1:]:
|
|
fields = line.decode("ascii", "strict").upper().split()
|
|
if not fields:
|
|
continue
|
|
keyword = fields[0]
|
|
if keyword.startswith("AUTH="):
|
|
result.setdefault("AUTH", []).append(keyword[5:])
|
|
result["AUTH"].extend(fields[1:])
|
|
else:
|
|
result.setdefault(keyword, []).extend(fields[1:])
|
|
return result
|
|
|
|
|
|
def encode(value: bytes | str) -> str:
|
|
if isinstance(value, str):
|
|
value = value.encode("utf-8")
|
|
return base64.b64encode(value).decode("ascii")
|
|
|
|
|
|
def decode_challenge(lines: list[bytes]) -> bytes:
|
|
if len(lines) != 1:
|
|
raise SMTPCheckError(f"unexpected multiline AUTH challenge {lines!r}")
|
|
try:
|
|
return base64.b64decode(lines[0], validate=True)
|
|
except ValueError as error:
|
|
raise SMTPCheckError(
|
|
f"invalid base64 AUTH challenge {lines[0]!r}"
|
|
) from error
|
|
|
|
|
|
def connect(port: int, implicit_tls: bool = False) -> SMTPConnection:
|
|
client = SMTPConnection(port, implicit_tls)
|
|
client.response(220)
|
|
return client
|
|
|
|
|
|
def assert_auth_capabilities(
|
|
client: SMTPConnection, expect_starttls: bool
|
|
) -> None:
|
|
_, lines = client.command(f"EHLO {HELO_NAME}", 250)
|
|
advertised = capabilities(lines)
|
|
mechanisms = set(advertised.get("AUTH", []))
|
|
if not {"PLAIN", "LOGIN"}.issubset(mechanisms):
|
|
raise SMTPCheckError(
|
|
f"AUTH PLAIN LOGIN not advertised: {sorted(mechanisms)}"
|
|
)
|
|
if ("STARTTLS" in advertised) != expect_starttls:
|
|
raise SMTPCheckError(
|
|
"STARTTLS advertisement did not match the TLS state"
|
|
)
|
|
|
|
|
|
def auth_plain(port: int, implicit_tls: bool, password: str, expected: int) -> str:
|
|
client = connect(port, implicit_tls)
|
|
try:
|
|
if not implicit_tls:
|
|
client.command(f"EHLO {HELO_NAME}", 250)
|
|
client.command("STARTTLS", 220)
|
|
client.start_tls()
|
|
assert_auth_capabilities(client, False)
|
|
payload = encode(b"\0" + USERNAME.encode() + b"\0" + password.encode())
|
|
client.command(f"AUTH PLAIN {payload}", expected)
|
|
client.command("QUIT", 221)
|
|
return client.tls_version
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def auth_login(port: int, implicit_tls: bool) -> str:
|
|
client = connect(port, implicit_tls)
|
|
try:
|
|
if not implicit_tls:
|
|
client.command(f"EHLO {HELO_NAME}", 250)
|
|
client.command("STARTTLS", 220)
|
|
client.start_tls()
|
|
assert_auth_capabilities(client, False)
|
|
_, username_challenge = client.command("AUTH LOGIN", 334)
|
|
if decode_challenge(username_challenge) not in {
|
|
b"User Name",
|
|
b"Username:",
|
|
}:
|
|
raise SMTPCheckError(
|
|
f"unexpected LOGIN username challenge {username_challenge!r}"
|
|
)
|
|
_, password_challenge = client.command(encode(USERNAME), 334)
|
|
if decode_challenge(password_challenge) not in {
|
|
b"Password",
|
|
b"Password:",
|
|
}:
|
|
raise SMTPCheckError(
|
|
f"unexpected LOGIN password challenge {password_challenge!r}"
|
|
)
|
|
client.command(encode(PASSWORD), 235)
|
|
client.command("QUIT", 221)
|
|
return client.tls_version
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def check_submission() -> str:
|
|
client = connect(SUBMISSION_PORT)
|
|
try:
|
|
_, pre_tls_lines = client.command(f"EHLO {HELO_NAME}", 250)
|
|
pre_tls = capabilities(pre_tls_lines)
|
|
if "STARTTLS" not in pre_tls:
|
|
raise SMTPCheckError("submission did not advertise STARTTLS")
|
|
if "AUTH" in pre_tls:
|
|
raise SMTPCheckError("submission advertised AUTH before TLS")
|
|
client.command("AUTH PLAIN =", 538)
|
|
client.command("STARTTLS", 220)
|
|
client.start_tls()
|
|
assert_auth_capabilities(client, False)
|
|
client.command("STARTTLS", 554)
|
|
client.command("QUIT", 221)
|
|
tls_version = client.tls_version
|
|
finally:
|
|
client.close()
|
|
|
|
if auth_plain(SUBMISSION_PORT, False, PASSWORD, 235) != tls_version:
|
|
raise SMTPCheckError("submission TLS version changed between sessions")
|
|
auth_plain(SUBMISSION_PORT, False, WRONG_PASSWORD, 535)
|
|
auth_login(SUBMISSION_PORT, False)
|
|
return tls_version
|
|
|
|
|
|
def check_smtps() -> str:
|
|
client = connect(SMTPS_PORT, True)
|
|
try:
|
|
assert_auth_capabilities(client, False)
|
|
client.command("QUIT", 221)
|
|
tls_version = client.tls_version
|
|
finally:
|
|
client.close()
|
|
|
|
if auth_plain(SMTPS_PORT, True, PASSWORD, 235) != tls_version:
|
|
raise SMTPCheckError("SMTPS TLS version changed between sessions")
|
|
auth_plain(SMTPS_PORT, True, WRONG_PASSWORD, 535)
|
|
auth_login(SMTPS_PORT, True)
|
|
return tls_version
|
|
|
|
|
|
def main() -> int:
|
|
if not PASSWORD:
|
|
raise SMTPCheckError("BONGO_TEST_PASSWORD must be set")
|
|
if WRONG_PASSWORD == PASSWORD:
|
|
raise SMTPCheckError(
|
|
"BONGO_TEST_WRONG_PASSWORD must differ from BONGO_TEST_PASSWORD"
|
|
)
|
|
submission_tls = check_submission()
|
|
smtps_tls = check_smtps()
|
|
print(
|
|
"SMTP-02/03 PASS "
|
|
f"host={HOST} submission={SUBMISSION_PORT}/{submission_tls} "
|
|
f"smtps={SMTPS_PORT}/{smtps_tls} "
|
|
"pre-tls-auth=538 auth=PLAIN,LOGIN invalid=535 "
|
|
"repeated-starttls=554"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (OSError, UnicodeError, ValueError, SMTPCheckError) as error:
|
|
print(f"SMTP-02/03 FAIL: {error}")
|
|
raise SystemExit(1)
|