From 4e08eb551981e8bf9cf872c24d226b2ad8175e6c Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Fri, 24 Jul 2026 06:35:31 +0200 Subject: [PATCH] Verify authenticated SMTP submission --- contrib/testing/README.md | 4 + contrib/testing/smtp-submission-auth-check.py | 268 ++++++++++++++++++ docs/release-testing-0.7.md | 4 +- docs/test-evidence/0.7-r1.md | 35 +++ 4 files changed, 309 insertions(+), 2 deletions(-) create mode 100755 contrib/testing/smtp-submission-auth-check.py diff --git a/contrib/testing/README.md b/contrib/testing/README.md index e719597..f8dfa54 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -18,6 +18,10 @@ one-off files in `/tmp`: the message through IMAP/POP over explicit and implicit TLS. - `smtp-basic-check.py` exercises the port-25 greeting and the non-delivery HELO/EHLO, NOOP, RSET, HELP, VRFY-policy, and QUIT command surface. +- `smtp-submission-auth-check.py` exercises port 587 STARTTLS and port 465 + implicit TLS directly, including the shared GSASL PLAIN and LOGIN + mechanisms, pre-TLS AUTH rejection, invalid credentials, and repeated + STARTTLS rejection. It authenticates but does not submit a message. - `sanitizer-protocol-session.sh` runs that protocol path under ASan/UBSan/LSan or TSan and validates clean process shutdown. - `asan-protocol-session.sh` and `tsan-protocol-session.sh` preserve the two diff --git a/contrib/testing/smtp-submission-auth-check.py b/contrib/testing/smtp-submission-auth-check.py new file mode 100755 index 0000000..fc8fbf6 --- /dev/null +++ b/contrib/testing/smtp-submission-auth-check.py @@ -0,0 +1,268 @@ +#!/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) diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 1bad150..ca448dd 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -124,8 +124,8 @@ inputs and outputs is absent from the ZIP. | ID | Test | R1 | R2 | R3 | | --- | --- | --- | --- | --- | | SMTP-01 | Port 25 greeting, HELO, EHLO, NOOP, RSET, HELP, VRFY policy, QUIT | [PASS](test-evidence/0.7-r1.md#smtp-01) | | | -| SMTP-02 | Port 587 STARTTLS submission with shared GSASL authentication | | | | -| SMTP-03 | Port 465 implicit TLS submission | | | | +| SMTP-02 | Port 587 STARTTLS submission with shared GSASL authentication | [PASS](test-evidence/0.7-r1.md#smtp-0203) | | | +| SMTP-03 | Port 465 implicit TLS submission | [PASS](test-evidence/0.7-r1.md#smtp-0203) | | | | SMTP-04 | Trusted port 26 accepts only configured networks and applies limits | | | | | SMTP-05 | Internal sender/domain normalisation uses peer DNS/IP mapping safely | | | | | SMTP-06 | Local authenticated delivery from test1 to test2 | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 0ce6ef4..2ac4ae8 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -2023,3 +2023,38 @@ SMTP-01 PASS host=127.0.0.1 port=25 greeting=yes helo-lines=1 ehlo-lines=11 noop The focused native `smtp-capabilities` and `smtp-protocol` tests also passed. The live test sends no envelope or message data and therefore leaves Store, Queue and external systems unchanged. + +## SMTP-02/03 + +Result: **PASS** + +The reusable `smtp-submission-auth-check.py` exercises both authenticated +submission listeners without relying on a high-level SMTP client's mechanism +selection: + +```sh +export BONGO_TEST_PASSWORD='' +./contrib/testing/smtp-submission-auth-check.py +``` + +On port 587 the first EHLO advertised STARTTLS but did not advertise AUTH. +An explicit AUTH attempt containing no real credential was rejected with +`538`. After STARTTLS and the mandatory second EHLO, the server advertised +exactly the supported GSASL password mechanisms, including PLAIN and LOGIN. +Separate TLS sessions completed both mechanisms with the disposable `test1` +credential and rejected a deliberately wrong password with `535`. A repeated +STARTTLS request in an already encrypted session was rejected with `554`. + +Port 465 performed TLS before the SMTP greeting, did not advertise STARTTLS, +and passed the same PLAIN, LOGIN, and invalid-password cases. Both listeners +negotiated TLS 1.3 while the test's client-side minimum was TLS 1.2. The +installed-service run reported: + +```text +SMTP-02/03 PASS host=127.0.0.1 submission=587/TLSv1.3 smtps=465/TLSv1.3 pre-tls-auth=538 auth=PLAIN,LOGIN invalid=535 repeated-starttls=554 +``` + +The focused native `sasl-password-mechanisms`, `smtp-capabilities`, and +`smtp-protocol` tests also passed. The live check authenticates only; it sends +no envelope or message data and therefore leaves Store, Queue, and external +systems unchanged.