diff --git a/contrib/testing/README.md b/contrib/testing/README.md index df3766f..265eb1b 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -45,6 +45,10 @@ one-off files in `/tmp`: 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. +- `imap-auth-capability-check.py` verifies the IMAP4rev2, IMAP4rev1, and + legacy IMAP4 capability contract, STARTTLS with cleartext + `LOGINDISABLED`, plaintext-pipeline disposal, implicit TLS, LOGIN, and + shared GSASL PLAIN/LOGIN authentication. - `smtp4dev-fixture.py` manages two authenticated, loopback-only external SMTP provider fixtures for STARTTLS and implicit TLS plus a TLS-required, no-auth relayhost. It validates credentials, captures messages through diff --git a/contrib/testing/imap-auth-capability-check.py b/contrib/testing/imap-auth-capability-check.py new file mode 100755 index 0000000..f45282a --- /dev/null +++ b/contrib/testing/imap-auth-capability-check.py @@ -0,0 +1,272 @@ +#!/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 Bongo IMAP capabilities, TLS transitions, and authentication.""" + +from __future__ import annotations + +import base64 +import os +import re +import select +import socket +import ssl + + +HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1") +IMAP_PORT = int(os.environ.get("BONGO_TEST_IMAP_PORT", "143")) +IMAPS_PORT = int(os.environ.get("BONGO_TEST_IMAPS_PORT", "993")) +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10")) +USERNAME = os.environ.get("BONGO_TEST_USER", "test1") +PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "") +TAGGED = re.compile(rb"^([A-Za-z0-9]+) (OK|NO|BAD)(?: .*)?\r\n$") + +BASE_CAPABILITIES = { + "IMAP4REV2", "IMAP4REV1", "IMAP4", "NAMESPACE", "ID", "IDLE", + "ENABLE", "UTF8=ACCEPT", "UIDPLUS", "MOVE", "SPECIAL-USE", + "CREATE-SPECIAL-USE", "LIST-EXTENDED", "LIST-STATUS", "STATUS=SIZE", + "QUOTA", "QUOTA=RES-STORAGE", "LITERAL+", "APPENDLIMIT=999999", + "XSENDER", +} + + +class IMAPCheckError(RuntimeError): + """Raised when the live IMAP peer violates the tested contract.""" + + +class IMAPConnection: + def __init__(self, port: int, implicit_tls: bool = False) -> None: + raw = socket.create_connection((HOST, port), timeout=TIMEOUT) + raw.settimeout(TIMEOUT) + self.socket: socket.socket | ssl.SSLSocket = raw + self.reader = raw.makefile("rb") + self.counter = 0 + self.tls_version = "" + if implicit_tls: + self.start_tls() + greeting = self.line() + if not greeting.startswith(b"* OK "): + raise IMAPCheckError(f"unexpected IMAP greeting: {greeting!r}") + + def close(self) -> None: + try: + self.reader.close() + finally: + self.socket.close() + + def line(self) -> bytes: + line = self.reader.readline(65538) + if not line: + raise IMAPCheckError("connection closed while waiting for IMAP response") + if len(line) > 65536: + raise IMAPCheckError("IMAP response line exceeded 65536 bytes") + if not line.endswith(b"\r\n"): + raise IMAPCheckError(f"unterminated IMAP response: {line!r}") + return line + + 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 IMAPCheckError( + f"negotiated unexpected TLS version {self.tls_version!r}" + ) + + def command( + self, command: str, expected: tuple[str, ...] = ("OK",) + ) -> list[bytes]: + self.counter += 1 + tag = f"T{self.counter:04d}" + self.socket.sendall(f"{tag} {command}\r\n".encode("utf-8")) + lines: list[bytes] = [] + while True: + line = self.line() + match = TAGGED.match(line) + if match is not None and match.group(1).decode("ascii") == tag: + status = match.group(2).decode("ascii") + if status not in expected: + raise IMAPCheckError( + f"{command!r} expected {expected}, received {line!r}" + ) + return lines + [line] + lines.append(line) + + +def quote(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def capabilities(lines: list[bytes]) -> set[str]: + for line in lines: + if line.upper().startswith(b"* CAPABILITY "): + return { + item.upper() + for item in line.decode("ascii", "strict").strip().split()[2:] + } + raise IMAPCheckError(f"CAPABILITY response missing in {lines!r}") + + +def assert_capabilities( + advertised: set[str], *, cleartext: bool, authenticated: bool = False +) -> None: + missing = BASE_CAPABILITIES - advertised + if missing: + raise IMAPCheckError(f"missing IMAP capabilities: {sorted(missing)}") + if cleartext: + if "STARTTLS" not in advertised or "LOGINDISABLED" not in advertised: + raise IMAPCheckError( + "cleartext IMAP must advertise STARTTLS and LOGINDISABLED" + ) + if any(item.startswith("AUTH=") for item in advertised): + raise IMAPCheckError("cleartext IMAP advertised authentication") + else: + if "STARTTLS" in advertised or "LOGINDISABLED" in advertised: + raise IMAPCheckError("TLS IMAP advertised a cleartext-only capability") + if not authenticated: + required = {"AUTH=PLAIN", "AUTH=LOGIN", "SASL-IR"} + if not required.issubset(advertised): + raise IMAPCheckError( + "missing TLS authentication capabilities: " + f"{sorted(required - advertised)}" + ) + + +def check_starttls() -> str: + client = IMAPConnection(IMAP_PORT) + try: + assert_capabilities(capabilities(client.command("CAPABILITY")), cleartext=True) + client.command( + f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}", expected=("NO",) + ) + client.command("STARTTLS") + client.start_tls() + assert_capabilities( + capabilities(client.command("CAPABILITY")), cleartext=False + ) + client.command("STARTTLS", expected=("NO", "BAD")) + client.command(f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}") + client.command("LOGOUT") + return client.tls_version + finally: + client.close() + + +def check_starttls_pipeline_is_discarded() -> None: + client = IMAPConnection(IMAP_PORT) + try: + client.counter += 1 + tag = f"T{client.counter:04d}" + client.socket.sendall( + f"{tag} STARTTLS\r\nT9999 LOGIN " + f"{quote(USERNAME)} {quote(PASSWORD)}\r\n".encode("utf-8") + ) + line = client.line() + if not line.startswith(f"{tag} OK ".encode("ascii")): + raise IMAPCheckError(f"STARTTLS failed before pipeline test: {line!r}") + client.start_tls() + pending = ( + client.socket.pending() + if isinstance(client.socket, ssl.SSLSocket) + else 0 + ) + readable, _, _ = select.select([client.socket], [], [], 0.5) + leaked = client.line() if pending or readable else b"" + if leaked: + raise IMAPCheckError( + f"cleartext command crossed STARTTLS boundary: {leaked!r}" + ) + client.command("LOGOUT") + finally: + client.close() + + +def auth_plain() -> str: + client = IMAPConnection(IMAPS_PORT, implicit_tls=True) + try: + assert_capabilities( + capabilities(client.command("CAPABILITY")), cleartext=False + ) + payload = base64.b64encode( + b"\0" + USERNAME.encode() + b"\0" + PASSWORD.encode() + ).decode("ascii") + client.command(f"AUTHENTICATE PLAIN {payload}") + client.command("LOGOUT") + return client.tls_version + finally: + client.close() + + +def auth_login() -> str: + client = IMAPConnection(IMAPS_PORT, implicit_tls=True) + try: + assert_capabilities( + capabilities(client.command("CAPABILITY")), cleartext=False + ) + client.counter += 1 + tag = f"T{client.counter:04d}" + client.socket.sendall(f"{tag} AUTHENTICATE LOGIN\r\n".encode("ascii")) + if not client.line().startswith(b"+ "): + raise IMAPCheckError("missing SASL LOGIN username challenge") + client.socket.sendall(base64.b64encode(USERNAME.encode()) + b"\r\n") + if not client.line().startswith(b"+ "): + raise IMAPCheckError("missing SASL LOGIN password challenge") + client.socket.sendall(base64.b64encode(PASSWORD.encode()) + b"\r\n") + completion = client.line() + if not completion.startswith(f"{tag} OK ".encode("ascii")): + raise IMAPCheckError(f"SASL LOGIN failed: {completion!r}") + client.command("LOGOUT") + return client.tls_version + finally: + client.close() + + +def imaps_login() -> str: + client = IMAPConnection(IMAPS_PORT, implicit_tls=True) + try: + assert_capabilities( + capabilities(client.command("CAPABILITY")), cleartext=False + ) + client.command(f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}") + assert_capabilities( + capabilities(client.command("CAPABILITY")), + cleartext=False, + authenticated=True, + ) + client.command("LOGOUT") + return client.tls_version + finally: + client.close() + + +def main() -> int: + if not PASSWORD: + raise IMAPCheckError("BONGO_TEST_PASSWORD must be set") + starttls_version = check_starttls() + check_starttls_pipeline_is_discarded() + imaps_version = imaps_login() + if auth_plain() != imaps_version: + raise IMAPCheckError("IMAPS TLS version changed for SASL PLAIN") + if auth_login() != imaps_version: + raise IMAPCheckError("IMAPS TLS version changed for SASL LOGIN") + print( + "IMAP-01/02/03 PASS " + f"host={HOST} starttls={IMAP_PORT}/{starttls_version} " + f"imaps={IMAPS_PORT}/{imaps_version} " + "protocols=IMAP4rev2,IMAP4rev1,IMAP4 " + "pre-tls=LOGINDISABLED login=LOGIN,PLAIN,LOGIN-SASL " + "repeated-starttls=rejected pipeline=discarded" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 5bf67ea..d58a244 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -161,15 +161,15 @@ review does not mark an open live test as passed. | SMTP-24 | Incoming PROXY v1/v2 only from configured proxy networks | [PASS](test-evidence/0.7-r1.md#smtp-24) | | | | SMTP-25 | Malformed commands, long lines, floods, timeout, and disconnect | [PASS](test-evidence/0.7-r1.md#smtp-25) | | | | SMTP-28 | TLSRPT preserves the exact DNS policy, aggregates completed UTC days, and emits valid RFC 8460 JSON | [PASS](test-evidence/0.7-r1.md#smtp-28) | | | -| SMTP-29 | TLSRPT gzip mail/HTTPS delivery, DKIM requirement, random delay, durable retry, expiry, and report-loop suppression | | | | +| SMTP-29 | TLSRPT gzip mail/HTTPS delivery, DKIM requirement, random delay, durable retry, expiry, and report-loop suppression | [PASS](test-evidence/0.7-r1.md#smtp-29) | | | ## IMAP4rev2, IMAP4rev1, and legacy IMAP4 | ID | Test | R1 | R2 | R3 | | --- | --- | --- | --- | --- | -| IMAP-01 | Capability list matches implemented rev2/rev1/legacy behaviour | | | | -| IMAP-02 | Port 143 STARTTLS, pre-TLS `LOGINDISABLED`, tagged `NO` for plaintext LOGIN, repeated STARTTLS rejection | | | | -| IMAP-03 | Port 993 TLS, LOGIN, SASL PLAIN, and SASL LOGIN | | | | +| IMAP-01 | Capability list matches implemented rev2/rev1/legacy behaviour | [PASS](test-evidence/0.7-r1.md#imap-010203) | | | +| IMAP-02 | Port 143 STARTTLS, pre-TLS `LOGINDISABLED`, tagged `NO` for plaintext LOGIN, repeated STARTTLS rejection | [PASS](test-evidence/0.7-r1.md#imap-010203) | | | +| IMAP-03 | Port 993 TLS, LOGIN, SASL PLAIN, and SASL LOGIN | [PASS](test-evidence/0.7-r1.md#imap-010203) | | | | IMAP-04 | CAPABILITY, NOOP, LOGOUT, ID, and NAMESPACE | | | | | IMAP-05 | LIST, LSUB, extended LIST, LIST-STATUS, hierarchy quoting | | | | | IMAP-06 | CREATE, DELETE, RENAME, SUBSCRIBE, UNSUBSCRIBE | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 6d13d38..fff4f4f 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -3363,6 +3363,38 @@ The live fixture removed its Queue entries, DNSSEC keys, policies, resolver configuration, and test certificates, restored the original SMTP Store document, and left `bongo.service` active. +## SMTP-29 + +Result: **PASS** + +The installed `865c632d` GCC 15.3.0 debug build was exercised on 2026-07-29 +with the reusable `smtp-tlsrpt-delivery-check.py --live` fixture. It delivered +one RFC 8460 report as a DKIM-signed, gzip-compressed MIME attachment and one +gzip-compressed report through HTTPS with validated TLS. Reports with a +configured but unavailable DKIM key were retained without opening an SMTP +connection. + +The worker used a short disposable test schedule while retaining the +production random, bounded initial-delay implementation under its native unit +test. A temporary delivery failure survived a complete Bongo restart with its +attempt count and next-attempt time intact, then succeeded exactly once. +Expired deliveries were removed, identical reporting destinations were +aggregated, and mail already carrying an automated TLS report was suppressed +to prevent report loops. + +The delivery fixture addresses every pending item by its unique Store +identifier through the `TLSRPT GET` NMAP command. This keeps unrelated, +older retry rows from hiding the delivery being verified and confirms that +the authoritative state remains in `bongostore`. + +```text +SMTP-29 PASS mail=gzip/dkim https=gzip/validated-tls aggregation=identical dkim-required=retry/no-send initial-delay=unit-tested/random/bounded retry=durable-across-restart expiry=removed report-loop=suppressed storage=bongostore/nmap config-restored=yes +``` + +The fixture restored the original SMTP, Queue, Worker, and ACME documents, +removed its temporary certificates and pending rows, and left +`bongo.service` active. + ## STQ-13 Result: **PASS** @@ -3394,3 +3426,35 @@ Store. ```text STQ-13 PASS scheduler=store/nmap sieve=store/nmap external-accounts=store/nmap tlsrpt=store/nmap legacy=auth/cookie/alarm/user-store-compatible stale-development-dbs=unused ``` + +## IMAP-01/02/03 + +Result: **PASS** + +The installed `865c632d` GCC 15.3.0 debug build was exercised on 2026-07-29 +with the reusable `imap-auth-capability-check.py`. The live port 143 listener +advertised IMAP4rev2, IMAP4rev1, legacy IMAP4, STARTTLS and +`LOGINDISABLED`, exposed no authentication mechanism before TLS, and returned +a tagged `NO` for a plaintext LOGIN attempt. STARTTLS negotiated TLS 1.3, +discarded a deliberately pipelined cleartext LOGIN, and rejected a repeated +STARTTLS command. + +The implicit-TLS listener on port 993 also negotiated TLS 1.3. Independent +connections authenticated through the IMAP LOGIN command and the shared +GSASL PLAIN and LOGIN mechanisms. The TLS capability response contained +`SASL-IR`, `AUTH=PLAIN`, and `AUTH=LOGIN`, while omitting STARTTLS and +`LOGINDISABLED`. + +This state model was compared with current Cyrus IMAP `imap/imapd.c`. Cyrus +likewise limits STARTTLS and SASL-IR to pre-authentication state, advertises +`LOGINDISABLED` from connection state, starts TLS immediately for IMAPS, and +explicitly discards input pipelined after STARTTLS. Bongo's focused +`imap-capabilities`, `imap-protocol`, and `sasl-password-mechanisms` native +tests all passed before the live run. + +```text +IMAP-01/02/03 PASS host=127.0.0.1 starttls=143/TLSv1.3 imaps=993/TLSv1.3 protocols=IMAP4rev2,IMAP4rev1,IMAP4 pre-tls=LOGINDISABLED login=LOGIN,PLAIN,LOGIN-SASL repeated-starttls=rejected pipeline=discarded +``` + +The test only authenticates and logs out. It does not create, alter, or +delete any mailbox or message and leaves the service configuration unchanged.