Skip to main content

io_smtp/sasl/
auth_anonymous.rs

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