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