deltachat/qr/
dclogin_scheme.rs

1use std::collections::HashMap;
2
3use anyhow::{Context as _, Result, bail};
4
5use deltachat_contact_tools::may_be_valid_addr;
6
7use super::{DCLOGIN_SCHEME, Qr};
8use crate::login_param::{EnteredCertificateChecks, EnteredLoginParam, EnteredServerLoginParam};
9use crate::provider::Socket;
10
11/// Options for `dclogin:` scheme.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum LoginOptions {
14    /// Unsupported version.
15    UnsuportedVersion(u32),
16
17    /// Version 1.
18    V1 {
19        /// IMAP server password.
20        ///
21        /// Used for SMTP if separate SMTP password is not provided.
22        mail_pw: String,
23
24        /// IMAP host.
25        imap_host: Option<String>,
26
27        /// IMAP port.
28        imap_port: Option<u16>,
29
30        /// IMAP username.
31        imap_username: Option<String>,
32
33        /// IMAP password.
34        imap_password: Option<String>,
35
36        /// IMAP socket security.
37        imap_security: Option<Socket>,
38
39        /// SMTP host.
40        smtp_host: Option<String>,
41
42        /// SMTP port.
43        smtp_port: Option<u16>,
44
45        /// SMTP username.
46        smtp_username: Option<String>,
47
48        /// SMTP password.
49        smtp_password: Option<String>,
50
51        /// SMTP socket security.
52        smtp_security: Option<Socket>,
53
54        /// Certificate checks.
55        certificate_checks: Option<EnteredCertificateChecks>,
56    },
57}
58
59/// scheme: `dclogin://user@host/?p=password&v=1[&options]`
60/// read more about the scheme at <https://github.com/deltachat/interface/blob/master/uri-schemes.md#DCLOGIN>
61pub(super) fn decode_login(qr: &str) -> Result<Qr> {
62    let qr = qr.replacen("://", ":", 1);
63
64    let url = url::Url::parse(&qr).with_context(|| format!("Malformed url: {qr:?}"))?;
65    let payload = qr
66        .get(DCLOGIN_SCHEME.len()..)
67        .context("invalid DCLOGIN payload E1")?;
68
69    let addr = payload
70        .split(['?', '/'])
71        .next()
72        .context("invalid DCLOGIN payload E3")?;
73
74    if url.scheme().eq_ignore_ascii_case("dclogin") {
75        let options = url.query_pairs();
76        if options.count() == 0 {
77            bail!("invalid DCLOGIN payload E4")
78        }
79        // load options into hashmap
80        let parameter_map: HashMap<String, String> = options
81            .map(|(key, value)| (key.into_owned(), value.into_owned()))
82            .collect();
83
84        // check if username is there
85        if !may_be_valid_addr(addr) {
86            bail!("invalid DCLOGIN payload: invalid username E5");
87        }
88
89        // apply to result struct
90        let options: LoginOptions = match parameter_map.get("v").map(|i| i.parse::<u32>()) {
91            Some(Ok(1)) => LoginOptions::V1 {
92                mail_pw: parameter_map
93                    .get("p")
94                    .map(|s| s.to_owned())
95                    .context("password missing")?,
96                imap_host: parameter_map.get("ih").map(|s| s.to_owned()),
97                imap_port: parse_port(parameter_map.get("ip"))
98                    .context("could not parse imap port")?,
99                imap_username: parameter_map.get("iu").map(|s| s.to_owned()),
100                imap_password: parameter_map.get("ipw").map(|s| s.to_owned()),
101                imap_security: parse_socket_security(parameter_map.get("is"))?,
102                smtp_host: parameter_map.get("sh").map(|s| s.to_owned()),
103                smtp_port: parse_port(parameter_map.get("sp"))
104                    .context("could not parse smtp port")?,
105                smtp_username: parameter_map.get("su").map(|s| s.to_owned()),
106                smtp_password: parameter_map.get("spw").map(|s| s.to_owned()),
107                smtp_security: parse_socket_security(parameter_map.get("ss"))?,
108                certificate_checks: parse_certificate_checks(parameter_map.get("ic"))?,
109            },
110            Some(Ok(v)) => LoginOptions::UnsuportedVersion(v),
111            Some(Err(_)) => bail!("version could not be parsed as number E6"),
112            None => bail!("invalid DCLOGIN payload: version missing E7"),
113        };
114
115        Ok(Qr::Login {
116            address: addr.to_owned(),
117            options,
118        })
119    } else {
120        bail!("Bad scheme for account URL: {payload:?}.");
121    }
122}
123
124fn parse_port(port: Option<&String>) -> core::result::Result<Option<u16>, std::num::ParseIntError> {
125    match port {
126        Some(p) => Ok(Some(p.parse::<u16>()?)),
127        None => Ok(None),
128    }
129}
130
131fn parse_socket_security(security: Option<&String>) -> Result<Option<Socket>> {
132    Ok(match security.map(|s| s.as_str()) {
133        Some("ssl") => Some(Socket::Ssl),
134        Some("starttls") => Some(Socket::Starttls),
135        Some("default") => Some(Socket::Automatic),
136        Some("plain") => Some(Socket::Plain),
137        Some(other) => bail!("Unknown security level: {other}"),
138        None => None,
139    })
140}
141
142fn parse_certificate_checks(
143    certificate_checks: Option<&String>,
144) -> Result<Option<EnteredCertificateChecks>> {
145    Ok(match certificate_checks.map(|s| s.as_str()) {
146        Some("0") => Some(EnteredCertificateChecks::Automatic),
147        Some("1") => Some(EnteredCertificateChecks::Strict),
148        Some("2") => Some(EnteredCertificateChecks::AcceptInvalidCertificates),
149        Some("3") => Some(EnteredCertificateChecks::AcceptInvalidCertificates2),
150        Some(other) => bail!("Unknown certificatecheck level: {other}"),
151        None => None,
152    })
153}
154
155pub(crate) fn login_param_from_login_qr(
156    addr: &str,
157    options: LoginOptions,
158) -> Result<EnteredLoginParam> {
159    match options {
160        LoginOptions::V1 {
161            mail_pw,
162            imap_host,
163            imap_port,
164            imap_username,
165            imap_password,
166            imap_security,
167            smtp_host,
168            smtp_port,
169            smtp_username,
170            smtp_password,
171            smtp_security,
172            certificate_checks,
173        } => {
174            let param = EnteredLoginParam {
175                addr: addr.to_string(),
176                imap: EnteredServerLoginParam {
177                    server: imap_host.unwrap_or_default(),
178                    port: imap_port.unwrap_or_default(),
179                    security: imap_security.unwrap_or_default(),
180                    user: imap_username.unwrap_or_default(),
181                    password: imap_password.unwrap_or(mail_pw),
182                },
183                smtp: EnteredServerLoginParam {
184                    server: smtp_host.unwrap_or_default(),
185                    port: smtp_port.unwrap_or_default(),
186                    security: smtp_security.unwrap_or_default(),
187                    user: smtp_username.unwrap_or_default(),
188                    password: smtp_password.unwrap_or_default(),
189                },
190                certificate_checks: certificate_checks.unwrap_or_default(),
191                oauth2: false,
192            };
193            Ok(param)
194        }
195        _ => bail!(
196            "DeltaChat does not understand this QR Code yet, please update the app and try again."
197        ),
198    }
199}
200
201#[cfg(test)]
202mod test {
203    use anyhow::bail;
204
205    use super::{LoginOptions, decode_login};
206    use crate::{login_param::EnteredCertificateChecks, provider::Socket, qr::Qr};
207
208    macro_rules! login_options_just_pw {
209        ($pw: expr) => {
210            LoginOptions::V1 {
211                mail_pw: $pw,
212                imap_host: None,
213                imap_port: None,
214                imap_username: None,
215                imap_password: None,
216                imap_security: None,
217                smtp_host: None,
218                smtp_port: None,
219                smtp_username: None,
220                smtp_password: None,
221                smtp_security: None,
222                certificate_checks: None,
223            }
224        };
225    }
226
227    #[test]
228    fn minimal_no_options() -> anyhow::Result<()> {
229        let result = decode_login("dclogin://email@host.tld?p=123&v=1")?;
230        if let Qr::Login { address, options } = result {
231            assert_eq!(address, "email@host.tld".to_owned());
232            assert_eq!(options, login_options_just_pw!("123".to_owned()));
233        } else {
234            bail!("wrong type")
235        }
236        let result = decode_login("dclogin://email@host.tld/?p=123456&v=1")?;
237        if let Qr::Login { address, options } = result {
238            assert_eq!(address, "email@host.tld".to_owned());
239            assert_eq!(options, login_options_just_pw!("123456".to_owned()));
240        } else {
241            bail!("wrong type")
242        }
243        let result = decode_login("dclogin://email@host.tld/ignored/path?p=123456&v=1")?;
244        if let Qr::Login { address, options } = result {
245            assert_eq!(address, "email@host.tld".to_owned());
246            assert_eq!(options, login_options_just_pw!("123456".to_owned()));
247        } else {
248            bail!("wrong type")
249        }
250        Ok(())
251    }
252    #[test]
253    fn minimal_no_options_no_double_slash() -> anyhow::Result<()> {
254        let result = decode_login("dclogin:email@host.tld?p=123&v=1")?;
255        if let Qr::Login { address, options } = result {
256            assert_eq!(address, "email@host.tld".to_owned());
257            assert_eq!(options, login_options_just_pw!("123".to_owned()));
258        } else {
259            bail!("wrong type")
260        }
261        let result = decode_login("dclogin:email@host.tld/?p=123456&v=1")?;
262        if let Qr::Login { address, options } = result {
263            assert_eq!(address, "email@host.tld".to_owned());
264            assert_eq!(options, login_options_just_pw!("123456".to_owned()));
265        } else {
266            bail!("wrong type")
267        }
268        let result = decode_login("dclogin:email@host.tld/ignored/path?p=123456&v=1")?;
269        if let Qr::Login { address, options } = result {
270            assert_eq!(address, "email@host.tld".to_owned());
271            assert_eq!(options, login_options_just_pw!("123456".to_owned()));
272        } else {
273            bail!("wrong type")
274        }
275        Ok(())
276    }
277
278    #[test]
279    fn no_version_set() {
280        assert!(decode_login("dclogin:email@host.tld?p=123").is_err());
281    }
282
283    #[test]
284    fn invalid_version_set() {
285        assert!(decode_login("dclogin:email@host.tld?p=123&v=").is_err());
286        assert!(decode_login("dclogin:email@host.tld?p=123&v=%40").is_err());
287        assert!(decode_login("dclogin:email@host.tld?p=123&v=-20").is_err());
288        assert!(decode_login("dclogin:email@host.tld?p=123&v=hi").is_err());
289    }
290
291    #[test]
292    fn version_too_new() -> anyhow::Result<()> {
293        let result = decode_login("dclogin:email@host.tld/?p=123456&v=2")?;
294        if let Qr::Login { options, .. } = result {
295            assert_eq!(options, LoginOptions::UnsuportedVersion(2));
296        } else {
297            bail!("wrong type");
298        }
299        let result = decode_login("dclogin:email@host.tld/?p=123456&v=5")?;
300        if let Qr::Login { options, .. } = result {
301            assert_eq!(options, LoginOptions::UnsuportedVersion(5));
302        } else {
303            bail!("wrong type");
304        }
305        Ok(())
306    }
307
308    #[test]
309    fn all_advanced_options() -> anyhow::Result<()> {
310        let result = decode_login(
311            "dclogin:email@host.tld?p=secret&v=1&ih=imap.host.tld&ip=4000&iu=max&ipw=87654&is=ssl&ic=1&sh=mail.host.tld&sp=3000&su=max@host.tld&spw=3242HS&ss=plain&sc=3",
312        )?;
313        if let Qr::Login { address, options } = result {
314            assert_eq!(address, "email@host.tld".to_owned());
315            assert_eq!(
316                options,
317                LoginOptions::V1 {
318                    mail_pw: "secret".to_owned(),
319                    imap_host: Some("imap.host.tld".to_owned()),
320                    imap_port: Some(4000),
321                    imap_username: Some("max".to_owned()),
322                    imap_password: Some("87654".to_owned()),
323                    imap_security: Some(Socket::Ssl),
324                    smtp_host: Some("mail.host.tld".to_owned()),
325                    smtp_port: Some(3000),
326                    smtp_username: Some("max@host.tld".to_owned()),
327                    smtp_password: Some("3242HS".to_owned()),
328                    smtp_security: Some(Socket::Plain),
329                    certificate_checks: Some(EnteredCertificateChecks::Strict),
330                }
331            );
332        } else {
333            bail!("wrong type")
334        }
335        Ok(())
336    }
337
338    #[test]
339    fn uri_encoded_password() -> anyhow::Result<()> {
340        let result = decode_login(
341            "dclogin:email@host.tld?p=%7BDaehFl%3B%22as%40%21fhdodn5%24234%22%7B%7Dfg&v=1",
342        )?;
343        if let Qr::Login { address, options } = result {
344            assert_eq!(address, "email@host.tld".to_owned());
345            assert_eq!(
346                options,
347                login_options_just_pw!("{DaehFl;\"as@!fhdodn5$234\"{}fg".to_owned())
348            );
349        } else {
350            bail!("wrong type")
351        }
352        Ok(())
353    }
354
355    #[test]
356    fn email_with_plus_extension() -> anyhow::Result<()> {
357        let result = decode_login("dclogin:usename+extension@host?p=1234&v=1")?;
358        if let Qr::Login { address, options } = result {
359            assert_eq!(address, "usename+extension@host".to_owned());
360            assert_eq!(options, login_options_just_pw!("1234".to_owned()));
361        } else {
362            bail!("wrong type")
363        }
364        Ok(())
365    }
366
367    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
368    async fn test_decode_dclogin_ipv4() -> anyhow::Result<()> {
369        let result = decode_login("dclogin://test@[127.0.0.1]?p=1234&v=1")?;
370        if let Qr::Login { address, options } = result {
371            assert_eq!(address, "test@[127.0.0.1]".to_owned());
372            assert_eq!(options, login_options_just_pw!("1234".to_owned()));
373        } else {
374            unreachable!("wrong type");
375        }
376        Ok(())
377    }
378
379    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
380    async fn test_decode_dclogin_ipv6() -> anyhow::Result<()> {
381        let result =
382            decode_login("dclogin://test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]?p=1234&v=1")?;
383        if let Qr::Login { address, options } = result {
384            assert_eq!(
385                address,
386                "test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]".to_owned()
387            );
388            assert_eq!(options, login_options_just_pw!("1234".to_owned()));
389        } else {
390            unreachable!("wrong type");
391        }
392        Ok(())
393    }
394}