From a9d19bc020dfbee495e4f33b1e426d043b436be2 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 20 Jun 2026 23:21:15 +0200 Subject: [PATCH 01/10] fix: use new QR-contained addresses when alice and bob have dropped their singular relay and added a new one and then perform a securejoin --- deltachat-rpc-client/tests/test_securejoin.py | 18 ++++++++++++ src/contact.rs | 4 ++- src/mimefactory.rs | 28 +++++++++++-------- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/deltachat-rpc-client/tests/test_securejoin.py b/deltachat-rpc-client/tests/test_securejoin.py index 54799adf13..238eff840a 100644 --- a/deltachat-rpc-client/tests/test_securejoin.py +++ b/deltachat-rpc-client/tests/test_securejoin.py @@ -704,3 +704,21 @@ def test_withdraw_securejoin_qr(acfactory): and "Ignoring RequestWithAuth message because of invalid auth code." in event.msg ): break + + +def test_qr_scan_updates_new_relay_address(acfactory): + alice, bob = acfactory.get_online_accounts(2) + + bob.secure_join(alice.get_qr_code()) + alice.wait_for_securejoin_inviter_success() + bob.wait_for_securejoin_joiner_success() + + for ac in [alice, bob]: + old_addr = ac.get_config("configured_addr") + ac.add_transport_from_qr(acfactory.get_account_qr()) + ac.set_config("configured_addr", ac.list_transports()[1]["addr"]) + ac.delete_transport(old_addr) + + bob.secure_join(alice.get_qr_code()) + alice.wait_for_securejoin_inviter_success() + bob.wait_for_securejoin_joiner_success() diff --git a/src/contact.rs b/src/contact.rs index 310600c96a..df4367cf1a 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -1026,7 +1026,9 @@ impl Contact { || row_authname.is_empty()); row_id = id; - if origin >= row_origin && addr != row_addr { + let qr_with_fingerprint = !fingerprint.is_empty() + && origin == Origin::UnhandledSecurejoinQrScan; + if (origin >= row_origin || qr_with_fingerprint) && addr != row_addr { update_addr = true; } if update_name || update_authname || update_addr || origin > row_origin { diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 07a899e7ab..3be965e147 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -271,9 +271,7 @@ impl MimeFactory { let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; - let relays = - addresses_from_public_key(&public_key).unwrap_or_else(|| vec![addr.clone()]); - recipients.extend(relays); + recipients.extend(relay_addrs(&public_key, &addr)); to.push((authname, addr.clone())); encryption_pubkeys = Some(vec![(addr, public_key)]); @@ -353,7 +351,7 @@ impl MimeFactory { }; if add_timestamp >= remove_timestamp { let relays = if let Some(public_key) = public_key_opt { - let addrs = addresses_from_public_key(&public_key); + let addrs = relay_addrs(&public_key, &addr); keys.push((addr.clone(), public_key)); addrs } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) { @@ -361,10 +359,10 @@ impl MimeFactory { if is_encrypted { warn!(context, "Missing key for {addr}"); } - None + vec![addr.clone()] } else { - None - }.unwrap_or_else(|| vec![addr.clone()]); + vec![addr.clone()] + }; if !recipients_contain_addr(&to, &addr) { if id != ContactId::SELF { @@ -393,7 +391,7 @@ impl MimeFactory { if let Some(email_to_remove) = email_to_remove && email_to_remove == addr { let relays = if let Some(public_key) = public_key_opt { - let addrs = addresses_from_public_key(&public_key); + let addrs = relay_addrs(&public_key, &addr); keys.push((addr.clone(), public_key)); addrs } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) { @@ -401,10 +399,10 @@ impl MimeFactory { if is_encrypted { warn!(context, "Missing key for {addr}"); } - None + vec![addr.clone()] } else { - None - }.unwrap_or_else(|| vec![addr.clone()]); + vec![addr.clone()] + }; // This is a "member removed" message, // we need to notify removed member @@ -2203,6 +2201,14 @@ async fn build_avatar_file(context: &Context, path: &str) -> Result { Ok(encoded_body) } +fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec { + let mut addrs = addresses_from_public_key(public_key).unwrap_or_default(); + if !addrs.iter().any(|r| r == addr) { + addrs.push(addr.to_string()); + } + addrs +} + fn recipients_contain_addr(recipients: &[(String, String)], addr: &str) -> bool { let addr_lc = addr.to_lowercase(); recipients From fdcfb20ab015a0f0aabac91c516843f4a77dfb5d Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 22 Jun 2026 13:20:35 +0200 Subject: [PATCH 02/10] test: In test_qr_scan_updates_new_relay_address(), test that sending a message actually works --- deltachat-rpc-client/tests/test_securejoin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deltachat-rpc-client/tests/test_securejoin.py b/deltachat-rpc-client/tests/test_securejoin.py index 238eff840a..d9da04de91 100644 --- a/deltachat-rpc-client/tests/test_securejoin.py +++ b/deltachat-rpc-client/tests/test_securejoin.py @@ -709,7 +709,7 @@ def test_withdraw_securejoin_qr(acfactory): def test_qr_scan_updates_new_relay_address(acfactory): alice, bob = acfactory.get_online_accounts(2) - bob.secure_join(alice.get_qr_code()) + bob_alice_chat = bob.secure_join(alice.get_qr_code()) alice.wait_for_securejoin_inviter_success() bob.wait_for_securejoin_joiner_success() @@ -722,3 +722,7 @@ def test_qr_scan_updates_new_relay_address(acfactory): bob.secure_join(alice.get_qr_code()) alice.wait_for_securejoin_inviter_success() bob.wait_for_securejoin_joiner_success() + + bob_alice_chat.send_text("hi") + snapshot = alice.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == "hi" From 3f2bb385c7617f272decdb736f3633fddecf1efb Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 22 Jun 2026 15:40:20 +0200 Subject: [PATCH 03/10] Revert "fix: use new QR-contained addresses when alice and bob have dropped their singular relay and added a new one and then perform a securejoin" This reverts commit a9d19bc020dfbee495e4f33b1e426d043b436be2. --- src/contact.rs | 4 +--- src/mimefactory.rs | 28 +++++++++++----------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/contact.rs b/src/contact.rs index df4367cf1a..310600c96a 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -1026,9 +1026,7 @@ impl Contact { || row_authname.is_empty()); row_id = id; - let qr_with_fingerprint = !fingerprint.is_empty() - && origin == Origin::UnhandledSecurejoinQrScan; - if (origin >= row_origin || qr_with_fingerprint) && addr != row_addr { + if origin >= row_origin && addr != row_addr { update_addr = true; } if update_name || update_authname || update_addr || origin > row_origin { diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 3be965e147..07a899e7ab 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -271,7 +271,9 @@ impl MimeFactory { let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; - recipients.extend(relay_addrs(&public_key, &addr)); + let relays = + addresses_from_public_key(&public_key).unwrap_or_else(|| vec![addr.clone()]); + recipients.extend(relays); to.push((authname, addr.clone())); encryption_pubkeys = Some(vec![(addr, public_key)]); @@ -351,7 +353,7 @@ impl MimeFactory { }; if add_timestamp >= remove_timestamp { let relays = if let Some(public_key) = public_key_opt { - let addrs = relay_addrs(&public_key, &addr); + let addrs = addresses_from_public_key(&public_key); keys.push((addr.clone(), public_key)); addrs } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) { @@ -359,10 +361,10 @@ impl MimeFactory { if is_encrypted { warn!(context, "Missing key for {addr}"); } - vec![addr.clone()] + None } else { - vec![addr.clone()] - }; + None + }.unwrap_or_else(|| vec![addr.clone()]); if !recipients_contain_addr(&to, &addr) { if id != ContactId::SELF { @@ -391,7 +393,7 @@ impl MimeFactory { if let Some(email_to_remove) = email_to_remove && email_to_remove == addr { let relays = if let Some(public_key) = public_key_opt { - let addrs = relay_addrs(&public_key, &addr); + let addrs = addresses_from_public_key(&public_key); keys.push((addr.clone(), public_key)); addrs } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) { @@ -399,10 +401,10 @@ impl MimeFactory { if is_encrypted { warn!(context, "Missing key for {addr}"); } - vec![addr.clone()] + None } else { - vec![addr.clone()] - }; + None + }.unwrap_or_else(|| vec![addr.clone()]); // This is a "member removed" message, // we need to notify removed member @@ -2201,14 +2203,6 @@ async fn build_avatar_file(context: &Context, path: &str) -> Result { Ok(encoded_body) } -fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec { - let mut addrs = addresses_from_public_key(public_key).unwrap_or_default(); - if !addrs.iter().any(|r| r == addr) { - addrs.push(addr.to_string()); - } - addrs -} - fn recipients_contain_addr(recipients: &[(String, String)], addr: &str) -> bool { let addr_lc = addr.to_lowercase(); recipients From c9974d0c24815efe6911040ba2443da228c86ad0 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 22 Jun 2026 16:28:22 +0200 Subject: [PATCH 04/10] fix: Rerun the full securejoin protocol if the address was outdated --- deltachat-contact-tools/src/lib.rs | 2 +- deltachat-jsonrpc/src/api/types/qr.rs | 3 +++ src/qr.rs | 6 ++++++ src/securejoin/bob.rs | 26 +++++++++++++++++--------- src/securejoin/qrinvite.rs | 17 +++++++++++++++++ 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 8ea7c0b6db..1a958d51f3 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -39,7 +39,7 @@ mod vcard; pub use vcard::{make_vcard, parse_vcard, VcardContact}; /// Valid contact address. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ContactAddress(String); impl Deref for ContactAddress { diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 6a4f4cd7ba..4bf55f398a 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -236,6 +236,7 @@ impl From for QrObject { invitenumber, authcode, is_v3, + .. } => { let contact_id = contact_id.to_u32(); let fingerprint = fingerprint.human_readable(); @@ -255,6 +256,7 @@ impl From for QrObject { invitenumber, authcode, is_v3, + .. } => { let contact_id = contact_id.to_u32(); let fingerprint = fingerprint.human_readable(); @@ -276,6 +278,7 @@ impl From for QrObject { authcode, invitenumber, is_v3, + .. } => { let contact_id = contact_id.to_u32(); let fingerprint = fingerprint.human_readable(); diff --git a/src/qr.rs b/src/qr.rs index d4531442c3..007eeed437 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -64,6 +64,7 @@ pub enum Qr { /// Whether the inviter supports the new Securejoin v3 protocol is_v3: bool, + addr: ContactAddress, }, /// Ask the user whether to join the group. @@ -88,6 +89,7 @@ pub enum Qr { /// Whether the inviter supports the new Securejoin v3 protocol is_v3: bool, + addr: ContactAddress, }, /// Ask whether to join the broadcast channel. @@ -115,6 +117,7 @@ pub enum Qr { /// Whether the inviter supports the new Securejoin v3 protocol is_v3: bool, + addr: ContactAddress, }, /// Contact fingerprint is verified. @@ -563,6 +566,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { grpid, contact_id, fingerprint, + addr, invitenumber, authcode, is_v3, @@ -599,6 +603,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { grpid, contact_id, fingerprint, + addr, invitenumber, authcode, is_v3, @@ -624,6 +629,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { Ok(Qr::AskVerifyContact { contact_id, fingerprint, + addr, invitenumber, authcode, is_v3, diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index f6bd9e6788..40e9a9c725 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -1,19 +1,21 @@ //! Bob's side of SecureJoin handling, the joiner-side. use anyhow::{Context as _, Result}; +use pgp::composed::SignedPublicKey; use super::HandshakeMessage; use super::qrinvite::QrInvite; use crate::chat::{self, ChatId, is_contact_in_chat}; use crate::constants::{Blocked, Chattype}; -use crate::contact::{Contact, Origin}; +use crate::contact::Origin; use crate::context::Context; use crate::events::EventType; -use crate::key::self_fingerprint; +use crate::key::{DcKey as _, self_fingerprint}; use crate::log::LogExt; use crate::message::{self, Message, MsgId, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::{Param, Params}; +use crate::pgp::addresses_from_public_key; use crate::securejoin::{ ContactId, encrypted_and_signed, insert_into_smtp, verify_sender_by_fingerprint, }; @@ -58,13 +60,20 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Broadcast { .. } => {} } - let has_key = context + let public_key_bytes: Option> = context .sql - .exists( - "SELECT COUNT(*) FROM public_keys WHERE fingerprint=?", + .query_get_value( + "SELECT public_key FROM public_keys WHERE fingerprint=?", (invite.fingerprint().hex(),), ) .await?; + let has_up_to_date_key = if let Some(public_key_bytes) = public_key_bytes { + let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; + let addrs = addresses_from_public_key(&public_key); + addrs.is_some_and(|addrs| addrs.iter().any(|a| a == invite.addr())) + } else { + false + }; // Now start the protocol and initialise the state. { @@ -97,7 +106,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul progress: JoinerProgress::Succeeded.into_u16(), }); return Ok(joining_chat_id); - } else if has_key + } else if has_up_to_date_key && verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()) .await? { @@ -154,7 +163,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Contact { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it is // used to send the handshake messages. - if !has_key { + if !has_up_to_date_key { chat::add_info_msg_with_cmd( context, private_chat_id, @@ -310,8 +319,7 @@ pub(crate) async fn send_handshake_message( if invite.is_v3() && matches!(step, BobHandshakeMsg::Request) { // Send a minimal symmetrically-encrypted vc-request-pubkey message let rfc724_mid = create_outgoing_rfc724_mid(); - let contact = Contact::get_by_id(context, invite.contact_id()).await?; - let recipient = contact.get_addr(); + let recipient = invite.addr(); let alice_fp = invite.fingerprint().hex(); let auth = invite.authcode(); let shared_secret = format!("securejoin/{alice_fp}/{auth}"); diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index 8c224b0296..2ca057ff63 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -18,6 +18,7 @@ pub enum QrInvite { Contact { contact_id: ContactId, fingerprint: Fingerprint, + addr: String, invitenumber: String, authcode: String, #[serde(default)] @@ -26,6 +27,7 @@ pub enum QrInvite { Group { contact_id: ContactId, fingerprint: Fingerprint, + addr: String, name: String, grpid: String, invitenumber: String, @@ -36,6 +38,7 @@ pub enum QrInvite { Broadcast { contact_id: ContactId, fingerprint: Fingerprint, + addr: String, name: String, grpid: String, invitenumber: String, @@ -92,6 +95,14 @@ impl QrInvite { QrInvite::Broadcast { is_v3, .. } => is_v3, } } + + pub(crate) fn addr(&self) -> &str { + match self { + QrInvite::Contact { addr, .. } => addr, + QrInvite::Group { addr, .. } => addr, + QrInvite::Broadcast { addr, .. } => addr, + } + } } impl TryFrom for QrInvite { @@ -102,12 +113,14 @@ impl TryFrom for QrInvite { Qr::AskVerifyContact { contact_id, fingerprint, + addr, invitenumber, authcode, is_v3, } => Ok(QrInvite::Contact { contact_id, fingerprint, + addr: addr.to_string(), invitenumber, authcode, is_v3, @@ -117,12 +130,14 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, + addr, invitenumber, authcode, is_v3, } => Ok(QrInvite::Group { contact_id, fingerprint, + addr: addr.to_string(), name: grpname, grpid, invitenumber, @@ -134,6 +149,7 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, + addr, authcode, invitenumber, is_v3, @@ -142,6 +158,7 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, + addr: addr.to_string(), authcode, invitenumber, is_v3, From d8c70dab7a400f044ffb99acc8038188278eb44b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 22 Jun 2026 16:32:39 +0200 Subject: [PATCH 05/10] clippy --- src/qr.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/qr.rs b/src/qr.rs index 007eeed437..54baa525a9 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -56,6 +56,9 @@ pub enum Qr { /// Fingerprint of the contact key as scanned from the QR code. fingerprint: Fingerprint, + /// The inviter's address. + addr: ContactAddress, + /// Invite number. invitenumber: String, @@ -64,7 +67,6 @@ pub enum Qr { /// Whether the inviter supports the new Securejoin v3 protocol is_v3: bool, - addr: ContactAddress, }, /// Ask the user whether to join the group. @@ -81,6 +83,9 @@ pub enum Qr { /// Fingerprint of the contact key as scanned from the QR code. fingerprint: Fingerprint, + /// The inviter's address. + addr: ContactAddress, + /// Invite number. invitenumber: String, @@ -89,7 +94,6 @@ pub enum Qr { /// Whether the inviter supports the new Securejoin v3 protocol is_v3: bool, - addr: ContactAddress, }, /// Ask whether to join the broadcast channel. @@ -110,6 +114,9 @@ pub enum Qr { /// Fingerprint of the contact's key as scanned from the QR code. fingerprint: Fingerprint, + /// The inviter's address. + addr: ContactAddress, + /// Invite number. invitenumber: String, /// Authentication code. @@ -117,7 +124,6 @@ pub enum Qr { /// Whether the inviter supports the new Securejoin v3 protocol is_v3: bool, - addr: ContactAddress, }, /// Contact fingerprint is verified. From 8fd602bed9bcadbf17fa5f5cc4c0398aee533524 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 23 Jun 2026 11:38:21 +0200 Subject: [PATCH 06/10] Make sure that we can have multiple addresses in the future --- src/qr.rs | 18 +++++++++--------- src/securejoin.rs | 4 ++-- src/securejoin/bob.rs | 10 ++++++---- src/securejoin/qrinvite.rs | 29 ++++++++++++++++------------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/qr.rs b/src/qr.rs index 54baa525a9..9a07249555 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -56,8 +56,8 @@ pub enum Qr { /// Fingerprint of the contact key as scanned from the QR code. fingerprint: Fingerprint, - /// The inviter's address. - addr: ContactAddress, + /// The inviter's addresses. + addrs: Vec, /// Invite number. invitenumber: String, @@ -83,8 +83,8 @@ pub enum Qr { /// Fingerprint of the contact key as scanned from the QR code. fingerprint: Fingerprint, - /// The inviter's address. - addr: ContactAddress, + /// The inviter's addresses. + addrs: Vec, /// Invite number. invitenumber: String, @@ -114,8 +114,8 @@ pub enum Qr { /// Fingerprint of the contact's key as scanned from the QR code. fingerprint: Fingerprint, - /// The inviter's address. - addr: ContactAddress, + /// The inviter's addresses. + addrs: Vec, /// Invite number. invitenumber: String, @@ -572,7 +572,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { grpid, contact_id, fingerprint, - addr, + addrs: vec![addr.to_string()], invitenumber, authcode, is_v3, @@ -609,7 +609,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { grpid, contact_id, fingerprint, - addr, + addrs: vec![addr.to_string()], invitenumber, authcode, is_v3, @@ -635,7 +635,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { Ok(Qr::AskVerifyContact { contact_id, fingerprint, - addr, + addrs: vec![addr.to_string()], invitenumber, authcode, is_v3, diff --git a/src/securejoin.rs b/src/securejoin.rs index 068bf0fa1e..98442cd080 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -741,7 +741,7 @@ pub(crate) async fn handle_securejoin_handshake( async fn insert_into_smtp( context: &Context, rfc724_mid: &str, - recipient: &str, + recipients: &str, rendered_message: String, msg_id: MsgId, ) -> Result<(), Error> { @@ -750,7 +750,7 @@ async fn insert_into_smtp( .execute( "INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id) VALUES (?1, ?2, ?3, ?4)", - (&rfc724_mid, &recipient, &rendered_message, msg_id), + (&rfc724_mid, &recipients, &rendered_message, msg_id), ) .await?; Ok(()) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 40e9a9c725..34083c1438 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -69,8 +69,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul .await?; let has_up_to_date_key = if let Some(public_key_bytes) = public_key_bytes { let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; - let addrs = addresses_from_public_key(&public_key); - addrs.is_some_and(|addrs| addrs.iter().any(|a| a == invite.addr())) + let addrs_in_key = addresses_from_public_key(&public_key); + // The key is up to date if it contains all the addresses from the QR code: + addrs_in_key + .is_some_and(|addrs_in_key| invite.addrs().iter().all(|a| addrs_in_key.contains(a))) } else { false }; @@ -319,7 +321,7 @@ pub(crate) async fn send_handshake_message( if invite.is_v3() && matches!(step, BobHandshakeMsg::Request) { // Send a minimal symmetrically-encrypted vc-request-pubkey message let rfc724_mid = create_outgoing_rfc724_mid(); - let recipient = invite.addr(); + let recipients = invite.addrs().join(" "); let alice_fp = invite.fingerprint().hex(); let auth = invite.authcode(); let shared_secret = format!("securejoin/{alice_fp}/{auth}"); @@ -335,7 +337,7 @@ pub(crate) async fn send_handshake_message( .await?; let msg_id = message::insert_tombstone(context, &rfc724_mid).await?; - insert_into_smtp(context, &rfc724_mid, recipient, rendered_message, msg_id).await?; + insert_into_smtp(context, &rfc724_mid, &recipients, rendered_message, msg_id).await?; context.scheduler.interrupt_smtp().await; } else { let mut msg = Message { diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index 2ca057ff63..a912a4d383 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -18,7 +18,8 @@ pub enum QrInvite { Contact { contact_id: ContactId, fingerprint: Fingerprint, - addr: String, + #[serde(default)] + addrs: Vec, invitenumber: String, authcode: String, #[serde(default)] @@ -27,7 +28,8 @@ pub enum QrInvite { Group { contact_id: ContactId, fingerprint: Fingerprint, - addr: String, + #[serde(default)] + addrs: Vec, name: String, grpid: String, invitenumber: String, @@ -38,7 +40,8 @@ pub enum QrInvite { Broadcast { contact_id: ContactId, fingerprint: Fingerprint, - addr: String, + #[serde(default)] + addrs: Vec, name: String, grpid: String, invitenumber: String, @@ -96,11 +99,11 @@ impl QrInvite { } } - pub(crate) fn addr(&self) -> &str { + pub(crate) fn addrs(&self) -> &Vec { match self { - QrInvite::Contact { addr, .. } => addr, - QrInvite::Group { addr, .. } => addr, - QrInvite::Broadcast { addr, .. } => addr, + QrInvite::Contact { addrs, .. } => addrs, + QrInvite::Group { addrs, .. } => addrs, + QrInvite::Broadcast { addrs, .. } => addrs, } } } @@ -113,14 +116,14 @@ impl TryFrom for QrInvite { Qr::AskVerifyContact { contact_id, fingerprint, - addr, + addrs, invitenumber, authcode, is_v3, } => Ok(QrInvite::Contact { contact_id, fingerprint, - addr: addr.to_string(), + addrs, invitenumber, authcode, is_v3, @@ -130,14 +133,14 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, - addr, + addrs, invitenumber, authcode, is_v3, } => Ok(QrInvite::Group { contact_id, fingerprint, - addr: addr.to_string(), + addrs, name: grpname, grpid, invitenumber, @@ -149,7 +152,7 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, - addr, + addrs, authcode, invitenumber, is_v3, @@ -158,7 +161,7 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, - addr: addr.to_string(), + addrs, authcode, invitenumber, is_v3, From f4103c69a836d9e71f0ce0962dd5b9d5df1d3577 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 23 Jun 2026 16:05:47 +0200 Subject: [PATCH 07/10] Small simplification --- src/securejoin/bob.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 34083c1438..6d8fea92f6 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -67,12 +67,12 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul (invite.fingerprint().hex(),), ) .await?; + + // The key is up to date iff it contains all the addresses from the QR code: let has_up_to_date_key = if let Some(public_key_bytes) = public_key_bytes { let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; - let addrs_in_key = addresses_from_public_key(&public_key); - // The key is up to date if it contains all the addresses from the QR code: - addrs_in_key - .is_some_and(|addrs_in_key| invite.addrs().iter().all(|a| addrs_in_key.contains(a))) + let addrs_in_key = addresses_from_public_key(&public_key).unwrap_or_default(); + invite.addrs().iter().all(|a| addrs_in_key.contains(a)) } else { false }; From 4d18b05ed3654d6e1e769aa7d2592d32a951a394 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 25 Jun 2026 18:36:07 +0200 Subject: [PATCH 08/10] hpk's suggestion --- src/securejoin/bob.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 6d8fea92f6..a2b38c5260 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -68,8 +68,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul ) .await?; - // The key is up to date iff it contains all the addresses from the QR code: - let has_up_to_date_key = if let Some(public_key_bytes) = public_key_bytes { + let key_contains_all_invite_addrs = if let Some(public_key_bytes) = public_key_bytes { let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; let addrs_in_key = addresses_from_public_key(&public_key).unwrap_or_default(); invite.addrs().iter().all(|a| addrs_in_key.contains(a)) @@ -108,7 +107,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul progress: JoinerProgress::Succeeded.into_u16(), }); return Ok(joining_chat_id); - } else if has_up_to_date_key + } else if key_contains_all_invite_addrs && verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()) .await? { @@ -165,7 +164,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Contact { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it is // used to send the handshake messages. - if !has_up_to_date_key { + if !key_contains_all_invite_addrs { chat::add_info_msg_with_cmd( context, private_chat_id, From 415e244ecff8a6864abbd9f113bd27cc9592c8a3 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 25 Jun 2026 18:41:22 +0200 Subject: [PATCH 09/10] make unwrap_or_default() explicit --- src/securejoin/bob.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index a2b38c5260..3e9589fc30 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -70,8 +70,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul let key_contains_all_invite_addrs = if let Some(public_key_bytes) = public_key_bytes { let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; - let addrs_in_key = addresses_from_public_key(&public_key).unwrap_or_default(); - invite.addrs().iter().all(|a| addrs_in_key.contains(a)) + if let Some(addrs_in_key) = addresses_from_public_key(&public_key) { + invite.addrs().iter().all(|a| addrs_in_key.contains(a)) + } else { + // This can happen if the inviter is using an old version of Delta Chat + // that doesn't put the relay list into the key. + // In this case, we never take the securejoin protocol shortcut, which is fine. + false + } } else { false }; From 9d004a1020ed4dab3b08425e7e37ae715ff7c63b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 25 Jun 2026 18:45:52 +0200 Subject: [PATCH 10/10] Make type explicit --- src/securejoin/bob.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 3e9589fc30..e4d64556f3 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -60,7 +60,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Broadcast { .. } => {} } - let public_key_bytes: Option> = context + let public_key_bytes: Option> = context .sql .query_get_value( "SELECT public_key FROM public_keys WHERE fingerprint=?",