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::types::{domain::Domain, ehlo_domain::EhloDomain},
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 = EhloDomain::Domain(Domain(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::trace;
57use secrecy::SecretBox;
58use thiserror::Error;
59
60use crate::{
61    coroutine::*,
62    rfc4954::{auth::SmtpAuthCommand, auth_data::SmtpAuthData},
63    rfc5321::{
64        ehlo::{SmtpEhlo, SmtpEhloError},
65        types::{ehlo_domain::EhloDomain, reply_code::ReplyCode},
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    /// Refresh capabilities with an `EHLO` after a successful auth.
81    pub ensure_capabilities: bool,
82}
83
84impl Default for SmtpAuthAnonymousOptions {
85    fn default() -> Self {
86        Self {
87            initial_request: true,
88            ensure_capabilities: true,
89        }
90    }
91}
92
93/// Failure causes during the SMTP AUTH ANONYMOUS exchange.
94#[derive(Debug, Error)]
95pub enum SmtpAuthAnonymousError {
96    #[error("SMTP AUTH ANONYMOUS failed: rejected {code} {message}")]
97    Rejected { code: u16, message: String },
98    #[error("SMTP AUTH ANONYMOUS failed: server sent an unexpected continuation request")]
99    UnexpectedContinuationRequest,
100    #[error("SMTP AUTH ANONYMOUS failed: server did not send the expected continuation request")]
101    ExpectedContinuationRequest,
102    #[error("SMTP AUTH ANONYMOUS failed: {0}")]
103    Send(#[from] SendSmtpCommandError),
104    #[error(transparent)]
105    Ehlo(#[from] SmtpEhloError),
106}
107
108/// I/O-free SMTP AUTH ANONYMOUS coroutine.
109pub struct SmtpAuthAnonymous {
110    state: State,
111    domain: Option<EhloDomain<'static>>,
112    payload: Option<Vec<u8>>,
113    opts: SmtpAuthAnonymousOptions,
114}
115
116impl SmtpAuthAnonymous {
117    /// Pass [`None`] for an empty trace.
118    pub fn new(
119        trace: Option<&str>,
120        domain: EhloDomain<'_>,
121        opts: SmtpAuthAnonymousOptions,
122    ) -> Self {
123        let payload = trace.unwrap_or("").as_bytes().to_vec();
124
125        let state = if opts.initial_request {
126            let cmd = SmtpAuthCommand {
127                mechanism: Cow::Borrowed(ANONYMOUS),
128                initial_response: Some(SecretBox::new(payload.clone().into_boxed_slice())),
129            };
130            State::Send(SendSmtpCommand::new(cmd))
131        } else {
132            let cmd = SmtpAuthCommand {
133                mechanism: Cow::Borrowed(ANONYMOUS),
134                initial_response: None,
135            };
136            State::Send(SendSmtpCommand::new(cmd))
137        };
138
139        Self {
140            state,
141            domain: Some(domain.into_static()),
142            payload: Some(payload),
143            opts,
144        }
145    }
146}
147
148impl SmtpCoroutine for SmtpAuthAnonymous {
149    type Yield = SmtpYield;
150    type Return = Result<(), SmtpAuthAnonymousError>;
151
152    fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
153        loop {
154            trace!("auth anonymous: {}", self.state);
155
156            match &mut self.state {
157                State::Send(send) => {
158                    let out = smtp_try!(send, arg);
159
160                    if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
161                        if self.opts.initial_request {
162                            self.advance_after_auth();
163                            continue;
164                        }
165                        return SmtpCoroutineState::Complete(Err(
166                            SmtpAuthAnonymousError::ExpectedContinuationRequest,
167                        ));
168                    }
169
170                    if out.response.code == ReplyCode::AUTH_CONTINUE {
171                        if self.opts.initial_request {
172                            return SmtpCoroutineState::Complete(Err(
173                                SmtpAuthAnonymousError::UnexpectedContinuationRequest,
174                            ));
175                        }
176                        let payload = self.payload.take().expect("payload taken twice");
177                        let data = SmtpAuthData::r#continue(payload.into_boxed_slice());
178                        self.state = State::Continue(SendSmtpCommand::new(data));
179                        continue;
180                    }
181
182                    let code = out.response.code.code();
183                    let message = out.response.text().to_string();
184                    return SmtpCoroutineState::Complete(Err(SmtpAuthAnonymousError::Rejected {
185                        code,
186                        message,
187                    }));
188                }
189                State::Continue(send) => {
190                    let out = smtp_try!(send, arg);
191
192                    if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
193                        self.advance_after_auth();
194                        continue;
195                    }
196
197                    let code = out.response.code.code();
198                    let message = out.response.text().to_string();
199                    return SmtpCoroutineState::Complete(Err(SmtpAuthAnonymousError::Rejected {
200                        code,
201                        message,
202                    }));
203                }
204                State::Ehlo(ehlo) => {
205                    let _ = smtp_try!(ehlo, arg);
206                    return SmtpCoroutineState::Complete(Ok(()));
207                }
208                State::Done => return SmtpCoroutineState::Complete(Ok(())),
209            }
210        }
211    }
212}
213
214impl SmtpAuthAnonymous {
215    fn advance_after_auth(&mut self) {
216        let _ = self.payload.take();
217        if self.opts.ensure_capabilities {
218            let domain = self.domain.take().expect("domain taken twice");
219            self.state = State::Ehlo(SmtpEhlo::new(domain));
220        } else {
221            self.state = State::Done;
222        }
223    }
224}
225
226enum State {
227    Send(SendSmtpCommand<SmtpAuthCommand<'static>>),
228    Continue(SendSmtpCommand<SmtpAuthData>),
229    Ehlo(SmtpEhlo),
230    Done,
231}
232
233impl fmt::Display for State {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        match self {
236            Self::Send(_) => f.write_str("send auth anonymous"),
237            Self::Continue(_) => f.write_str("send trace"),
238            Self::Ehlo(_) => f.write_str("refresh capabilities"),
239            Self::Done => f.write_str("done"),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use crate::rfc5321::types::domain::Domain;
247
248    use super::*;
249
250    fn domain() -> EhloDomain<'static> {
251        EhloDomain::Domain(Domain(Cow::Borrowed("example.com")))
252    }
253
254    #[test]
255    fn ir_success_then_ehlo_returns_ok() {
256        let opts = SmtpAuthAnonymousOptions::default();
257        let mut auth = SmtpAuthAnonymous::new(Some("trace@example.com"), domain(), opts);
258
259        let _ = expect_wants_write(&mut auth, None);
260        expect_wants_read(&mut auth);
261        let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
262        expect_wants_read(&mut auth);
263        expect_complete_ok(&mut auth, b"250 server.example.com\r\n");
264    }
265
266    #[test]
267    fn ir_empty_trace_returns_ok() {
268        let opts = SmtpAuthAnonymousOptions {
269            initial_request: true,
270            ensure_capabilities: false,
271        };
272        let mut auth = SmtpAuthAnonymous::new(None, domain(), opts);
273
274        let bytes = expect_wants_write(&mut auth, None);
275        let line = core::str::from_utf8(&bytes).expect("utf8 command");
276        assert!(line.contains("AUTH ANONYMOUS"));
277
278        expect_wants_read(&mut auth);
279        expect_complete_ok(&mut auth, b"235 OK\r\n");
280    }
281
282    #[test]
283    fn non_ir_success_returns_ok() {
284        let opts = SmtpAuthAnonymousOptions {
285            initial_request: false,
286            ensure_capabilities: false,
287        };
288        let mut auth = SmtpAuthAnonymous::new(Some("trace@example.com"), domain(), opts);
289        let _ = expect_wants_write(&mut auth, None);
290        expect_wants_read(&mut auth);
291        let _ = expect_wants_write(&mut auth, Some(b"334 \r\n"));
292        expect_wants_read(&mut auth);
293        expect_complete_ok(&mut auth, b"235 OK\r\n");
294    }
295
296    #[test]
297    fn rejected_returns_rejected_error() {
298        let opts = SmtpAuthAnonymousOptions::default();
299        let mut auth = SmtpAuthAnonymous::new(Some("trace@example.com"), domain(), opts);
300        let _ = expect_wants_write(&mut auth, None);
301        expect_wants_read(&mut auth);
302
303        let err = expect_complete_err(&mut auth, b"535 anonymous disallowed\r\n");
304        let SmtpAuthAnonymousError::Rejected { code, message } = err else {
305            panic!("expected SmtpAuthAnonymousError::Rejected, got {err:?}");
306        };
307        assert_eq!(code, 535);
308        assert_eq!(message, "anonymous disallowed");
309    }
310
311    #[test]
312    fn eof_returns_eof_error() {
313        let opts = SmtpAuthAnonymousOptions::default();
314        let mut auth = SmtpAuthAnonymous::new(Some("trace@example.com"), domain(), opts);
315        let _ = expect_wants_write(&mut auth, None);
316        expect_wants_read(&mut auth);
317
318        let err = expect_complete_err(&mut auth, b"");
319        assert!(matches!(
320            err,
321            SmtpAuthAnonymousError::Send(SendSmtpCommandError::Eof)
322        ));
323    }
324
325    // --- utils
326
327    fn expect_wants_write(cor: &mut SmtpAuthAnonymous, arg: Option<&[u8]>) -> Vec<u8> {
328        match cor.resume(arg) {
329            SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
330            state => panic!("expected WantsWrite, got {state:?}"),
331        }
332    }
333
334    fn expect_wants_read(cor: &mut SmtpAuthAnonymous) {
335        match cor.resume(None) {
336            SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
337            state => panic!("expected WantsRead, got {state:?}"),
338        }
339    }
340
341    fn expect_complete_ok(cor: &mut SmtpAuthAnonymous, reply: &[u8]) {
342        match cor.resume(Some(reply)) {
343            SmtpCoroutineState::Complete(Ok(())) => {}
344            state => panic!("expected Complete(Ok), got {state:?}"),
345        }
346    }
347
348    fn expect_complete_err(cor: &mut SmtpAuthAnonymous, reply: &[u8]) -> SmtpAuthAnonymousError {
349        match cor.resume(Some(reply)) {
350            SmtpCoroutineState::Complete(Err(err)) => err,
351            state => panic!("expected Complete(Err), got {state:?}"),
352        }
353    }
354}