Validate live IMAP authentication and TLS

This commit is contained in:
Mario Fetka
2026-07-29 07:36:55 +02:00
parent 865c632df4
commit a5fbb83b3b
4 changed files with 344 additions and 4 deletions
+4
View File
@@ -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
+272
View File
@@ -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())
+4 -4
View File
@@ -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 | | | |
+64
View File
@@ -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.