mail_send/smtp/
auth.rs

1/*
2 * Copyright Stalwart Labs Ltd.
3 *
4 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
5 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
7 * option. This file may not be copied, modified, or distributed
8 * except according to those terms.
9 */
10
11use std::{fmt::Display, hash::Hash};
12
13use base64::{engine, Engine};
14use smtp_proto::{
15    response::generate::BitToString, EhloResponse, AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN,
16    AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2,
17};
18use tokio::io::{AsyncRead, AsyncWrite};
19
20use crate::{Credentials, SmtpClient};
21
22impl<T: AsyncRead + AsyncWrite + Unpin> SmtpClient<T> {
23    pub async fn authenticate<U>(
24        &mut self,
25        credentials: impl AsRef<Credentials<U>>,
26        capabilities: impl AsRef<EhloResponse<String>>,
27    ) -> crate::Result<&mut Self>
28    where
29        U: AsRef<str> + PartialEq + Eq + Hash,
30    {
31        let credentials = credentials.as_ref();
32        let capabilities = capabilities.as_ref();
33        let mut available_mechanisms = match &credentials {
34            Credentials::Plain { .. } => AUTH_CRAM_MD5 | AUTH_DIGEST_MD5 | AUTH_LOGIN | AUTH_PLAIN,
35            Credentials::OAuthBearer { .. } => AUTH_OAUTHBEARER,
36            Credentials::XOauth2 { .. } => AUTH_XOAUTH2,
37        } & capabilities.auth_mechanisms;
38
39        // Try authenticating from most secure to least secure
40        let mut has_err = None;
41        let mut has_failed = false;
42
43        while available_mechanisms != 0 && !has_failed {
44            let mechanism = 1 << ((63 - available_mechanisms.leading_zeros()) as u64);
45            available_mechanisms ^= mechanism;
46            match self.auth(mechanism, credentials).await {
47                Ok(_) => {
48                    return Ok(self);
49                }
50                Err(err) => match err {
51                    crate::Error::UnexpectedReply(reply) => {
52                        has_failed = reply.code() == 535;
53                        has_err = reply.into();
54                    }
55                    crate::Error::UnsupportedAuthMechanism => (),
56                    _ => return Err(err),
57                },
58            }
59        }
60
61        if let Some(has_err) = has_err {
62            Err(crate::Error::AuthenticationFailed(has_err))
63        } else {
64            Err(crate::Error::UnsupportedAuthMechanism)
65        }
66    }
67
68    pub(crate) async fn auth<U>(
69        &mut self,
70        mechanism: u64,
71        credentials: &Credentials<U>,
72    ) -> crate::Result<()>
73    where
74        U: AsRef<str> + PartialEq + Eq + Hash,
75    {
76        let mut reply = if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 {
77            self.cmd(
78                format!(
79                    "AUTH {} {}\r\n",
80                    mechanism.to_mechanism(),
81                    credentials.encode(mechanism, "")?,
82                )
83                .as_bytes(),
84            )
85            .await?
86        } else {
87            self.cmd(format!("AUTH {}\r\n", mechanism.to_mechanism()).as_bytes())
88                .await?
89        };
90
91        for _ in 0..3 {
92            match reply.code() {
93                334 => {
94                    reply = self
95                        .cmd(
96                            format!("{}\r\n", credentials.encode(mechanism, reply.message())?)
97                                .as_bytes(),
98                        )
99                        .await?;
100                }
101                235 => {
102                    return Ok(());
103                }
104                _ => {
105                    return Err(crate::Error::UnexpectedReply(reply));
106                }
107            }
108        }
109
110        Err(crate::Error::UnexpectedReply(reply))
111    }
112}
113
114#[derive(Debug, Clone)]
115pub enum Error {
116    InvalidChallenge,
117}
118
119impl<T: AsRef<str> + PartialEq + Eq + Hash> Credentials<T> {
120    /// Creates a new `Credentials` instance.
121    pub fn new(username: T, secret: T) -> Credentials<T> {
122        Credentials::Plain { username, secret }
123    }
124
125    /// Creates a new XOAuth2 `Credentials` instance.
126    pub fn new_xoauth2(username: T, secret: T) -> Credentials<T> {
127        Credentials::XOauth2 { username, secret }
128    }
129
130    /// Creates a new OAuthBearer `Credentials` instance.
131    pub fn new_oauth(payload: T) -> Credentials<T> {
132        Credentials::OAuthBearer { token: payload }
133    }
134
135    /// Creates a new OAuthBearer `Credentials` instance from a Bearer token.
136    pub fn new_oauth_from_token(token: T) -> Credentials<String> {
137        Credentials::OAuthBearer {
138            token: format!("auth=Bearer {}\x01\x01", token.as_ref()),
139        }
140    }
141
142    pub fn encode(&self, mechanism: u64, challenge: &str) -> crate::Result<String> {
143        Ok(engine::general_purpose::STANDARD.encode(
144            match (mechanism, self) {
145                (AUTH_PLAIN, Credentials::Plain { username, secret }) => {
146                    format!("\u{0}{}\u{0}{}", username.as_ref(), secret.as_ref())
147                }
148
149                (AUTH_LOGIN, Credentials::Plain { username, secret }) => {
150                    let challenge = engine::general_purpose::STANDARD.decode(challenge)?;
151                    let username = username.as_ref();
152                    let secret = secret.as_ref();
153
154                    if b"user name"
155                        .eq_ignore_ascii_case(challenge.get(0..9).ok_or(Error::InvalidChallenge)?)
156                        || b"username".eq_ignore_ascii_case(
157                            // Because Google makes its own standards
158                            challenge.get(0..8).ok_or(Error::InvalidChallenge)?,
159                        )
160                    {
161                        &username
162                    } else if b"password"
163                        .eq_ignore_ascii_case(challenge.get(0..8).ok_or(Error::InvalidChallenge)?)
164                    {
165                        &secret
166                    } else {
167                        return Err(Error::InvalidChallenge.into());
168                    }
169                    .to_string()
170                }
171
172                #[cfg(feature = "digest-md5")]
173                (AUTH_DIGEST_MD5, Credentials::Plain { username, secret }) => {
174                    let mut buf = Vec::with_capacity(10);
175                    let mut key = None;
176                    let mut in_quote = false;
177                    let mut values = std::collections::HashMap::new();
178                    let challenge = engine::general_purpose::STANDARD.decode(challenge)?;
179                    let challenge_len = challenge.len();
180                    let username = username.as_ref();
181                    let secret = secret.as_ref();
182
183                    for (pos, byte) in challenge.into_iter().enumerate() {
184                        let add_key = match byte {
185                            b'=' if !in_quote => {
186                                if key.is_none() && !buf.is_empty() {
187                                    key = String::from_utf8_lossy(&buf).into_owned().into();
188                                    buf.clear();
189                                } else {
190                                    return Err(Error::InvalidChallenge.into());
191                                }
192                                false
193                            }
194                            b',' if !in_quote => true,
195                            b'"' => {
196                                in_quote = !in_quote;
197                                false
198                            }
199                            _ => {
200                                buf.push(byte);
201                                false
202                            }
203                        };
204
205                        if (add_key || pos == challenge_len - 1) && key.is_some() && !buf.is_empty()
206                        {
207                            values.insert(
208                                key.take().unwrap(),
209                                String::from_utf8_lossy(&buf).into_owned(),
210                            );
211                            buf.clear();
212                        }
213                    }
214
215                    let (digest_uri, realm, realm_response) =
216                        if let Some(realm) = values.get("realm") {
217                            (
218                                format!("smtp/{realm}"),
219                                realm.as_str(),
220                                format!(",realm=\"{realm}\""),
221                            )
222                        } else {
223                            ("smtp/localhost".to_string(), "", "".to_string())
224                        };
225
226                    let credentials =
227                        md5::compute(format!("{username}:{realm}:{secret}").as_bytes());
228
229                    let a2 = md5::compute(
230                        if values.get("qpop").is_some_and(|v| v == "auth") {
231                            format!("AUTHENTICATE:{digest_uri}")
232                        } else {
233                            format!("AUTHENTICATE:{digest_uri}:00000000000000000000000000000000")
234                        }
235                        .as_bytes(),
236                    );
237
238                    #[allow(unused_variables)]
239                    let cnonce = {
240                        use rand::RngCore;
241                        let mut buf = [0u8; 16];
242                        rand::rng().fill_bytes(&mut buf);
243                        engine::general_purpose::STANDARD.encode(buf)
244                    };
245
246                    #[cfg(test)]
247                    let cnonce = "OA6MHXh6VqTrRk".to_string();
248                    let nonce = values.remove("nonce").unwrap_or_default();
249                    let qop = values.remove("qop").unwrap_or_default();
250                    let charset = values
251                        .remove("charset")
252                        .unwrap_or_else(|| "utf-8".to_string());
253
254                    format!(
255                        concat!(
256                            "charset={},username=\"{}\",realm=\"{}\",nonce=\"{}\",nc=00000001,",
257                            "cnonce=\"{}\",digest-uri=\"{}\",response={:x},qop={}"
258                        ),
259                        charset,
260                        username,
261                        realm_response,
262                        nonce,
263                        cnonce,
264                        digest_uri,
265                        md5::compute(
266                            format!("{credentials:x}:{nonce}:00000001:{cnonce}:{qop}:{a2:x}")
267                                .as_bytes()
268                        ),
269                        qop
270                    )
271                }
272
273                #[cfg(feature = "cram-md5")]
274                (AUTH_CRAM_MD5, Credentials::Plain { username, secret }) => {
275                    let mut secret_opad: Vec<u8> = vec![0x5c; 64];
276                    let mut secret_ipad: Vec<u8> = vec![0x36; 64];
277                    let username = username.as_ref();
278                    let secret = secret.as_ref();
279
280                    if secret.len() < 64 {
281                        for (pos, byte) in secret.as_bytes().iter().enumerate() {
282                            secret_opad[pos] = *byte ^ 0x5c;
283                            secret_ipad[pos] = *byte ^ 0x36;
284                        }
285                    } else {
286                        for (pos, byte) in md5::compute(secret.as_bytes()).iter().enumerate() {
287                            secret_opad[pos] = *byte ^ 0x5c;
288                            secret_ipad[pos] = *byte ^ 0x36;
289                        }
290                    }
291
292                    secret_ipad
293                        .extend_from_slice(&engine::general_purpose::STANDARD.decode(challenge)?);
294                    secret_opad.extend_from_slice(&md5::compute(&secret_ipad).0);
295
296                    format!("{} {:x}", username, md5::compute(&secret_opad))
297                }
298
299                (AUTH_XOAUTH2, Credentials::XOauth2 { username, secret }) => format!(
300                    "user={}\x01auth=Bearer {}\x01\x01",
301                    username.as_ref(),
302                    secret.as_ref()
303                ),
304                (AUTH_OAUTHBEARER, Credentials::OAuthBearer { token }) => {
305                    token.as_ref().to_string()
306                }
307                _ => return Err(crate::Error::UnsupportedAuthMechanism),
308            }
309            .as_bytes(),
310        ))
311    }
312}
313
314impl<'x> From<(&'x str, &'x str)> for Credentials<&'x str> {
315    fn from(credentials: (&'x str, &'x str)) -> Self {
316        Credentials::Plain {
317            username: credentials.0,
318            secret: credentials.1,
319        }
320    }
321}
322
323impl From<(String, String)> for Credentials<String> {
324    fn from(credentials: (String, String)) -> Self {
325        Credentials::Plain {
326            username: credentials.0,
327            secret: credentials.1,
328        }
329    }
330}
331
332impl<U: AsRef<str> + PartialEq + Eq + Hash> AsRef<Credentials<U>> for Credentials<U> {
333    fn as_ref(&self) -> &Credentials<U> {
334        self
335    }
336}
337
338impl Display for Error {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        match self {
341            Error::InvalidChallenge => write!(f, "Invalid challenge received."),
342        }
343    }
344}
345
346#[cfg(test)]
347mod test {
348
349    use smtp_proto::{AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN, AUTH_PLAIN, AUTH_XOAUTH2};
350
351    use crate::smtp::auth::Credentials;
352
353    #[test]
354    fn auth_encode() {
355        // Digest-MD5
356        #[cfg(feature = "digest-md5")]
357        assert_eq!(
358            Credentials::new("chris", "secret")
359                .encode(
360                    AUTH_DIGEST_MD5,
361                    concat!(
362                        "cmVhbG09ImVsd29vZC5pbm5vc29mdC5jb20iLG5vbmNlPSJPQTZNRzl0",
363                        "RVFHbTJoaCIscW9wPSJhdXRoIixhbGdvcml0aG09bWQ1LXNlc3MsY2hh",
364                        "cnNldD11dGYtOA=="
365                    ),
366                )
367                .unwrap(),
368            concat!(
369                "Y2hhcnNldD11dGYtOCx1c2VybmFtZT0iY2hyaXMiLHJlYWxtPSIscmVhbG0",
370                "9ImVsd29vZC5pbm5vc29mdC5jb20iIixub25jZT0iT0E2TUc5dEVRR20yaG",
371                "giLG5jPTAwMDAwMDAxLGNub25jZT0iT0E2TUhYaDZWcVRyUmsiLGRpZ2Vzd",
372                "C11cmk9InNtdHAvZWx3b29kLmlubm9zb2Z0LmNvbSIscmVzcG9uc2U9NDQ2",
373                "NjIxODg3MzlmYzcxOGNlYmYyZjA4MTk4MWI4ZDIscW9wPWF1dGg=",
374            )
375        );
376
377        // Challenge-Response Authentication Mechanism (CRAM)
378        #[cfg(feature = "cram-md5")]
379        assert_eq!(
380            Credentials::new("tim", "tanstaaftanstaaf")
381                .encode(
382                    AUTH_CRAM_MD5,
383                    "PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+",
384                )
385                .unwrap(),
386            "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw"
387        );
388
389        // SASL XOAUTH2
390        assert_eq!(
391            Credentials::XOauth2 {
392                username: "someuser@example.com",
393                secret: "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg"
394            }
395            .encode(AUTH_XOAUTH2, "",)
396            .unwrap(),
397            concat!(
398                "dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5Ln",
399                "ZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ=="
400            )
401        );
402
403        // Login
404        assert_eq!(
405            Credentials::new("tim", "tanstaaftanstaaf")
406                .encode(AUTH_LOGIN, "VXNlciBOYW1lAA==",)
407                .unwrap(),
408            "dGlt"
409        );
410        assert_eq!(
411            Credentials::new("tim", "tanstaaftanstaaf")
412                .encode(AUTH_LOGIN, "UGFzc3dvcmQA",)
413                .unwrap(),
414            "dGFuc3RhYWZ0YW5zdGFhZg=="
415        );
416
417        // Plain
418        assert_eq!(
419            Credentials::new("tim", "tanstaaftanstaaf")
420                .encode(AUTH_PLAIN, "",)
421                .unwrap(),
422            "AHRpbQB0YW5zdGFhZnRhbnN0YWFm"
423        );
424    }
425}