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