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