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