Skip to main content

deltachat/
qr.rs

1//! # QR code module.
2
3mod dclogin_scheme;
4use std::collections::BTreeMap;
5use std::sync::LazyLock;
6
7use anyhow::{Context as _, Result, anyhow, bail, ensure};
8pub use dclogin_scheme::LoginOptions;
9pub(crate) use dclogin_scheme::login_param_from_login_qr;
10use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
11use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
12use rand::TryRngCore as _;
13use rand::distr::{Alphanumeric, SampleString};
14use serde::Deserialize;
15
16use crate::config::Config;
17use crate::contact::{Contact, ContactId, Origin};
18use crate::context::Context;
19use crate::key::Fingerprint;
20use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam, EnteredLoginParam};
21use crate::net::http::post_empty;
22use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
23use crate::token;
24use crate::tools::{time, validate_id};
25
26const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase
27const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
28const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
29const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
30pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
31const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
32const MAILTO_SCHEME: &str = "mailto:";
33const MATMSG_SCHEME: &str = "MATMSG:";
34const VCARD_SCHEME: &str = "BEGIN:VCARD";
35const SMTP_SCHEME: &str = "SMTP:";
36const HTTPS_SCHEME: &str = "https://";
37const SHADOWSOCKS_SCHEME: &str = "ss://";
38
39/// Backup transfer based on iroh-net.
40pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
41
42/// Version written to Backups and Backup-QR-Codes.
43/// Imports will fail when they have a larger version.
44pub(crate) const DCBACKUP_VERSION: i32 = 5;
45
46/// Scanned QR code.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Qr {
49    /// Ask the user whether to verify the contact.
50    ///
51    /// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`].
52    AskVerifyContact {
53        /// ID of the contact.
54        contact_id: ContactId,
55
56        /// Fingerprint of the contact key as scanned from the QR code.
57        fingerprint: Fingerprint,
58
59        /// The inviter's addresses.
60        addrs: Vec<String>,
61
62        /// Invite number.
63        invitenumber: String,
64
65        /// Authentication code.
66        authcode: String,
67
68        /// Whether the inviter supports the new Securejoin v3 protocol
69        is_v3: bool,
70    },
71
72    /// Ask the user whether to join the group.
73    AskVerifyGroup {
74        /// Group name.
75        grpname: String,
76
77        /// Group ID.
78        grpid: String,
79
80        /// ID of the contact.
81        contact_id: ContactId,
82
83        /// Fingerprint of the contact key as scanned from the QR code.
84        fingerprint: Fingerprint,
85
86        /// The inviter's addresses.
87        addrs: Vec<String>,
88
89        /// Invite number.
90        invitenumber: String,
91
92        /// Authentication code.
93        authcode: String,
94
95        /// Whether the inviter supports the new Securejoin v3 protocol
96        is_v3: bool,
97    },
98
99    /// Ask whether to join the broadcast channel.
100    AskJoinBroadcast {
101        /// The user-visible name of this broadcast channel
102        name: String,
103
104        /// A string of random characters,
105        /// uniquely identifying this broadcast channel across all databases/clients.
106        /// Called `grpid` for historic reasons:
107        /// The id of multi-user chats is always called `grpid` in the database
108        /// because groups were once the only multi-user chats.
109        grpid: String,
110
111        /// ID of the contact who owns the channel and created the QR code.
112        contact_id: ContactId,
113
114        /// Fingerprint of the contact's key as scanned from the QR code.
115        fingerprint: Fingerprint,
116
117        /// The inviter's addresses.
118        addrs: Vec<String>,
119
120        /// Invite number.
121        invitenumber: String,
122        /// Authentication code.
123        authcode: String,
124
125        /// Whether the inviter supports the new Securejoin v3 protocol
126        is_v3: bool,
127    },
128
129    /// Contact fingerprint is verified.
130    ///
131    /// Ask the user if they want to start chatting.
132    FprOk {
133        /// Contact ID.
134        contact_id: ContactId,
135    },
136
137    /// Scanned fingerprint does not match the last seen fingerprint.
138    FprMismatch {
139        /// Contact ID.
140        contact_id: Option<ContactId>,
141    },
142
143    /// The scanned QR code contains a fingerprint but no e-mail address.
144    FprWithoutAddr {
145        /// Key fingerprint.
146        fingerprint: String,
147    },
148
149    /// Ask the user if they want to create an account on the given domain.
150    Account {
151        /// Server domain name.
152        domain: String,
153    },
154
155    /// Provides a backup that can be retrieved using iroh-net based backup transfer protocol.
156    Backup2 {
157        /// Iroh node address.
158        node_addr: iroh::NodeAddr,
159
160        /// Authentication token.
161        auth_token: String,
162    },
163
164    /// The QR code is a backup, but it is too new. The user has to update its Delta Chat.
165    BackupTooNew {},
166
167    /// Ask the user if they want to use the given proxy.
168    ///
169    /// Note that HTTP(S) URLs without a path
170    /// and query parameters are treated as HTTP(S) proxy URL.
171    /// UI may want to still offer to open the URL
172    /// in the browser if QR code contents
173    /// starts with `http://` or `https://`
174    /// and the QR code was not scanned from
175    /// the proxy configuration screen.
176    Proxy {
177        /// Proxy URL.
178        ///
179        /// This is the URL that is going to be added.
180        url: String,
181
182        /// Host extracted from the URL to display in the UI.
183        host: String,
184
185        /// Port extracted from the URL to display in the UI.
186        port: u16,
187    },
188
189    /// Contact address is scanned.
190    ///
191    /// Optionally, a draft message could be provided.
192    /// Ask the user if they want to start chatting.
193    Addr {
194        /// Contact ID.
195        contact_id: ContactId,
196
197        /// Draft message.
198        draft: Option<String>,
199    },
200
201    /// URL scanned.
202    ///
203    /// Ask the user if they want to open a browser or copy the URL to clipboard.
204    Url {
205        /// URL.
206        url: String,
207    },
208
209    /// Text scanned.
210    ///
211    /// Ask the user if they want to copy the text to clipboard.
212    Text {
213        /// Scanned text.
214        text: String,
215    },
216
217    /// Ask the user if they want to withdraw their own QR code.
218    WithdrawVerifyContact {
219        /// Contact ID.
220        contact_id: ContactId,
221
222        /// Fingerprint of the contact key as scanned from the QR code.
223        fingerprint: Fingerprint,
224
225        /// Invite number.
226        invitenumber: String,
227
228        /// Authentication code.
229        authcode: String,
230    },
231
232    /// Ask the user if they want to withdraw their own group invite QR code.
233    WithdrawVerifyGroup {
234        /// Group name.
235        grpname: String,
236
237        /// Group ID.
238        grpid: String,
239
240        /// Contact ID.
241        contact_id: ContactId,
242
243        /// Fingerprint of the contact key as scanned from the QR code.
244        fingerprint: Fingerprint,
245
246        /// Invite number.
247        invitenumber: String,
248
249        /// Authentication code.
250        authcode: String,
251    },
252
253    /// Ask the user if they want to withdraw their own broadcast channel invite QR code.
254    WithdrawJoinBroadcast {
255        /// The user-visible name of this broadcast channel
256        name: String,
257
258        /// A string of random characters,
259        /// uniquely identifying this broadcast channel across all databases/clients.
260        /// Called `grpid` for historic reasons:
261        /// The id of multi-user chats is always called `grpid` in the database
262        /// because groups were once the only multi-user chats.
263        grpid: String,
264
265        /// Contact ID. Always `ContactId::SELF`.
266        contact_id: ContactId,
267
268        /// Fingerprint of the contact's key as scanned from the QR code.
269        fingerprint: Fingerprint,
270
271        /// Invite number.
272        invitenumber: String,
273
274        /// Authentication code.
275        authcode: String,
276    },
277
278    /// Ask the user if they want to revive their own QR code.
279    ReviveVerifyContact {
280        /// Contact ID.
281        contact_id: ContactId,
282
283        /// Fingerprint of the contact key as scanned from the QR code.
284        fingerprint: Fingerprint,
285
286        /// Invite number.
287        invitenumber: String,
288
289        /// Authentication code.
290        authcode: String,
291    },
292
293    /// Ask the user if they want to revive their own group invite QR code.
294    ReviveVerifyGroup {
295        /// Group name.
296        grpname: String,
297
298        /// Group ID.
299        grpid: String,
300
301        /// Contact ID.
302        contact_id: ContactId,
303
304        /// Fingerprint of the contact key as scanned from the QR code.
305        fingerprint: Fingerprint,
306
307        /// Invite number.
308        invitenumber: String,
309
310        /// Authentication code.
311        authcode: String,
312    },
313
314    /// Ask the user if they want to revive their own broadcast channel invite QR code.
315    ReviveJoinBroadcast {
316        /// The user-visible name of this broadcast channel
317        name: String,
318
319        /// A string of random characters,
320        /// uniquely identifying this broadcast channel across all databases/clients.
321        /// Called `grpid` for historic reasons:
322        /// The id of multi-user chats is always called `grpid` in the database
323        /// because groups were once the only multi-user chats.
324        grpid: String,
325
326        /// Contact ID. Always `ContactId::SELF`.
327        contact_id: ContactId,
328
329        /// Fingerprint of the contact's key as scanned from the QR code.
330        fingerprint: Fingerprint,
331
332        /// Invite number.
333        invitenumber: String,
334
335        /// Authentication code.
336        authcode: String,
337    },
338
339    /// `dclogin:` scheme parameters.
340    ///
341    /// Ask the user if they want to login with the email address.
342    Login {
343        /// Email address.
344        address: String,
345
346        /// Login parameters.
347        options: LoginOptions,
348    },
349}
350
351// hack around the changed JSON accidentally used by an iroh upgrade, see #6518 for more details and for code snippet.
352// this hack is mainly needed to give ppl time to upgrade and can be removed after some months (added 2025-02)
353fn fix_add_second_device_qr(qr: &str) -> String {
354    qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
355        .replacen(r#""]}}"#, r#""]}"#, 1)
356}
357
358fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
359    string.to_lowercase().starts_with(&pattern.to_lowercase())
360}
361
362/// Checks a scanned QR code.
363///
364/// The function should be called after a QR code is scanned.
365/// The function takes the raw text scanned and checks what can be done with it.
366pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
367    let qr = qr.trim();
368    let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
369        decode_openpgp(context, qr)
370            .await
371            .context("failed to decode OPENPGP4FPR QR code")?
372    } else if qr.starts_with(IDELTACHAT_SCHEME) {
373        decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
374    } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
375        decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
376    } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
377        decode_account(qr)?
378    } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
379        dclogin_scheme::decode_login(qr)?
380    } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
381        decode_tg_socks_proxy(context, qr)?
382    } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
383        decode_shadowsocks_proxy(qr)?
384    } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
385        let qr_fixed = fix_add_second_device_qr(qr);
386        decode_backup2(&qr_fixed)?
387    } else if qr.starts_with(MAILTO_SCHEME) {
388        decode_mailto(context, qr).await?
389    } else if qr.starts_with(SMTP_SCHEME) {
390        decode_smtp(context, qr).await?
391    } else if qr.starts_with(MATMSG_SCHEME) {
392        decode_matmsg(context, qr).await?
393    } else if qr.starts_with(VCARD_SCHEME) {
394        decode_vcard(context, qr).await?
395    } else if let Ok(url) = url::Url::parse(qr) {
396        match url.scheme() {
397            "socks5" => Qr::Proxy {
398                url: qr.to_string(),
399                host: url.host_str().context("URL has no host")?.to_string(),
400                port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
401            },
402            "http" | "https" => {
403                // Parsing with a non-standard scheme
404                // is a hack to work around the `url` crate bug
405                // <https://github.com/servo/rust-url/issues/957>.
406                let url = if let Some(rest) = qr.strip_prefix("http://") {
407                    url::Url::parse(&format!("foobarbaz://{rest}"))?
408                } else if let Some(rest) = qr.strip_prefix("https://") {
409                    url::Url::parse(&format!("foobarbaz://{rest}"))?
410                } else {
411                    // Should not happen.
412                    url
413                };
414
415                if url.port().is_none() | (url.path() != "") | url.query().is_some() {
416                    // URL without a port, with a path or query cannot be a proxy URL.
417                    Qr::Url {
418                        url: qr.to_string(),
419                    }
420                } else {
421                    Qr::Proxy {
422                        url: qr.to_string(),
423                        host: url.host_str().context("URL has no host")?.to_string(),
424                        port: url
425                            .port_or_known_default()
426                            .context("HTTP(S) URLs are guaranteed to return Some port")?,
427                    }
428                }
429            }
430            _ => Qr::Url {
431                url: qr.to_string(),
432            },
433        }
434    } else {
435        Qr::Text {
436            text: qr.to_string(),
437        }
438    };
439    Ok(qrcode)
440}
441
442/// Formats the text of the [`Qr::Backup2`] variant.
443///
444/// This is the inverse of [`check_qr`] for that variant only.
445///
446/// TODO: Refactor this so all variants have a correct [`Display`] and transform `check_qr`
447/// into `FromStr`.
448pub fn format_backup(qr: &Qr) -> Result<String> {
449    match qr {
450        Qr::Backup2 {
451            node_addr,
452            auth_token,
453        } => {
454            let node_addr = serde_json::to_string(node_addr)?;
455            Ok(format!(
456                "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
457            ))
458        }
459        _ => Err(anyhow!("Not a backup QR code")),
460    }
461}
462
463/// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH`
464///     or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH`
465///     or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&b=BROADCAST_NAME&x=BROADCAST_ID&j=INVITENUMBER&s=AUTH`
466///     or: `OPENPGP4FPR:FINGERPRINT#a=ADDR`
467async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
468    let payload = qr
469        .get(OPENPGP4FPR_SCHEME.len()..)
470        .context("Invalid OPENPGP4FPR scheme")?;
471
472    // macOS and iOS sometimes replace the # with %23 (uri encode it), we should be able to parse this wrong format too.
473    // see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info
474    let (fingerprint, fragment) = match payload
475        .split_once('#')
476        .or_else(|| payload.split_once("%23"))
477    {
478        Some(pair) => pair,
479        None => (payload, ""),
480    };
481    let fingerprint: Fingerprint = fingerprint
482        .parse()
483        .context("Failed to parse fingerprint in the QR code")?;
484
485    let param: BTreeMap<&str, &str> = fragment
486        .split('&')
487        .filter_map(|s| {
488            if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
489                Some((key, value))
490            } else {
491                None
492            }
493        })
494        .collect();
495
496    let addr = if let Some(addr) = param.get("a") {
497        Some(normalize_address(addr)?)
498    } else {
499        None
500    };
501
502    let name = decode_name(&param, "n")?.unwrap_or_default();
503
504    let mut invitenumber = param
505        .get("i")
506        // For historic reansons, broadcasts currently use j instead of i for the invitenumber:
507        .or_else(|| param.get("j"))
508        .filter(|&s| validate_id(s))
509        .map(|s| s.to_string());
510    let authcode = param
511        .get("s")
512        .filter(|&s| validate_id(s))
513        .map(|s| s.to_string());
514    let grpid = param
515        .get("x")
516        .filter(|&s| validate_id(s))
517        .map(|s| s.to_string());
518
519    let grpname = decode_name(&param, "g")?;
520    let broadcast_name = decode_name(&param, "b")?;
521
522    let mut is_v3 = param.get("v") == Some(&"3");
523
524    if authcode.is_some() && invitenumber.is_none() {
525        // Securejoin v3 doesn't need an invitenumber.
526        // We want to remove the invitenumber and the `v=3` parameter eventually;
527        // therefore, we accept v3 QR codes without an invitenumber.
528        is_v3 = true;
529        invitenumber = Some("".to_string());
530    }
531
532    if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
533        let addr = ContactAddress::new(addr)?;
534        let (contact_id, _) = Contact::add_or_lookup_ex(
535            context,
536            &name,
537            &addr,
538            &fingerprint.hex(),
539            Origin::UnhandledSecurejoinQrScan,
540        )
541        .await
542        .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
543
544        if let (Some(grpid), Some(grpname)) = (grpid.clone(), grpname) {
545            if context
546                .is_self_addr(&addr)
547                .await
548                .with_context(|| format!("can't check if address {addr:?} is our address"))?
549            {
550                if token::exists(context, token::Namespace::Auth, &authcode).await? {
551                    Ok(Qr::WithdrawVerifyGroup {
552                        grpname,
553                        grpid,
554                        contact_id,
555                        fingerprint,
556                        invitenumber,
557                        authcode,
558                    })
559                } else {
560                    Ok(Qr::ReviveVerifyGroup {
561                        grpname,
562                        grpid,
563                        contact_id,
564                        fingerprint,
565                        invitenumber,
566                        authcode,
567                    })
568                }
569            } else {
570                Ok(Qr::AskVerifyGroup {
571                    grpname,
572                    grpid,
573                    contact_id,
574                    fingerprint,
575                    addrs: vec![addr.to_string()],
576                    invitenumber,
577                    authcode,
578                    is_v3,
579                })
580            }
581        } else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
582            if context
583                .is_self_addr(&addr)
584                .await
585                .with_context(|| format!("Can't check if {addr:?} is our address"))?
586            {
587                if token::exists(context, token::Namespace::Auth, &authcode).await? {
588                    Ok(Qr::WithdrawJoinBroadcast {
589                        name,
590                        grpid,
591                        contact_id,
592                        fingerprint,
593                        invitenumber,
594                        authcode,
595                    })
596                } else {
597                    Ok(Qr::ReviveJoinBroadcast {
598                        name,
599                        grpid,
600                        contact_id,
601                        fingerprint,
602                        invitenumber,
603                        authcode,
604                    })
605                }
606            } else {
607                Ok(Qr::AskJoinBroadcast {
608                    name,
609                    grpid,
610                    contact_id,
611                    fingerprint,
612                    addrs: vec![addr.to_string()],
613                    invitenumber,
614                    authcode,
615                    is_v3,
616                })
617            }
618        } else if context.is_self_addr(&addr).await? {
619            if token::exists(context, token::Namespace::Auth, &authcode).await? {
620                Ok(Qr::WithdrawVerifyContact {
621                    contact_id,
622                    fingerprint,
623                    invitenumber,
624                    authcode,
625                })
626            } else {
627                Ok(Qr::ReviveVerifyContact {
628                    contact_id,
629                    fingerprint,
630                    invitenumber,
631                    authcode,
632                })
633            }
634        } else {
635            Ok(Qr::AskVerifyContact {
636                contact_id,
637                fingerprint,
638                addrs: vec![addr.to_string()],
639                invitenumber,
640                authcode,
641                is_v3,
642            })
643        }
644    } else if let Some(addr) = addr {
645        let fingerprint = fingerprint.hex();
646        let (contact_id, _) =
647            Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
648                .await?;
649        let contact = Contact::get_by_id(context, contact_id).await?;
650
651        if contact.public_key(context).await?.is_some() {
652            Ok(Qr::FprOk { contact_id })
653        } else {
654            Ok(Qr::FprMismatch {
655                contact_id: Some(contact_id),
656            })
657        }
658    } else {
659        Ok(Qr::FprWithoutAddr {
660            fingerprint: fingerprint.human_readable(),
661        })
662    }
663}
664
665fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
666    if let Some(encoded_name) = param.get(key) {
667        let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
668        let mut name = match percent_decode_str(&encoded_name).decode_utf8() {
669            Ok(name) => name.to_string(),
670            Err(err) => bail!("Invalid QR param {key}: {err}"),
671        };
672        if let Some(n) = name.strip_suffix('_') {
673            name = format!("{n}…");
674        }
675        Ok(Some(name))
676    } else {
677        Ok(None)
678    }
679}
680
681/// scheme: `https://i.delta.chat[/]#FINGERPRINT&a=ADDR[&OPTIONAL_PARAMS]`
682async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
683    let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
684    let qr = qr.replacen('&', "#", 1);
685    decode_openpgp(context, &qr)
686        .await
687        .with_context(|| format!("failed to decode {prefix} QR code"))
688}
689
690/// scheme: `DCACCOUNT:example.org`
691/// or `DCACCOUNT:https://example.org/new`
692/// or `DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3`
693fn decode_account(qr: &str) -> Result<Qr> {
694    let payload = qr
695        .get(DCACCOUNT_SCHEME.len()..)
696        .context("Invalid DCACCOUNT payload")?;
697
698    // Handle `dcaccount://...` URLs.
699    let payload = payload.strip_prefix("//").unwrap_or(payload);
700    if payload.is_empty() {
701        bail!("dcaccount payload is empty");
702    }
703    if payload.starts_with("https://") {
704        let url = url::Url::parse(payload).context("Invalid account URL")?;
705        if url.scheme() == "https" {
706            Ok(Qr::Account {
707                domain: url
708                    .host_str()
709                    .context("can't extract account setup domain")?
710                    .to_string(),
711            })
712        } else {
713            bail!("Bad scheme for account URL: {:?}.", url.scheme());
714        }
715    } else {
716        if payload.starts_with("/") {
717            // Handle `dcaccount:///` URL reported to have been created
718            // by Telegram link parser at
719            // <https://support.delta.chat/t/could-not-find-dns-resolutions-for-imap-993-when-adding-a-relay/4907>
720            bail!("Hostname in dcaccount URL cannot start with /");
721        }
722        Ok(Qr::Account {
723            domain: payload.to_string(),
724        })
725    }
726}
727
728/// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123`
729fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
730    let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
731
732    let mut host: Option<String> = None;
733    let mut port: u16 = DEFAULT_SOCKS_PORT;
734    let mut user: Option<String> = None;
735    let mut pass: Option<String> = None;
736    for (key, value) in url.query_pairs() {
737        if key == "server" {
738            host = Some(value.to_string());
739        } else if key == "port" {
740            port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
741        } else if key == "user" {
742            user = Some(value.to_string());
743        } else if key == "pass" {
744            pass = Some(value.to_string());
745        }
746    }
747
748    let Some(host) = host else {
749        bail!("Bad t.me/socks url: {url:?}");
750    };
751
752    let mut url = "socks5://".to_string();
753    if let Some(pass) = pass {
754        url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
755        url += ":";
756        url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
757        url += "@";
758    };
759    url += &host;
760    url += ":";
761    url += &port.to_string();
762
763    Ok(Qr::Proxy { url, host, port })
764}
765
766/// Decodes `ss://` URLs for Shadowsocks proxies.
767fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
768    let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
769    let addr = server_config.addr();
770    let host = addr.host().to_string();
771    let port = addr.port();
772    Ok(Qr::Proxy {
773        url: qr.to_string(),
774        host,
775        port,
776    })
777}
778
779/// Decodes a `DCBACKUP` QR code.
780fn decode_backup2(qr: &str) -> Result<Qr> {
781    let version_and_payload = qr
782        .strip_prefix(DCBACKUP_SCHEME_PREFIX)
783        .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
784    let (version, payload) = version_and_payload
785        .split_once(':')
786        .context("DCBACKUP scheme separator missing")?;
787    let version: i32 = version.parse().context("Not a valid number")?;
788    if version > DCBACKUP_VERSION {
789        return Ok(Qr::BackupTooNew {});
790    }
791
792    let (auth_token, node_addr) = payload
793        .split_once('&')
794        .context("Backup QR code has no separator")?;
795    let auth_token = auth_token.to_string();
796    let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
797        .context("Invalid node addr in backup QR code")?;
798
799    Ok(Qr::Backup2 {
800        node_addr,
801        auth_token,
802    })
803}
804
805#[derive(Debug, Deserialize)]
806struct CreateAccountSuccessResponse {
807    /// Email address.
808    email: String,
809
810    /// Password.
811    password: String,
812}
813#[derive(Debug, Deserialize)]
814struct CreateAccountErrorResponse {
815    /// Reason for the failure to create account returned by the server.
816    reason: String,
817}
818
819/// Takes a QR with `DCACCOUNT:` scheme, parses its parameters,
820/// downloads additional information from the contained URL
821/// and returns the login parameters.
822pub(crate) async fn login_param_from_account_qr(
823    context: &Context,
824    qr: &str,
825) -> Result<EnteredLoginParam> {
826    let payload = qr
827        .get(DCACCOUNT_SCHEME.len()..)
828        .context("Invalid DCACCOUNT scheme")?;
829
830    if !payload.starts_with(HTTPS_SCHEME) {
831        let rng = &mut rand::rngs::OsRng.unwrap_err();
832        let username = Alphanumeric.sample_string(rng, 9);
833        let addr = username + "@" + payload;
834        let password = Alphanumeric.sample_string(rng, 50);
835
836        let param = EnteredLoginParam {
837            addr,
838            imap: EnteredImapLoginParam {
839                password,
840                ..Default::default()
841            },
842            smtp: Default::default(),
843            certificate_checks: EnteredCertificateChecks::Strict,
844            oauth2: false,
845        };
846        return Ok(param);
847    }
848
849    let (response_text, response_success) = post_empty(context, payload).await?;
850    if response_success {
851        let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
852            .with_context(|| {
853                format!("Cannot create account, response is malformed:\n{response_text:?}")
854            })?;
855
856        let param = EnteredLoginParam {
857            addr: email,
858            imap: EnteredImapLoginParam {
859                password,
860                ..Default::default()
861            },
862            smtp: Default::default(),
863            certificate_checks: EnteredCertificateChecks::Strict,
864            oauth2: false,
865        };
866
867        Ok(param)
868    } else {
869        match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
870            Ok(error) => Err(anyhow!(error.reason)),
871            Err(parse_error) => {
872                error!(
873                    context,
874                    "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
875                );
876                bail!("Cannot create account, unexpected server response:\n{response_text:?}")
877            }
878        }
879    }
880}
881
882/// Sets configuration values from a QR code.
883/// "DCACCOUNT:" and "DCLOGIN:" QR codes configure `context`, but I/O mustn't be started for such QR
884/// codes, consider using [`Context::add_transport_from_qr`] which also restarts I/O.
885pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
886    match check_qr(context, qr).await? {
887        Qr::Account { .. } => {
888            let mut param = login_param_from_account_qr(context, qr).await?;
889            context.add_transport_inner(&mut param).await?
890        }
891        Qr::Proxy { url, .. } => {
892            let old_proxy_url_value = context
893                .get_config(Config::ProxyUrl)
894                .await?
895                .unwrap_or_default();
896
897            // Normalize the URL.
898            let url = ProxyConfig::from_url(&url)?.to_url();
899
900            let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
901                .chain(
902                    old_proxy_url_value
903                        .split('\n')
904                        .filter(|s| !s.is_empty() && *s != url),
905                )
906                .collect();
907            context
908                .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
909                .await?;
910            context.set_config_bool(Config::ProxyEnabled, true).await?;
911        }
912        Qr::WithdrawVerifyContact {
913            invitenumber,
914            authcode,
915            ..
916        } => {
917            token::delete(context, "").await?;
918            context
919                .sync_qr_code_token_deletion(invitenumber, authcode)
920                .await?;
921        }
922        Qr::WithdrawVerifyGroup {
923            grpid,
924            invitenumber,
925            authcode,
926            ..
927        }
928        | Qr::WithdrawJoinBroadcast {
929            grpid,
930            invitenumber,
931            authcode,
932            ..
933        } => {
934            token::delete(context, &grpid).await?;
935            context
936                .sync_qr_code_token_deletion(invitenumber, authcode)
937                .await?;
938        }
939        Qr::ReviveVerifyContact {
940            invitenumber,
941            authcode,
942            ..
943        } => {
944            let timestamp = time();
945            token::save(
946                context,
947                token::Namespace::InviteNumber,
948                None,
949                &invitenumber,
950                timestamp,
951            )
952            .await?;
953            token::save(context, token::Namespace::Auth, None, &authcode, timestamp).await?;
954            context.sync_qr_code_tokens(None).await?;
955            context.scheduler.interrupt_smtp().await;
956        }
957        Qr::ReviveVerifyGroup {
958            invitenumber,
959            authcode,
960            grpid,
961            ..
962        }
963        | Qr::ReviveJoinBroadcast {
964            invitenumber,
965            authcode,
966            grpid,
967            ..
968        } => {
969            let timestamp = time();
970            token::save(
971                context,
972                token::Namespace::InviteNumber,
973                Some(&grpid),
974                &invitenumber,
975                timestamp,
976            )
977            .await?;
978            token::save(
979                context,
980                token::Namespace::Auth,
981                Some(&grpid),
982                &authcode,
983                timestamp,
984            )
985            .await?;
986            context.sync_qr_code_tokens(Some(&grpid)).await?;
987            context.scheduler.interrupt_smtp().await;
988        }
989        Qr::Login { address, options } => {
990            let mut param = login_param_from_login_qr(&address, options)?;
991            context.add_transport_inner(&mut param).await?
992        }
993        _ => bail!("QR code does not contain config"),
994    }
995
996    Ok(())
997}
998
999/// Extract address for the mailto scheme.
1000///
1001/// Scheme: `mailto:addr...?subject=...&body=..`
1002async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
1003    let payload = qr
1004        .get(MAILTO_SCHEME.len()..)
1005        .context("Invalid mailto: scheme")?;
1006
1007    let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
1008
1009    let param: BTreeMap<&str, &str> = query
1010        .split('&')
1011        .filter_map(|s| {
1012            if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
1013                Some((key, value))
1014            } else {
1015                None
1016            }
1017        })
1018        .collect();
1019
1020    let subject = if let Some(subject) = param.get("subject") {
1021        subject.to_string()
1022    } else {
1023        "".to_string()
1024    };
1025    let draft = if let Some(body) = param.get("body") {
1026        if subject.is_empty() {
1027            body.to_string()
1028        } else {
1029            subject + "\n" + body
1030        }
1031    } else {
1032        subject
1033    };
1034    let draft = draft.replace('+', "%20"); // sometimes spaces are encoded as `+`
1035    let draft = match percent_decode_str(&draft).decode_utf8() {
1036        Ok(decoded_draft) => decoded_draft.to_string(),
1037        Err(_err) => draft,
1038    };
1039
1040    let addr = normalize_address(addr)?;
1041    let name = "";
1042    Qr::from_address(
1043        context,
1044        name,
1045        &addr,
1046        if draft.is_empty() { None } else { Some(draft) },
1047    )
1048    .await
1049}
1050
1051/// Extract address for the smtp scheme.
1052///
1053/// Scheme: `SMTP:addr...:subject...:body...`
1054async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
1055    let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
1056
1057    let (addr, _rest) = payload
1058        .split_once(':')
1059        .context("Invalid SMTP scheme payload")?;
1060    let addr = normalize_address(addr)?;
1061    let name = "";
1062    Qr::from_address(context, name, &addr, None).await
1063}
1064
1065/// Extract address for the matmsg scheme.
1066///
1067/// Scheme: `MATMSG:TO:addr...;SUB:subject...;BODY:body...;`
1068///
1069/// There may or may not be linebreaks after the fields.
1070#[expect(clippy::arithmetic_side_effects)]
1071async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
1072    // Does not work when the text `TO:` is used in subject/body _and_ TO: is not the first field.
1073    // we ignore this case.
1074    let addr = if let Some(to_index) = qr.find("TO:") {
1075        let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
1076        if let Some(semi_index) = addr.find(';') {
1077            addr.get(..semi_index).unwrap_or_default().trim()
1078        } else {
1079            addr
1080        }
1081    } else {
1082        bail!("Invalid MATMSG found");
1083    };
1084
1085    let addr = normalize_address(addr)?;
1086    let name = "";
1087    Qr::from_address(context, name, &addr, None).await
1088}
1089
1090static VCARD_NAME_RE: LazyLock<regex::Regex> =
1091    LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
1092static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
1093    LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
1094
1095/// Extract address for the vcard scheme.
1096///
1097/// Scheme: `VCARD:BEGIN\nN:last name;first name;...;\nEMAIL;<type>:addr...;`
1098async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
1099    let name = VCARD_NAME_RE
1100        .captures(qr)
1101        .and_then(|caps| {
1102            let last_name = caps.get(1)?.as_str().trim();
1103            let first_name = caps.get(2)?.as_str().trim();
1104
1105            Some(format!("{first_name} {last_name}"))
1106        })
1107        .unwrap_or_default();
1108
1109    let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
1110        normalize_address(cap.as_str().trim())?
1111    } else {
1112        bail!("Bad e-mail address");
1113    };
1114
1115    Qr::from_address(context, &name, &addr, None).await
1116}
1117
1118impl Qr {
1119    /// Creates a new scanned QR code of a contact address.
1120    ///
1121    /// May contain a message draft.
1122    pub async fn from_address(
1123        context: &Context,
1124        name: &str,
1125        addr: &str,
1126        draft: Option<String>,
1127    ) -> Result<Self> {
1128        let addr = ContactAddress::new(addr)?;
1129        let (contact_id, _) =
1130            Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
1131        Ok(Qr::Addr { contact_id, draft })
1132    }
1133}
1134
1135/// URL decodes a given address, does basic email validation on the result.
1136fn normalize_address(addr: &str) -> Result<String> {
1137    // urldecoding is needed at least for OPENPGP4FPR but should not hurt in the other cases
1138    let new_addr = percent_decode_str(addr).decode_utf8()?;
1139    let new_addr = addr_normalize(&new_addr);
1140
1141    ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
1142
1143    Ok(new_addr.to_string())
1144}
1145
1146#[cfg(test)]
1147mod qr_tests;