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