Validate live IMAP authentication and TLS
This commit is contained in:
@@ -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
@@ -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())
|
||||
@@ -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 | | | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user