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