Skip to main content

wasm_smtp/client/
starttls.rs

1//! STARTTLS support (RFC 3207) for [`super::SmtpClient`].
2//!
3//! Only available on transports that implement [`crate::transport::StartTlsCapable`].
4//! For Implicit TLS (port 465) use [`super::SmtpClient::connect`] instead.
5
6use crate::error::{ProtocolError, SmtpError, SmtpOp};
7use crate::protocol::{
8    format_command,
9    ehlo_advertises_starttls,
10};
11use crate::session::SessionState;
12use crate::tracing_helpers::{smtp_debug, smtp_error};
13use crate::transport::StartTlsCapable;
14use super::SmtpClient;
15
16// STARTTLS (RFC 3207) — only available on transports that can be upgraded
17// to TLS in-place.
18// -----------------------------------------------------------------------------
19
20impl<T: StartTlsCapable> SmtpClient<T> {
21    /// Connect, read the greeting, send `EHLO`, issue `STARTTLS`, upgrade
22    /// the transport to TLS, and re-issue `EHLO` on the secure stream.
23    ///
24    /// This is the convenience entry point for the STARTTLS submission flow
25    /// on ports 587 / 25. The returned client is in
26    /// [`SessionState::Authentication`] just like one returned by
27    /// [`Self::connect`] would be — meaning the caller proceeds with
28    /// [`Self::login`] (or skips straight to [`Self::send_mail`] for
29    /// unauthenticated submission) without observing the TLS upgrade
30    /// itself.
31    ///
32    /// Use [`Self::connect`] for Implicit TLS on port 465 instead. STARTTLS
33    /// is appropriate when the transport must remain plaintext until the
34    /// server has accepted the upgrade request.
35    ///
36    /// # Errors
37    ///
38    /// Returns the same error categories as [`Self::connect`] for the
39    /// pre-upgrade phase. Additionally:
40    ///
41    /// - [`ProtocolError::ExtensionUnavailable`] with `name: "STARTTLS"`
42    ///   if the server's first `EHLO` reply did not advertise the
43    ///   extension.
44    /// - [`ProtocolError::UnexpectedCode`] with `during: SmtpOp::StartTls`
45    ///   if the server rejected `STARTTLS` itself.
46    /// - [`SmtpError::Io`] if the transport-level upgrade fails.
47    pub async fn connect_starttls(transport: T, ehlo_domain: &str) -> Result<Self, SmtpError> {
48        let mut client = Self::connect(transport, ehlo_domain).await?;
49        client.starttls().await?;
50        Ok(client)
51    }
52
53    /// Issue `STARTTLS` on an already-connected client, upgrade the
54    /// transport, and re-issue `EHLO` per RFC 3207 §4.2.
55    ///
56    /// May only be called immediately after [`Self::connect`]. Calling it
57    /// after [`Self::login`] or [`Self::send_mail`] returns
58    /// [`InvalidInputError`] without touching the wire.
59    ///
60    /// # Errors
61    ///
62    /// - [`ProtocolError::ExtensionUnavailable`] with `name: "STARTTLS"`
63    ///   if the server did not advertise the extension. In this case the
64    ///   client is moved to [`SessionState::Closed`] so subsequent calls
65    ///   fail fast — accidentally falling back to plaintext authentication
66    ///   would defeat the purpose of asking for STARTTLS.
67    /// - [`ProtocolError::UnexpectedCode`] with `during: SmtpOp::StartTls`
68    ///   if the server rejected the command.
69    /// - [`SmtpError::Io`] if the transport-level upgrade fails.
70    pub async fn starttls(&mut self) -> Result<(), SmtpError> {
71        self.assert_state_in(&[SessionState::Authentication])?;
72        smtp_debug!("STARTTLS: requesting upgrade");
73
74        if !ehlo_advertises_starttls(&self.capabilities) {
75            smtp_error!("STARTTLS: extension not advertised; refusing to fall back to plaintext");
76            self.mark_closed_on_logical_failure();
77            return Err(ProtocolError::ExtensionUnavailable { name: "STARTTLS" }.into());
78        }
79
80        // Send STARTTLS and require a 220 reply before touching the
81        // transport. Per RFC 3207, a 4xx/5xx reply leaves the channel
82        // plaintext and the client is free to try other things — but for
83        // simplicity, and to avoid silently falling through to plaintext
84        // AUTH, we treat any non-220 here as a fatal error.
85        self.transition(SessionState::StartTls)?;
86        self.write_all(&format_command("STARTTLS")).await?;
87        self.expect_code(220, SmtpOp::StartTls).await?;
88
89        // STARTTLS injection / pipelining defense (RFC 3207 §5):
90        //
91        // Between the `220` reply and the TLS handshake the channel is
92        // still plaintext. An attacker who is willing to corrupt the
93        // server's reply stream may try to pipeline additional SMTP
94        // commands ("EHLO ..\r\nMAIL FROM:..\r\n") onto the buffer
95        // before the TLS upgrade, hoping the client will read those
96        // bytes back AFTER the upgrade and treat them as if they had
97        // arrived over the secured channel. (See CVE-2011-1575 for the
98        // historical Postfix case; equivalent client-side bugs exist.)
99        //
100        // The defense is to refuse to start TLS when there are any
101        // unread bytes in the receive buffer after the 220. Honest
102        // servers do not pipeline data into the STARTTLS handshake
103        // window — they wait for the client to begin the TLS
104        // ClientHello. Any bytes here are therefore evidence of an
105        // injection or of a server bug that we want to surface
106        // loudly rather than silently absorb.
107        let residue = self.rx_buf.len() - self.rx_pos;
108        if residue > 0 {
109            smtp_error!(
110                byte_count = residue,
111                "STARTTLS: refusing to upgrade due to non-empty rx buffer (injection defense)"
112            );
113            self.mark_closed_on_logical_failure();
114            return Err(ProtocolError::StartTlsBufferResidue {
115                byte_count: residue,
116            }
117            .into());
118        }
119
120        // Upgrade the transport. Discard previously-advertised
121        // capabilities: RFC 3207 §4.2 mandates that the server may
122        // advertise a different set after the TLS upgrade.
123        self.capabilities.clear();
124        self.transport.upgrade_to_tls().await.map_err(|e| {
125            smtp_error!("STARTTLS: TLS upgrade failed at transport layer");
126            self.mark_closed_on_logical_failure();
127            SmtpError::Io(e)
128        })?;
129        self.audit.on_event(&crate::audit::SmtpAuditEvent::TlsUpgraded);
130
131        // RFC 3207 §4.2: re-issue EHLO on the now-secure channel. We
132        // reuse send_ehlo, which writes the command, parses the reply,
133        // refreshes self.capabilities, and transitions to
134        // SessionState::Authentication.
135        self.transition(SessionState::Ehlo)?;
136        // Cloning is cheap relative to a network round-trip and avoids a
137        // borrow-checker conflict with the &mut self call.
138        let domain = self.ehlo_domain.clone();
139        self.send_ehlo(&domain).await?;
140        smtp_debug!(
141            capability_count = self.capabilities.len(),
142            "STARTTLS: upgrade complete; re-EHLO done"
143        );
144        Ok(())
145    }
146}
147