mod dclogin_scheme;
use std::collections::BTreeMap;
use anyhow::{anyhow, bail, ensure, Context as _, Result};
pub use dclogin_scheme::LoginOptions;
use deltachat_contact_tools::{addr_normalize, may_be_valid_addr, ContactAddress};
use once_cell::sync::Lazy;
use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize;
use self::dclogin_scheme::configure_from_login_qr;
use crate::chat::ChatIdBlocked;
use crate::config::Config;
use crate::constants::Blocked;
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::key::Fingerprint;
use crate::message::Message;
use crate::net::http::post_empty;
use crate::net::proxy::{ProxyConfig, DEFAULT_SOCKS_PORT};
use crate::peerstate::Peerstate;
use crate::token;
use crate::tools::validate_id;
const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
const DCWEBRTC_SCHEME: &str = "DCWEBRTC:";
const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
const MAILTO_SCHEME: &str = "mailto:";
const MATMSG_SCHEME: &str = "MATMSG:";
const VCARD_SCHEME: &str = "BEGIN:VCARD";
const SMTP_SCHEME: &str = "SMTP:";
const HTTPS_SCHEME: &str = "https://";
const SHADOWSOCKS_SCHEME: &str = "ss://";
pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
pub(crate) const DCBACKUP_VERSION: i32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Qr {
AskVerifyContact {
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
},
AskVerifyGroup {
grpname: String,
grpid: String,
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
},
FprOk {
contact_id: ContactId,
},
FprMismatch {
contact_id: Option<ContactId>,
},
FprWithoutAddr {
fingerprint: String,
},
Account {
domain: String,
},
Backup2 {
node_addr: iroh::NodeAddr,
auth_token: String,
},
BackupTooNew {},
WebrtcInstance {
domain: String,
instance_pattern: String,
},
Proxy {
url: String,
host: String,
port: u16,
},
Addr {
contact_id: ContactId,
draft: Option<String>,
},
Url {
url: String,
},
Text {
text: String,
},
WithdrawVerifyContact {
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
},
WithdrawVerifyGroup {
grpname: String,
grpid: String,
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
},
ReviveVerifyContact {
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
},
ReviveVerifyGroup {
grpname: String,
grpid: String,
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
},
Login {
address: String,
options: LoginOptions,
},
}
fn fix_add_second_device_qr(qr: &str) -> String {
qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
.replacen(r#""]}}"#, r#""]}"#, 1)
}
fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
string.to_lowercase().starts_with(&pattern.to_lowercase())
}
pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
let qr = qr.trim();
let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
decode_openpgp(context, qr)
.await
.context("failed to decode OPENPGP4FPR QR code")?
} else if qr.starts_with(IDELTACHAT_SCHEME) {
decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
} else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
} else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
decode_account(qr)?
} else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
dclogin_scheme::decode_login(qr)?
} else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) {
decode_webrtc_instance(context, qr)?
} else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
decode_tg_socks_proxy(context, qr)?
} else if qr.starts_with(SHADOWSOCKS_SCHEME) {
decode_shadowsocks_proxy(qr)?
} else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
let qr_fixed = fix_add_second_device_qr(qr);
decode_backup2(&qr_fixed)?
} else if qr.starts_with(MAILTO_SCHEME) {
decode_mailto(context, qr).await?
} else if qr.starts_with(SMTP_SCHEME) {
decode_smtp(context, qr).await?
} else if qr.starts_with(MATMSG_SCHEME) {
decode_matmsg(context, qr).await?
} else if qr.starts_with(VCARD_SCHEME) {
decode_vcard(context, qr).await?
} else if let Ok(url) = url::Url::parse(qr) {
match url.scheme() {
"socks5" => Qr::Proxy {
url: qr.to_string(),
host: url.host_str().context("URL has no host")?.to_string(),
port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
},
"http" | "https" => {
let url = if let Some(rest) = qr.strip_prefix("http://") {
url::Url::parse(&format!("foobarbaz://{rest}"))?
} else if let Some(rest) = qr.strip_prefix("https://") {
url::Url::parse(&format!("foobarbaz://{rest}"))?
} else {
url
};
if url.port().is_none() | (url.path() != "") | url.query().is_some() {
Qr::Url {
url: qr.to_string(),
}
} else {
Qr::Proxy {
url: qr.to_string(),
host: url.host_str().context("URL has no host")?.to_string(),
port: url
.port_or_known_default()
.context("HTTP(S) URLs are guaranteed to return Some port")?,
}
}
}
_ => Qr::Url {
url: qr.to_string(),
},
}
} else {
Qr::Text {
text: qr.to_string(),
}
};
Ok(qrcode)
}
pub fn format_backup(qr: &Qr) -> Result<String> {
match qr {
Qr::Backup2 {
ref node_addr,
ref auth_token,
} => {
let node_addr = serde_json::to_string(node_addr)?;
Ok(format!(
"{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
))
}
_ => Err(anyhow!("Not a backup QR code")),
}
}
async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
let payload = qr
.get(OPENPGP4FPR_SCHEME.len()..)
.context("Invalid OPENPGP4FPR scheme")?;
let (fingerprint, fragment) = match payload
.split_once('#')
.or_else(|| payload.split_once("%23"))
{
Some(pair) => pair,
None => (payload, ""),
};
let fingerprint: Fingerprint = fingerprint
.parse()
.context("Failed to parse fingerprint in the QR code")?;
let param: BTreeMap<&str, &str> = fragment
.split('&')
.filter_map(|s| {
if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
Some((key, value))
} else {
None
}
})
.collect();
let addr = if let Some(addr) = param.get("a") {
Some(normalize_address(addr)?)
} else {
None
};
let name = if let Some(encoded_name) = param.get("n") {
let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
Ok(name) => name.to_string(),
Err(err) => bail!("Invalid name: {}", err),
}
} else {
"".to_string()
};
let invitenumber = param
.get("i")
.filter(|&s| validate_id(s))
.map(|s| s.to_string());
let authcode = param
.get("s")
.filter(|&s| validate_id(s))
.map(|s| s.to_string());
let grpid = param
.get("x")
.filter(|&s| validate_id(s))
.map(|s| s.to_string());
let grpname = if grpid.is_some() {
if let Some(encoded_name) = param.get("g") {
let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
Ok(name) => Some(name.to_string()),
Err(err) => bail!("Invalid group name: {}", err),
}
} else {
None
}
} else {
None
};
let peerstate = Peerstate::from_fingerprint(context, &fingerprint)
.await
.context("Can't load peerstate")?;
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
let addr = ContactAddress::new(addr)?;
let (contact_id, _) =
Contact::add_or_lookup(context, &name, &addr, Origin::UnhandledSecurejoinQrScan)
.await
.with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
if let (Some(grpid), Some(grpname)) = (grpid, grpname) {
if context
.is_self_addr(&addr)
.await
.with_context(|| format!("can't check if address {addr:?} is our address"))?
{
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
Ok(Qr::WithdrawVerifyGroup {
grpname,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
} else {
Ok(Qr::ReviveVerifyGroup {
grpname,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else {
Ok(Qr::AskVerifyGroup {
grpname,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else if context.is_self_addr(&addr).await? {
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
Ok(Qr::WithdrawVerifyContact {
contact_id,
fingerprint,
invitenumber,
authcode,
})
} else {
Ok(Qr::ReviveVerifyContact {
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else {
Ok(Qr::AskVerifyContact {
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else if let Some(addr) = addr {
if let Some(peerstate) = peerstate {
let peerstate_addr = ContactAddress::new(&peerstate.addr)?;
let (contact_id, _) =
Contact::add_or_lookup(context, &name, &peerstate_addr, Origin::UnhandledQrScan)
.await
.context("add_or_lookup")?;
ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Request)
.await
.context("Failed to create (new) chat for contact")?;
Ok(Qr::FprOk { contact_id })
} else {
let contact_id = Contact::lookup_id_by_addr(context, &addr, Origin::Unknown)
.await
.with_context(|| format!("Error looking up contact {addr:?}"))?;
Ok(Qr::FprMismatch { contact_id })
}
} else {
Ok(Qr::FprWithoutAddr {
fingerprint: fingerprint.to_string(),
})
}
}
async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
let qr = qr.replacen('&', "#", 1);
decode_openpgp(context, &qr)
.await
.with_context(|| format!("failed to decode {prefix} QR code"))
}
fn decode_account(qr: &str) -> Result<Qr> {
let payload = qr
.get(DCACCOUNT_SCHEME.len()..)
.context("Invalid DCACCOUNT payload")?;
let url = url::Url::parse(payload).context("Invalid account URL")?;
if url.scheme() == "http" || url.scheme() == "https" {
Ok(Qr::Account {
domain: url
.host_str()
.context("can't extract account setup domain")?
.to_string(),
})
} else {
bail!("Bad scheme for account URL: {:?}.", url.scheme());
}
}
fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result<Qr> {
let payload = qr
.get(DCWEBRTC_SCHEME.len()..)
.context("Invalid DCWEBRTC payload")?;
let (_type, url) = Message::parse_webrtc_instance(payload);
let url = url::Url::parse(&url).context("Invalid WebRTC instance")?;
if url.scheme() == "http" || url.scheme() == "https" {
Ok(Qr::WebrtcInstance {
domain: url
.host_str()
.context("can't extract WebRTC instance domain")?
.to_string(),
instance_pattern: payload.to_string(),
})
} else {
bail!("Bad URL scheme for WebRTC instance: {:?}", url.scheme());
}
}
fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
let mut host: Option<String> = None;
let mut port: u16 = DEFAULT_SOCKS_PORT;
let mut user: Option<String> = None;
let mut pass: Option<String> = None;
for (key, value) in url.query_pairs() {
if key == "server" {
host = Some(value.to_string());
} else if key == "port" {
port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
} else if key == "user" {
user = Some(value.to_string());
} else if key == "pass" {
pass = Some(value.to_string());
}
}
let Some(host) = host else {
bail!("Bad t.me/socks url: {:?}", url);
};
let mut url = "socks5://".to_string();
if let Some(pass) = pass {
url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
url += ":";
url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
url += "@";
};
url += &host;
url += ":";
url += &port.to_string();
Ok(Qr::Proxy { url, host, port })
}
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host().to_string();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
host,
port,
})
}
fn decode_backup2(qr: &str) -> Result<Qr> {
let version_and_payload = qr
.strip_prefix(DCBACKUP_SCHEME_PREFIX)
.ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
let (version, payload) = version_and_payload
.split_once(':')
.context("DCBACKUP scheme separator missing")?;
let version: i32 = version.parse().context("Not a valid number")?;
if version > DCBACKUP_VERSION {
return Ok(Qr::BackupTooNew {});
}
let (auth_token, node_addr) = payload
.split_once('&')
.context("Backup QR code has no separator")?;
let auth_token = auth_token.to_string();
let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
.context("Invalid node addr in backup QR code")?;
Ok(Qr::Backup2 {
node_addr,
auth_token,
})
}
#[derive(Debug, Deserialize)]
struct CreateAccountSuccessResponse {
email: String,
password: String,
}
#[derive(Debug, Deserialize)]
struct CreateAccountErrorResponse {
reason: String,
}
async fn set_account_from_qr(context: &Context, qr: &str) -> Result<()> {
let url_str = qr
.get(DCACCOUNT_SCHEME.len()..)
.context("Invalid DCACCOUNT scheme")?;
if !url_str.starts_with(HTTPS_SCHEME) {
bail!("DCACCOUNT QR codes must use HTTPS scheme");
}
let (response_text, response_success) = post_empty(context, url_str).await?;
if response_success {
let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
.with_context(|| {
format!("Cannot create account, response is malformed:\n{response_text:?}")
})?;
context
.set_config_internal(Config::Addr, Some(&email))
.await?;
context
.set_config_internal(Config::MailPw, Some(&password))
.await?;
Ok(())
} else {
match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
Ok(error) => Err(anyhow!(error.reason)),
Err(parse_error) => {
context.emit_event(EventType::Error(format!(
"Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
)));
bail!(
"Cannot create account, unexpected server response:\n{:?}",
response_text
)
}
}
}
}
pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
match check_qr(context, qr).await? {
Qr::Account { .. } => set_account_from_qr(context, qr).await?,
Qr::WebrtcInstance {
domain: _,
instance_pattern,
} => {
context
.set_config_internal(Config::WebrtcInstance, Some(&instance_pattern))
.await?;
}
Qr::Proxy { url, .. } => {
let old_proxy_url_value = context
.get_config(Config::ProxyUrl)
.await?
.unwrap_or_default();
let url = ProxyConfig::from_url(&url)?.to_url();
let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
.chain(
old_proxy_url_value
.split('\n')
.filter(|s| !s.is_empty() && *s != url),
)
.collect();
context
.set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
.await?;
context.set_config_bool(Config::ProxyEnabled, true).await?;
}
Qr::WithdrawVerifyContact {
invitenumber,
authcode,
..
} => {
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
token::delete(context, token::Namespace::Auth, &authcode).await?;
context
.sync_qr_code_token_deletion(invitenumber, authcode)
.await?;
}
Qr::WithdrawVerifyGroup {
invitenumber,
authcode,
..
} => {
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
token::delete(context, token::Namespace::Auth, &authcode).await?;
context
.sync_qr_code_token_deletion(invitenumber, authcode)
.await?;
}
Qr::ReviveVerifyContact {
invitenumber,
authcode,
..
} => {
token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?;
token::save(context, token::Namespace::Auth, None, &authcode).await?;
context.sync_qr_code_tokens(None).await?;
context.scheduler.interrupt_inbox().await;
}
Qr::ReviveVerifyGroup {
invitenumber,
authcode,
grpid,
..
} => {
token::save(
context,
token::Namespace::InviteNumber,
Some(&grpid),
&invitenumber,
)
.await?;
token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?;
context.sync_qr_code_tokens(Some(&grpid)).await?;
context.scheduler.interrupt_inbox().await;
}
Qr::Login { address, options } => {
configure_from_login_qr(context, &address, options).await?
}
_ => bail!("QR code does not contain config"),
}
Ok(())
}
async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
let payload = qr
.get(MAILTO_SCHEME.len()..)
.context("Invalid mailto: scheme")?;
let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
let param: BTreeMap<&str, &str> = query
.split('&')
.filter_map(|s| {
if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
Some((key, value))
} else {
None
}
})
.collect();
let subject = if let Some(subject) = param.get("subject") {
subject.to_string()
} else {
"".to_string()
};
let draft = if let Some(body) = param.get("body") {
if subject.is_empty() {
body.to_string()
} else {
subject + "\n" + body
}
} else {
subject
};
let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
Ok(decoded_draft) => decoded_draft.to_string(),
Err(_err) => draft,
};
let addr = normalize_address(addr)?;
let name = "";
Qr::from_address(
context,
name,
&addr,
if draft.is_empty() { None } else { Some(draft) },
)
.await
}
async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
let (addr, _rest) = payload
.split_once(':')
.context("Invalid SMTP scheme payload")?;
let addr = normalize_address(addr)?;
let name = "";
Qr::from_address(context, name, &addr, None).await
}
async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
let addr = if let Some(to_index) = qr.find("TO:") {
let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
if let Some(semi_index) = addr.find(';') {
addr.get(..semi_index).unwrap_or_default().trim()
} else {
addr
}
} else {
bail!("Invalid MATMSG found");
};
let addr = normalize_address(addr)?;
let name = "";
Qr::from_address(context, name, &addr, None).await
}
static VCARD_NAME_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
static VCARD_EMAIL_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
let name = VCARD_NAME_RE
.captures(qr)
.and_then(|caps| {
let last_name = caps.get(1)?.as_str().trim();
let first_name = caps.get(2)?.as_str().trim();
Some(format!("{first_name} {last_name}"))
})
.unwrap_or_default();
let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
normalize_address(cap.as_str().trim())?
} else {
bail!("Bad e-mail address");
};
Qr::from_address(context, &name, &addr, None).await
}
impl Qr {
pub async fn from_address(
context: &Context,
name: &str,
addr: &str,
draft: Option<String>,
) -> Result<Self> {
let addr = ContactAddress::new(addr)?;
let (contact_id, _) =
Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
Ok(Qr::Addr { contact_id, draft })
}
}
fn normalize_address(addr: &str) -> Result<String> {
let new_addr = percent_decode_str(addr).decode_utf8()?;
let new_addr = addr_normalize(&new_addr);
ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
Ok(new_addr.to_string())
}
#[cfg(test)]
mod qr_tests;