Skip to main content

io_smtp/sasl/
auth_plain.rs

1//! SMTP SASL PLAIN coroutine; supports both the non-IR and SASL-IR
2//! (RFC 4954 §4) flows.
3//!
4//! PLAIN: <https://www.rfc-editor.org/rfc/rfc4616>
5//! AUTH:  <https://www.rfc-editor.org/rfc/rfc4954>
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use std::{
11//!     borrow::Cow,
12//!     io::{Read, Write},
13//!     net::TcpStream,
14//! };
15//!
16//! use secrecy::SecretString;
17//!
18//! use io_smtp::{
19//!     coroutine::{SmtpCoroutine, SmtpCoroutineState, SmtpYield},
20//!     rfc5321::{SmtpDomain, SmtpEhloDomain},
21//!     sasl::auth_plain::{SmtpAuthPlain, SmtpAuthPlainOptions},
22//! };
23//!
24//! // Ready stream needed (TCP-connected, TLS-negociated, EHLO consumed)
25//! let mut stream = TcpStream::connect("localhost:25").unwrap();
26//!
27//! let mut buf = [0u8; 4096];
28//!
29//! let password = SecretString::from("secret".to_string());
30//! let domain = SmtpEhloDomain::SmtpDomain(SmtpDomain(Cow::Borrowed("example.com")));
31//! let opts = SmtpAuthPlainOptions::default();
32//! let mut coroutine = SmtpAuthPlain::new("alice", &password, domain, opts);
33//! let mut arg = None;
34//!
35//! loop {
36//!     match coroutine.resume(arg.take()) {
37//!         SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => {
38//!             stream.write_all(&bytes).unwrap();
39//!         }
40//!         SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {
41//!             let n = stream.read(&mut buf).unwrap();
42//!             arg = Some(&buf[..n]);
43//!         }
44//!         SmtpCoroutineState::Complete(Ok(())) => break,
45//!         SmtpCoroutineState::Complete(Err(err)) => panic!("{err}"),
46//!     }
47//! }
48//! ```
49
50use core::fmt;
51
52use alloc::{
53    borrow::Cow,
54    string::{String, ToString},
55    vec::Vec,
56};
57
58use bounded_static::IntoBoundedStatic;
59use log::debug;
60use secrecy::{ExposeSecret, SecretBox, SecretString};
61use thiserror::Error;
62
63use crate::{
64    coroutine::*,
65    rfc4954::{auth::SmtpAuthCommand, auth_data::SmtpAuthData},
66    rfc5321::{
67        SmtpEhloDomain, SmtpReplyCode,
68        ehlo::{SmtpEhlo, SmtpEhloError},
69    },
70    send::*,
71    smtp_try,
72};
73
74/// The SASL mechanism name as it appears on the wire.
75pub const PLAIN: &str = "PLAIN";
76
77/// Options for [`SmtpAuthPlain::new`].
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct SmtpAuthPlainOptions {
80    /// `true` selects SASL-IR (inline credentials); `false` selects
81    /// the non-IR challenge-response flow.
82    pub initial_request: bool,
83    /// Whether to refresh capabilities with an `EHLO` after a successful auth.
84    /// Disabled by default because the mechanism does not add a security layer.
85    pub ensure_capabilities: bool,
86}
87
88impl Default for SmtpAuthPlainOptions {
89    fn default() -> Self {
90        Self {
91            initial_request: true,
92            ensure_capabilities: false,
93        }
94    }
95}
96
97/// Failure causes during the SMTP AUTH PLAIN exchange.
98#[derive(Debug, Error)]
99pub enum SmtpAuthPlainError {
100    /// The server rejected the authentication.
101    #[error("SMTP AUTH PLAIN failed: rejected {code} {message}")]
102    Rejected {
103        /// The reply code.
104        code: u16,
105        /// The reply text.
106        message: String,
107    },
108    /// The server challenged despite the inline initial response.
109    #[error("SMTP AUTH PLAIN failed: server sent an unexpected continuation request")]
110    UnexpectedContinuationRequest,
111    /// The server accepted before the expected challenge.
112    #[error("SMTP AUTH PLAIN failed: server did not send the expected continuation request")]
113    ExpectedContinuationRequest,
114    /// The underlying command exchange failed.
115    #[error("SMTP AUTH PLAIN failed: {0}")]
116    Send(#[from] SmtpCommandSendError),
117    /// The post-authentication capability refresh failed.
118    #[error(transparent)]
119    Ehlo(#[from] SmtpEhloError),
120}
121
122/// I/O-free SMTP AUTH PLAIN coroutine.
123pub struct SmtpAuthPlain {
124    state: State,
125    domain: Option<SmtpEhloDomain<'static>>,
126    payload: Option<Vec<u8>>,
127    opts: SmtpAuthPlainOptions,
128}
129
130impl SmtpAuthPlain {
131    /// Creates the coroutine from the credentials and the client
132    /// identity used by the capability refresh.
133    pub fn new(
134        login: &str,
135        password: &SecretString,
136        domain: SmtpEhloDomain<'_>,
137        opts: SmtpAuthPlainOptions,
138    ) -> Self {
139        let mut payload = Vec::new();
140        payload.push(0);
141        payload.extend_from_slice(login.as_bytes());
142        payload.push(0);
143        payload.extend_from_slice(password.expose_secret().as_bytes());
144
145        let state = if opts.initial_request {
146            let cmd = SmtpAuthCommand {
147                mechanism: Cow::Borrowed(PLAIN),
148                initial_response: Some(SecretBox::new(payload.clone().into_boxed_slice())),
149            };
150            State::Send(SmtpCommandSend::new(cmd))
151        } else {
152            let cmd = SmtpAuthCommand {
153                mechanism: Cow::Borrowed(PLAIN),
154                initial_response: None,
155            };
156            State::Send(SmtpCommandSend::new(cmd))
157        };
158
159        Self {
160            state,
161            domain: Some(domain.into_static()),
162            payload: Some(payload),
163            opts,
164        }
165    }
166}
167
168impl SmtpCoroutine for SmtpAuthPlain {
169    type Yield = SmtpYield;
170    type Return = Result<(), SmtpAuthPlainError>;
171
172    fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
173        loop {
174            match &mut self.state {
175                State::Send(send) => {
176                    let out = smtp_try!(send, arg);
177
178                    if out.response.code == SmtpReplyCode::AUTH_SUCCESSFUL {
179                        if self.opts.initial_request {
180                            // NOTE: IR flow: success on first reply.
181                            self.advance_after_auth();
182                            continue;
183                        }
184                        // NOTE: non-IR flow: success before the
185                        // challenge, technically RFC 4954 §4 forbids
186                        // this.
187                        return SmtpCoroutineState::Complete(Err(
188                            SmtpAuthPlainError::ExpectedContinuationRequest,
189                        ));
190                    }
191
192                    if out.response.code == SmtpReplyCode::AUTH_CONTINUE {
193                        if self.opts.initial_request {
194                            // NOTE: IR flow: server should never
195                            // challenge.
196                            return SmtpCoroutineState::Complete(Err(
197                                SmtpAuthPlainError::UnexpectedContinuationRequest,
198                            ));
199                        }
200                        // NOTE: non-IR flow: send credentials now.
201                        let payload = self.payload.take().expect("payload taken twice");
202                        let data = SmtpAuthData::r#continue(payload.into_boxed_slice());
203                        self.state = State::Continue(SmtpCommandSend::new(data));
204                        debug!("challenge received, sending credentials");
205                        continue;
206                    }
207
208                    let code = out.response.code.code();
209                    let message = out.response.text().to_string();
210                    return SmtpCoroutineState::Complete(Err(SmtpAuthPlainError::Rejected {
211                        code,
212                        message,
213                    }));
214                }
215                State::Continue(send) => {
216                    let out = smtp_try!(send, arg);
217
218                    if out.response.code == SmtpReplyCode::AUTH_SUCCESSFUL {
219                        self.advance_after_auth();
220                        continue;
221                    }
222
223                    let code = out.response.code.code();
224                    let message = out.response.text().to_string();
225                    return SmtpCoroutineState::Complete(Err(SmtpAuthPlainError::Rejected {
226                        code,
227                        message,
228                    }));
229                }
230                State::Ehlo(ehlo) => {
231                    let _ = smtp_try!(ehlo, arg);
232                    debug!("capabilities refreshed");
233                    return SmtpCoroutineState::Complete(Ok(()));
234                }
235                State::Done => return SmtpCoroutineState::Complete(Ok(())),
236            }
237        }
238    }
239}
240
241impl SmtpAuthPlain {
242    fn advance_after_auth(&mut self) {
243        let _ = self.payload.take();
244        debug!("authenticated");
245        if self.opts.ensure_capabilities {
246            let domain = self.domain.take().expect("domain taken twice");
247            self.state = State::Ehlo(SmtpEhlo::new(domain));
248        } else {
249            self.state = State::Done;
250        }
251    }
252}
253
254enum State {
255    Send(SmtpCommandSend<SmtpAuthCommand<'static>>),
256    Continue(SmtpCommandSend<SmtpAuthData>),
257    Ehlo(SmtpEhlo),
258    Done,
259}
260
261impl fmt::Display for State {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            Self::Send(_) => f.write_str("send auth plain"),
265            Self::Continue(_) => f.write_str("send credentials"),
266            Self::Ehlo(_) => f.write_str("refresh capabilities"),
267            Self::Done => f.write_str("done"),
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use core::str::from_utf8;
275
276    use alloc::{borrow::Cow, string::ToString, vec::Vec};
277
278    use secrecy::SecretString;
279
280    use crate::{
281        coroutine::*,
282        rfc5321::{SmtpDomain, SmtpEhloDomain},
283        sasl::auth_plain::*,
284        send::SmtpCommandSendError,
285    };
286
287    fn domain() -> SmtpEhloDomain<'static> {
288        SmtpEhloDomain::SmtpDomain(SmtpDomain(Cow::Borrowed("example.com")))
289    }
290
291    fn password() -> SecretString {
292        SecretString::from("secret".to_string())
293    }
294
295    #[test]
296    fn ir_success_does_not_send_ehlo_by_default() {
297        let opts = SmtpAuthPlainOptions::default();
298        let mut auth = SmtpAuthPlain::new("alice", &password(), domain(), opts);
299
300        let bytes = expect_wants_write(&mut auth, None);
301        let line = from_utf8(&bytes).expect("utf8 command");
302        assert!(line.starts_with("AUTH PLAIN "));
303
304        expect_wants_read(&mut auth);
305        expect_complete_ok(&mut auth, b"235 OK\r\n");
306    }
307
308    #[test]
309    fn ir_success_with_ehlo_returns_ok() {
310        let opts = SmtpAuthPlainOptions {
311            initial_request: true,
312            ensure_capabilities: true,
313        };
314        let mut auth = SmtpAuthPlain::new("alice", &password(), domain(), opts);
315
316        let _ = expect_wants_write(&mut auth, None);
317        expect_wants_read(&mut auth);
318        let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
319        expect_wants_read(&mut auth);
320        expect_complete_ok(&mut auth, b"250-server.example.com\r\n250 AUTH PLAIN\r\n");
321    }
322
323    #[test]
324    fn ir_success_without_ehlo_returns_ok() {
325        let opts = SmtpAuthPlainOptions {
326            initial_request: true,
327            ensure_capabilities: false,
328        };
329        let mut auth = SmtpAuthPlain::new("alice", &password(), domain(), opts);
330        let _ = expect_wants_write(&mut auth, None);
331        expect_wants_read(&mut auth);
332        expect_complete_ok(&mut auth, b"235 OK\r\n");
333    }
334
335    #[test]
336    fn ir_invalid_credentials_returns_rejected_error() {
337        let opts = SmtpAuthPlainOptions::default();
338        let mut auth = SmtpAuthPlain::new("alice", &password(), domain(), opts);
339        let _ = expect_wants_write(&mut auth, None);
340        expect_wants_read(&mut auth);
341
342        let err = expect_complete_err(&mut auth, b"535 wrong password\r\n");
343        let SmtpAuthPlainError::Rejected { code, message } = err else {
344            panic!("expected SmtpAuthPlainError::Rejected, got {err:?}");
345        };
346        assert_eq!(code, 535);
347        assert_eq!(message, "wrong password");
348    }
349
350    #[test]
351    fn non_ir_success_returns_ok() {
352        let opts = SmtpAuthPlainOptions {
353            initial_request: false,
354            ensure_capabilities: false,
355        };
356        let mut auth = SmtpAuthPlain::new("alice", &password(), domain(), opts);
357
358        let bytes = expect_wants_write(&mut auth, None);
359        let line = from_utf8(&bytes).expect("utf8 command");
360        assert!(line.trim_end().ends_with("AUTH PLAIN"));
361
362        expect_wants_read(&mut auth);
363        let creds = expect_wants_write(&mut auth, Some(b"334 \r\n"));
364        assert!(creds.ends_with(b"\r\n"));
365
366        expect_wants_read(&mut auth);
367        expect_complete_ok(&mut auth, b"235 OK\r\n");
368    }
369
370    #[test]
371    fn eof_returns_eof_error() {
372        let opts = SmtpAuthPlainOptions::default();
373        let mut auth = SmtpAuthPlain::new("alice", &password(), domain(), opts);
374        let _ = expect_wants_write(&mut auth, None);
375        expect_wants_read(&mut auth);
376
377        let err = expect_complete_err(&mut auth, b"");
378        assert!(matches!(
379            err,
380            SmtpAuthPlainError::Send(SmtpCommandSendError::Eof)
381        ));
382    }
383
384    fn expect_wants_write(cor: &mut SmtpAuthPlain, arg: Option<&[u8]>) -> Vec<u8> {
385        match cor.resume(arg) {
386            SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
387            state => panic!("expected WantsWrite, got {state:?}"),
388        }
389    }
390
391    fn expect_wants_read(cor: &mut SmtpAuthPlain) {
392        match cor.resume(None) {
393            SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
394            state => panic!("expected WantsRead, got {state:?}"),
395        }
396    }
397
398    fn expect_complete_ok(cor: &mut SmtpAuthPlain, reply: &[u8]) {
399        match cor.resume(Some(reply)) {
400            SmtpCoroutineState::Complete(Ok(())) => {}
401            state => panic!("expected Complete(Ok), got {state:?}"),
402        }
403    }
404
405    fn expect_complete_err(cor: &mut SmtpAuthPlain, reply: &[u8]) -> SmtpAuthPlainError {
406        match cor.resume(Some(reply)) {
407            SmtpCoroutineState::Complete(Err(err)) => err,
408            state => panic!("expected Complete(Err), got {state:?}"),
409        }
410    }
411}