mail_send/smtp/
auth.rs

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