1313import re
1414import socket
1515import subprocess
16- from dataclasses import dataclass
16+ from dataclasses import dataclass , replace
1717from typing import Optional
1818
1919from quantumvalidator .constants import (
@@ -66,7 +66,7 @@ class TLSProbeResult:
6666 error : Optional [str ] = None
6767 """Error message if the probe failed, None on success."""
6868 detected_starttls : Optional [str ] = None
69- """STARTTLS mode auto- detected from server banner ('smtp'/'imap'/'pop3' ), or None."""
69+ """STARTTLS mode detected from server banner (e.g. `` 'smtp'``/``'ftp'``/``'nntp'``/``'ssh'`` ), or `` None`` ."""
7070
7171 @property
7272 def ok (self ) -> bool :
@@ -112,6 +112,9 @@ def probe_tls(
112112 if detected == "ssh" :
113113 return _probe_ssh (host , port , timeout )
114114
115+ if detected == "ftp" :
116+ return _probe_ftp (host , port , timeout )
117+
115118 if detected :
116119 logger .info (
117120 "Banner detected '%s' for %s:%d — probing with -starttls %s" ,
@@ -129,7 +132,7 @@ def _build_cmd(host: str, port: int, starttls: Optional[str]) -> list[str]:
129132
130133 :param host: Target hostname or IP.
131134 :param port: TCP port.
132- :param starttls: STARTTLS mode (``'smtp'``/``'imap '``/``'pop3 '``), or ``None`` for raw TLS.
135+ :param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``/``'ftp '``/``'xmpp '``), or ``None`` for raw TLS.
133136 :returns: Command list for ``subprocess.run``.
134137 :rtype: list[str]
135138 """
@@ -161,7 +164,7 @@ def _run_openssl(
161164
162165 :param host: Target hostname or IP.
163166 :param port: TCP port.
164- :param starttls: STARTTLS mode (``'smtp'``/``'imap '``/``'pop3 '``), or ``None`` for raw TLS.
167+ :param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``/``'ftp '``/``'xmpp '``), or ``None`` for raw TLS.
165168 :param timeout: Connection timeout in seconds.
166169 :returns: Probe result; ``raw_output`` always populated from subprocess output.
167170 :rtype: TLSProbeResult
@@ -362,26 +365,104 @@ def _probe_ssh(host: str, port: int, timeout: int) -> TLSProbeResult:
362365 )
363366
364367
365- def _fingerprint_banner (output : str ) -> Optional [str ]:
366- """Detect STARTTLS mode from raw openssl output containing a server banner.
368+ def _probe_ftp (host : str , port : int , timeout : int ) -> TLSProbeResult :
369+ """Pre-check AUTH TLS support, then probe FTP STARTTLS for PQC key exchange.
370+
371+ ``openssl s_client -starttls ftp`` hangs when the server rejects ``AUTH TLS``
372+ instead of responding with ``234 Proceed with negotiation``. This function
373+ issues ``AUTH TLS`` over a raw socket first; only if the server responds with
374+ ``234`` does it dispatch to ``_run_openssl``.
375+
376+ :param host: Target hostname or IP.
377+ :param port: TCP port (typically 21).
378+ :param timeout: Connection and read timeout in seconds.
379+ :returns: Probe result with ``detected_starttls="ftp"``.
380+ :rtype: TLSProbeResult
381+ """
382+ try :
383+ with socket .create_connection ((host , port ), timeout = timeout ) as sock :
384+ sock .settimeout (timeout )
385+ # Consume the 220 greeting with a loop — a single recv() is not
386+ # guaranteed to return the full line when TCP segments are small.
387+ buf = b""
388+ while b"\n " not in buf and len (buf ) < 1024 :
389+ chunk = sock .recv (256 )
390+ if not chunk :
391+ break
392+ buf += chunk
393+ sock .sendall (b"AUTH TLS\r \n " )
394+ # Read the AUTH TLS response, also loop-safe.
395+ resp = b""
396+ while b"\n " not in resp and len (resp ) < 1024 :
397+ chunk = sock .recv (256 )
398+ if not chunk :
399+ break
400+ resp += chunk
401+ raw = resp .decode ("utf-8" , errors = "replace" )
402+ except (OSError , socket .timeout ) as exc :
403+ logger .warning ("FTP AUTH TLS check failed for %s:%d: %s" , host , port , exc )
404+ return TLSProbeResult (
405+ host = host , port = port ,
406+ tls_version = None , negotiated_group = None ,
407+ error = str (exc ),
408+ detected_starttls = "ftp" ,
409+ )
367410
368- Scans all lines for known application-protocol banner prefixes that indicate
369- the server expects a STARTTLS upgrade rather than direct TLS.
411+ first_line = (raw .strip ().splitlines ()[0 ] if raw .strip () else "(no response)" ).strip ()
412+ if not first_line .startswith ("234" ):
413+ logger .info (
414+ "FTP server %s:%d does not support AUTH TLS: %s" , host , port , first_line
415+ )
416+ return TLSProbeResult (
417+ host = host , port = port ,
418+ tls_version = None , negotiated_group = None ,
419+ error = f"FTP server does not support AUTH TLS: { first_line [:100 ]} " ,
420+ detected_starttls = "ftp" ,
421+ )
370422
371- :param output: Combined stdout+stderr from a failed raw TLS probe.
372- :returns: Detected STARTTLS mode (``'smtp'``/``'imap'``/``'pop3'``), or ``None``.
423+ logger .info ("FTP server %s:%d accepted AUTH TLS — probing TLS" , host , port )
424+ return replace (_run_openssl (host , port , starttls = "ftp" , timeout = timeout ), detected_starttls = "ftp" )
425+
426+
427+ def _fingerprint_banner (output : str ) -> Optional [str ]:
428+ """Detect STARTTLS mode from a server's opening banner.
429+
430+ Scans all lines for known application-protocol banner prefixes that
431+ indicate the server expects a STARTTLS upgrade rather than direct TLS.
432+
433+ FTP and LMTP both open with ``"220 "`` like SMTP; they are distinguished
434+ by banner content: LMTP servers include ``"LMTP"``, FTP servers include
435+ ``"FTP"`` (without ``"ESMTP"``). FTP servers that omit the word "FTP"
436+ from their greeting (e.g. FileZilla) will fall back to ``"smtp"``.
437+ NNTP is identified by ``"200 "``/``"201 "`` prefixes, which are uncommon
438+ as opening banners outside NNTP but not unique — treat as a best-effort
439+ heuristic. Protocols that send no banner on connect (XMPP, LDAP, MySQL,
440+ PostgreSQL) cannot be auto-detected here.
441+
442+ :param output: Server banner string (raw TCP read before TLS handshake).
443+ :returns: openssl ``-starttls`` mode string, or ``None`` if unrecognised.
373444 :rtype: str | None
374445 """
375446 for line in output .splitlines ():
376447 stripped = line .strip ()
377448 if stripped .startswith ("220 " ):
449+ lower = stripped .lower ()
450+ if "lmtp" in lower :
451+ return "lmtp"
452+ if "ftp" in lower and "esmtp" not in lower :
453+ return "ftp"
378454 return "smtp"
379455 if stripped .startswith ("* OK" ):
380456 return "imap"
381457 if stripped .startswith ("+OK" ):
382458 return "pop3"
383459 if stripped .startswith ("SSH-" ):
384460 return "ssh"
461+ if stripped .startswith (("200 " , "201 " )):
462+ return "nntp"
463+ # ManageSieve (RFC 5804): capability lines are double-quoted strings
464+ if stripped .startswith (('"IMPLEMENTATION"' , '"SIEVE"' , '"STARTTLS"' )):
465+ return "sieve"
385466 return None
386467
387468
0 commit comments