From 3e11991bfcd99fea83856a9cadc8f530befcb2e7 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 7 Jul 2025 16:31:49 +0200 Subject: [PATCH 01/69] feat: Symmetric encryption. No decryption, no sharing of the secret, not tested. --- src/e2ee.rs | 20 ++++++++++++++++++++ src/mimefactory.rs | 42 +++++++++++++++++++++++++++++++++--------- src/param.rs | 5 +++++ src/pgp.rs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/e2ee.rs b/src/e2ee.rs index 9968c22457..4ca0cd1a94 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -59,6 +59,26 @@ impl EncryptHelper { Ok(ctext) } + /// TODO documentation + pub async fn encrypt_for_broadcast( + self, + context: &Context, + passphrase: &str, + mail_to_encrypt: MimePart<'static>, + compress: bool, + ) -> Result { + let sign_key = load_self_secret_key(context).await?; + + let mut raw_message = Vec::new(); + let cursor = Cursor::new(&mut raw_message); + mail_to_encrypt.clone().write_part(cursor).ok(); + + let ctext = + pgp::encrypt_for_broadcast(raw_message, passphrase, Some(sign_key), compress).await?; + + Ok(ctext) + } + /// Signs the passed-in `mail` using the private key from `context`. /// Returns the payload and the signature. pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result { diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 0d971bf243..caacc56bc2 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1143,18 +1143,42 @@ impl MimeFactory { Loaded::Mdn { .. } => true, }; - // Encrypt to self unconditionally, - // even for a single-device setup. - let mut encryption_keyring = vec![encrypt_helper.public_key.clone()]; - encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone())); + let symmetric_key = match &self.loaded { + Loaded::Message { chat, .. } if chat.typ == Chattype::OutBroadcast => { + // If there is no symmetric key yet + // (because this is an old broadcast channel, + // created before we had symmetric encryption), + // we just encrypt asymmetrically. + // Symmetric encryption exists since 2025-08; + // some time after that, we can think about requiring everyone + // to switch to symmetrically-encrypted broadcast lists. + chat.param.get(Param::SymmetricKey) + } + _ => None, + }; + + let encrypted = if let Some(symmetric_key) = symmetric_key { + encrypt_helper + .encrypt_for_broadcast(context, symmetric_key, message, compress) + .await? + } else { + // Asymmetric encryption + + // Encrypt to self unconditionally, + // even for a single-device setup. + let mut encryption_keyring = vec![encrypt_helper.public_key.clone()]; + encryption_keyring + .extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone())); + + encrypt_helper + .encrypt(context, encryption_keyring, message, compress) + .await? + }; // XXX: additional newline is needed // to pass filtermail at - // - let encrypted = encrypt_helper - .encrypt(context, encryption_keyring, message, compress) - .await? - + "\n"; + // : + let encrypted = encrypted + "\n"; // Set the appropriate Content-Type for the outer message MimePart::new( diff --git a/src/param.rs b/src/param.rs index 9e0433a256..5d32c23da1 100644 --- a/src/param.rs +++ b/src/param.rs @@ -169,6 +169,11 @@ pub enum Param { /// post something to the mailing list. ListPost = b'p', + /// For Chats of type [`Chattype::OutBroadcast`] and [`Chattype::InBroadcast`]: + /// The symmetric key shared among all chat participants, + /// used to encrypt and decrypt messages. + SymmetricKey = b'z', + /// For Contacts: If this is the List-Post address of a mailing list, contains /// the List-Id of the mailing list (which is also used as the group id of the chat). ListId = b's', diff --git a/src/pgp.rs b/src/pgp.rs index e00a41310b..735b6a2105 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -12,6 +12,7 @@ use pgp::composed::{ SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, StandaloneSignature, SubkeyParamsBuilder, TheRing, }; +use pgp::crypto::aead::{AeadAlgorithm, ChunkSize}; use pgp::crypto::ecc_curve::ECCCurve; use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm; @@ -322,6 +323,41 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { .await? } +/// Symmetric encryption. +pub async fn encrypt_for_broadcast( + plain: Vec, + passphrase: &str, + private_key_for_signing: Option, + compress: bool, +) -> Result { + let passphrase = Password::from(passphrase.to_string()); + + tokio::task::spawn_blocking(move || { + let mut rng = thread_rng(); + let s2k = StringToKey::new_default(&mut rng); + let msg = MessageBuilder::from_bytes("", plain); + let mut msg = msg.seipd_v2( + &mut rng, + SymmetricKeyAlgorithm::AES128, + AeadAlgorithm::Ocb, + ChunkSize::C8KiB, + ); + msg.encrypt_with_password(&mut rng, s2k, &passphrase)?; + + if let Some(ref skey) = private_key_for_signing { + msg.sign(&**skey, Password::empty(), HASH_ALGORITHM); + if compress { + msg.compression(CompressionAlgorithm::ZLIB); + } + } + + let encoded_msg = msg.to_armored_string(&mut rng, Default::default())?; + + Ok(encoded_msg) + }) + .await? +} + /// Symmetric decryption. pub async fn symm_decrypt( passphrase: &str, From c99ab4f2eca108555e49a214e59dd4d7f5ba395b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 7 Jul 2025 17:41:31 +0200 Subject: [PATCH 02/69] WIP: Start with decryption, and a test for it. Next TODO: SQL table migartion. --- Cargo.toml | 2 +- src/decrypt.rs | 3 +- src/e2ee.rs | 3 +- src/mimeparser.rs | 78 ++++++++++++++++++++++--------------------- src/pgp.rs | 51 ++++++++++++++++++++++------ src/sql/migrations.rs | 9 +++++ 6 files changed, 94 insertions(+), 52 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f907070c6d..dfa45345ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ opt-level = 1 # Make anyhow `backtrace` feature useful. # With `debug = 0` there are no line numbers in the backtrace # produced with RUST_BACKTRACE=1. -debug = 1 +debug = 'full' opt-level = 0 [profile.fuzz] diff --git a/src/decrypt.rs b/src/decrypt.rs index 8c3b9de150..7100ade685 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -14,13 +14,14 @@ use crate::pgp; pub fn try_decrypt<'a>( mail: &'a ParsedMail<'a>, private_keyring: &'a [SignedSecretKey], + symmetric_secrets: &[&str], ) -> Result>> { let Some(encrypted_data_part) = get_encrypted_mime(mail) else { return Ok(None); }; let data = encrypted_data_part.get_body_raw()?; - let msg = pgp::pk_decrypt(data, private_keyring)?; + let msg = pgp::decrypt(data, private_keyring, symmetric_secrets)?; Ok(Some(msg)) } diff --git a/src/e2ee.rs b/src/e2ee.rs index 4ca0cd1a94..c1d1d77d2e 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -73,8 +73,7 @@ impl EncryptHelper { let cursor = Cursor::new(&mut raw_message); mail_to_encrypt.clone().write_part(cursor).ok(); - let ctext = - pgp::encrypt_for_broadcast(raw_message, passphrase, Some(sign_key), compress).await?; + let ctext = pgp::encrypt_for_broadcast(raw_message, passphrase, sign_key, compress).await?; Ok(ctext) } diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 1036cbb06b..ff494e0ef1 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -338,50 +338,52 @@ impl MimeMessage { let mail_raw; // Memory location for a possible decrypted message. let decrypted_msg; // Decrypted signed OpenPGP message. + let symmetric_secrets = - let (mail, is_encrypted) = - match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring)) { - Ok(Some(mut msg)) => { - mail_raw = msg.as_data_vec().unwrap_or_default(); - - let decrypted_mail = mailparse::parse_mail(&mail_raw)?; - if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { - info!( - context, - "decrypted message mime-body:\n{}", - String::from_utf8_lossy(&mail_raw), - ); - } - - decrypted_msg = Some(msg); + let (mail, is_encrypted) = match tokio::task::block_in_place(|| { + try_decrypt(&mail, &private_keyring, symmetric_secrets) + }) { + Ok(Some(mut msg)) => { + mail_raw = msg.as_data_vec().unwrap_or_default(); - timestamp_sent = Self::get_timestamp_sent( - &decrypted_mail.headers, - timestamp_sent, - timestamp_rcvd, + let decrypted_mail = mailparse::parse_mail(&mail_raw)?; + if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { + info!( + context, + "decrypted message mime-body:\n{}", + String::from_utf8_lossy(&mail_raw), ); + } - if let Some(protected_aheader_value) = decrypted_mail - .headers - .get_header_value(HeaderDef::Autocrypt) - { - aheader_value = Some(protected_aheader_value); - } + decrypted_msg = Some(msg); - (Ok(decrypted_mail), true) - } - Ok(None) => { - mail_raw = Vec::new(); - decrypted_msg = None; - (Ok(mail), false) - } - Err(err) => { - mail_raw = Vec::new(); - decrypted_msg = None; - warn!(context, "decryption failed: {:#}", err); - (Err(err), false) + timestamp_sent = Self::get_timestamp_sent( + &decrypted_mail.headers, + timestamp_sent, + timestamp_rcvd, + ); + + if let Some(protected_aheader_value) = decrypted_mail + .headers + .get_header_value(HeaderDef::Autocrypt) + { + aheader_value = Some(protected_aheader_value); } - }; + + (Ok(decrypted_mail), true) + } + Ok(None) => { + mail_raw = Vec::new(); + decrypted_msg = None; + (Ok(mail), false) + } + Err(err) => { + mail_raw = Vec::new(); + decrypted_msg = None; + warn!(context, "decryption failed: {:#}", err); + (Err(err), false) + } + }; let autocrypt_header = if !incoming { None diff --git a/src/pgp.rs b/src/pgp.rs index 735b6a2105..67cd812bc6 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -236,9 +236,10 @@ pub fn pk_calc_signature( /// /// Receiver private keys are provided in /// `private_keys_for_decryption`. -pub fn pk_decrypt( +pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], + symmetric_secrets: &[&str], ) -> Result> { let cursor = Cursor::new(ctext); let (msg, _headers) = Message::from_armor(cursor)?; @@ -246,10 +247,17 @@ pub fn pk_decrypt( let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect(); let empty_pw = Password::empty(); + // TODO it may degrade performance that we always try out all passwords here + let message_password: Vec = symmetric_secrets + .iter() + .map(|p| Password::from(*p)) + .collect(); + let message_password: Vec<&Password> = message_password.iter().collect(); + let ring = TheRing { secret_keys: skeys, key_passwords: vec![&empty_pw], - message_password: vec![], + message_password, session_keys: vec![], allow_legacy: false, }; @@ -327,7 +335,7 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { pub async fn encrypt_for_broadcast( plain: Vec, passphrase: &str, - private_key_for_signing: Option, + private_key_for_signing: SignedSecretKey, compress: bool, ) -> Result { let passphrase = Password::from(passphrase.to_string()); @@ -344,11 +352,9 @@ pub async fn encrypt_for_broadcast( ); msg.encrypt_with_password(&mut rng, s2k, &passphrase)?; - if let Some(ref skey) = private_key_for_signing { - msg.sign(&**skey, Password::empty(), HASH_ALGORITHM); - if compress { - msg.compression(CompressionAlgorithm::ZLIB); - } + msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM); + if compress { + msg.compression(CompressionAlgorithm::ZLIB); } let encoded_msg = msg.to_armored_string(&mut rng, Default::default())?; @@ -381,7 +387,10 @@ mod tests { use tokio::sync::OnceCell; use super::*; - use crate::test_utils::{alice_keypair, bob_keypair}; + use crate::{ + key::load_self_secret_key, + test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair}, + }; fn pk_decrypt_and_validate<'a>( ctext: &'a [u8], @@ -392,7 +401,7 @@ mod tests { HashSet, Vec, )> { - let mut msg = pk_decrypt(ctext.to_vec(), private_keys_for_decryption)?; + let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption)?; let content = msg.as_data_vec()?; let ret_signature_fingerprints = valid_signature_fingerprints(&msg, public_keys_for_validation)?; @@ -578,4 +587,26 @@ mod tests { assert_eq!(content, CLEARTEXT); assert_eq!(valid_signatures.len(), 0); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_encrypt_decrypt_broadcast() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let plain = Vec::from(b"this is the secret message"); + let shared_secret = "shared secret"; + let ctext = encrypt_for_broadcast( + plain, + shared_secret, + load_self_secret_key(alice).await?, + true, + ) + .await?; + + let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; + let decrypted = decrypt(ctext.into(), &bob_private_keyring)?; + + Ok(()) + } } diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 4a7c40e911..e17989d3e8 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1261,6 +1261,15 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); .await?; } + inc_and_check(&mut migration_version, 134)?; + if dbversion < migration_version { + sql.execute_migration( + "CREATE TABLE symmetric_secrets( + chat_id INTEGER PRIMARY KEY NOT NULL, + symmetric_secret: ", + ) + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? From 04c80238da73a221587d013884839d926b01eed4 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 11 Jul 2025 15:56:03 +0200 Subject: [PATCH 03/69] feat: Save the secret to encrypt and decrypt messages. Next: Send it in a 'member added' message. --- src/chat/chat_tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/decrypt.rs | 2 +- src/mimefactory.rs | 1 + src/mimeparser.rs | 15 +++++++++++++-- src/param.rs | 2 +- src/pgp.rs | 20 +++++++++++++------- src/sql/migrations.rs | 9 ++++++--- 7 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 3a7ca45de7..0f489a7d56 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3055,6 +3055,45 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_encrypt_decrypt_broadcast_integration() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let bob_without_secret = &tcm.bob().await; + + let secret = "secret"; + + let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; + + tcm.section("Create a broadcast channel with Bob, and send a message"); + let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; + add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + + let mut alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; + alice_chat.param.set(Param::SymmetricKey, secret); + alice_chat.update_param(alice).await?; + + // TODO the chat_id 10 is magical here: + bob.sql + .execute( + "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (10, ?)", + (secret,), + ) + .await?; + + let sent = alice + .send_text(alice_chat_id, "Symmetrically encrypted message") + .await; + let rcvd = bob.recv_msg(&sent).await; + assert_eq!(rcvd.text, "Symmetrically encrypted message"); + + tcm.section("If Bob doesn't know the secret, he can't decrypt the message"); + bob_without_secret.recv_msg_trash(&sent).await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_create_for_contact_with_blocked() -> Result<()> { let t = TestContext::new().await; diff --git a/src/decrypt.rs b/src/decrypt.rs index 7100ade685..3da5217d8f 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -14,7 +14,7 @@ use crate::pgp; pub fn try_decrypt<'a>( mail: &'a ParsedMail<'a>, private_keyring: &'a [SignedSecretKey], - symmetric_secrets: &[&str], + symmetric_secrets: &[String], ) -> Result>> { let Some(encrypted_data_part) = get_encrypted_mime(mail) else { return Ok(None); diff --git a/src/mimefactory.rs b/src/mimefactory.rs index caacc56bc2..e6dda6eb36 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1158,6 +1158,7 @@ impl MimeFactory { }; let encrypted = if let Some(symmetric_key) = symmetric_key { + info!(context, "Symmetrically encrypting for broadcast channel."); encrypt_helper .encrypt_for_broadcast(context, symmetric_key, message, compress) .await? diff --git a/src/mimeparser.rs b/src/mimeparser.rs index ff494e0ef1..2207106dfe 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -338,10 +338,21 @@ impl MimeMessage { let mail_raw; // Memory location for a possible decrypted message. let decrypted_msg; // Decrypted signed OpenPGP message. - let symmetric_secrets = + let symmetric_secrets: Vec = context + .sql + .query_map( + "SELECT secret FROM broadcasts_shared_secrets", + (), + |row| row.get(0), + |rows| { + rows.collect::, _>>() + .map_err(Into::into) + }, + ) + .await?; let (mail, is_encrypted) = match tokio::task::block_in_place(|| { - try_decrypt(&mail, &private_keyring, symmetric_secrets) + try_decrypt(&mail, &private_keyring, &symmetric_secrets) }) { Ok(Some(mut msg)) => { mail_raw = msg.as_data_vec().unwrap_or_default(); diff --git a/src/param.rs b/src/param.rs index 5d32c23da1..098c412435 100644 --- a/src/param.rs +++ b/src/param.rs @@ -169,7 +169,7 @@ pub enum Param { /// post something to the mailing list. ListPost = b'p', - /// For Chats of type [`Chattype::OutBroadcast`] and [`Chattype::InBroadcast`]: + /// For Chats of type [`Chattype::OutBroadcast`] and [`Chattype::InBroadcast`]: // TODO (or just OutBroadcast) /// The symmetric key shared among all chat participants, /// used to encrypt and decrypt messages. SymmetricKey = b'z', diff --git a/src/pgp.rs b/src/pgp.rs index 67cd812bc6..69beddded9 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -239,7 +239,7 @@ pub fn pk_calc_signature( pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], - symmetric_secrets: &[&str], + symmetric_secrets: &[String], ) -> Result> { let cursor = Cursor::new(ctext); let (msg, _headers) = Message::from_armor(cursor)?; @@ -250,7 +250,7 @@ pub fn decrypt( // TODO it may degrade performance that we always try out all passwords here let message_password: Vec = symmetric_secrets .iter() - .map(|p| Password::from(*p)) + .map(|p| Password::from(p.as_str())) .collect(); let message_password: Vec<&Password> = message_password.iter().collect(); @@ -320,7 +320,7 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { tokio::task::spawn_blocking(move || { let mut rng = thread_rng(); let s2k = StringToKey::new_default(&mut rng); - let builder = MessageBuilder::from_bytes("", plain); + let builder: MessageBuilder<'_> = MessageBuilder::from_bytes("", plain); let mut builder = builder.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); builder.encrypt_with_password(s2k, &passphrase)?; @@ -389,7 +389,7 @@ mod tests { use super::*; use crate::{ key::load_self_secret_key, - test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair}, + test_utils::{TestContextManager, alice_keypair, bob_keypair}, }; fn pk_decrypt_and_validate<'a>( @@ -401,7 +401,7 @@ mod tests { HashSet, Vec, )> { - let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption)?; + let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?; let content = msg.as_data_vec()?; let ret_signature_fingerprints = valid_signature_fingerprints(&msg, public_keys_for_validation)?; @@ -597,7 +597,7 @@ mod tests { let plain = Vec::from(b"this is the secret message"); let shared_secret = "shared secret"; let ctext = encrypt_for_broadcast( - plain, + plain.clone(), shared_secret, load_self_secret_key(alice).await?, true, @@ -605,7 +605,13 @@ mod tests { .await?; let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; - let decrypted = decrypt(ctext.into(), &bob_private_keyring)?; + let mut decrypted = decrypt( + ctext.into(), + &bob_private_keyring, + &[shared_secret.to_string()], + )?; + + assert_eq!(decrypted.as_data_vec()?, plain); Ok(()) } diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index e17989d3e8..d794e2678d 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1264,10 +1264,13 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); inc_and_check(&mut migration_version, 134)?; if dbversion < migration_version { sql.execute_migration( - "CREATE TABLE symmetric_secrets( - chat_id INTEGER PRIMARY KEY NOT NULL, - symmetric_secret: ", + "CREATE TABLE broadcasts_shared_secrets( + chat_id INTEGER PRIMARY KEY NOT NULL, -- TODO we don't actually need the chat_id + secret TEXT NOT NULL + ) STRICT", + migration_version, ) + .await?; } let new_version = sql From c1d1bf76a72e91a2d137f27743584aba2728e008 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 21 Jul 2025 17:34:46 +0200 Subject: [PATCH 04/69] feat: Add create_broadcast_shared_secret() --- src/chat.rs | 6 +++--- src/tools.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index d717460744..683650e9b1 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -43,9 +43,9 @@ use crate::smtp::send_msg_to_smtp; use crate::stock_str; use crate::sync::{self, Sync::*, SyncData}; use crate::tools::{ - IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid, - create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset, - smeared_time, time, truncate_msg_text, + IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id, + create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path, + gm2local_offset, smeared_time, time, truncate_msg_text, }; use crate::webxdc::StatusUpdateSerial; use crate::{chatlist_events, imap}; diff --git a/src/tools.rs b/src/tools.rs index 59cca8d158..fe462266a9 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -300,6 +300,25 @@ pub(crate) fn create_id() -> String { base64::engine::general_purpose::URL_SAFE.encode(arr) } +/// Generate a shared secret for a broadcast channel, consisting of 64 characters.. +/// +/// The string generated by this function has 384 bits of entropy +/// and is returned as 64 Base64 characters, each containing 6 bits of entropy. +/// 384 is chosen because it is sufficiently secure +/// (larger than AES-128 keys used for message encryption) +/// and divides both by 8 (byte size) and 6 (number of bits in a single Base64 character). +// TODO ask someone what a good size would be here - also, not sure whether the AES-128 thing is true +pub(crate) fn create_broadcast_shared_secret() -> String { + // ThreadRng implements CryptoRng trait and is supposed to be cryptographically secure. + let mut rng = thread_rng(); + + // Generate 384 random bits. + let mut arr = [0u8; 48]; + rng.fill(&mut arr[..]); + + base64::engine::general_purpose::URL_SAFE.encode(arr) +} + /// Returns true if given string is a valid ID. /// /// All IDs generated with `create_id()` should be considered valid. From 3aa227bf4b89f0e4db879c87178920d8115e30da Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 21 Jul 2025 17:35:10 +0200 Subject: [PATCH 05/69] sync broadcast secret for multidevice --- src/chat.rs | 31 +++++++++++++++++++++++++------ src/chat/chat_tests.rs | 15 ++++++++++++++- src/param.rs | 2 +- src/qr.rs | 1 + src/receive_imf.rs | 25 ++++++++++++++++++++++++- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 683650e9b1..5952a8c945 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -3748,7 +3748,8 @@ pub async fn create_group_ex( /// Returns the created chat's id. pub async fn create_broadcast(context: &Context, chat_name: String) -> Result { let grpid = create_id(); - create_broadcast_ex(context, Sync, grpid, chat_name).await + let secret = create_broadcast_shared_secret(); + create_broadcast_ex(context, Sync, grpid, chat_name, secret).await } pub(crate) async fn create_broadcast_ex( @@ -3756,6 +3757,7 @@ pub(crate) async fn create_broadcast_ex( sync: sync::Sync, grpid: String, chat_name: String, + secret: String, ) -> Result { let row_id = { let chat_name = &chat_name; @@ -3784,6 +3786,13 @@ pub(crate) async fn create_broadcast_ex( create_smeared_timestamp(context), ), )?; + let chat_id = t.last_insert_rowid(); + // TODO code duplication of `INSERT INTO broadcasts_shared_secrets` + t.execute( + "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.chat_id", + (chat_id, &secret), + )?; Ok(t.last_insert_rowid().try_into()?) }; context.sql.transaction(trans_fn).await? @@ -3795,7 +3804,7 @@ pub(crate) async fn create_broadcast_ex( if sync.into() { let id = SyncId::Grpid(grpid); - let action = SyncAction::CreateBroadcast(chat_name); + let action = SyncAction::CreateBroadcast { chat_name, secret }; self::sync(context, id, action).await.log_err(context).ok(); } @@ -5007,7 +5016,10 @@ pub(crate) enum SyncAction { SetVisibility(ChatVisibility), SetMuted(MuteDuration), /// Create broadcast channel with the given name. - CreateBroadcast(String), + CreateBroadcast { + chat_name: String, + secret: String, + }, Rename(String), /// Set chat contacts by their addresses. SetContacts(Vec), @@ -5070,8 +5082,15 @@ impl Context { .id } SyncId::Grpid(grpid) => { - if let SyncAction::CreateBroadcast(name) = action { - create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?; + if let SyncAction::CreateBroadcast { chat_name, secret } = action { + create_broadcast_ex( + self, + Nosync, + grpid.clone(), + chat_name.clone(), + secret.to_string(), + ) + .await?; return Ok(()); } get_chat_id_by_grpid(self, grpid) @@ -5094,7 +5113,7 @@ impl Context { SyncAction::Accept => chat_id.accept_ex(self, Nosync).await, SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await, SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await, - SyncAction::CreateBroadcast(_) => { + SyncAction::CreateBroadcast { .. } => { Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 0f489a7d56..bd69fa3e38 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3846,12 +3846,25 @@ async fn test_sync_name() -> Result<()> { let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?; sync(alice0, alice1).await; let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; + set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?; - sync(alice0, alice1).await; + //sync(alice0, alice1).await; // crash + + let sent = alice0.pop_sent_msg().await; + let rcvd = alice1.recv_msg(&sent).await; + assert_eq!(rcvd.from_id, ContactId::SELF); + assert_eq!(rcvd.to_id, ContactId::SELF); + assert_eq!( + rcvd.text, + "You changed group name from \"Channel\" to \"Broadcast channel 42\"." + ); + assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged); let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid) .await? .unwrap() .0; + assert_eq!(rcvd.chat_id, a1_broadcast_id); + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42"); diff --git a/src/param.rs b/src/param.rs index 098c412435..4fd0c87063 100644 --- a/src/param.rs +++ b/src/param.rs @@ -172,7 +172,7 @@ pub enum Param { /// For Chats of type [`Chattype::OutBroadcast`] and [`Chattype::InBroadcast`]: // TODO (or just OutBroadcast) /// The symmetric key shared among all chat participants, /// used to encrypt and decrypt messages. - SymmetricKey = b'z', + SymmetricKey = b'z', // TODO remove this again /// For Contacts: If this is the List-Post address of a mailing list, contains /// the List-Id of the mailing list (which is also used as the group id of the chat). diff --git a/src/qr.rs b/src/qr.rs index 6453188033..2076837dd5 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -381,6 +381,7 @@ pub fn format_backup(qr: &Qr) -> Result { /// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH` +/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&c=CHANNELNAME&x=CHANNELID&s=SHAREDSECRET` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR` async fn decode_openpgp(context: &Context, qr: &str) -> Result { let payload = qr diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 980fddead8..7d756b6ece 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3487,8 +3487,22 @@ async fn apply_out_broadcast_changes( chat: &mut Chat, from_id: ContactId, ) -> Result { + // TODO code duplication with apply_in_broadcast_changes() ensure!(chat.typ == Chattype::OutBroadcast); + let mut send_event_chat_modified = false; + let mut better_msg = None; + + apply_chat_name_and_avatar_changes( + context, + mime_parser, + from_id, + chat, + &mut send_event_chat_modified, + &mut better_msg, + ) + .await?; + if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { // The sender of the message left the broadcast channel remove_from_chat_contacts_table(context, chat.id, from_id).await?; @@ -3501,7 +3515,16 @@ async fn apply_out_broadcast_changes( }); } - Ok(GroupChangesInfo::default()) + if send_event_chat_modified { + context.emit_event(EventType::ChatModified(chat.id)); + chatlist_events::emit_chatlist_item_changed(context, chat.id); + } + Ok(GroupChangesInfo { + better_msg, + added_removed_id: None, + silent: false, + extra_msgs: vec![], + }) } async fn apply_in_broadcast_changes( From 7c6b52b7f32ff6eaba51c7247e145b9b0ec64f54 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 21 Jul 2025 17:36:50 +0200 Subject: [PATCH 06/69] Make it compile --- src/param.rs | 2 +- src/receive_imf.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/param.rs b/src/param.rs index 4fd0c87063..0dbfa1b0f1 100644 --- a/src/param.rs +++ b/src/param.rs @@ -172,7 +172,7 @@ pub enum Param { /// For Chats of type [`Chattype::OutBroadcast`] and [`Chattype::InBroadcast`]: // TODO (or just OutBroadcast) /// The symmetric key shared among all chat participants, /// used to encrypt and decrypt messages. - SymmetricKey = b'z', // TODO remove this again + SymmetricKey = b'z', // TODO remove this /// For Contacts: If this is the List-Post address of a mailing list, contains /// the List-Id of the mailing list (which is also used as the group id of the chat). diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 7d756b6ece..7e6531402d 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -44,7 +44,7 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on use crate::simplify; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{self, buf_compress, remove_subject_prefix}; +use crate::tools::{self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix}; use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location}; use crate::{contact, imap}; @@ -1559,7 +1559,8 @@ async fn do_chat_assignment( } else { let name = compute_mailinglist_name(mailinglist_header, &listid, mime_parser); - chat::create_broadcast_ex(context, Nosync, listid, name).await? + let secret = create_broadcast_shared_secret(); + chat::create_broadcast_ex(context, Nosync, listid, name, secret).await? }, ); } From 2a2b54a03104fc62b9da552139a65fa1926be8c0 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 21 Jul 2025 17:37:17 +0200 Subject: [PATCH 07/69] feat: Store symmetric key non-redundantly in the database --- src/chat/chat_tests.rs | 13 ++++++++----- src/mimefactory.rs | 12 +++++++++--- src/param.rs | 5 ----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index bd69fa3e38..da2f4d399c 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3067,13 +3067,16 @@ async fn test_encrypt_decrypt_broadcast_integration() -> Result<()> { let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; tcm.section("Create a broadcast channel with Bob, and send a message"); - let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; + let alice_chat_id = create_broadcast_ex( + alice, + Sync, + "My Channel".to_string(), + "grpid".to_string(), + secret.to_string(), + ) + .await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; - let mut alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; - alice_chat.param.set(Param::SymmetricKey, secret); - alice_chat.update_param(alice).await?; - // TODO the chat_id 10 is magical here: bob.sql .execute( diff --git a/src/mimefactory.rs b/src/mimefactory.rs index e6dda6eb36..952b9e64f3 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1143,7 +1143,7 @@ impl MimeFactory { Loaded::Mdn { .. } => true, }; - let symmetric_key = match &self.loaded { + let symmetric_key: Option = match &self.loaded { Loaded::Message { chat, .. } if chat.typ == Chattype::OutBroadcast => { // If there is no symmetric key yet // (because this is an old broadcast channel, @@ -1152,7 +1152,13 @@ impl MimeFactory { // Symmetric encryption exists since 2025-08; // some time after that, we can think about requiring everyone // to switch to symmetrically-encrypted broadcast lists. - chat.param.get(Param::SymmetricKey) + context + .sql + .query_get_value( + "SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?", + (chat.id,), + ) + .await? } _ => None, }; @@ -1160,7 +1166,7 @@ impl MimeFactory { let encrypted = if let Some(symmetric_key) = symmetric_key { info!(context, "Symmetrically encrypting for broadcast channel."); encrypt_helper - .encrypt_for_broadcast(context, symmetric_key, message, compress) + .encrypt_for_broadcast(context, &symmetric_key, message, compress) .await? } else { // Asymmetric encryption diff --git a/src/param.rs b/src/param.rs index 0dbfa1b0f1..9e0433a256 100644 --- a/src/param.rs +++ b/src/param.rs @@ -169,11 +169,6 @@ pub enum Param { /// post something to the mailing list. ListPost = b'p', - /// For Chats of type [`Chattype::OutBroadcast`] and [`Chattype::InBroadcast`]: // TODO (or just OutBroadcast) - /// The symmetric key shared among all chat participants, - /// used to encrypt and decrypt messages. - SymmetricKey = b'z', // TODO remove this - /// For Contacts: If this is the List-Post address of a mailing list, contains /// the List-Id of the mailing list (which is also used as the group id of the chat). ListId = b's', From 461aa26f0e53e24aaeab3b891e052dd9be9a971f Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 21 Jul 2025 17:37:48 +0200 Subject: [PATCH 08/69] feat: Add broadcast QR type (todo: documentation) --- deltachat-ffi/src/lot.rs | 6 ++++ deltachat-jsonrpc/src/api/types/qr.rs | 32 +++++++++++++++++++ src/qr.rs | 45 ++++++++++++++++++++++++++- src/securejoin/bob.rs | 2 ++ src/securejoin/qrinvite.rs | 20 ++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/deltachat-ffi/src/lot.rs b/deltachat-ffi/src/lot.rs index e77483ef7d..392a2b4bae 100644 --- a/deltachat-ffi/src/lot.rs +++ b/deltachat-ffi/src/lot.rs @@ -45,6 +45,7 @@ impl Lot { Self::Qr(qr) => match qr { Qr::AskVerifyContact { .. } => None, Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)), + Qr::AskJoinBroadcast { broadcast_name, .. } => Some(Cow::Borrowed(broadcast_name)), Qr::FprOk { .. } => None, Qr::FprMismatch { .. } => None, Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)), @@ -99,6 +100,7 @@ impl Lot { Self::Qr(qr) => match qr { Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact, Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup, + Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast, Qr::FprOk { .. } => LotState::QrFprOk, Qr::FprMismatch { .. } => LotState::QrFprMismatch, Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr, @@ -126,6 +128,7 @@ impl Lot { Self::Qr(qr) => match qr { Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(), Qr::AskVerifyGroup { .. } => Default::default(), + Qr::AskJoinBroadcast { .. } => Default::default(), Qr::FprOk { contact_id } => contact_id.to_u32(), Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(), Qr::FprWithoutAddr { .. } => Default::default(), @@ -169,6 +172,9 @@ pub enum LotState { /// text1=groupname QrAskVerifyGroup = 202, + /// text1=broadcast_name + QrAskJoinBroadcast = 204, + /// id=contact QrFprOk = 210, diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 61d8141f76..b7c177b09c 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -34,6 +34,21 @@ pub enum QrObject { /// Authentication code. authcode: String, }, + /// Ask the user whether to join the broadcast channel. + AskJoinBroadcast { + /// Chat name. + broadcast_name: String, + /// Group ID. + grpid: String, + /// ID of the contact. + contact_id: u32, + /// Fingerprint of the contact key as scanned from the QR code. + fingerprint: String, + + /// The secret shared between all members, + /// used to symmetrically encrypt&decrypt messages. + shared_secret: String, + }, /// Contact fingerprint is verified. /// /// Ask the user if they want to start chatting. @@ -207,6 +222,23 @@ impl From for QrObject { authcode, } } + Qr::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + shared_secret, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + shared_secret, + } + } Qr::FprOk { contact_id } => { let contact_id = contact_id.to_u32(); QrObject::FprOk { contact_id } diff --git a/src/qr.rs b/src/qr.rs index 2076837dd5..2cdd8de0bc 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -84,6 +84,21 @@ pub enum Qr { authcode: String, }, + /// Ask whether to join the broadcast channel. + AskJoinBroadcast { + // TODO document + broadcast_name: String, + + // TODO not sure wheter it makes sense to call this grpid just because it's called like this in the db + grpid: String, + + contact_id: ContactId, + + fingerprint: Fingerprint, + + shared_secret: String, + }, + /// Contact fingerprint is verified. /// /// Ask the user if they want to start chatting. @@ -381,7 +396,7 @@ pub fn format_backup(qr: &Qr) -> Result { /// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH` -/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&c=CHANNELNAME&x=CHANNELID&s=SHAREDSECRET` +/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=BROADCAST_NAME&x=BROADCAST_ID&b=BROADCAST_SHARED_SECRET` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR` async fn decode_openpgp(context: &Context, qr: &str) -> Result { let payload = qr @@ -440,6 +455,10 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .get("x") .filter(|&s| validate_id(s)) .map(|s| s.to_string()); + let broadcast_shared_secret = param + .get("b") + .filter(|&s| validate_id(s)) + .map(|s| s.to_string()); let grpname = if grpid.is_some() { if let Some(encoded_name) = param.get("g") { @@ -526,6 +545,30 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { authcode, }) } + } else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(shared_secret)) = + (&addr, grpname, grpid, broadcast_shared_secret) + { + // This is a broadcast channel invite link. + // TODO code duplication with the previous block + // TODO at some point, we can mark this person as verified + let addr = ContactAddress::new(addr)?; + let (contact_id, _) = Contact::add_or_lookup_ex( + context, + &name, + &addr, + &fingerprint.hex(), + Origin::UnhandledSecurejoinQrScan, + ) + .await + .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?; + + Ok(Qr::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + shared_secret, + }) } else if let Some(addr) = addr { let fingerprint = fingerprint.hex(); let (contact_id, _) = diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 5392f94692..8173b312ff 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -47,6 +47,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul let hidden = match invite { QrInvite::Contact { .. } => Blocked::Not, QrInvite::Group { .. } => Blocked::Yes, + QrInvite::Broadcast { .. } => Blocked::Yes, }; let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden) .await @@ -113,6 +114,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul chat::add_info_msg(context, group_chat_id, &msg, time()).await?; Ok(group_chat_id) } + QrInvite::Broadcast { .. } => {} QrInvite::Contact { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it // uses it to send the handshake messages. diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index 023d6875b6..c472ac3cf6 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -29,6 +29,13 @@ pub enum QrInvite { invitenumber: String, authcode: String, }, + Broadcast { + broadcast_name: String, + grpid: String, + contact_id: ContactId, + fingerprint: Fingerprint, + shared_secret: String, + }, } impl QrInvite { @@ -95,6 +102,19 @@ impl TryFrom for QrInvite { invitenumber, authcode, }), + Qr::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + shared_secret, + } => Ok(QrInvite::Broadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + shared_secret, + }), _ => bail!("Unsupported QR type"), } } From 5c61e0431eff46e76fadbb4083d619960e30680b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Wed, 23 Jul 2025 18:42:50 +0200 Subject: [PATCH 09/69] Adapt the rest of the code to the new QR code type --- src/securejoin/bob.rs | 44 ++++++++++++++++++++++++++++++++------ src/securejoin/qrinvite.rs | 10 +++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 8173b312ff..61f31e2879 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -114,7 +114,24 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul chat::add_info_msg(context, group_chat_id, &msg, time()).await?; Ok(group_chat_id) } - QrInvite::Broadcast { .. } => {} + QrInvite::Broadcast { .. } => { + // For a secure-join we need to create the group and add the contact. The group will + // only become usable once the protocol is finished. + let group_chat_id = joining_chat_id(context, &invite, chat_id).await?; + if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? { + chat::add_to_chat_contacts_table( + context, + time(), + group_chat_id, + &[invite.contact_id()], + ) + .await?; + } + // TODO this message should be translatable: + let msg = "You were invited to join this channel. Waiting for the channel owner's device to reply…"; + chat::add_info_msg(context, group_chat_id, msg, time()).await?; + Ok(group_chat_id) + } QrInvite::Contact { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it // uses it to send the handshake messages. @@ -206,7 +223,7 @@ pub(super) async fn handle_auth_required( .await?; match invite { - QrInvite::Contact { .. } => {} + QrInvite::Contact { .. } | QrInvite::Broadcast { .. } => {} QrInvite::Group { .. } => { // The message reads "Alice replied, waiting to be added to the group…", // so only show it on secure-join and not on setup-contact. @@ -325,10 +342,14 @@ impl BobHandshakeMsg { Self::Request => match invite { QrInvite::Contact { .. } => "vc-request", QrInvite::Group { .. } => "vg-request", + QrInvite::Broadcast { .. } => "broadcast-request", }, Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", + QrInvite::Broadcast { .. } => { + panic!("There is no request-with-auth for broadcasts") + } // TODO remove panic }, } } @@ -348,8 +369,19 @@ async fn joining_chat_id( ) -> Result { match invite { QrInvite::Contact { .. } => Ok(alice_chat_id), - QrInvite::Group { grpid, name, .. } => { - let group_chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? { + QrInvite::Group { grpid, name, .. } + | QrInvite::Broadcast { + broadcast_name: name, + grpid, + .. + } => { + let chattype = if matches!(invite, QrInvite::Group { .. }) { + Chattype::Group + } else { + Chattype::InBroadcast + }; + + let chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? { Some((chat_id, _protected, _blocked)) => { chat_id.unblock_ex(context, Nosync).await?; chat_id @@ -357,7 +389,7 @@ async fn joining_chat_id( None => { ChatId::create_multiuser_record( context, - Chattype::Group, + chattype, grpid, name, Blocked::Not, @@ -368,7 +400,7 @@ async fn joining_chat_id( .await? } }; - Ok(group_chat_id) + Ok(chat_id) } } } diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index c472ac3cf6..bcf6e90cec 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -45,14 +45,18 @@ impl QrInvite { /// translated to a contact ID. pub fn contact_id(&self) -> ContactId { match self { - Self::Contact { contact_id, .. } | Self::Group { contact_id, .. } => *contact_id, + Self::Contact { contact_id, .. } + | Self::Group { contact_id, .. } + | Self::Broadcast { contact_id, .. } => *contact_id, } } /// The fingerprint of the inviter. pub fn fingerprint(&self) -> &Fingerprint { match self { - Self::Contact { fingerprint, .. } | Self::Group { fingerprint, .. } => fingerprint, + Self::Contact { fingerprint, .. } + | Self::Group { fingerprint, .. } + | Self::Broadcast { fingerprint, .. } => fingerprint, } } @@ -60,6 +64,7 @@ impl QrInvite { pub fn invitenumber(&self) -> &str { match self { Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber, + Self::Broadcast { .. } => panic!("broadcast invite has no invite number"), // TODO panic } } @@ -67,6 +72,7 @@ impl QrInvite { pub fn authcode(&self) -> &str { match self { Self::Contact { authcode, .. } | Self::Group { authcode, .. } => authcode, + Self::Broadcast { .. } => panic!("broadcast invite has no authcode"), // TODO panic } } } From 9390cfcb0fcf3a5acb98adb883a4b035dc4f1c4e Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 25 Jul 2025 22:50:18 +0200 Subject: [PATCH 10/69] test: Add test_send_avatar_in_securejoin --- src/securejoin/securejoin_tests.rs | 50 +++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index b5f3fcd84a..fb8306497c 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -8,7 +8,8 @@ use crate::key::self_fingerprint; use crate::receive_imf::receive_imf; use crate::stock_str::{self, messages_e2e_encrypted}; use crate::test_utils::{ - TestContext, TestContextManager, TimeShiftFalsePositiveNote, get_chat_msg, + AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager, + TimeShiftFalsePositiveNote, get_chat_msg, }; use crate::tools::SystemTime; use std::time::Duration; @@ -819,3 +820,50 @@ async fn test_wrong_auth_token() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_send_avatar_in_securejoin() -> Result<()> { + async fn exec_securejoin_group( + tcm: &TestContextManager, + scanner: &TestContext, + scanned: &TestContext, + ) { + let chat_id = chat::create_group_chat(scanned, ProtectionStatus::Protected, "group") + .await + .unwrap(); + let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(scanner, scanned, &qr).await; + } + + for alice_scans in [true, false] { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let file = alice.dir.path().join("avatar.png"); + tokio::fs::write(&file, AVATAR_64x64_BYTES).await?; + alice + .set_config(Config::Selfavatar, Some(file.to_str().unwrap())) + .await?; + + if alice_scans { + tcm.execute_securejoin(alice, bob).await; + //exec_securejoin_group(&tcm, alice, bob).await; + //exec_securejoin_broadcast(&tcm, alice, bob).await; + // TODO also test these + } else { + tcm.execute_securejoin(bob, alice).await; + //exec_securejoin_group(&tcm, bob, alice).await; + //exec_securejoin_broadcast(&tcm, alice, bob).await; + } + + let alice_on_bob = bob.add_or_lookup_contact_no_key(&alice).await; + let avatar = alice_on_bob.get_profile_image(&bob).await?.unwrap(); + assert_eq!( + avatar.file_name().unwrap().to_str().unwrap(), + AVATAR_64x64_DEDUPLICATED + ); + } + + Ok(()) +} From 810df62670d4712e90a6c2a34e94c9f872f5ea35 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 16:32:11 +0200 Subject: [PATCH 11/69] Broadcast-securejoin is working!! --- deltachat-jsonrpc/src/api/types/qr.rs | 4 ++ src/chat.rs | 60 ++++++++++++++++--- src/chat/chat_tests.rs | 7 ++- src/mimefactory.rs | 22 +++---- src/qr.rs | 18 ++++-- src/receive_imf.rs | 4 +- src/securejoin.rs | 83 +++++++++++++++++---------- src/securejoin/bob.rs | 67 ++++++++++++++++----- src/securejoin/qrinvite.rs | 14 +++-- src/test_utils.rs | 6 +- src/tools.rs | 19 ------ 11 files changed, 208 insertions(+), 96 deletions(-) diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index b7c177b09c..7c4207e1ff 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -45,6 +45,8 @@ pub enum QrObject { /// Fingerprint of the contact key as scanned from the QR code. fingerprint: String, + authcode: String, + /// The secret shared between all members, /// used to symmetrically encrypt&decrypt messages. shared_secret: String, @@ -227,6 +229,7 @@ impl From for QrObject { grpid, contact_id, fingerprint, + authcode, shared_secret, } => { let contact_id = contact_id.to_u32(); @@ -236,6 +239,7 @@ impl From for QrObject { grpid, contact_id, fingerprint, + authcode, shared_secret, } } diff --git a/src/chat.rs b/src/chat.rs index 5952a8c945..59ce2f4c30 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -43,9 +43,9 @@ use crate::smtp::send_msg_to_smtp; use crate::stock_str; use crate::sync::{self, Sync::*, SyncData}; use crate::tools::{ - IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id, - create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path, - gm2local_offset, smeared_time, time, truncate_msg_text, + IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid, + create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset, + smeared_time, time, truncate_msg_text, }; use crate::webxdc::StatusUpdateSerial; use crate::{chatlist_events, imap}; @@ -1646,6 +1646,18 @@ impl Chat { self.typ == Chattype::Mailinglist } + /// Returns true if chat is an outgoing broadcast channel. + pub fn is_out_broadcast(&self) -> bool { + self.typ == Chattype::OutBroadcast + } + + /// Returns true if the chat is a broadcast channel, + /// regardless of whether self is on the sending + /// or receiving side. + pub fn is_any_broadcast(&self) -> bool { + matches!(self.typ, Chattype::OutBroadcast | Chattype::InBroadcast) + } + /// Returns None if user can send messages to this chat. /// /// Otherwise returns a reason useful for logging. @@ -1726,7 +1738,7 @@ impl Chat { match self.typ { Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true), Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await, - Chattype::InBroadcast => Ok(false), + Chattype::InBroadcast => Ok(true), } } @@ -2909,13 +2921,18 @@ async fn prepare_send_msg( CantSendReason::ContactRequest => { // Allow securejoin messages, they are supposed to repair the verification. // If the chat is a contact request, let the user accept it later. + msg.param.get_cmd() == SystemMessage::SecurejoinMessage } // Allow to send "Member removed" messages so we can leave the group/broadcast. // Necessary checks should be made anyway before removing contact // from the chat. - CantSendReason::NotAMember | CantSendReason::InBroadcast => { - msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup + CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup, + CantSendReason::InBroadcast => { + matches!( + msg.param.get_cmd(), + SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage + ) } CantSendReason::MissingKey => msg .param @@ -3748,7 +3765,7 @@ pub async fn create_group_ex( /// Returns the created chat's id. pub async fn create_broadcast(context: &Context, chat_name: String) -> Result { let grpid = create_id(); - let secret = create_broadcast_shared_secret(); + let secret = create_id(); create_broadcast_ex(context, Sync, grpid, chat_name, secret).await } @@ -3811,6 +3828,35 @@ pub(crate) async fn create_broadcast_ex( Ok(chat_id) } +pub(crate) async fn load_broadcast_shared_secret( + context: &Context, + chat_id: ChatId, +) -> Result> { + Ok(context + .sql + .query_get_value( + "SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?", + (chat_id,), + ) + .await?) +} + +pub(crate) async fn save_broadcast_shared_secret( + context: &Context, + chat_id: ChatId, + shared_secret: &str, +) -> Result<()> { + context + .sql + .execute( + "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.chat_id", + (chat_id, shared_secret), + ) + .await?; + Ok(()) +} + /// Set chat contacts in the `chats_contacts` table. pub(crate) async fn update_chat_contacts_table( context: &Context, diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index da2f4d399c..91c66d2dd0 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -7,6 +7,7 @@ use crate::imex::{ImexMode, has_backup, imex}; use crate::message::{MessengerMessage, delete_msgs}; use crate::mimeparser::{self, MimeMessage}; use crate::receive_imf::receive_imf; +use crate::securejoin::get_securejoin_qr; use crate::test_utils::{ AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync, @@ -2915,11 +2916,13 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; tcm.section("Create a broadcast channel with Bob, and send a message"); let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; - add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; + let mut sent = alice.send_text(alice_chat_id, "Hi somebody").await; assert!(!sent.payload.contains("List-ID")); diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 952b9e64f3..9eaba2f8db 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -15,7 +15,7 @@ use tokio::fs; use crate::aheader::{Aheader, EncryptPreference}; use crate::blob::BlobObject; -use crate::chat::{self, Chat}; +use crate::chat::{self, Chat, load_broadcast_shared_secret}; use crate::config::Config; use crate::constants::ASM_SUBJECT; use crate::constants::{Chattype, DC_FROM_HANDSHAKE}; @@ -231,6 +231,9 @@ impl MimeFactory { // Do not encrypt messages to mailing lists. encryption_keys = None; + } else if chat.is_out_broadcast() { + // Encrypt, but only symmetrically, not with the public keys. + encryption_keys = Some(Vec::new()); } else { let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup { msg.param.get(Param::Arg) @@ -563,8 +566,10 @@ impl MimeFactory { // messages are auto-sent unlike usual unencrypted messages. step == "vg-request-with-auth" || step == "vc-request-with-auth" + || step == "vb-request-with-auth" || step == "vg-member-added" || step == "vc-contact-confirm" + // TODO possibly add vb-member-added here } } @@ -1144,7 +1149,7 @@ impl MimeFactory { }; let symmetric_key: Option = match &self.loaded { - Loaded::Message { chat, .. } if chat.typ == Chattype::OutBroadcast => { + Loaded::Message { chat, .. } if chat.is_any_broadcast() => { // If there is no symmetric key yet // (because this is an old broadcast channel, // created before we had symmetric encryption), @@ -1152,13 +1157,7 @@ impl MimeFactory { // Symmetric encryption exists since 2025-08; // some time after that, we can think about requiring everyone // to switch to symmetrically-encrypted broadcast lists. - context - .sql - .query_get_value( - "SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?", - (chat.id,), - ) - .await? + load_broadcast_shared_secret(context, chat.id).await? } _ => None, }; @@ -1515,7 +1514,10 @@ impl MimeFactory { let param2 = msg.param.get(Param::Arg2).unwrap_or_default(); if !param2.is_empty() { headers.push(( - if step == "vg-request-with-auth" || step == "vc-request-with-auth" { + if step == "vg-request-with-auth" + || step == "vc-request-with-auth" + || step == "vb-request-with-auth" + { "Secure-Join-Auth" } else { "Secure-Join-Invitenumber" diff --git a/src/qr.rs b/src/qr.rs index 2cdd8de0bc..c767503f38 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -96,6 +96,8 @@ pub enum Qr { fingerprint: Fingerprint, + authcode: String, + shared_secret: String, }, @@ -396,7 +398,7 @@ pub fn format_backup(qr: &Qr) -> Result { /// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH` -/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=BROADCAST_NAME&x=BROADCAST_ID&b=BROADCAST_SHARED_SECRET` +/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=BROADCAST_NAME&x=BROADCAST_ID&s=AUTH&b=BROADCAST_SHARED_SECRET` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR` async fn decode_openpgp(context: &Context, qr: &str) -> Result { let payload = qr @@ -474,7 +476,9 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { None }; - if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) { + if let (Some(addr), Some(invitenumber), Some(authcode)) = + (&addr, invitenumber, authcode.clone()) + { let addr = ContactAddress::new(addr)?; let (contact_id, _) = Contact::add_or_lookup_ex( context, @@ -545,8 +549,13 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { authcode, }) } - } else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(shared_secret)) = - (&addr, grpname, grpid, broadcast_shared_secret) + } else if let ( + Some(addr), + Some(broadcast_name), + Some(grpid), + Some(authcode), + Some(shared_secret), + ) = (&addr, grpname, grpid, authcode, broadcast_shared_secret) { // This is a broadcast channel invite link. // TODO code duplication with the previous block @@ -567,6 +576,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { grpid, contact_id, fingerprint, + authcode, shared_secret, }) } else if let Some(addr) = addr { diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 7e6531402d..0e33a8dd32 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -44,7 +44,7 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on use crate::simplify; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix}; +use crate::tools::{self, buf_compress, create_id, remove_subject_prefix}; use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location}; use crate::{contact, imap}; @@ -1559,7 +1559,7 @@ async fn do_chat_assignment( } else { let name = compute_mailinglist_name(mailinglist_header, &listid, mime_parser); - let secret = create_broadcast_shared_secret(); + let secret = create_id(); chat::create_broadcast_ex(context, Nosync, listid, name, secret).await? }, ); diff --git a/src/securejoin.rs b/src/securejoin.rs index 4cba5407ea..776c24c2b4 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -4,7 +4,10 @@ use anyhow::{Context as _, Error, Result, ensure}; use deltachat_contact_tools::ContactAddress; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; -use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid}; +use crate::chat::{ + self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid, + load_broadcast_shared_secret, +}; use crate::chatlist_events; use crate::config::Config; use crate::constants::{Blocked, Chattype, NON_ALPHANUMERIC_WITHOUT_DOT}; @@ -46,9 +49,9 @@ fn inviter_progress(context: &Context, contact_id: ContactId, progress: usize) { /// Generates a Secure Join QR code. /// -/// With `group` set to `None` this generates a setup-contact QR code, with `group` set to a -/// [`ChatId`] generates a join-group QR code for the given chat. -pub async fn get_securejoin_qr(context: &Context, group: Option) -> Result { +/// With `chat` set to `None` this generates a setup-contact QR code, with `chat` set to a +/// [`ChatId`] generates a join-group/join-broadcast-channel QR code for the given chat. +pub async fn get_securejoin_qr(context: &Context, chat: Option) -> Result { /*======================================================= ==== Alice - the inviter side ==== ==== Step 1 in "Setup verified contact" protocol ==== @@ -56,12 +59,13 @@ pub async fn get_securejoin_qr(context: &Context, group: Option) -> Resu ensure_secret_key_exists(context).await.ok(); - let chat = match group { + let chat = match chat { Some(id) => { let chat = Chat::load_from_db(context, id).await?; ensure!( - chat.typ == Chattype::Group, - "Can't generate SecureJoin QR code for 1:1 chat {id}" + chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, + "Can't generate SecureJoin QR code for chat {id} of type {}", + chat.typ ); ensure!( !chat.grpid.is_empty(), @@ -93,24 +97,44 @@ pub async fn get_securejoin_qr(context: &Context, group: Option) -> Resu utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string(); let qr = if let Some(chat) = chat { - // parameters used: a=g=x=i=s= - let group_name = chat.get_name(); - let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string(); - if sync_token { - context - .sync_qr_code_tokens(Some(chat.grpid.as_str())) - .await?; - context.scheduler.interrupt_inbox().await; + if chat.typ == Chattype::OutBroadcast { + let broadcast_name = chat.get_name(); + let broadcast_name_urlencoded = + utf8_percent_encode(broadcast_name, NON_ALPHANUMERIC).to_string(); + let broadcast_secret = load_broadcast_shared_secret(context, chat.id) + .await? + .context("Could not find broadcast secret")?; + + format!( + "https://i.delta.chat/#{}&a={}&g={}&x={}&s={}&b={}", + fingerprint.hex(), + self_addr_urlencoded, + &broadcast_name_urlencoded, + &chat.grpid, + &auth, + broadcast_secret + ) + } else { + // parameters used: a=g=x=i=s= + let group_name = chat.get_name(); + let group_name_urlencoded = + utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string(); + if sync_token { + context + .sync_qr_code_tokens(Some(chat.grpid.as_str())) + .await?; + context.scheduler.interrupt_inbox().await; + } + format!( + "https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}", + fingerprint.hex(), + self_addr_urlencoded, + &group_name_urlencoded, + &chat.grpid, + &invitenumber, + &auth, + ) } - format!( - "https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}", - fingerprint.hex(), - self_addr_urlencoded, - &group_name_urlencoded, - &chat.grpid, - &invitenumber, - &auth, - ) } else { // parameters used: a=n=i=s= if sync_token { @@ -265,9 +289,9 @@ pub(crate) async fn handle_securejoin_handshake( info!(context, "Received secure-join message {step:?}."); - let join_vg = step.starts_with("vg-"); - - if !matches!(step, "vg-request" | "vc-request") { + // TODO talk with link2xt about whether we need to protect against this identity-misbinding attack, + // and if so, how + if !matches!(step, "vg-request" | "vc-request" | "vb-request-with-auth") { let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); for (addr, key) in &mime_message.gossiped_keys { @@ -337,7 +361,7 @@ pub(crate) async fn handle_securejoin_handshake( ========================================================*/ bob::handle_auth_required(context, mime_message).await } - "vg-request-with-auth" | "vc-request-with-auth" => { + "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => { /*========================================================== ==== Alice - the inviter side ==== ==== Steps 5+6 in "Setup verified contact" protocol ==== @@ -398,7 +422,7 @@ pub(crate) async fn handle_securejoin_handshake( ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?; // for setup-contact, make Alice's one-to-one chat with Bob visible // (secure-join-information are shown in the group chat) - if !join_vg { + if step.starts_with("vc-") { ChatId::create_for_contact(context, contact_id).await?; } context.emit_event(EventType::ContactsChanged(Some(contact_id))); @@ -499,6 +523,7 @@ pub(crate) async fn handle_securejoin_handshake( /// we know that we are Alice (inviter-observer) /// that just marked peer (Bob) as verified /// in response to correct vc-request-with-auth message. +// TODO here I may be able to fix some multi-device things pub(crate) async fn observe_securejoin_on_other_device( context: &Context, mime_message: &MimeMessage, diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 61f31e2879..9f18f3997f 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -4,7 +4,9 @@ use anyhow::{Context as _, Result}; use super::HandshakeMessage; use super::qrinvite::QrInvite; -use crate::chat::{self, ChatId, ProtectionStatus, is_contact_in_chat}; +use crate::chat::{ + self, ChatId, ProtectionStatus, is_contact_in_chat, save_broadcast_shared_secret, +}; use crate::constants::{Blocked, Chattype}; use crate::contact::Origin; use crate::context::Context; @@ -56,8 +58,43 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?; context.emit_event(EventType::ContactsChanged(None)); - // Now start the protocol and initialise the state. - { + if let QrInvite::Broadcast { shared_secret, .. } = &invite { + // TODO this causes some performance penalty because joining_chat_id is used again below, + // but maybe it's fine + let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?; + // TODO save the secret to the second device + save_broadcast_shared_secret(context, broadcast_chat_id, shared_secret).await?; + + if verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await? { + info!(context, "Using fast securejoin with symmetric encryption"); + + // The message has to be sent into the broadcast chat, rather than the 1:1 chat, + // so that it will be symmetrically encrypted + send_handshake_message( + context, + &invite, + broadcast_chat_id, + BobHandshakeMsg::RequestWithAuth, + ) + .await?; + + // Mark 1:1 chat as verified already. + chat_id + .set_protection( + context, + ProtectionStatus::Protected, + time(), + Some(invite.contact_id()), + ) + .await?; + + context.emit_event(EventType::SecurejoinJoinerProgress { + contact_id: invite.contact_id(), + progress: JoinerProgress::RequestWithAuthSent.to_usize(), + }); + } + } else { + // Start the original (non-broadcast) protocol and initialise the state. let has_key = context .sql .exists( @@ -115,22 +152,22 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul Ok(group_chat_id) } QrInvite::Broadcast { .. } => { - // For a secure-join we need to create the group and add the contact. The group will - // only become usable once the protocol is finished. - let group_chat_id = joining_chat_id(context, &invite, chat_id).await?; - if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? { + // TODO code duplication with previous block + let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?; + if !is_contact_in_chat(context, broadcast_chat_id, invite.contact_id()).await? { chat::add_to_chat_contacts_table( context, time(), - group_chat_id, + broadcast_chat_id, &[invite.contact_id()], ) .await?; } + // TODO this message should be translatable: let msg = "You were invited to join this channel. Waiting for the channel owner's device to reply…"; - chat::add_info_msg(context, group_chat_id, msg, time()).await?; - Ok(group_chat_id) + chat::add_info_msg(context, broadcast_chat_id, msg, time()).await?; + Ok(broadcast_chat_id) } QrInvite::Contact { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it @@ -318,7 +355,7 @@ pub(crate) async fn send_handshake_message( pub(crate) enum BobHandshakeMsg { /// vc-request or vg-request Request, - /// vc-request-with-auth or vg-request-with-auth + /// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth RequestWithAuth, } @@ -342,14 +379,14 @@ impl BobHandshakeMsg { Self::Request => match invite { QrInvite::Contact { .. } => "vc-request", QrInvite::Group { .. } => "vg-request", - QrInvite::Broadcast { .. } => "broadcast-request", + QrInvite::Broadcast { .. } => { + panic!("There is no request-with-auth for broadcasts") + } // TODO remove panic }, Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", - QrInvite::Broadcast { .. } => { - panic!("There is no request-with-auth for broadcasts") - } // TODO remove panic + QrInvite::Broadcast { .. } => "vb-request-with-auth", }, } } diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index bcf6e90cec..5413b1fbd6 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -30,10 +30,11 @@ pub enum QrInvite { authcode: String, }, Broadcast { - broadcast_name: String, - grpid: String, contact_id: ContactId, fingerprint: Fingerprint, + broadcast_name: String, + grpid: String, + authcode: String, shared_secret: String, }, } @@ -71,8 +72,9 @@ impl QrInvite { /// The `AUTH` code of the setup-contact/secure-join protocol. pub fn authcode(&self) -> &str { match self { - Self::Contact { authcode, .. } | Self::Group { authcode, .. } => authcode, - Self::Broadcast { .. } => panic!("broadcast invite has no authcode"), // TODO panic + Self::Contact { authcode, .. } + | Self::Group { authcode, .. } + | Self::Broadcast { authcode, .. } => authcode, } } } @@ -113,15 +115,17 @@ impl TryFrom for QrInvite { grpid, contact_id, fingerprint, + authcode, shared_secret, } => Ok(QrInvite::Broadcast { broadcast_name, grpid, contact_id, fingerprint, + authcode, shared_secret, }), - _ => bail!("Unsupported QR type"), + _ => bail!("Unsupported QR type: {qr:?}"), } } } diff --git a/src/test_utils.rs b/src/test_utils.rs index 573ef65df3..56e8a7ec93 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -226,15 +226,15 @@ impl TestContextManager { pub async fn exec_securejoin_qr( &self, scanner: &TestContext, - scanned: &TestContext, + inviter: &TestContext, qr: &str, ) -> ChatId { let chat_id = join_securejoin(&scanner.ctx, qr).await.unwrap(); loop { if let Some(sent) = scanner.pop_sent_msg_opt(Duration::ZERO).await { - scanned.recv_msg_opt(&sent).await; - } else if let Some(sent) = scanned.pop_sent_msg_opt(Duration::ZERO).await { + inviter.recv_msg_opt(&sent).await; + } else if let Some(sent) = inviter.pop_sent_msg_opt(Duration::ZERO).await { scanner.recv_msg_opt(&sent).await; } else { break; diff --git a/src/tools.rs b/src/tools.rs index fe462266a9..59cca8d158 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -300,25 +300,6 @@ pub(crate) fn create_id() -> String { base64::engine::general_purpose::URL_SAFE.encode(arr) } -/// Generate a shared secret for a broadcast channel, consisting of 64 characters.. -/// -/// The string generated by this function has 384 bits of entropy -/// and is returned as 64 Base64 characters, each containing 6 bits of entropy. -/// 384 is chosen because it is sufficiently secure -/// (larger than AES-128 keys used for message encryption) -/// and divides both by 8 (byte size) and 6 (number of bits in a single Base64 character). -// TODO ask someone what a good size would be here - also, not sure whether the AES-128 thing is true -pub(crate) fn create_broadcast_shared_secret() -> String { - // ThreadRng implements CryptoRng trait and is supposed to be cryptographically secure. - let mut rng = thread_rng(); - - // Generate 384 random bits. - let mut arr = [0u8; 48]; - rng.fill(&mut arr[..]); - - base64::engine::general_purpose::URL_SAFE.encode(arr) -} - /// Returns true if given string is a valid ID. /// /// All IDs generated with `create_id()` should be considered valid. From 34ebfbbf2bbc5dae2e271c45c96ebdd37b7d956d Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 16:32:40 +0200 Subject: [PATCH 12/69] fix: make test_broadcast work, return an error when trying to add manually add a contact to a broadcast list, don't have unpromoted broadcast lists, make basic multi-device, inviter side, work --- src/chat.rs | 14 +++------ src/chat/chat_tests.rs | 71 +++++++++++++++++++++++++++--------------- src/securejoin.rs | 28 ++++++++++++----- 3 files changed, 72 insertions(+), 41 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 59ce2f4c30..7e4f0ace7c 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -3794,8 +3794,8 @@ pub(crate) async fn create_broadcast_ex( } t.execute( "INSERT INTO chats \ - (type, name, grpid, param, created_timestamp) \ - VALUES(?, ?, ?, \'U=1\', ?);", + (type, name, grpid, created_timestamp) \ + VALUES(?, ?, ?, ?);", ( Chattype::OutBroadcast, &chat_name, @@ -3971,8 +3971,8 @@ pub(crate) async fn add_contact_to_chat_ex( // this also makes sure, no contacts are added to special or normal chats let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, - "{} is not a group/broadcast where one can add members", + chat.typ == Chattype::Group, + "{} is not a group where one can add members", chat_id ); ensure!( @@ -3981,10 +3981,6 @@ pub(crate) async fn add_contact_to_chat_ex( contact_id ); ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed"); - ensure!( - chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF, - "Cannot add SELF to broadcast channel." - ); ensure!( chat.is_encrypted(context).await? == contact.is_key_contact(), "Only key-contacts can be added to encrypted chats" @@ -4036,7 +4032,7 @@ pub(crate) async fn add_contact_to_chat_ex( } add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?; } - if chat.typ == Chattype::Group && chat.is_promoted() { + if chat.is_promoted() { msg.viewtype = Viewtype::Text; let contact_addr = contact.get_addr().to_lowercase(); diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 91c66d2dd0..10e26d94cb 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2626,44 +2626,67 @@ async fn test_can_send_group() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_broadcast() -> Result<()> { +async fn test_broadcast_change_name() -> Result<()> { // create two context, send two messages so both know the other - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - let fiona = TestContext::new_fiona().await; + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let fiona = &tcm.fiona().await; + tcm.section("Alice sends a message to Bob"); let chat_alice = alice.create_chat(&bob).await; send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?; bob.recv_msg(&alice.pop_sent_msg().await).await; + tcm.section("Bob sends a message to Alice"); let chat_bob = bob.create_chat(&alice).await; send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?; let msg = alice.recv_msg(&bob.pop_sent_msg().await).await; assert!(msg.get_showpadlock()); - // test broadcast channel let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?; - add_contact_to_chat( - &alice, - broadcast_id, - get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(), - ) - .await?; - let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; - add_contact_to_chat(&alice, broadcast_id, fiona_contact_id).await?; - set_chat_name(&alice, broadcast_id, "Broadcast channel").await?; + let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap(); + + tcm.section("Alice invites Bob to her channel"); + tcm.exec_securejoin_qr(bob, alice, &qr).await; + tcm.section("Alice invites Fiona to her channel"); + tcm.exec_securejoin_qr(fiona, alice, &qr).await; + { + tcm.section("Alice changes the chat name"); + set_chat_name(&alice, broadcast_id, "My great broadcast").await?; + let sent = alice.pop_sent_msg().await; + + tcm.section("Bob receives the name-change system message"); + let msg = bob.recv_msg(&sent).await; + assert_eq!(msg.subject, "Re: My great broadcast"); + let bob_chat = Chat::load_from_db(bob, msg.chat_id).await?; + assert_eq!(bob_chat.name, "My great broadcast"); + + tcm.section("Fiona receives the name-change system message"); + let msg = fiona.recv_msg(&sent).await; + assert_eq!(msg.subject, "Re: My great broadcast"); + let fiona_chat = Chat::load_from_db(fiona, msg.chat_id).await?; + assert_eq!(fiona_chat.name, "My great broadcast"); + } + + { + tcm.section("Alice changes the chat name again, but the system message is lost somehow"); + set_chat_name(&alice, broadcast_id, "Broadcast channel").await?; + let chat = Chat::load_from_db(&alice, broadcast_id).await?; assert_eq!(chat.typ, Chattype::OutBroadcast); assert_eq!(chat.name, "Broadcast channel"); assert!(!chat.is_self_talk()); + tcm.section("Alice sends a text message 'ola!'"); send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?; let msg = alice.get_last_msg().await; assert_eq!(msg.chat_id, chat.id); } { + tcm.section("Bob receives the 'ola!' message"); let sent_msg = alice.pop_sent_msg().await; let msg = bob.parse_msg(&sent_msg).await; assert!(msg.was_encrypted()); @@ -2676,7 +2699,7 @@ async fn test_broadcast() -> Result<()> { let msg = bob.recv_msg(&sent_msg).await; assert_eq!(msg.get_text(), "ola!"); - assert_eq!(msg.subject, "Broadcast channel"); + assert_eq!(msg.subject, "Re: Broadcast channel"); assert!(msg.get_showpadlock()); assert!(msg.get_override_sender_name().is_none()); let chat = Chat::load_from_db(&bob, msg.chat_id).await?; @@ -2684,17 +2707,15 @@ async fn test_broadcast() -> Result<()> { assert_ne!(chat.id, chat_bob.id); assert_eq!(chat.name, "Broadcast channel"); assert!(!chat.is_self_talk()); - } - - { - // Alice changes the name: - set_chat_name(&alice, broadcast_id, "My great broadcast").await?; - let sent = alice.send_text(broadcast_id, "I changed the title!").await; - let msg = bob.recv_msg(&sent).await; - assert_eq!(msg.subject, "Re: My great broadcast"); - let bob_chat = Chat::load_from_db(&bob, msg.chat_id).await?; - assert_eq!(bob_chat.name, "My great broadcast"); + tcm.section("Fiona receives the 'ola!' message"); + let msg = fiona.recv_msg(&sent_msg).await; + assert_eq!(msg.get_text(), "ola!"); + assert!(msg.get_showpadlock()); + assert!(msg.get_override_sender_name().is_none()); + let chat = Chat::load_from_db(fiona, msg.chat_id).await?; + assert_eq!(chat.typ, Chattype::InBroadcast); + assert_eq!(chat.name, "Broadcast channel"); } Ok(()) diff --git a/src/securejoin.rs b/src/securejoin.rs index 776c24c2b4..b88a63e7c3 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -5,8 +5,8 @@ use deltachat_contact_tools::ContactAddress; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid, - load_broadcast_shared_secret, + self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, add_to_chat_contacts_table, + get_chat_id_by_grpid, load_broadcast_shared_secret, }; use crate::chatlist_events; use crate::config::Config; @@ -27,6 +27,7 @@ use crate::qr::check_qr; use crate::securejoin::bob::JoinerProgress; use crate::sync::Sync::*; use crate::token; +use crate::tools::time; mod bob; mod qrinvite; @@ -436,13 +437,26 @@ pub(crate) async fn handle_securejoin_handshake( mime_message.timestamp_sent, ) .await?; - chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true) - .await?; + if step.starts_with("vb-") { + // TODO extract into variable + add_to_chat_contacts_table(context, time(), group_chat_id, &[contact_id]) + .await?; + } else { + chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true) + .await?; + } inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 1000); - // IMAP-delete the message to avoid handling it by another device and adding the - // member twice. Another device will know the member's key from Autocrypt-Gossip. - Ok(HandshakeMessage::Done) + if step.starts_with("vb-") { + // For broadcasts, we don't want to delete the message, + // because the other device should also internally add the member + // and see the key (because it won't see the member via autocrypt-gossip). + Ok(HandshakeMessage::Propagate) + } else { + // IMAP-delete the message to avoid handling it by another device and adding the + // member twice. Another device will know the member's key from Autocrypt-Gossip. + Ok(HandshakeMessage::Done) + } } else { // Setup verified contact. secure_connection_established( From eeece9efdf66bcc4cb6cacabd6212c1783cef3c8 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 16:45:24 +0200 Subject: [PATCH 13/69] Make basic multi-device work on joiner side, fix test_only_minimal_data_are_forwarded --- src/chat.rs | 26 +++++++++++++++++++------- src/chat/chat_tests.rs | 8 +++++--- src/securejoin/bob.rs | 19 ++++++++++++++++--- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 7e4f0ace7c..453b463750 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -3821,7 +3821,10 @@ pub(crate) async fn create_broadcast_ex( if sync.into() { let id = SyncId::Grpid(grpid); - let action = SyncAction::CreateBroadcast { chat_name, secret }; + let action = SyncAction::CreateOutBroadcast { + chat_name, + shared_secret: secret, + }; self::sync(context, id, action).await.log_err(context).ok(); } @@ -3844,16 +3847,17 @@ pub(crate) async fn load_broadcast_shared_secret( pub(crate) async fn save_broadcast_shared_secret( context: &Context, chat_id: ChatId, - shared_secret: &str, + secret: &str, ) -> Result<()> { context .sql .execute( "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.chat_id", - (chat_id, shared_secret), + (chat_id, secret), ) .await?; + Ok(()) } @@ -5058,9 +5062,13 @@ pub(crate) enum SyncAction { SetVisibility(ChatVisibility), SetMuted(MuteDuration), /// Create broadcast channel with the given name. - CreateBroadcast { + CreateOutBroadcast { chat_name: String, - secret: String, + shared_secret: String, + }, + CreateInBroadcast { + chat_name: String, + shared_secret: String, }, Rename(String), /// Set chat contacts by their addresses. @@ -5124,7 +5132,11 @@ impl Context { .id } SyncId::Grpid(grpid) => { - if let SyncAction::CreateBroadcast { chat_name, secret } = action { + if let SyncAction::CreateOutBroadcast { + chat_name, + shared_secret: secret, + } = action + { create_broadcast_ex( self, Nosync, @@ -5155,7 +5167,7 @@ impl Context { SyncAction::Accept => chat_id.accept_ex(self, Nosync).await, SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await, SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await, - SyncAction::CreateBroadcast { .. } => { + SyncAction::CreateOutBroadcast { .. } | SyncAction::CreateInBroadcast { .. } => { Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 10e26d94cb..c4d87a04c6 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2263,7 +2263,8 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> { let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?; add_contact_to_chat(&bob, group_id, charlie_id).await?; let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?; - add_contact_to_chat(&bob, broadcast_id, charlie_id).await?; + let qr = get_securejoin_qr(&bob, Some(broadcast_id)).await?; + tcm.exec_securejoin_qr(&charlie, &bob, &qr).await; for chat_id in &[single_id, group_id, broadcast_id] { forward_msgs(&bob, &[orig_msg.id], *chat_id).await?; let sent_msg = bob.pop_sent_msg().await; @@ -3046,8 +3047,9 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { tcm.section("Alice creates broadcast channel with Bob."); let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; - let bob_contact = alice.add_or_lookup_contact(bob0).await.id; - add_contact_to_chat(alice, alice_chat_id, bob_contact).await?; + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob0, alice, &qr).await; + sync(bob0, bob1).await; tcm.section("Alice sends first message to broadcast."); let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 9f18f3997f..e83d404d74 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -12,7 +12,7 @@ use crate::contact::Origin; use crate::context::Context; use crate::events::EventType; use crate::key::self_fingerprint; -use crate::log::info; +use crate::log::{LogExt as _, info}; use crate::message::{Message, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::Param; @@ -58,13 +58,26 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?; context.emit_event(EventType::ContactsChanged(None)); - if let QrInvite::Broadcast { shared_secret, .. } = &invite { + if let QrInvite::Broadcast { + shared_secret, + grpid, + broadcast_name, + .. + } = &invite + { // TODO this causes some performance penalty because joining_chat_id is used again below, // but maybe it's fine let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?; - // TODO save the secret to the second device + save_broadcast_shared_secret(context, broadcast_chat_id, shared_secret).await?; + let id = chat::SyncId::Grpid(grpid.to_string()); + let action = chat::SyncAction::CreateInBroadcast { + chat_name: broadcast_name.to_string(), + shared_secret: shared_secret.to_string(), + }; + chat::sync(context, id, action).await.log_err(context).ok(); + if verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await? { info!(context, "Using fast securejoin with symmetric encryption"); From dda48a78c689908948f9d8a69b54aa16eb58baf6 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 21:30:56 +0200 Subject: [PATCH 14/69] fix: Make syncing of QR tokens work, make test_sync_broadcast pass --- src/chat/chat_tests.rs | 9 +++++++-- src/securejoin.rs | 14 +++++++------- src/test_utils.rs | 38 +++++++++++++++++++++++++++++++------- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index c4d87a04c6..6a7eda3f2e 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3832,8 +3832,13 @@ async fn test_sync_broadcast() -> Result<()> { assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name()); assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); - add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; - sync(alice0, alice1).await; + + let qr = get_securejoin_qr(alice0, Some(a0_broadcast_id)) + .await + .unwrap(); + sync(alice0, alice1).await; // Sync QR code + tcm.exec_securejoin_qr_multi_device(bob, &[alice0, alice1], &qr) + .await; // This also imports Bob's key from the vCard. // Otherwise it is possible that second device diff --git a/src/securejoin.rs b/src/securejoin.rs index b88a63e7c3..2f87e93ac4 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -98,6 +98,13 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option) -> Resul utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string(); let qr = if let Some(chat) = chat { + if sync_token { + context + .sync_qr_code_tokens(Some(chat.grpid.as_str())) + .await?; + context.scheduler.interrupt_inbox().await; + } + if chat.typ == Chattype::OutBroadcast { let broadcast_name = chat.get_name(); let broadcast_name_urlencoded = @@ -105,7 +112,6 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option) -> Resul let broadcast_secret = load_broadcast_shared_secret(context, chat.id) .await? .context("Could not find broadcast secret")?; - format!( "https://i.delta.chat/#{}&a={}&g={}&x={}&s={}&b={}", fingerprint.hex(), @@ -120,12 +126,6 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option) -> Resul let group_name = chat.get_name(); let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string(); - if sync_token { - context - .sync_qr_code_tokens(Some(chat.grpid.as_str())) - .await?; - context.scheduler.interrupt_inbox().await; - } format!( "https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}", fingerprint.hex(), diff --git a/src/test_utils.rs b/src/test_utils.rs index 56e8a7ec93..cd1bdd64bb 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -225,18 +225,42 @@ impl TestContextManager { /// chat with `scanned`, for a SecureJoin QR this is the group chat. pub async fn exec_securejoin_qr( &self, - scanner: &TestContext, + joiner: &TestContext, inviter: &TestContext, qr: &str, ) -> ChatId { - let chat_id = join_securejoin(&scanner.ctx, qr).await.unwrap(); + self.exec_securejoin_qr_multi_device(joiner, &[inviter], qr) + .await + } + + /// Executes SecureJoin initiated by `scanner` scanning `qr` generated by `scanned`. + /// + /// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1 + /// chat with `scanned`, for a SecureJoin QR this is the group chat. + pub async fn exec_securejoin_qr_multi_device( + &self, + joiner: &TestContext, + inviters: &[&TestContext], + qr: &str, + ) -> ChatId { + let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap(); loop { - if let Some(sent) = scanner.pop_sent_msg_opt(Duration::ZERO).await { - inviter.recv_msg_opt(&sent).await; - } else if let Some(sent) = inviter.pop_sent_msg_opt(Duration::ZERO).await { - scanner.recv_msg_opt(&sent).await; - } else { + let mut something_sent = false; + if let Some(sent) = joiner.pop_sent_msg_opt(Duration::ZERO).await { + for inviter in inviters { + inviter.recv_msg_opt(&sent).await; + } + something_sent = true; + } + for inviter in inviters { + if let Some(sent) = inviter.pop_sent_msg_opt(Duration::ZERO).await { + joiner.recv_msg_opt(&sent).await; + something_sent = true; + } + } + + if !something_sent { break; } } From 98b4cad810cb898b5ae3b89d23aea070e5ba8208 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 21:36:03 +0200 Subject: [PATCH 15/69] make test_block_broadcast pass --- src/chat/chat_tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 6a7eda3f2e..1d199708c8 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2872,11 +2872,13 @@ async fn test_block_broadcast() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; tcm.section("Create a broadcast channel with Bob, and send a message"); let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; - add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; + let sent = alice.send_text(alice_chat_id, "Hi somebody").await; let rcvd = bob.recv_msg(&sent).await; @@ -2884,7 +2886,7 @@ async fn test_block_broadcast() -> Result<()> { assert_eq!(chats.len(), 1); assert_eq!(chats.get_chat_id(0)?, rcvd.chat_id); - assert_eq!(rcvd.chat_blocked, Blocked::Request); + assert_eq!(rcvd.chat_blocked, Blocked::Not); let blocked = Contact::get_all_blocked(bob).await.unwrap(); assert_eq!(blocked.len(), 0); From ab52eaca17abae66dc2eeb83d04fefd2f5093591 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 21:51:37 +0200 Subject: [PATCH 16/69] test: Fix test_broadcast_multidev --- src/chat/chat_tests.rs | 56 ++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 1d199708c8..e5b7576813 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2732,45 +2732,43 @@ async fn test_broadcast_change_name() -> Result<()> { /// `test_sync_broadcast()` tests that synchronization works via sync messages. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_broadcast_multidev() -> Result<()> { - let alices = [ - TestContext::new_alice().await, - TestContext::new_alice().await, - ]; - let bob = TestContext::new_bob().await; - let a1b_contact_id = alices[1].add_or_lookup_contact(&bob).await.id; - - let a0_broadcast_id = create_broadcast(&alices[0], "Channel".to_string()).await?; - let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?; - set_chat_name(&alices[0], a0_broadcast_id, "Broadcast channel 42").await?; - let sent_msg = alices[0].send_text(a0_broadcast_id, "hi").await; - let msg = alices[1].recv_msg(&sent_msg).await; - let a1_broadcast_id = get_chat_id_by_grpid(&alices[1], &a0_broadcast_chat.grpid) + let mut tcm = TestContextManager::new(); + let alice0 = &tcm.alice().await; + let alice1 = &tcm.alice().await; + for a in &[alice0, alice1] { + a.set_config_bool(Config::SyncMsgs, true).await?; + } + let bob = &tcm.bob().await; + + let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?; + sync(alice0, alice1).await; + let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; + set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?; + let sent_msg = alice0.send_text(a0_broadcast_id, "hi").await; + let msg = alice1.recv_msg(&sent_msg).await; + let a1_broadcast_id = get_chat_id_by_grpid(&alice1, &a0_broadcast_chat.grpid) .await? .unwrap() .0; assert_eq!(msg.chat_id, a1_broadcast_id); - let a1_broadcast_chat = Chat::load_from_db(&alices[1], a1_broadcast_id).await?; + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42"); - assert!( - get_chat_contacts(&alices[1], a1_broadcast_id) - .await? - .is_empty() - ); + assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); + + let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id)) + .await + .unwrap(); + tcm.exec_securejoin_qr(bob, alice1, &qr).await; - add_contact_to_chat(&alices[1], a1_broadcast_id, a1b_contact_id).await?; - set_chat_name(&alices[1], a1_broadcast_id, "Broadcast channel 43").await?; - let sent_msg = alices[1].send_text(a1_broadcast_id, "hi").await; - let msg = alices[0].recv_msg(&sent_msg).await; + set_chat_name(alice1, a1_broadcast_id, "Broadcast channel 43").await?; + let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await; + let msg = alice0.recv_msg(&sent_msg).await; assert_eq!(msg.chat_id, a0_broadcast_id); - let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?; + let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; assert_eq!(a0_broadcast_chat.get_type(), Chattype::OutBroadcast); assert_eq!(a0_broadcast_chat.get_name(), "Broadcast channel 42"); - assert!( - get_chat_contacts(&alices[0], a0_broadcast_id) - .await? - .is_empty() - ); + assert!(get_chat_contacts(alice0, a0_broadcast_id).await?.is_empty()); Ok(()) } From dc2c9121f903b7f38ebc5f5e90837e199a9cf406 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 22:26:24 +0200 Subject: [PATCH 17/69] test: Fix one panic in test_broadcasts_name_and_avatar, but there is another one where I couldn't find the problem --- src/chat/chat_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index e5b7576813..ef7e83e737 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2794,7 +2794,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { assert_eq!(alice_chat.typ, Chattype::OutBroadcast); let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; - assert_eq!(alice_chat.is_promoted(), false); + assert_eq!(alice_chat.is_promoted(), true); // Broadcast channels are never unpromoted let sent = alice.send_text(alice_chat_id, "Hi nobody").await; let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; assert_eq!(alice_chat.is_promoted(), true); From 55b3a7f7cfd55e644bb45f423c5d01e2fe7f9ae2 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 23:00:06 +0200 Subject: [PATCH 18/69] fix: Make joining a channel work with multi-device, fix test_leave_broadcast_multidevice --- src/chat.rs | 55 ++++++++++++++++++++++++++++++++---------- src/chat/chat_tests.rs | 3 +++ src/sync.rs | 3 ++- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 453b463750..af70974113 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -5132,19 +5132,8 @@ impl Context { .id } SyncId::Grpid(grpid) => { - if let SyncAction::CreateOutBroadcast { - chat_name, - shared_secret: secret, - } = action - { - create_broadcast_ex( - self, - Nosync, - grpid.clone(), - chat_name.clone(), - secret.to_string(), - ) - .await?; + let handled = self.handle_sync_create_chat(action, grpid).await?; + if handled { return Ok(()); } get_chat_id_by_grpid(self, grpid) @@ -5168,6 +5157,7 @@ impl Context { SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await, SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await, SyncAction::CreateOutBroadcast { .. } | SyncAction::CreateInBroadcast { .. } => { + // Create action should have been handled by handle_sync_create_chat() already Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, @@ -5179,6 +5169,45 @@ impl Context { } } + async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &String) -> Result { + Ok(match action { + SyncAction::CreateOutBroadcast { + chat_name, + shared_secret, + } => { + create_broadcast_ex( + self, + Nosync, + grpid.clone(), + chat_name.clone(), + shared_secret.to_string(), + ) + .await?; + return Ok(true); + } + SyncAction::CreateInBroadcast { + chat_name, + shared_secret, + } => { + let chat_id = ChatId::create_multiuser_record( + self, + Chattype::InBroadcast, + grpid, + chat_name, + Blocked::Not, + ProtectionStatus::Unprotected, + None, + create_smeared_timestamp(self), + ) + .await?; + save_broadcast_shared_secret(self, chat_id, shared_secret).await?; + + return Ok(true); + } + _ => false, + }) + } + /// Emits the appropriate `MsgsChanged` event. Should be called if the number of unnoticed /// archived chats could decrease. In general we don't want to make an extra db query to know if /// a noticed chat is archived. Emitting events should be cheap, a false-positive `MsgsChanged` diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index ef7e83e737..25e7db2da9 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3044,6 +3044,9 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { let alice = &tcm.alice().await; let bob0 = &tcm.bob().await; let bob1 = &tcm.bob().await; + for b in [bob0, bob1] { + b.set_config_bool(Config::SyncMsgs, true).await?; + } tcm.section("Alice creates broadcast channel with Bob."); let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; diff --git a/src/sync.rs b/src/sync.rs index 90e302f06b..0a191fc323 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,6 +1,6 @@ //! # Synchronize items between devices. -use anyhow::Result; +use anyhow::{Context as _, Result}; use mail_builder::mime::MimePart; use serde::{Deserialize, Serialize}; @@ -270,6 +270,7 @@ impl Context { Ok(()) } } + .with_context(|| format!("Sync data {:?}", item.data)) .log_err(self) .ok(); } From f790e9fb46ddf0a56a35060630ba5dd367f12449 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 1 Aug 2025 23:03:07 +0200 Subject: [PATCH 19/69] test: fix test_leave_broadcast --- src/chat/chat_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 25e7db2da9..647dfd3f6a 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2989,8 +2989,8 @@ async fn test_leave_broadcast() -> Result<()> { tcm.section("Alice creates broadcast channel with Bob."); let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; - let bob_contact = alice.add_or_lookup_contact(bob).await.id; - add_contact_to_chat(alice, alice_chat_id, bob_contact).await?; + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; tcm.section("Alice sends first message to broadcast."); let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; From e8e9dddee5216665d15bb7f45fbcb887a5bb9b44 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:16:47 +0200 Subject: [PATCH 20/69] test: fix test_encrypt_decrypt_broadcast() --- src/chat.rs | 2 +- src/chat/chat_tests.rs | 26 ++++++++++++++++---------- src/securejoin/bob.rs | 4 ++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index af70974113..64d0c64de5 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -5197,7 +5197,7 @@ impl Context { Blocked::Not, ProtectionStatus::Unprotected, None, - create_smeared_timestamp(self), + smeared_time(self), ) .await?; save_broadcast_shared_secret(self, chat_id, shared_secret).await?; diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 647dfd3f6a..89b8b7bbb8 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3085,13 +3085,14 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_encrypt_decrypt_broadcast_integration() -> Result<()> { +async fn test_encrypt_decrypt_broadcast() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; let bob_without_secret = &tcm.bob().await; let secret = "secret"; + let grpid = "grpid"; let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; @@ -3100,19 +3101,24 @@ async fn test_encrypt_decrypt_broadcast_integration() -> Result<()> { alice, Sync, "My Channel".to_string(), - "grpid".to_string(), + grpid.to_string(), secret.to_string(), ) .await?; - add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + add_to_chat_contacts_table(alice, time(), alice_chat_id, &[alice_bob_contact_id]).await?; - // TODO the chat_id 10 is magical here: - bob.sql - .execute( - "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (10, ?)", - (secret,), - ) - .await?; + let bob_chat_id = ChatId::create_multiuser_record( + bob, + Chattype::InBroadcast, + grpid, + "My Channel", + Blocked::Not, + ProtectionStatus::Unprotected, + None, + time(), + ) + .await?; + save_broadcast_shared_secret(bob, bob_chat_id, secret).await?; let sent = alice .send_text(alice_chat_id, "Symmetrically encrypted message") diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index e83d404d74..a8dee9fd3a 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -19,7 +19,7 @@ use crate::param::Param; use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint}; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{create_smeared_timestamp, time}; +use crate::tools::{smeared_time, time}; /// Starts the securejoin protocol with the QR `invite`. /// @@ -445,7 +445,7 @@ async fn joining_chat_id( Blocked::Not, ProtectionStatus::Unprotected, // protection is added later as needed None, - create_smeared_timestamp(context), + smeared_time(context), ) .await? } From 86a7b685812580680f4feaa86ec5fcd98dd38feb Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:16:54 +0200 Subject: [PATCH 21/69] fix: Actually send broadcast message to recipients, ALL TESTS PASS NOW - fix test_broadcasts_name_and_avatar(). --- src/chat/chat_tests.rs | 4 ++-- src/mimefactory.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 89b8b7bbb8..3d78c3bdc9 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2786,7 +2786,6 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { let alice = &tcm.alice().await; alice.set_config(Config::Displayname, Some("Alice")).await?; let bob = &tcm.bob().await; - let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; tcm.section("Create a broadcast channel"); let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; @@ -2801,7 +2800,8 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { assert_eq!(sent.recipients, "alice@example.org"); tcm.section("Add a contact to the chat and send a message"); - add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; let sent = alice.send_text(alice_chat_id, "Hi somebody").await; assert_eq!(sent.recipients, "bob@example.net alice@example.org"); diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 9eaba2f8db..36b045b731 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -231,9 +231,6 @@ impl MimeFactory { // Do not encrypt messages to mailing lists. encryption_keys = None; - } else if chat.is_out_broadcast() { - // Encrypt, but only symmetrically, not with the public keys. - encryption_keys = Some(Vec::new()); } else { let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup { msg.param.get(Param::Arg) @@ -332,7 +329,7 @@ impl MimeFactory { if let Some(public_key) = public_key_opt { keys.push((addr.clone(), public_key)) - } else if id != ContactId::SELF { + } else if id != ContactId::SELF && !chat.is_any_broadcast() { missing_key_addresses.insert(addr.clone()); if is_encrypted { warn!(context, "Missing key for {addr}"); @@ -353,7 +350,7 @@ impl MimeFactory { if let Some(public_key) = public_key_opt { keys.push((addr.clone(), public_key)) - } else if id != ContactId::SELF { + } else if id != ContactId::SELF && !chat.is_any_broadcast() { missing_key_addresses.insert(addr.clone()); if is_encrypted { warn!(context, "Missing key for {addr}"); @@ -420,6 +417,9 @@ impl MimeFactory { encryption_keys = if !is_encrypted { None + } else if chat.is_out_broadcast() { + // Encrypt, but only symmetrically, not with the public keys. + Some(Vec::new()) } else { if keys.is_empty() && !recipients.is_empty() { bail!( From e241af9b7048b32064c3d591f074b96f63b3c7f0 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:17:00 +0200 Subject: [PATCH 22/69] Add TODO --- src/mimefactory.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 36b045b731..471a40243e 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -415,6 +415,9 @@ impl MimeFactory { req_mdn = true; } + // TODO if hidden_recipients but email_to_remove is some, + // only send to email_to_remove + encryption_keys = if !is_encrypted { None } else if chat.is_out_broadcast() { From 99b24d4214de7e34c6c71aed67da8e34d51b6860 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:17:06 +0200 Subject: [PATCH 23/69] fix: Let Alice send vb-member-added so that the chat is immediately shown on Bob's device --- src/chat.rs | 6 +--- src/chat/chat_tests.rs | 31 ++++++++++++++++ src/mimefactory.rs | 32 +++++++++++------ src/securejoin.rs | 35 ++++++++++--------- src/test_utils.rs | 15 +++++--- .../test_broadcast_joining_golden_alice | 6 ++++ .../golden/test_broadcast_joining_golden_bob | 5 +++ 7 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 test-data/golden/test_broadcast_joining_golden_alice create mode 100644 test-data/golden/test_broadcast_joining_golden_bob diff --git a/src/chat.rs b/src/chat.rs index 64d0c64de5..ae4f9ec93a 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -3975,7 +3975,7 @@ pub(crate) async fn add_contact_to_chat_ex( // this also makes sure, no contacts are added to special or normal chats let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group, + chat.typ == Chattype::Group || (from_handshake && chat.typ == Chattype::OutBroadcast), "{} is not a group where one can add members", chat_id ); @@ -3984,7 +3984,6 @@ pub(crate) async fn add_contact_to_chat_ex( "invalid contact_id {} for adding to group", contact_id ); - ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed"); ensure!( chat.is_encrypted(context).await? == contact.is_key_contact(), "Only key-contacts can be added to encrypted chats" @@ -4031,9 +4030,6 @@ pub(crate) async fn add_contact_to_chat_ex( ); return Ok(false); } - if is_contact_in_chat(context, chat_id, contact_id).await? { - return Ok(false); - } add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?; } if chat.is_promoted() { diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 3d78c3bdc9..16ee0c0f74 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2859,6 +2859,37 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { Ok(()) } +/// Tests that directly after broadcast-securejoin, +/// the brodacast is shown correctly on both devices. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_broadcast_joining_golden() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + alice.set_config(Config::Displayname, Some("Alice")).await?; + + tcm.section("Create a broadcast channel with an avatar"); + let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; + let file = alice.get_blobdir().join("avatar.png"); + tokio::fs::write(&file, AVATAR_64x64_BYTES).await?; + set_chat_profile_image(alice, alice_chat_id, file.to_str().unwrap()).await?; + alice.pop_sent_msg().await; // TODO check if Alice wrongly sends out a message here + + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await; + + // TODO it's not nice that it says 'you added member bob' + // and 'Secure-Join: vb-request-with-auth' + alice + .golden_test_chat(alice_chat_id, "test_broadcast_joining_golden_alice") + .await; + bob.golden_test_chat(bob_chat_id, "test_broadcast_joining_golden_bob") + .await; + + Ok(()) +} + /// - Create a broadcast channel /// - Block it /// - Check that the broadcast channel appears in the list of blocked contacts diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 471a40243e..f5b191b141 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeSet, HashSet}; use std::io::Cursor; -use anyhow::{Context as _, Result, bail, ensure}; +use anyhow::{Context as _, Result, bail}; use base64::Engine as _; use data_encoding::BASE32_NOPAD; use deltachat_contact_tools::sanitize_bidi_characters; @@ -415,8 +415,18 @@ impl MimeFactory { req_mdn = true; } - // TODO if hidden_recipients but email_to_remove is some, - // only send to email_to_remove + // If undisclosed_recipients, and this is a member-added/removed message, + // only send to the added/removed member + if undisclosed_recipients + && matches!( + msg.param.get_cmd(), + SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup + ) + { + if let Some(member) = msg.param.get(Param::Arg) { + recipients.retain(|addr| addr == member); + } + } encryption_keys = if !is_encrypted { None @@ -571,6 +581,7 @@ impl MimeFactory { || step == "vc-request-with-auth" || step == "vb-request-with-auth" || step == "vg-member-added" + || step == "vb-member-added" || step == "vc-contact-confirm" // TODO possibly add vb-member-added here } @@ -1396,7 +1407,6 @@ impl MimeFactory { match command { SystemMessage::MemberRemovedFromGroup => { - ensure!(chat.typ != Chattype::OutBroadcast); let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default(); if email_to_remove @@ -1420,7 +1430,6 @@ impl MimeFactory { } } SystemMessage::MemberAddedToGroup => { - ensure!(chat.typ != Chattype::OutBroadcast); // TODO: lookup the contact by ID rather than email address. // We are adding key-contacts, the cannot be looked up by address. let email_to_add = msg.param.get(Param::Arg).unwrap_or_default(); @@ -1434,14 +1443,15 @@ impl MimeFactory { )); } if 0 != msg.param.get_int(Param::Arg2).unwrap_or_default() & DC_FROM_HANDSHAKE { - info!( - context, - "Sending secure-join message {:?}.", "vg-member-added", - ); + let step = match chat.typ { + Chattype::Group => "vg-member-added", + Chattype::OutBroadcast => "vb-member-added", + _ => bail!("Wrong chattype {}", chat.typ), + }; + info!(context, "Sending secure-join message {:?}.", step,); headers.push(( "Secure-Join", - mail_builder::headers::raw::Raw::new("vg-member-added".to_string()) - .into(), + mail_builder::headers::raw::Raw::new(step.to_string()).into(), )); } } diff --git a/src/securejoin.rs b/src/securejoin.rs index 2f87e93ac4..ac05cdf6d3 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -5,8 +5,8 @@ use deltachat_contact_tools::ContactAddress; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, add_to_chat_contacts_table, - get_chat_id_by_grpid, load_broadcast_shared_secret, + self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid, + load_broadcast_shared_secret, }; use crate::chatlist_events; use crate::config::Config; @@ -27,7 +27,6 @@ use crate::qr::check_qr; use crate::securejoin::bob::JoinerProgress; use crate::sync::Sync::*; use crate::token; -use crate::tools::time; mod bob; mod qrinvite; @@ -292,7 +291,10 @@ pub(crate) async fn handle_securejoin_handshake( // TODO talk with link2xt about whether we need to protect against this identity-misbinding attack, // and if so, how - if !matches!(step, "vg-request" | "vc-request" | "vb-request-with-auth") { + if !matches!( + step, + "vg-request" | "vc-request" | "vb-request-with-auth" | "vb-member-added" + ) { let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); for (addr, key) in &mime_message.gossiped_keys { @@ -437,14 +439,9 @@ pub(crate) async fn handle_securejoin_handshake( mime_message.timestamp_sent, ) .await?; - if step.starts_with("vb-") { - // TODO extract into variable - add_to_chat_contacts_table(context, time(), group_chat_id, &[contact_id]) - .await?; - } else { - chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true) - .await?; - } + + chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true) + .await?; inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 1000); if step.starts_with("vb-") { @@ -485,7 +482,7 @@ pub(crate) async fn handle_securejoin_handshake( }); Ok(HandshakeMessage::Ignore) } - "vg-member-added" => { + "vg-member-added" | "vb-member-added" => { let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded) else { warn!( @@ -553,7 +550,11 @@ pub(crate) async fn observe_securejoin_on_other_device( if !matches!( step, - "vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm" + "vg-request-with-auth" + | "vc-request-with-auth" + | "vg-member-added" + | "vb-member-added" + | "vc-contact-confirm" ) { return Ok(HandshakeMessage::Ignore); }; @@ -593,10 +594,12 @@ pub(crate) async fn observe_securejoin_on_other_device( if step == "vg-member-added" { inviter_progress(context, contact_id, 800); } - if step == "vg-member-added" || step == "vc-contact-confirm" { + if step == "vg-member-added" || step == "vb-member-added" || step == "vc-contact-confirm" { inviter_progress(context, contact_id, 1000); } + // TODO not sure if I should ad vb-request-with-auth here + // Actually, I'm not even sure why vg-request-with-auth is here - why do we create a 1:1 chat?? if step == "vg-request-with-auth" || step == "vc-request-with-auth" { // This actually reflects what happens on the first device (which does the secure // join) and causes a subsequent "vg-member-added" message to create an unblocked @@ -604,7 +607,7 @@ pub(crate) async fn observe_securejoin_on_other_device( ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Not).await?; } - if step == "vg-member-added" { + if step == "vg-member-added" || step == "vb-member-added" { Ok(HandshakeMessage::Propagate) } else { Ok(HandshakeMessage::Ignore) diff --git a/src/test_utils.rs b/src/test_utils.rs index cd1bdd64bb..5b82a991eb 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -219,10 +219,10 @@ impl TestContextManager { self.exec_securejoin_qr(scanner, scanned, &qr).await } - /// Executes SecureJoin initiated by `scanner` scanning `qr` generated by `scanned`. + /// Executes SecureJoin initiated by `joiner` scanning `qr` generated by `inviter`. /// /// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1 - /// chat with `scanned`, for a SecureJoin QR this is the group chat. + /// chat with `inviter`, for a SecureJoin QR this is the group chat. pub async fn exec_securejoin_qr( &self, joiner: &TestContext, @@ -233,16 +233,23 @@ impl TestContextManager { .await } - /// Executes SecureJoin initiated by `scanner` scanning `qr` generated by `scanned`. + /// Executes SecureJoin initiated by `joiner` + /// scanning `qr` generated by one of the `inviters` devices. + /// All of the `inviters` devices will get the messages and send replies. /// /// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1 - /// chat with `scanned`, for a SecureJoin QR this is the group chat. + /// chat with `inviter`, for a SecureJoin QR this is the group chat. pub async fn exec_securejoin_qr_multi_device( &self, joiner: &TestContext, inviters: &[&TestContext], qr: &str, ) -> ChatId { + assert!(joiner.pop_sent_msg_opt(Duration::ZERO).await.is_none()); + for inviter in inviters { + assert!(inviter.pop_sent_msg_opt(Duration::ZERO).await.is_none()); + } + let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap(); loop { diff --git a/test-data/golden/test_broadcast_joining_golden_alice b/test-data/golden/test_broadcast_joining_golden_alice new file mode 100644 index 0000000000..3f71ff12f4 --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_alice @@ -0,0 +1,6 @@ +OutBroadcast#Chat#10: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png +-------------------------------------------------------------------------------- +Msg#10🔒: Me (Contact#Contact#Self): You changed the group image. [INFO] √ +Msg#12🔒: Me (Contact#Contact#Self): You added member bob@example.net. [INFO] √ +Msg#13🔒: (Contact#Contact#10): Secure-Join: vb-request-with-auth [FRESH] +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_broadcast_joining_golden_bob b/test-data/golden/test_broadcast_joining_golden_bob new file mode 100644 index 0000000000..308762a74a --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -0,0 +1,5 @@ +InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png +-------------------------------------------------------------------------------- +Msg#12: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO] +Msg#13🔒: (Contact#Contact#10): I added member bob@example.net. [FRESH][INFO] +-------------------------------------------------------------------------------- From f02d203d2c0f8f5b2714b07b58f5608a43ea2bc0 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:17:13 +0200 Subject: [PATCH 24/69] fix: Correct member-added info messages --- src/chat.rs | 10 +++++++++- src/receive_imf.rs | 17 +++++++++++++++++ .../golden/test_broadcast_joining_golden_alice | 2 +- .../golden/test_broadcast_joining_golden_bob | 2 +- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index ae4f9ec93a..b7845642ea 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4036,7 +4036,15 @@ pub(crate) async fn add_contact_to_chat_ex( msg.viewtype = Viewtype::Text; let contact_addr = contact.get_addr().to_lowercase(); - msg.text = stock_str::msg_add_member_local(context, contact.id, ContactId::SELF).await; + let added_by = if from_handshake && chat.is_out_broadcast() { + // The contact was added via a QR code rather than explicit user action, + // and there is added information in saying 'You added member Alice' + // if self is the only one who can add members. + ContactId::UNDEFINED + } else { + ContactId::SELF + }; + msg.text = stock_str::msg_add_member_local(context, contact.id, added_by).await; msg.param.set_cmd(SystemMessage::MemberAddedToGroup); msg.param.set(Param::Arg, contact_addr); msg.param.set_int(Param::Arg2, from_handshake.into()); diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 0e33a8dd32..1d73105b52 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3514,6 +3514,17 @@ async fn apply_out_broadcast_changes( silent: true, extra_msgs: vec![], }); + } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { + // TODO this may lookup the wrong contact if multiple contacts have the same email addr. + // We can send sync messages instead, + // lookup the fingerprint by gossip header (like it's done for groups right now) + // or add a header ChatGroupMemberAddedFpr. + let contact = lookup_key_contact_by_address(context, added_addr, None).await?; + if let Some(contact) = contact { + better_msg.get_or_insert( + stock_str::msg_add_member_local(context, contact, ContactId::UNDEFINED).await, + ); + } } if send_event_chat_modified { @@ -3556,6 +3567,12 @@ async fn apply_in_broadcast_changes( better_msg .get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await); } + } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { + if context.is_self_addr(added_addr).await? { + better_msg.get_or_insert( + stock_str::msg_add_member_local(context, ContactId::SELF, from_id).await, + ); + } } if send_event_chat_modified { diff --git a/test-data/golden/test_broadcast_joining_golden_alice b/test-data/golden/test_broadcast_joining_golden_alice index 3f71ff12f4..de6e8c9cd9 100644 --- a/test-data/golden/test_broadcast_joining_golden_alice +++ b/test-data/golden/test_broadcast_joining_golden_alice @@ -1,6 +1,6 @@ OutBroadcast#Chat#10: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png -------------------------------------------------------------------------------- Msg#10🔒: Me (Contact#Contact#Self): You changed the group image. [INFO] √ -Msg#12🔒: Me (Contact#Contact#Self): You added member bob@example.net. [INFO] √ +Msg#12🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √ Msg#13🔒: (Contact#Contact#10): Secure-Join: vb-request-with-auth [FRESH] -------------------------------------------------------------------------------- diff --git a/test-data/golden/test_broadcast_joining_golden_bob b/test-data/golden/test_broadcast_joining_golden_bob index 308762a74a..aa32d1c1c7 100644 --- a/test-data/golden/test_broadcast_joining_golden_bob +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -1,5 +1,5 @@ InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png -------------------------------------------------------------------------------- Msg#12: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO] -Msg#13🔒: (Contact#Contact#10): I added member bob@example.net. [FRESH][INFO] +Msg#13🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO] -------------------------------------------------------------------------------- From 2bd7911a7c343b3d92abbfdf0ec2141121f06a88 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:17:18 +0200 Subject: [PATCH 25/69] fix: Don't show a weird 'vb-request-with-auth' message when a subscriber joins --- src/chat/chat_tests.rs | 2 -- src/securejoin.rs | 13 +++---------- .../golden/test_broadcast_joining_golden_alice | 1 - 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 16ee0c0f74..de60e60b19 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2879,8 +2879,6 @@ async fn test_broadcast_joining_golden() -> Result<()> { let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await; - // TODO it's not nice that it says 'you added member bob' - // and 'Secure-Join: vb-request-with-auth' alice .golden_test_chat(alice_chat_id, "test_broadcast_joining_golden_alice") .await; diff --git a/src/securejoin.rs b/src/securejoin.rs index ac05cdf6d3..70b6a718d1 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -444,16 +444,9 @@ pub(crate) async fn handle_securejoin_handshake( .await?; inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 1000); - if step.starts_with("vb-") { - // For broadcasts, we don't want to delete the message, - // because the other device should also internally add the member - // and see the key (because it won't see the member via autocrypt-gossip). - Ok(HandshakeMessage::Propagate) - } else { - // IMAP-delete the message to avoid handling it by another device and adding the - // member twice. Another device will know the member's key from Autocrypt-Gossip. - Ok(HandshakeMessage::Done) - } + // IMAP-delete the message to avoid handling it by another device and adding the + // member twice. Another device will know the member's key from Autocrypt-Gossip. + Ok(HandshakeMessage::Done) } else { // Setup verified contact. secure_connection_established( diff --git a/test-data/golden/test_broadcast_joining_golden_alice b/test-data/golden/test_broadcast_joining_golden_alice index de6e8c9cd9..46128b029e 100644 --- a/test-data/golden/test_broadcast_joining_golden_alice +++ b/test-data/golden/test_broadcast_joining_golden_alice @@ -2,5 +2,4 @@ OutBroadcast#Chat#10: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a55 -------------------------------------------------------------------------------- Msg#10🔒: Me (Contact#Contact#Self): You changed the group image. [INFO] √ Msg#12🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √ -Msg#13🔒: (Contact#Contact#10): Secure-Join: vb-request-with-auth [FRESH] -------------------------------------------------------------------------------- From bdc39cf1847192b17bb223b08676493dff4a9f28 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:17:22 +0200 Subject: [PATCH 26/69] Add some print statements for debugging --- src/e2ee.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/e2ee.rs b/src/e2ee.rs index c1d1d77d2e..deae246be3 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -54,6 +54,11 @@ impl EncryptHelper { let cursor = Cursor::new(&mut raw_message); mail_to_encrypt.clone().write_part(cursor).ok(); + println!( + "\nEncrypting pk:\n{}\n", + str::from_utf8(&raw_message).unwrap() + ); + let ctext = pgp::pk_encrypt(raw_message, keyring, Some(sign_key), compress).await?; Ok(ctext) @@ -73,6 +78,11 @@ impl EncryptHelper { let cursor = Cursor::new(&mut raw_message); mail_to_encrypt.clone().write_part(cursor).ok(); + println!( + "\nEncrypting symm:\n{}\n", + str::from_utf8(&raw_message).unwrap() + ); + let ctext = pgp::encrypt_for_broadcast(raw_message, passphrase, sign_key, compress).await?; Ok(ctext) From 0620118790e83663d8e84f8bb2159fb5003cd2cf Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 17:17:49 +0200 Subject: [PATCH 27/69] feat: Increase secret size to 256 bits of entropy This is for quantumn computers. When trying to break AES, quantumn computers give a square-root speedup, i.e. the 144 bits of entropy would take as many queries as breaking 72 bits of entropy on a normal computer. This neglects e.g. the costs of quantumn circuits and quantumn error correction [1], so, 144 bits entropy would actually have been fine, but in order to be on the very safe side and so that noone can complain, let's increase it to 256 bits. [1]: https://csrc.nist.gov/csrc/media/Events/2024/fifth-pqc-standardization-conference/documents/papers/on-practical-cost-of-grover.pdf --- src/chat.rs | 8 ++++---- src/qr.rs | 4 ++-- src/receive_imf.rs | 4 ++-- src/tools.rs | 24 ++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index b7845642ea..7595bd6529 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -43,9 +43,9 @@ use crate::smtp::send_msg_to_smtp; use crate::stock_str; use crate::sync::{self, Sync::*, SyncData}; use crate::tools::{ - IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid, - create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset, - smeared_time, time, truncate_msg_text, + IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id, + create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path, + gm2local_offset, smeared_time, time, truncate_msg_text, }; use crate::webxdc::StatusUpdateSerial; use crate::{chatlist_events, imap}; @@ -3765,7 +3765,7 @@ pub async fn create_group_ex( /// Returns the created chat's id. pub async fn create_broadcast(context: &Context, chat_name: String) -> Result { let grpid = create_id(); - let secret = create_id(); + let secret = create_broadcast_shared_secret(); create_broadcast_ex(context, Sync, grpid, chat_name, secret).await } diff --git a/src/qr.rs b/src/qr.rs index c767503f38..0eea2da10f 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -20,7 +20,7 @@ use crate::message::Message; use crate::net::http::post_empty; use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig}; use crate::token; -use crate::tools::validate_id; +use crate::tools::{validate_broadcast_shared_secret, validate_id}; const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#"; @@ -459,7 +459,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .map(|s| s.to_string()); let broadcast_shared_secret = param .get("b") - .filter(|&s| validate_id(s)) + .filter(|&s| validate_broadcast_shared_secret(s)) .map(|s| s.to_string()); let grpname = if grpid.is_some() { diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 1d73105b52..6d6c425d6c 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -44,7 +44,7 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on use crate::simplify; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{self, buf_compress, create_id, remove_subject_prefix}; +use crate::tools::{self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix}; use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location}; use crate::{contact, imap}; @@ -1559,7 +1559,7 @@ async fn do_chat_assignment( } else { let name = compute_mailinglist_name(mailinglist_header, &listid, mime_parser); - let secret = create_id(); + let secret = create_broadcast_shared_secret(); chat::create_broadcast_ex(context, Nosync, listid, name, secret).await? }, ); diff --git a/src/tools.rs b/src/tools.rs index 59cca8d158..0acd7cf616 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -300,6 +300,25 @@ pub(crate) fn create_id() -> String { base64::engine::general_purpose::URL_SAFE.encode(arr) } +/// Generate a shared secret for a broadcast channel, consisting of 43 characters. +/// +/// The string generated by this function has 258 bits of entropy +/// and is returned as 43 Base64 characters, each containing 6 bits of entropy. +/// 256 is chosen because we may switch to AES-256 keys in the future, +/// and so that the shared secret definitely won't be the weak spot. +pub(crate) fn create_broadcast_shared_secret() -> String { + // ThreadRng implements CryptoRng trait and is supposed to be cryptographically secure. + let mut rng = thread_rng(); + + // Generate 264 random bits. + let mut arr = [0u8; 33]; + rng.fill(&mut arr[..]); + + let mut res = base64::engine::general_purpose::URL_SAFE.encode(arr); + res.truncate(43); + res +} + /// Returns true if given string is a valid ID. /// /// All IDs generated with `create_id()` should be considered valid. @@ -308,6 +327,11 @@ pub(crate) fn validate_id(s: &str) -> bool { s.chars().all(|c| alphabet.contains(c)) && s.len() > 10 && s.len() <= 32 } +pub(crate) fn validate_broadcast_shared_secret(s: &str) -> bool { + let alphabet = base64::alphabet::URL_SAFE.as_str(); + s.chars().all(|c| alphabet.contains(c)) && s.len() == 43 +} + /// Function generates a Message-ID that can be used for a new outgoing message. /// - this function is called for all outgoing messages. /// - the message ID should be globally unique From 16c902cbe81cdf731306e41bb1842ca1fd8f55e2 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 20:57:53 +0200 Subject: [PATCH 28/69] Remove unused and problematic ensure! `secret_keys.is_empty()` only checked whether any secret keys were passed. This is not helpful, and made decrypting fail in the benchmark. --- src/pgp.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pgp.rs b/src/pgp.rs index 69beddded9..30d36bc76e 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -261,11 +261,7 @@ pub fn decrypt( session_keys: vec![], allow_legacy: false, }; - let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?; - anyhow::ensure!( - !ring_result.secret_keys.is_empty(), - "decryption failed, no matching secret keys" - ); + let (msg, _ring_result) = msg.decrypt_the_ring(ring, true)?; // remove one layer of compression let msg = msg.decompress()?; From 0a0f747de88a4e71d069efe79e5d363794c8d8ad Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 20:59:18 +0200 Subject: [PATCH 29/69] Add benchmark for message decryption --- Cargo.toml | 5 +++ benches/encrypt_decrypt.rs | 90 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 ++ src/pgp.rs | 5 +++ src/tools.rs | 5 +++ 5 files changed, 108 insertions(+) create mode 100644 benches/encrypt_decrypt.rs diff --git a/Cargo.toml b/Cargo.toml index dfa45345ba..672cfd8680 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,11 @@ name = "receive_emails" required-features = ["internals"] harness = false +[[bench]] +name = "encrypt_decrypt" +required-features = ["internals"] +harness = false + [[bench]] name = "get_chat_msgs" harness = false diff --git a/benches/encrypt_decrypt.rs b/benches/encrypt_decrypt.rs new file mode 100644 index 0000000000..7fb1052b42 --- /dev/null +++ b/benches/encrypt_decrypt.rs @@ -0,0 +1,90 @@ +use std::hint::black_box; +use std::path::PathBuf; + +use criterion::{Criterion, criterion_group, criterion_main}; +use deltachat::{ + Events, + config::Config, + context::Context, + imex::{ImexMode, imex}, + pgp::{create_dummy_keypair, decrypt, encrypt_for_broadcast, pk_encrypt}, + receive_imf::receive_imf, + stock_str::StockStrings, + tools::create_broadcast_shared_secret_pub, +}; +use rand::{Rng, thread_rng}; +use tempfile::tempdir; + +const NUM_SECRETS: usize = 500; + +fn criterion_benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("Decrypt"); + group.sample_size(10); + group.bench_function("Decrypt symmetrically encrypted", |b| { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut plain: Vec = vec![0; 500]; + thread_rng().fill(&mut plain[..]); + let (secrets, encrypted) = rt.block_on(async { + let secrets: Vec = (0..NUM_SECRETS) + .map(|_| create_broadcast_shared_secret_pub()) + .collect(); + let secret = secrets[thread_rng().gen_range::(0..NUM_SECRETS)].clone(); + let encrypted = encrypt_for_broadcast( + plain.clone(), + black_box(&secret), + create_dummy_keypair("alice@example.org").unwrap().secret, + true, + ) + .await + .unwrap(); + + (secrets, encrypted) + }); + + b.iter(|| { + let mut msg = + decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap(); + let decrypted = msg.as_data_vec().unwrap(); + + assert_eq!(black_box(decrypted), plain); + }); + }); + group.bench_function("Decrypt pk encrypted", |b| { + // TODO code duplication with previous benchmark + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut plain: Vec = vec![0; 500]; + thread_rng().fill(&mut plain[..]); + let key_pair = create_dummy_keypair("alice@example.org").unwrap(); + let (secrets, encrypted) = rt.block_on(async { + let secrets: Vec = (0..NUM_SECRETS) + .map(|_| create_broadcast_shared_secret_pub()) + .collect(); + let encrypted = pk_encrypt( + plain.clone(), + vec![black_box(key_pair.public.clone())], + Some(key_pair.secret.clone()), + true, + ) + .await + .unwrap(); + + (secrets, encrypted) + }); + + b.iter(|| { + let mut msg = decrypt( + encrypted.clone().into_bytes(), + &[key_pair.secret.clone()], + black_box(&secrets), + ) + .unwrap(); + let decrypted = msg.as_data_vec().unwrap(); + + assert_eq!(black_box(decrypted), plain); + }); + }); + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index 3c6402cbfe..1f0f248e6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,7 +74,10 @@ mod mimefactory; pub mod mimeparser; pub mod oauth2; mod param; +#[cfg(not(feature = "internals"))] mod pgp; +#[cfg(feature = "internals")] +pub mod pgp; pub mod provider; pub mod qr; pub mod qr_code_generator; diff --git a/src/pgp.rs b/src/pgp.rs index 30d36bc76e..46e085a29d 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -149,6 +149,11 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result { Ok(key_pair) } +#[cfg(feature = "internals")] +pub fn create_dummy_keypair(addr: &str) -> Result { + create_keypair(EmailAddress::new(addr)?) +} + /// Selects a subkey of the public key to use for encryption. /// /// Returns `None` if the public key cannot be used for encryption. diff --git a/src/tools.rs b/src/tools.rs index 0acd7cf616..b4cce6ee60 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -319,6 +319,11 @@ pub(crate) fn create_broadcast_shared_secret() -> String { res } +#[cfg(feature = "internals")] +pub fn create_broadcast_shared_secret_pub() -> String { + create_broadcast_shared_secret() +} + /// Returns true if given string is a valid ID. /// /// All IDs generated with `create_id()` should be considered valid. From 8e8a5246715d8642b603a313abe1972dcd9e61e0 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 4 Aug 2025 20:59:40 +0200 Subject: [PATCH 30/69] Speed up message decryption by not iterating in the s2k algorithm The passphrase has as much entropy as the session key, so, there is no point in making the computation slow by iterating. --- src/pgp.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pgp.rs b/src/pgp.rs index 46e085a29d..5d2234ab67 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -18,7 +18,7 @@ use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm; use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData}; use pgp::types::{CompressionAlgorithm, KeyDetails, Password, PublicKeyTrait, StringToKey}; -use rand::thread_rng; +use rand::{Rng as _, thread_rng}; use tokio::runtime::Handle; use crate::key::{DcKey, Fingerprint}; @@ -342,9 +342,14 @@ pub async fn encrypt_for_broadcast( let passphrase = Password::from(passphrase.to_string()); tokio::task::spawn_blocking(move || { - let mut rng = thread_rng(); - let s2k = StringToKey::new_default(&mut rng); let msg = MessageBuilder::from_bytes("", plain); + let mut rng = thread_rng(); + let mut salt = [0u8; 8]; + rng.fill(&mut salt[..]); + let s2k = StringToKey::Salted { + hash_alg: HashAlgorithm::default(), + salt, + }; let mut msg = msg.seipd_v2( &mut rng, SymmetricKeyAlgorithm::AES128, From 452c4cc2c72f9ee0ba6f7882e22a0e6a36fefc2e Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 5 Aug 2025 16:43:01 +0200 Subject: [PATCH 31/69] Add benchmark for message decryption --- Cargo.toml | 2 +- ...ypt_decrypt.rs => benchmark_decrypting.rs} | 84 +++++++++++++++++- src/benchmark_internals.rs | 34 ++++++++ src/lib.rs | 3 + .../message/text_from_alice_encrypted.eml | 87 +++++++++++++++++++ .../message/text_symmetrically_encrypted.eml | 56 ++++++++++++ 6 files changed, 261 insertions(+), 5 deletions(-) rename benches/{encrypt_decrypt.rs => benchmark_decrypting.rs} (50%) create mode 100644 src/benchmark_internals.rs create mode 100644 test-data/message/text_from_alice_encrypted.eml create mode 100644 test-data/message/text_symmetrically_encrypted.eml diff --git a/Cargo.toml b/Cargo.toml index 672cfd8680..bc17e7a7c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,7 +158,7 @@ required-features = ["internals"] harness = false [[bench]] -name = "encrypt_decrypt" +name = "benchmark_decrypting" required-features = ["internals"] harness = false diff --git a/benches/encrypt_decrypt.rs b/benches/benchmark_decrypting.rs similarity index 50% rename from benches/encrypt_decrypt.rs rename to benches/benchmark_decrypting.rs index 7fb1052b42..47162e52ac 100644 --- a/benches/encrypt_decrypt.rs +++ b/benches/benchmark_decrypting.rs @@ -1,14 +1,20 @@ -use std::hint::black_box; use std::path::PathBuf; +use std::{hint::black_box, io::Write}; use criterion::{Criterion, criterion_group, criterion_main}; +use deltachat::benchmark_internals::save_broadcast_shared_secret; use deltachat::{ Events, + benchmark_internals::key_from_asc, + benchmark_internals::parse_and_get_text, + benchmark_internals::store_self_keypair, + chat::ChatId, config::Config, context::Context, imex::{ImexMode, imex}, - pgp::{create_dummy_keypair, decrypt, encrypt_for_broadcast, pk_encrypt}, - receive_imf::receive_imf, + key, + pgp::{KeyPair, create_dummy_keypair, decrypt, encrypt_for_broadcast, pk_encrypt}, + receive_imf, stock_str::StockStrings, tools::create_broadcast_shared_secret_pub, }; @@ -17,6 +23,29 @@ use tempfile::tempdir; const NUM_SECRETS: usize = 500; +async fn create_context() -> Context { + let dir = tempdir().unwrap(); + let dbfile = dir.path().join("db.sqlite"); + let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new()) + .await + .unwrap(); + + context + .set_config(Config::ConfiguredAddr, Some("bob@example.net")) + .await + .unwrap(); + let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")) + .unwrap() + .0; + let public = secret.signed_public_key(); + let key_pair = KeyPair { public, secret }; + store_self_keypair(&context, &key_pair) + .await + .expect("Failed to save key"); + + context +} + fn criterion_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("Decrypt"); group.sample_size(10); @@ -28,7 +57,7 @@ fn criterion_benchmark(c: &mut Criterion) { let secrets: Vec = (0..NUM_SECRETS) .map(|_| create_broadcast_shared_secret_pub()) .collect(); - let secret = secrets[thread_rng().gen_range::(0..NUM_SECRETS)].clone(); + let secret = secrets[NUM_SECRETS / 2].clone(); let encrypted = encrypt_for_broadcast( plain.clone(), black_box(&secret), @@ -83,6 +112,53 @@ fn criterion_benchmark(c: &mut Criterion) { assert_eq!(black_box(decrypted), plain); }); }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut secrets: Vec = (0..NUM_SECRETS) + .map(|_| create_broadcast_shared_secret_pub()) + .collect(); + + // "secret" is the symmetric secret that was used to encrypt text_symmetrically_encrypted.eml: + secrets[NUM_SECRETS / 2] = "secret".to_string(); + + let context = rt.block_on(async { + let context = create_context().await; + for (i, secret) in secrets.iter().enumerate() { + save_broadcast_shared_secret(&context, ChatId::new(10 + i as u32), &secret) + .await + .unwrap(); + } + context + }); + + group.bench_function("Receive a public-key encrypted message", |b| { + b.to_async(&rt).iter(|| { + let ctx = context.clone(); + async move { + let text = parse_and_get_text( + &ctx, + include_bytes!("../test-data/message/text_from_alice_encrypted.eml"), + ) + .await + .unwrap(); + assert_eq!(text, "hi"); + } + }); + }); + group.bench_function("Receive a symmetrically encrypted message", |b| { + b.to_async(&rt).iter(|| { + let ctx = context.clone(); + async move { + let text = parse_and_get_text( + &ctx, + include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"), + ) + .await + .unwrap(); + assert_eq!(text, "Symmetrically encrypted message"); + } + }); + }); group.finish(); } diff --git a/src/benchmark_internals.rs b/src/benchmark_internals.rs new file mode 100644 index 0000000000..423e0882e8 --- /dev/null +++ b/src/benchmark_internals.rs @@ -0,0 +1,34 @@ +//! Re-exports of internal functions needed for benchmarks. + +use anyhow::Result; +use std::collections::BTreeMap; + +use crate::chat::ChatId; +use crate::context::Context; +use crate::key; +use crate::key::DcKey; +use crate::mimeparser::MimeMessage; +pub use crate::pgp; + +use self::pgp::KeyPair; + +pub fn key_from_asc(data: &str) -> Result<(key::SignedSecretKey, BTreeMap)> { + key::SignedSecretKey::from_asc(data) +} + +pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> { + crate::key::store_self_keypair(context, keypair).await +} + +pub async fn parse_and_get_text(context: &Context, imf_raw: &[u8]) -> Result { + let mime_parser = MimeMessage::from_bytes(context, imf_raw, None).await?; + Ok(mime_parser.parts.into_iter().next().unwrap().msg) +} + +pub async fn save_broadcast_shared_secret( + context: &Context, + chat_id: ChatId, + secret: &str, +) -> Result<()> { + crate::chat::save_broadcast_shared_secret(context, chat_id, secret).await +} diff --git a/src/lib.rs b/src/lib.rs index 1f0f248e6f..16f5ce4ecd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -111,6 +111,9 @@ pub mod accounts; pub mod peer_channels; pub mod reaction; +#[cfg(feature = "internals")] +pub mod benchmark_internals; + /// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed. pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG"; diff --git a/test-data/message/text_from_alice_encrypted.eml b/test-data/message/text_from_alice_encrypted.eml new file mode 100644 index 0000000000..6e7952e911 --- /dev/null +++ b/test-data/message/text_from_alice_encrypted.eml @@ -0,0 +1,87 @@ +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="1858da4ad2d3c8a9_43c3a6383df12469_9f050814a95fd805" +MIME-Version: 1.0 +From: +To: +Subject: [...] +Date: Tue, 5 Aug 2025 11:07:50 +0000 +References: <0e547a9e-0785-421b-a867-ee204695fecc@localhost> +Chat-Version: 1.0 +Autocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5 + C0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz7CkgQQFggAOgUCaJHmBRYhBC5vossjtTLXKGNLWGSw + j2Gp7ZRDAhsDAh4BBQsJCAcCBhUKCQgLAgQWAgMBAScCGQEACgkQZLCPYantlENwpwEAq3zTDP9K1u + pV6yNLz6F+ylJ9U0WFIglz/CRWEu8Ma6YBAOZxBxIEJ3QFcoYaZwNUQ7lKffFiyb0cgA7hQM2cokMN + uDgEXlh13RIKKwYBBAGXVQEFAQEHQAbtyNbLZIUBTwqeW2W5tVbrusWLJ+nTUmtF7perLbYdAwEIB8 + J4BBgWCAAgBQJokeYFAhsMFiEELm+iyyO1MtcoY0tYZLCPYantlEMACgkQZLCPYantlENQgQD8CTIi + nPoPpFmnGuLXMOBH8PEDxTL+RQJgUms3dpkj2MUA/iB3L8TEtOC4A2eu5XAHttLrF3GYo7dlTq4LfO + oJtmIC + + +--1858da4ad2d3c8a9_43c3a6383df12469_9f050814a95fd805 +Content-Type: application/pgp-encrypted; charset="utf-8" +Content-Description: PGP/MIME version identification +Content-Transfer-Encoding: 7bit + +Version: 1 + +--1858da4ad2d3c8a9_43c3a6383df12469_9f050814a95fd805 +Content-Type: application/octet-stream; name="encrypted.asc"; + charset="utf-8" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Transfer-Encoding: 7bit + +-----BEGIN PGP MESSAGE----- + +wU4D5tq63hTeebASAQdAzFyWEue9h9wPPAcI7hz99FfwjcEvff4ctFRyEmPOgBMg +vHjt4qNpXUoFavfv2Qz2+/1/EcbNANpWQ+NsU5lal9fBwEwD49jcm8SO4yIBB/9X +qCUWtr2j4A+wCb/yVMY2vlpAnA56LAz86fksVqjjYF2rGYBpjHbNAG1OyQMKNDGQ +iIjo4PIb9OHQJx71H1M8W8Tr4U0Z9BiZqOf+VLc9EvOKNl/mADS73MV9iZiHGwDy +8evrO6IdoiGOxvyO62X+cjxpSOB607vdFeJksPOkHwmLNc5SZ/S7zMHr5Qz1qoLI +ikZdxPspJrV157VrguTVuBnoM/QtVoSBy9F/DbmXMEPbwyybG4owFiGHGvC4chQi +LwJStREmEumj8W27ZtWWYp67U1bOQtldCv9iZJDczn0sa0bpOmmdAKft8ru/6NNM +CQT/U3+zTJlTSH5hLvLv0sZ6AeV7U983n4JkFsz2t0wqmpIHrjP/Q4dJ62L8EfLm +n+3y/w1MagdbjeiCBAevclH5F/E/kL5b2wc7TXrLFbKPe9juK8xddysX3do35PGH +aXWmPDj6rM53L1lLS61Jqxof+mW6AyhIcNAOoWOgDx4dOQu0vrKFLCDVjBht9NG6 +5DxNi7yKWZfMxVd5hBdOznGMsbaw4WqT516Hj5/Xb8ZtXjneatdX6aQGtJimgEC3 +WMCqmY1n/iqa/K9auFbbfxPoMkFNZChKtje0azqmPnlvDgAzG0n80446D/xbC4UZ +zcpw7Sug6Mi2heI0/Y8uvyTtVRaO2ZxTA2dt8RTFQbunhvIze8MDrscz3TTIZds+ +TelyYEETPJbxbjT0z34oGDY3nXfNAZalnmceHCsAYOw61BdlJ/2reQyxDjuZRPn7 +kT4P3DAbYLwJ7BhMr+lTWfJVPG7wD9BMfBOAg1yF1WsUPztskQoWluvDYcNACkbA +CdsuIo3Pe0lNgUillmAZN0IZNof7SvKoxXdJKP31re8cDB9fiE4utjjtWtSkLbIe +cBY/Pu/67+ohABu5DaRQFZ918rLQo82CAiRh7Y+iHvJtixs+7BhieKPtXs/hdgyn +WpPwmu1nVXJWVdUplYZE/VWK45y4JqSMU+I+yD9uBFi5HfKM2UbE6VvxwO6yOygQ +Ry0jOjennXnPEWbIQh4i3qjqNqciGIwcwJaUDf7OdnU7OMqmGNews6wsWbLXllC8 +hVXbrIO5wgQX1CiYHOi1l1mQLjAQWQLE9KYxgs0CH7b7BsSXBcty2FF3jOJoB2LF +NKtVfI6X/m8x6v0bKQ4qw579momfrmWgPyJCkoaqTeEJLEF9PiA3IgkObthw7Pwn +lLf3ku1QZfbKWHrUDSaPgMC9/Hxwer2SMBgQpX+MSJxTsEQJTXrCraB12aj4+dYm +cC8UE0re0MrGXgOYVixI5Gsegr04vlogY0AAokZfvyxO17EA31T2ML7QJfAJv7Dg +X8/3SsABJwP2J7O/G24sj+lmVfApgHVbe4JpQ4VbH6f5Ev38p4PisFvMKDREVdJw +Mxpaa9EFHDqMCX4gGpfDt+r/xy1WvtO4Qif5meqpyD/1dj7ZGJ/TfcGp8pK413T+ +wQflh2uQeXQZu11HKtx+Hp2vADTed7Ngu08fHdfdqT09ZH0VQSaTlrAbF1ZOzk2t +Dbg1XTudlKlGdJptRpKQX7oF7Q/t0antqBybTcGyFXEWsC08L3EgSf5XoI4ZrYXk +cMuXvP/4g78na0BMOeruVSDpzciFhEc4BHJDHr3vf+g/Ch4Aytwk7ACn58APv5O4 +7Eo6+oLPhOn3B7LVnyUcAIdW5qSfLGGtxjtfdFFrSeoK6alS25JmZJFFDjpKUotS +3SFSTVxovyNKbtluGt9p2i9sXQC8Hm4tU8+RwuD09Ld27i17WCILslOouq2k2NIu +9fBiOdO301pzFLZY9cqQ+g1SX9JTobPEkQrvm1lfn5CAuElmkQuoqa10GZF0CC+D +HKbCrDHCU7G4vv5fco4bYHJBc04Q8QhxO1jMq+rxow4nbTUvuJxuyB7bEhlraskm +Z5XWdHCYd+Lzek0hg8bdJts5wntG79MfFBrnWet6a3QQdi0zwA/KL40d58lSorWU +/mfdzWCkzH5TU4s7VHiIedIiN/fSanEXP8BayNcrnUscR2Tgl6ZkxhLJ/7/O+8i5 +vtMRlUVwzVJ/0JZbP/PrE+dcMBO/0bptQadAzJX2AukxYhS5jdPMSzfjFHSWxufv +Trek577NL0J0U/bH59BK+zOwmV89oCsHyfWvpZzwM7D5gQUJBdcSBsD/riVK56Du +/FzmKHOyRXxC7joVkduLxqOrMIyETPiZ38I3xvbMnQrJo3Mxvz20c5gEmIZ1RuuI +wUenj2lxjYabFgNVCFGx5wLmwMaaLJqvrH4Z8aB7m5W5xJtAWt1ZHs2sS37YEyY/ +pKDRCF0Dunwsnzrt1i+YjvzzM0cbSkmcByGgkkKIzNjUpxpxylYL6cwZNAxne4+i +yHZAH3Cb6OoC1jAs0i2jLaQOLfKJTf1G/eV27HLTTEX7CGU0f00k4GRDcgtvQyB7 ++klDI0Uf/SrrOAEc5AY6KhvqjQRsLpOC4dDkrXTfxxm6XqDxXTk4lgH0tuSPTFRw +K1NLoMDwT2yUGchYH5HG+FptZP8gtQFBWeTzSOrINlPe+upZYMDmEtsgmxPman3v +XmVFQs4m3hG4wx3f7SPtx0/+z+AkgTUzCuudvV+oLxbbq+7ZvTcYZoe36Bm/CIgM +t97rNeC3oXS+aIHEk6LU9ER+/7eI5R7jNY/c4K111DQu7o+cM3dxF08r+iUu8lR0 +O8C0FM6a45PcOsaIanFiTgv238UCkb9vwjXrJI572tjOCKHSXrhIEweKziq1bU4q +0tDEbUG5dRZk87HI8Vh1JNei8V8Nyq6A7XfHV3WBxgNWvjUCgIx/SCzitbg= +=indJ +-----END PGP MESSAGE----- + + +--1858da4ad2d3c8a9_43c3a6383df12469_9f050814a95fd805-- + diff --git a/test-data/message/text_symmetrically_encrypted.eml b/test-data/message/text_symmetrically_encrypted.eml new file mode 100644 index 0000000000..44406e7d43 --- /dev/null +++ b/test-data/message/text_symmetrically_encrypted.eml @@ -0,0 +1,56 @@ +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="1858db5a667e9817_308656fa7edcc46c_c8ec5a6c260b51bc" +MIME-Version: 1.0 +From: +To: "hidden-recipients": ; +Subject: [...] +Date: Tue, 5 Aug 2025 11:27:17 +0000 +Message-ID: <5f9a3e21-fbbd-43aa-9638-1927da98b772@localhost> +References: <5f9a3e21-fbbd-43aa-9638-1927da98b772@localhost> +Chat-Version: 1.0 +Autocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5 + C0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz7CkgQQFggAOgUCaJHqlBYhBC5vossjtTLXKGNLWGSw + j2Gp7ZRDAhsDAh4BBQsJCAcCBhUKCQgLAgQWAgMBAScCGQEACgkQZLCPYantlEM55QD9H8bPo4J8Yz + TlMuMQms7o7rW89FYX+WH//0IDbfgWysAA/2lDEwfcP0ufyJPvUMGUi62JcFS9LBwS0riKGpC6hiMM + uDgEXlh13RIKKwYBBAGXVQEFAQEHQAbtyNbLZIUBTwqeW2W5tVbrusWLJ+nTUmtF7perLbYdAwEIB8 + J4BBgWCAAgBQJokeqUAhsMFiEELm+iyyO1MtcoY0tYZLCPYantlEMACgkQZLCPYantlEPdsAEA8cjS + XsAtWnQtW6m7Yn53j5Wk+jl5b3plydWhh8kk8uAA/2gx7wuDYDW9V32NdacJFV2H7UtItsTjN3qp8f + l00TQB + + +--1858db5a667e9817_308656fa7edcc46c_c8ec5a6c260b51bc +Content-Type: application/pgp-encrypted; charset="utf-8" +Content-Description: PGP/MIME version identification +Content-Transfer-Encoding: 7bit + +Version: 1 + +--1858db5a667e9817_308656fa7edcc46c_c8ec5a6c260b51bc +Content-Type: application/octet-stream; name="encrypted.asc"; + charset="utf-8" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Transfer-Encoding: 7bit + +-----BEGIN PGP MESSAGE----- + +wz4GHAcCCgEI44vuKOnsZubFQrI4MW7LbfmxKq5N2VIQ8c2CIRIAnvAa3AMV3Deq +P69ilwwDCf2NRy8Xg42Dc9LBkAIHAgdRy6G2xao09tPMEBBhY9dF01x21w+MyWd4 +Hm8Qz/No8BPkvxJO8WqFmbO/U0EHMEXGpADzNjU82I1bamslr0xjohgkL7goDkKl +ZbHMV1XTrG4No57fpXZSlWKRK+cJaY9S5pdwAboHuzdxhbWf+lAT2mqntkXLAtdT +tYv0piXH5+czWFsFpJRH4egYknhO+V9kpE4QX4wnwSwDinsBqAeMawcU93V4Eso+ +JYacb9Rd6Sv3ApjB12vAQTlc5KAxSFdCRGQBFIWNAMf6X04dSrURgh/gy2AnnO4q +ViU2+o5yITN+6KXxQrfmtL+xcPY1vKiATH/n5HYo/MgkwkwCSqvC5eajuMmKqncX +4877OzvCq7ohAnZVuaQFHLJlavKNzS76Hx4AGKX8MojCzhpUfmLwcjBtmteohAJd +COxhIS6hQDrgipscFmPW7fHIlHPvz0B4G/oorMzg9sN/vu+IerCoP8DCIbVIN3eK +Nt8XZtY2bNnzzQyh6XP5E5dhHWMGFlJFA1rdnAZ6O36Vdmm5++E4oFhluOTXNKRd +XapcxtXwwHfm+294pi9P8TWpADXwH6Mt2gwhHh9BE68SstjdM29hSA89q4Kn4y8p +EEsplNl2A4ZeD2Xz868PwoLnRa1f2b5nzdeZhUtj4K2JFGbAJ6alJ5sjRZaZIxnE +rQVvpwRVgaBp9scIsKVT14czCVAYW3n4RMYB3zwTkSIoW0prWZAGlzMAjzlaspnU +zxXzeY7woy+vjRPCFJCxWRrZ20cDQzs5pnrjapxS8j72ByQ= +=SwRI +-----END PGP MESSAGE----- + + +--1858db5a667e9817_308656fa7edcc46c_c8ec5a6c260b51bc-- + From 05c8958b4047edccf200d67175233d49998282e7 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Wed, 6 Aug 2025 15:04:14 +0200 Subject: [PATCH 32/69] Improve TODOs --- src/receive_imf.rs | 6 ++++-- src/securejoin.rs | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 6d6c425d6c..5391d14175 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3517,8 +3517,10 @@ async fn apply_out_broadcast_changes( } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { // TODO this may lookup the wrong contact if multiple contacts have the same email addr. // We can send sync messages instead, - // lookup the fingerprint by gossip header (like it's done for groups right now) - // or add a header ChatGroupMemberAddedFpr. + // lookup the fingerprint by gossip header (like it's done for groups right now), + // add a header ChatGroupMemberAddedFpr, + // or only handle addition on receival of Bob's request message and solve the problem in a different way for member-removed. + // --> link2xt said to probably handle addition on receival of Bob's request message, and to add a header ChatGroupMemberRemovedFpr. let contact = lookup_key_contact_by_address(context, added_addr, None).await?; if let Some(contact) = contact { better_msg.get_or_insert( diff --git a/src/securejoin.rs b/src/securejoin.rs index 70b6a718d1..6b30280928 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -291,6 +291,8 @@ pub(crate) async fn handle_securejoin_handshake( // TODO talk with link2xt about whether we need to protect against this identity-misbinding attack, // and if so, how + // -> just put Alice's fingerprint into a header (can't put the gossip header bc we don't have this) + // -> or just ignore the problem for now - we will need to solve it for all messages anyways: https://github.com/chatmail/core/issues/7057 if !matches!( step, "vg-request" | "vc-request" | "vb-request-with-auth" | "vb-member-added" From a74d706138784733b9ace4f602827a275c23a729 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Wed, 6 Aug 2025 16:34:27 +0200 Subject: [PATCH 33/69] WIP, untested: Sending side of transferring the secret in member-added message --- src/chat.rs | 8 +++++++- src/headerdef.rs | 5 +++++ src/mimefactory.rs | 20 +++++++++++++++++++- src/param.rs | 21 +++++++++++++++++++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 7595bd6529..153583f854 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4038,7 +4038,7 @@ pub(crate) async fn add_contact_to_chat_ex( let contact_addr = contact.get_addr().to_lowercase(); let added_by = if from_handshake && chat.is_out_broadcast() { // The contact was added via a QR code rather than explicit user action, - // and there is added information in saying 'You added member Alice' + // and there is no useful information in saying 'You added member Alice' // if self is the only one who can add members. ContactId::UNDEFINED } else { @@ -4050,6 +4050,12 @@ pub(crate) async fn add_contact_to_chat_ex( msg.param.set_int(Param::Arg2, from_handshake.into()); msg.param .set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32); + if chat.is_out_broadcast() { + let secret = load_broadcast_shared_secret(context, chat_id) + .await? + .context("Failed to find broadcast shared secret")?; + msg.param.set(Param::Arg3, secret); + } send_msg(context, chat_id, &mut msg).await?; sync = Nosync; diff --git a/src/headerdef.rs b/src/headerdef.rs index 330a4d9ba0..0071a6da2b 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -93,6 +93,11 @@ pub enum HeaderDef { /// This message obsoletes the text of the message defined here by rfc724_mid. ChatEdit, + /// The secret shared amongst all recipients of this broadcast channel, + /// used to encrypt and decrypt messages. + /// This secret is sent to a new member in the member-addition message. + ChatBroadcastSecret, + /// [Autocrypt](https://autocrypt.org/) header. Autocrypt, AutocryptGossip, diff --git a/src/mimefactory.rs b/src/mimefactory.rs index f5b191b141..f91b031176 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -834,7 +834,7 @@ impl MimeFactory { } } - if let Loaded::Message { chat, .. } = &self.loaded { + if let Loaded::Message { msg, chat } = &self.loaded { if chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast { headers.push(( "List-ID", @@ -844,6 +844,15 @@ impl MimeFactory { )) .into(), )); + + if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup { + if let Some(secret) = msg.param.get(Param::Arg3) { + headers.push(( + "Chat-Broadcast-Secret", + mail_builder::headers::text::Text::new(secret.to_string()).into(), + )); + } + } } } @@ -1024,6 +1033,15 @@ impl MimeFactory { } else { unprotected_headers.push(header.clone()); } + } else if header_name == "chat-broadcast-secret" { + if is_encrypted { + protected_headers.push(header.clone()); + } else { + warn!( + context, + "Message is unnecrypted, not including broadcast secret" + ); + } } else if is_encrypted { protected_headers.push(header.clone()); diff --git a/src/param.rs b/src/param.rs index 9e0433a256..f263378c3a 100644 --- a/src/param.rs +++ b/src/param.rs @@ -106,12 +106,29 @@ pub enum Param { Arg = b'E', /// For Messages + /// + /// For `BobHandshakeMsg::Request`, this is the `Secure-Join-Invitenumber` header. + /// + /// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header. + /// + /// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced. + /// + /// For [`SystemMessage::MemberAddedToGroup`], + /// this is '1' if it was added because of a securejoin-handshake, and '0' otherwise. Arg2 = b'F', - /// `Secure-Join-Fingerprint` header for `{vc,vg}-request-with-auth` messages. + /// For Messages + /// + /// For `BobHandshakeMsg::RequestWithAuth`, + /// this contains the `Secure-Join-Fingerprint` header. + /// + /// For [`SystemMessage::MemberAddedToGroup`] that add to a broadcast channel, + /// this contains the broadcast channel's shared secret. Arg3 = b'G', - /// Deprecated `Secure-Join-Group` header for messages. + /// For Messages + /// + /// Deprecated `Secure-Join-Group` header for `BobHandshakeMsg::RequestWithAuth` messages. Arg4 = b'H', /// For Messages From abb1d4145c368bfa9708bec7a49507373ddb69cc Mon Sep 17 00:00:00 2001 From: Hocuri Date: Wed, 6 Aug 2025 16:36:44 +0200 Subject: [PATCH 34/69] WIP, untested: Receiving side of passing broadcast secret in a message --- src/receive_imf.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 5391d14175..997aec19cd 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -16,6 +16,7 @@ use regex::Regex; use crate::chat::{ self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table, + save_broadcast_shared_secret, }; use crate::config::Config; use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails}; @@ -3577,6 +3578,10 @@ async fn apply_in_broadcast_changes( } } + if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) { + save_broadcast_shared_secret(context, chat.id, secret).await?; + } + if send_event_chat_modified { context.emit_event(EventType::ChatModified(chat.id)); chatlist_events::emit_chatlist_item_changed(context, chat.id); From 73d0dc292736df35088100fff116ec07b8e2ff31 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 11:31:29 +0200 Subject: [PATCH 35/69] feat: Transfer the broadcast secret in an encrypted message rather than directly in the QR code --- benches/benchmark_decrypting.rs | 4 +- src/chat.rs | 15 +- src/chat/chat_tests.rs | 10 +- src/decrypt.rs | 12 +- src/message.rs | 12 ++ src/mimefactory.rs | 37 ++++- src/mimeparser.rs | 53 ++++++- src/param.rs | 6 + src/pgp.rs | 32 ++-- src/securejoin.rs | 27 +++- src/securejoin/bob.rs | 139 ++++++++---------- src/securejoin/qrinvite.rs | 27 +++- src/token.rs | 15 ++ .../golden/test_broadcast_joining_golden_bob | 4 +- 14 files changed, 262 insertions(+), 131 deletions(-) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index 47162e52ac..f46cf7a7e9 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -71,7 +71,7 @@ fn criterion_benchmark(c: &mut Criterion) { }); b.iter(|| { - let mut msg = + let (mut msg, _) = decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap(); let decrypted = msg.as_data_vec().unwrap(); @@ -101,7 +101,7 @@ fn criterion_benchmark(c: &mut Criterion) { }); b.iter(|| { - let mut msg = decrypt( + let (mut msg, _) = decrypt( encrypted.clone().into_bytes(), &[key_pair.secret.clone()], black_box(&secrets), diff --git a/src/chat.rs b/src/chat.rs index 153583f854..875ac69f8f 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2934,10 +2934,13 @@ async fn prepare_send_msg( SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage ) } - CantSendReason::MissingKey => msg - .param - .get_bool(Param::ForcePlaintext) - .unwrap_or_default(), + CantSendReason::MissingKey => { + msg.param + .get_bool(Param::ForcePlaintext) + .unwrap_or_default() + // V2 securejoin messages are symmetrically encrypted, no need for the public key: + || msg.securejoin_step() == Some("vb-request-v2") + } _ => false, }; if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? { @@ -3849,11 +3852,13 @@ pub(crate) async fn save_broadcast_shared_secret( chat_id: ChatId, secret: &str, ) -> Result<()> { + info!(context, "Saving broadcast secret for chat {chat_id}"); + info!(context, "dbg the new secret for chat {chat_id} is {secret}"); context .sql .execute( "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) - ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.chat_id", + ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret", (chat_id, secret), ) .await?; diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index de60e60b19..a5773fc731 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -7,7 +7,7 @@ use crate::imex::{ImexMode, has_backup, imex}; use crate::message::{MessengerMessage, delete_msgs}; use crate::mimeparser::{self, MimeMessage}; use crate::receive_imf::receive_imf; -use crate::securejoin::get_securejoin_qr; +use crate::securejoin::{get_securejoin_qr, join_securejoin}; use crate::test_utils::{ AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync, @@ -3080,8 +3080,12 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { tcm.section("Alice creates broadcast channel with Bob."); let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); - tcm.exec_securejoin_qr(bob0, alice, &qr).await; - sync(bob0, bob1).await; + join_securejoin(bob0, &qr).await.unwrap(); + let request = bob0.pop_sent_msg().await; + alice.recv_msg(&request).await; + let answer = alice.pop_sent_msg().await; + bob0.recv_msg(&answer).await; + bob1.recv_msg(&answer).await; tcm.section("Alice sends first message to broadcast."); let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; diff --git a/src/decrypt.rs b/src/decrypt.rs index 3da5217d8f..48f8cd4a27 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -10,18 +10,22 @@ use crate::pgp; /// Tries to decrypt a message, but only if it is structured as an Autocrypt message. /// -/// If successful and the message is encrypted, returns decrypted body. +/// If successful and the message is encrypted, returns a tuple of: +/// +/// - The decrypted and decompressed message +/// - If the message was symmetrically encrypted: +/// The index in `shared_secrets` of the secret used to decrypt the message. pub fn try_decrypt<'a>( mail: &'a ParsedMail<'a>, private_keyring: &'a [SignedSecretKey], - symmetric_secrets: &[String], -) -> Result>> { + shared_secrets: &[String], +) -> Result, Option)>> { let Some(encrypted_data_part) = get_encrypted_mime(mail) else { return Ok(None); }; let data = encrypted_data_part.get_body_raw()?; - let msg = pgp::decrypt(data, private_keyring, symmetric_secrets)?; + let msg = pgp::decrypt(data, private_keyring, shared_secrets)?; Ok(Some(msg)) } diff --git a/src/message.rs b/src/message.rs index 04fbad610a..3acd9dd144 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1381,6 +1381,18 @@ impl Message { pub fn error(&self) -> Option { self.error.clone() } + + // TODO this function could be used a lot more + /// If this is a secure-join message, + /// returns the current step, + /// which is put into the `Secure-Join` header. + pub(crate) fn securejoin_step(&self) -> Option<&str> { + if self.param.get_cmd() == SystemMessage::SecurejoinMessage { + self.param.get(Param::Arg) + } else { + None + } + } } /// State of the message. diff --git a/src/mimefactory.rs b/src/mimefactory.rs index f91b031176..dbc8c75026 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -329,7 +329,7 @@ impl MimeFactory { if let Some(public_key) = public_key_opt { keys.push((addr.clone(), public_key)) - } else if id != ContactId::SELF && !chat.is_any_broadcast() { + } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) { missing_key_addresses.insert(addr.clone()); if is_encrypted { warn!(context, "Missing key for {addr}"); @@ -350,7 +350,7 @@ impl MimeFactory { if let Some(public_key) = public_key_opt { keys.push((addr.clone(), public_key)) - } else if id != ContactId::SELF && !chat.is_any_broadcast() { + } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) { missing_key_addresses.insert(addr.clone()); if is_encrypted { warn!(context, "Missing key for {addr}"); @@ -430,7 +430,7 @@ impl MimeFactory { encryption_keys = if !is_encrypted { None - } else if chat.is_out_broadcast() { + } else if should_encrypt_symmetrically(&msg, &chat) { // Encrypt, but only symmetrically, not with the public keys. Some(Vec::new()) } else { @@ -579,7 +579,7 @@ impl MimeFactory { // messages are auto-sent unlike usual unencrypted messages. step == "vg-request-with-auth" || step == "vc-request-with-auth" - || step == "vb-request-with-auth" + || step == "vb-request-v2" || step == "vg-member-added" || step == "vb-member-added" || step == "vc-contact-confirm" @@ -825,7 +825,7 @@ impl MimeFactory { } else if let Loaded::Message { msg, .. } = &self.loaded { if msg.param.get_cmd() == SystemMessage::SecurejoinMessage { let step = msg.param.get(Param::Arg).unwrap_or_default(); - if step != "vg-request" && step != "vc-request" { + if step != "vg-request" && step != "vc-request" && step != "vb-request-v2" { headers.push(( "Auto-Submitted", mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(), @@ -1181,7 +1181,13 @@ impl MimeFactory { }; let symmetric_key: Option = match &self.loaded { - Loaded::Message { chat, .. } if chat.is_any_broadcast() => { + Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => { + // TODO rather than setting Arg2, bob.rs could set a param `Param::SharedSecretForEncryption` or similar + msg.param.get(Param::Arg2).map(|s| s.to_string()) + } + Loaded::Message { chat, msg } + if should_encrypt_with_broadcast_secret(msg, chat) => + { // If there is no symmetric key yet // (because this is an old broadcast channel, // created before we had symmetric encryption), @@ -1196,6 +1202,7 @@ impl MimeFactory { let encrypted = if let Some(symmetric_key) = symmetric_key { info!(context, "Symmetrically encrypting for broadcast channel."); + info!(context, "secret: {symmetric_key}"); // TODO encrypt_helper .encrypt_for_broadcast(context, &symmetric_key, message, compress) .await? @@ -1547,7 +1554,7 @@ impl MimeFactory { headers.push(( if step == "vg-request-with-auth" || step == "vc-request-with-auth" - || step == "vb-request-with-auth" + || step == "vb-request-v2" { "Secure-Join-Auth" } else { @@ -1863,6 +1870,22 @@ fn hidden_recipients() -> Address<'static> { Address::new_group(Some("hidden-recipients".to_string()), Vec::new()) } +fn should_encrypt_with_auth_token(msg: &Message) -> bool { + msg.param.get_cmd() == SystemMessage::SecurejoinMessage + && msg.param.get(Param::Arg).unwrap_or_default() == "vb-request-v2" +} + +fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool { + chat.is_any_broadcast() + && msg.param.get_cmd() != SystemMessage::SecurejoinMessage + // The member-added message in a broadcast must be asymmetrirally encrypted: + && msg.param.get_cmd() != SystemMessage::MemberAddedToGroup +} + +fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool { + should_encrypt_with_auth_token(msg) || should_encrypt_with_broadcast_secret(msg, chat) +} + async fn build_body_file(context: &Context, msg: &Message) -> Result> { let file_name = msg.get_filename().context("msg has no file")?; let blob = msg diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 2207106dfe..831efb4795 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -18,7 +18,6 @@ use crate::authres::handle_authres; use crate::blob::BlobObject; use crate::chat::ChatId; use crate::config::Config; -use crate::constants; use crate::contact::ContactId; use crate::context::Context; use crate::decrypt::{try_decrypt, validate_detached_signature}; @@ -35,6 +34,7 @@ use crate::tools::{ get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id, }; use crate::{chatlist_events, location, stock_str, tools}; +use crate::{constants, token}; /// A parsed MIME message. /// @@ -136,6 +136,10 @@ pub(crate) struct MimeMessage { /// Sender timestamp in secs since epoch. Allowed to be in the future due to unsynchronized /// clocks, but not too much. pub(crate) timestamp_sent: i64, + + /// How the message was encrypted (and now successfully decrypted): + /// The asymmetric key, an AUTH token, or a broadcast's shared secret. + pub(crate) was_encrypted_with: EncryptedWith, } #[derive(Debug, PartialEq)] @@ -218,6 +222,25 @@ pub enum SystemMessage { ChatE2ee = 50, } +#[derive(Debug)] +pub(crate) enum EncryptedWith { + AsymmetricKey, + BroadcastSecret(String), + AuthToken(String), + None, +} + +impl EncryptedWith { + pub(crate) fn auth_token(&self) -> Option<&str> { + match self { + EncryptedWith::AsymmetricKey => None, + EncryptedWith::BroadcastSecret(_) => None, + EncryptedWith::AuthToken(token) => Some(token), + EncryptedWith::None => None, + } + } +} + const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup"; impl MimeMessage { @@ -338,7 +361,7 @@ impl MimeMessage { let mail_raw; // Memory location for a possible decrypted message. let decrypted_msg; // Decrypted signed OpenPGP message. - let symmetric_secrets: Vec = context + let mut secrets: Vec = context .sql .query_map( "SELECT secret FROM broadcasts_shared_secrets", @@ -350,11 +373,13 @@ impl MimeMessage { }, ) .await?; + let num_broadcast_secrets = secrets.len(); + secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?); - let (mail, is_encrypted) = match tokio::task::block_in_place(|| { - try_decrypt(&mail, &private_keyring, &symmetric_secrets) + let (mail, is_encrypted, decrypted_with) = match tokio::task::block_in_place(|| { + try_decrypt(&mail, &private_keyring, &secrets) }) { - Ok(Some(mut msg)) => { + Ok(Some((mut msg, index_of_secret))) => { mail_raw = msg.as_data_vec().unwrap_or_default(); let decrypted_mail = mailparse::parse_mail(&mail_raw)?; @@ -381,18 +406,29 @@ impl MimeMessage { aheader_value = Some(protected_aheader_value); } - (Ok(decrypted_mail), true) + let decrypted_with = if let Some(index_of_secret) = index_of_secret { + let used_secret = secrets.into_iter().nth(index_of_secret).unwrap_or_default(); + if index_of_secret < num_broadcast_secrets { + EncryptedWith::BroadcastSecret(used_secret) + } else { + EncryptedWith::AuthToken(used_secret) + } + } else { + EncryptedWith::AsymmetricKey + }; + + (Ok(decrypted_mail), true, decrypted_with) } Ok(None) => { mail_raw = Vec::new(); decrypted_msg = None; - (Ok(mail), false) + (Ok(mail), false, EncryptedWith::None) } Err(err) => { mail_raw = Vec::new(); decrypted_msg = None; warn!(context, "decryption failed: {:#}", err); - (Err(err), false) + (Err(err), false, EncryptedWith::None) } }; @@ -566,6 +602,7 @@ impl MimeMessage { is_bot: None, timestamp_rcvd, timestamp_sent, + was_encrypted_with: decrypted_with, }; match partial { diff --git a/src/param.rs b/src/param.rs index f263378c3a..6dcd1ada35 100644 --- a/src/param.rs +++ b/src/param.rs @@ -103,6 +103,9 @@ pub enum Param { /// removed from the group. /// /// For "MemberAddedToGroup" this is the email address added to the group. + /// + /// For securejoin messages, this is the step, + /// which is put into the `Secure-Join` header. Arg = b'E', /// For Messages @@ -111,6 +114,9 @@ pub enum Param { /// /// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header. /// + /// For version two of the securejoin protocol (`vb-request-v2`), + /// this is the Auth token used to encrypt the message. + /// /// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced. /// /// For [`SystemMessage::MemberAddedToGroup`], diff --git a/src/pgp.rs b/src/pgp.rs index 5d2234ab67..10183b4f0b 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -8,7 +8,7 @@ use chrono::SubsecRound; use deltachat_contact_tools::EmailAddress; use pgp::armor::BlockType; use pgp::composed::{ - ArmorOptions, Deserializable, KeyType as PgpKeyType, Message, MessageBuilder, + ArmorOptions, Deserializable, InnerRingResult, KeyType as PgpKeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, StandaloneSignature, SubkeyParamsBuilder, TheRing, }; @@ -239,13 +239,19 @@ pub fn pk_calc_signature( /// Decrypts the message with keys from the private key keyring. /// -/// Receiver private keys are provided in -/// `private_keys_for_decryption`. +/// Receiver private keys are passed in `private_keys_for_decryption`, +/// shared secrets used for symmetric encryption +/// are passed in `shared_secrets`. +/// +/// Returns a tuple of: +/// - The decrypted and decompressed message +/// - If the message was symmetrically encrypted: +/// The index in `shared_secrets` of the secret used to decrypt the message. pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], - symmetric_secrets: &[String], -) -> Result> { + shared_secrets: &[String], +) -> Result<(pgp::composed::Message<'static>, Option)> { let cursor = Cursor::new(ctext); let (msg, _headers) = Message::from_armor(cursor)?; @@ -253,7 +259,7 @@ pub fn decrypt( let empty_pw = Password::empty(); // TODO it may degrade performance that we always try out all passwords here - let message_password: Vec = symmetric_secrets + let message_password: Vec = shared_secrets .iter() .map(|p| Password::from(p.as_str())) .collect(); @@ -266,12 +272,17 @@ pub fn decrypt( session_keys: vec![], allow_legacy: false, }; - let (msg, _ring_result) = msg.decrypt_the_ring(ring, true)?; + let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?; // remove one layer of compression let msg = msg.decompress()?; - Ok(msg) + let decrypted_with_secret = ring_result + .message_password + .iter() + .position(|&p| p == InnerRingResult::Ok); + + Ok((msg, decrypted_with_secret)) } /// Returns fingerprints @@ -407,7 +418,7 @@ mod tests { HashSet, Vec, )> { - let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?; + let (mut msg, _) = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?; let content = msg.as_data_vec()?; let ret_signature_fingerprints = valid_signature_fingerprints(&msg, public_keys_for_validation)?; @@ -611,13 +622,14 @@ mod tests { .await?; let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; - let mut decrypted = decrypt( + let (mut decrypted, index_of_secret) = decrypt( ctext.into(), &bob_private_keyring, &[shared_secret.to_string()], )?; assert_eq!(decrypted.as_data_vec()?, plain); + assert_eq!(index_of_secret, Some(0)); Ok(()) } diff --git a/src/securejoin.rs b/src/securejoin.rs index 6b30280928..2854548370 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -295,7 +295,7 @@ pub(crate) async fn handle_securejoin_handshake( // -> or just ignore the problem for now - we will need to solve it for all messages anyways: https://github.com/chatmail/core/issues/7057 if !matches!( step, - "vg-request" | "vc-request" | "vb-request-with-auth" | "vb-member-added" + "vg-request" | "vc-request" | "vb-request-v2" | "vb-member-added" ) { let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); @@ -366,7 +366,7 @@ pub(crate) async fn handle_securejoin_handshake( ========================================================*/ bob::handle_auth_required(context, mime_message).await } - "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => { + "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-v2" => { /*========================================================== ==== Alice - the inviter side ==== ==== Steps 5+6 in "Setup verified contact" protocol ==== @@ -389,8 +389,12 @@ pub(crate) async fn handle_securejoin_handshake( ); return Ok(HandshakeMessage::Ignore); } - // verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code - let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else { + // verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code, + // or that the message was encrypted with the secret written to the QR code. + let auth = mime_message + .get_header(HeaderDef::SecureJoinAuth) + .or_else(|| mime_message.was_encrypted_with.auth_token()); + let Some(auth) = auth else { warn!( context, "Ignoring {step} message because of missing auth code." @@ -446,9 +450,16 @@ pub(crate) async fn handle_securejoin_handshake( .await?; inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 1000); - // IMAP-delete the message to avoid handling it by another device and adding the - // member twice. Another device will know the member's key from Autocrypt-Gossip. - Ok(HandshakeMessage::Done) + if step.starts_with("vb-") { + // For broadcasts, we don't want to delete the message, + // because the other device should also internally add the member + // and see the key (because it won't see the member via autocrypt-gossip). + Ok(HandshakeMessage::Propagate) + } else { + // IMAP-delete the message to avoid handling it by another device and adding the + // member twice. Another device will know the member's key from Autocrypt-Gossip. + Ok(HandshakeMessage::Done) + } } else { // Setup verified contact. secure_connection_established( @@ -593,7 +604,7 @@ pub(crate) async fn observe_securejoin_on_other_device( inviter_progress(context, contact_id, 1000); } - // TODO not sure if I should ad vb-request-with-auth here + // TODO not sure if I should add vb-request-v2 here // Actually, I'm not even sure why vg-request-with-auth is here - why do we create a 1:1 chat?? if step == "vg-request-with-auth" || step == "vc-request-with-auth" { // This actually reflects what happens on the first device (which does the secure diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index a8dee9fd3a..fc07579f23 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -1,18 +1,16 @@ //! Bob's side of SecureJoin handling, the joiner-side. -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, bail}; use super::HandshakeMessage; use super::qrinvite::QrInvite; -use crate::chat::{ - self, ChatId, ProtectionStatus, is_contact_in_chat, save_broadcast_shared_secret, -}; +use crate::chat::{self, ChatId, ProtectionStatus, is_contact_in_chat}; use crate::constants::{Blocked, Chattype}; use crate::contact::Origin; use crate::context::Context; use crate::events::EventType; use crate::key::self_fingerprint; -use crate::log::{LogExt as _, info}; +use crate::log::info; use crate::message::{Message, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::Param; @@ -51,63 +49,48 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Group { .. } => Blocked::Yes, QrInvite::Broadcast { .. } => Blocked::Yes, }; - let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden) - .await - .with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?; + + // The 1:1 chat with the inviter + let private_chat_id = + ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden) + .await + .with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?; + + // The chat id of the 1:1 chat, group or broadcast that is being joined + let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?; ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?; context.emit_event(EventType::ContactsChanged(None)); - if let QrInvite::Broadcast { - shared_secret, - grpid, - broadcast_name, - .. - } = &invite - { - // TODO this causes some performance penalty because joining_chat_id is used again below, - // but maybe it's fine - let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?; - - save_broadcast_shared_secret(context, broadcast_chat_id, shared_secret).await?; - - let id = chat::SyncId::Grpid(grpid.to_string()); - let action = chat::SyncAction::CreateInBroadcast { - chat_name: broadcast_name.to_string(), - shared_secret: shared_secret.to_string(), - }; - chat::sync(context, id, action).await.log_err(context).ok(); + if invite.is_v2() { + if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await? + { + bail!("V2 protocol failed because of fingerprint mismatch"); + } + info!(context, "Using fast securejoin with symmetric encryption"); - if verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await? { - info!(context, "Using fast securejoin with symmetric encryption"); + let mut msg = Message { + viewtype: Viewtype::Text, + text: "Secure-Join: vb-request-v2".to_string(), + hidden: true, + ..Default::default() + }; + msg.param.set_cmd(SystemMessage::SecurejoinMessage); - // The message has to be sent into the broadcast chat, rather than the 1:1 chat, - // so that it will be symmetrically encrypted - send_handshake_message( - context, - &invite, - broadcast_chat_id, - BobHandshakeMsg::RequestWithAuth, - ) - .await?; + msg.param.set(Param::Arg, "vb-request-v2"); + msg.param.set(Param::Arg2, invite.authcode()); + msg.param.set_int(Param::GuaranteeE2ee, 1); + let bob_fp = self_fingerprint(context).await?; + msg.param.set(Param::Arg3, bob_fp); - // Mark 1:1 chat as verified already. - chat_id - .set_protection( - context, - ProtectionStatus::Protected, - time(), - Some(invite.contact_id()), - ) - .await?; + chat::send_msg(context, private_chat_id, &mut msg).await?; - context.emit_event(EventType::SecurejoinJoinerProgress { - contact_id: invite.contact_id(), - progress: JoinerProgress::RequestWithAuthSent.to_usize(), - }); - } + context.emit_event(EventType::SecurejoinJoinerProgress { + contact_id: invite.contact_id(), + progress: JoinerProgress::RequestWithAuthSent.to_usize(), + }); } else { - // Start the original (non-broadcast) protocol and initialise the state. + // Start the version 1 protocol and initialise the state. let has_key = context .sql .exists( @@ -122,11 +105,16 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul { // The scanned fingerprint matches Alice's key, we can proceed to step 4b. info!(context, "Taking securejoin protocol shortcut"); - send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth) - .await?; + send_handshake_message( + context, + &invite, + private_chat_id, + BobHandshakeMsg::RequestWithAuth, + ) + .await?; // Mark 1:1 chat as verified already. - chat_id + private_chat_id .set_protection( context, ProtectionStatus::Protected, @@ -140,9 +128,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul progress: JoinerProgress::RequestWithAuthSent.to_usize(), }); } else { - send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?; + send_handshake_message(context, &invite, private_chat_id, BobHandshakeMsg::Request) + .await?; - insert_new_db_entry(context, invite.clone(), chat_id).await?; + insert_new_db_entry(context, invite.clone(), private_chat_id).await?; } } @@ -150,28 +139,26 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Group { .. } => { // For a secure-join we need to create the group and add the contact. The group will // only become usable once the protocol is finished. - let group_chat_id = joining_chat_id(context, &invite, chat_id).await?; - if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? { + if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? { chat::add_to_chat_contacts_table( context, time(), - group_chat_id, + joining_chat_id, &[invite.contact_id()], ) .await?; } let msg = stock_str::secure_join_started(context, invite.contact_id()).await; - chat::add_info_msg(context, group_chat_id, &msg, time()).await?; - Ok(group_chat_id) + chat::add_info_msg(context, joining_chat_id, &msg, time()).await?; + Ok(joining_chat_id) } QrInvite::Broadcast { .. } => { // TODO code duplication with previous block - let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?; - if !is_contact_in_chat(context, broadcast_chat_id, invite.contact_id()).await? { + if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? { chat::add_to_chat_contacts_table( context, time(), - broadcast_chat_id, + joining_chat_id, &[invite.contact_id()], ) .await?; @@ -179,8 +166,8 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul // TODO this message should be translatable: let msg = "You were invited to join this channel. Waiting for the channel owner's device to reply…"; - chat::add_info_msg(context, broadcast_chat_id, msg, time()).await?; - Ok(broadcast_chat_id) + chat::add_info_msg(context, joining_chat_id, msg, time()).await?; + Ok(joining_chat_id) } QrInvite::Contact { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it @@ -189,14 +176,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul // race with its change, we don't add our message below the protection message. let sort_to_bottom = true; let (received, incoming) = (false, false); - let ts_sort = chat_id + let ts_sort = private_chat_id .calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming) .await?; - if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected { + if private_chat_id.is_protected(context).await? == ProtectionStatus::Unprotected { let ts_start = time(); chat::add_info_msg_with_cmd( context, - chat_id, + private_chat_id, &stock_str::securejoin_wait(context).await, SystemMessage::SecurejoinWait, ts_sort, @@ -207,7 +194,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul ) .await?; } - Ok(chat_id) + Ok(private_chat_id) } } } @@ -334,7 +321,7 @@ pub(crate) async fn send_handshake_message( match step { BobHandshakeMsg::Request => { // Sends the Secure-Join-Invitenumber header in mimefactory.rs. - msg.param.set(Param::Arg2, invite.invitenumber()); + msg.param.set_optional(Param::Arg2, invite.invitenumber()); msg.force_plaintext(); } BobHandshakeMsg::RequestWithAuth => { @@ -368,7 +355,7 @@ pub(crate) async fn send_handshake_message( pub(crate) enum BobHandshakeMsg { /// vc-request or vg-request Request, - /// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth + /// vc-request-with-auth, vg-request-with-auth, or vb-request-v2 RequestWithAuth, } @@ -399,7 +386,9 @@ impl BobHandshakeMsg { Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", - QrInvite::Broadcast { .. } => "vb-request-with-auth", + QrInvite::Broadcast { .. } => { + panic!("There is no request-with-auth for broadcasts") + } // TODO remove panic }, } } diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index 5413b1fbd6..20d20baaa9 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -18,7 +18,7 @@ pub enum QrInvite { Contact { contact_id: ContactId, fingerprint: Fingerprint, - invitenumber: String, + invitenumber: Option, authcode: String, }, Group { @@ -26,7 +26,7 @@ pub enum QrInvite { fingerprint: Fingerprint, name: String, grpid: String, - invitenumber: String, + invitenumber: Option, authcode: String, }, Broadcast { @@ -62,10 +62,12 @@ impl QrInvite { } /// The `INVITENUMBER` of the setup-contact/secure-join protocol. - pub fn invitenumber(&self) -> &str { + pub fn invitenumber(&self) -> Option<&str> { match self { - Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber, - Self::Broadcast { .. } => panic!("broadcast invite has no invite number"), // TODO panic + Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => { + invitenumber.as_deref() + } + Self::Broadcast { .. } => None, } } @@ -77,6 +79,17 @@ impl QrInvite { | Self::Broadcast { authcode, .. } => authcode, } } + + /// Whether this QR code uses the faster "version 2" protocol, + /// where the first message from Bob to Alice is symmetrically encrypted + /// with the AUTH code. + /// We may decide in the future to backwards-compatibly mark QR codes as V2, + /// but for now, everything without an invite number + /// is definitely V2, + /// because the invite number is needed for V1. + pub(crate) fn is_v2(&self) -> bool { + self.invitenumber().is_none() + } } impl TryFrom for QrInvite { @@ -92,7 +105,7 @@ impl TryFrom for QrInvite { } => Ok(QrInvite::Contact { contact_id, fingerprint, - invitenumber, + invitenumber: Some(invitenumber), authcode, }), Qr::AskVerifyGroup { @@ -107,7 +120,7 @@ impl TryFrom for QrInvite { fingerprint, name: grpname, grpid, - invitenumber, + invitenumber: Some(invitenumber), authcode, }), Qr::AskJoinBroadcast { diff --git a/src/token.rs b/src/token.rs index a5bdfc0681..70b11e48d2 100644 --- a/src/token.rs +++ b/src/token.rs @@ -61,6 +61,21 @@ pub async fn lookup( .await } +pub async fn lookup_all(context: &Context, namespace: Namespace) -> Result> { + context + .sql + .query_map( + "SELECT token FROM tokens WHERE namespc=? ORDER BY timestamp DESC LIMIT 1", + (namespace,), + |row| row.get(0), + |rows| { + rows.collect::, _>>() + .map_err(Into::into) + }, + ) + .await +} + pub async fn lookup_or_new( context: &Context, namespace: Namespace, diff --git a/test-data/golden/test_broadcast_joining_golden_bob b/test-data/golden/test_broadcast_joining_golden_bob index aa32d1c1c7..2dec039420 100644 --- a/test-data/golden/test_broadcast_joining_golden_bob +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -1,5 +1,5 @@ InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png -------------------------------------------------------------------------------- -Msg#12: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO] -Msg#13🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO] +Msg#11: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO] +Msg#12🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO] -------------------------------------------------------------------------------- From e63429f1497cd4e1768a4169abfdb4bad764927f Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 11:42:55 +0200 Subject: [PATCH 36/69] Don't include the broadcast's shared secret in the QR code --- deltachat-jsonrpc/src/api/types/qr.rs | 6 --- src/qr.rs | 55 +++++++++++++-------------- src/receive_imf.rs | 11 +++++- src/securejoin.rs | 6 +-- src/securejoin/qrinvite.rs | 3 -- src/tools.rs | 2 +- 6 files changed, 37 insertions(+), 46 deletions(-) diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 7c4207e1ff..7082a73817 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -46,10 +46,6 @@ pub enum QrObject { fingerprint: String, authcode: String, - - /// The secret shared between all members, - /// used to symmetrically encrypt&decrypt messages. - shared_secret: String, }, /// Contact fingerprint is verified. /// @@ -230,7 +226,6 @@ impl From for QrObject { contact_id, fingerprint, authcode, - shared_secret, } => { let contact_id = contact_id.to_u32(); let fingerprint = fingerprint.to_string(); @@ -240,7 +235,6 @@ impl From for QrObject { contact_id, fingerprint, authcode, - shared_secret, } } Qr::FprOk { contact_id } => { diff --git a/src/qr.rs b/src/qr.rs index 0eea2da10f..d6f53954c5 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -20,7 +20,7 @@ use crate::message::Message; use crate::net::http::post_empty; use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig}; use crate::token; -use crate::tools::{validate_broadcast_shared_secret, validate_id}; +use crate::tools::validate_id; const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#"; @@ -97,8 +97,6 @@ pub enum Qr { fingerprint: Fingerprint, authcode: String, - - shared_secret: String, }, /// Contact fingerprint is verified. @@ -398,7 +396,7 @@ pub fn format_backup(qr: &Qr) -> Result { /// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH` -/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=BROADCAST_NAME&x=BROADCAST_ID&s=AUTH&b=BROADCAST_SHARED_SECRET` +/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&b=BROADCAST_NAME&x=BROADCAST_ID&s=AUTH` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR` async fn decode_openpgp(context: &Context, qr: &str) -> Result { let payload = qr @@ -457,24 +455,9 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .get("x") .filter(|&s| validate_id(s)) .map(|s| s.to_string()); - let broadcast_shared_secret = param - .get("b") - .filter(|&s| validate_broadcast_shared_secret(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"); // sometimes spaces are encoded as `+` - 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 grpname = decode_chat_name(¶m, &grpid, "g")?; + let broadcast_name = decode_chat_name(¶m, &grpid, "b")?; if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode.clone()) @@ -549,13 +532,8 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { authcode, }) } - } else if let ( - Some(addr), - Some(broadcast_name), - Some(grpid), - Some(authcode), - Some(shared_secret), - ) = (&addr, grpname, grpid, authcode, broadcast_shared_secret) + } else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(authcode)) = + (&addr, broadcast_name, grpid, authcode) { // This is a broadcast channel invite link. // TODO code duplication with the previous block @@ -577,7 +555,6 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { contact_id, fingerprint, authcode, - shared_secret, }) } else if let Some(addr) = addr { let fingerprint = fingerprint.hex(); @@ -600,6 +577,26 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { } } +fn decode_chat_name( + param: &BTreeMap<&str, &str>, + grpid: &Option, + key: &str, +) -> Result> { + if grpid.is_some() { + if let Some(encoded_name) = param.get(key) { + let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+` + match percent_decode_str(&encoded_name).decode_utf8() { + Ok(name) => Ok(Some(name.to_string())), + Err(err) => bail!("Invalid group name: {}", err), + } + } else { + Ok(None) + } + } else { + Ok(None) + } +} + /// scheme: `https://i.delta.chat[/]#FINGERPRINT&a=ADDR[&OPTIONAL_PARAMS]` async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result { let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1); diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 997aec19cd..e4765a6359 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -45,7 +45,10 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on use crate::simplify; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix}; +use crate::tools::{ + self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix, + validate_broadcast_shared_secret, +}; use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location}; use crate::{contact, imap}; @@ -3579,7 +3582,11 @@ async fn apply_in_broadcast_changes( } if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) { - save_broadcast_shared_secret(context, chat.id, secret).await?; + if validate_broadcast_shared_secret(secret) { + save_broadcast_shared_secret(context, chat.id, secret).await?; + } else { + warn!(context, "Not saving invalid broadcast secret"); + } } if send_event_chat_modified { diff --git a/src/securejoin.rs b/src/securejoin.rs index 2854548370..ec75182c99 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -108,17 +108,13 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option) -> Resul let broadcast_name = chat.get_name(); let broadcast_name_urlencoded = utf8_percent_encode(broadcast_name, NON_ALPHANUMERIC).to_string(); - let broadcast_secret = load_broadcast_shared_secret(context, chat.id) - .await? - .context("Could not find broadcast secret")?; format!( - "https://i.delta.chat/#{}&a={}&g={}&x={}&s={}&b={}", + "https://i.delta.chat/#{}&a={}&b={}&x={}&s={}", fingerprint.hex(), self_addr_urlencoded, &broadcast_name_urlencoded, &chat.grpid, &auth, - broadcast_secret ) } else { // parameters used: a=g=x=i=s= diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index 20d20baaa9..0010ffc81a 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -35,7 +35,6 @@ pub enum QrInvite { broadcast_name: String, grpid: String, authcode: String, - shared_secret: String, }, } @@ -129,14 +128,12 @@ impl TryFrom for QrInvite { contact_id, fingerprint, authcode, - shared_secret, } => Ok(QrInvite::Broadcast { broadcast_name, grpid, contact_id, fingerprint, authcode, - shared_secret, }), _ => bail!("Unsupported QR type: {qr:?}"), } diff --git a/src/tools.rs b/src/tools.rs index b4cce6ee60..4e26eb5ee9 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -334,7 +334,7 @@ pub(crate) fn validate_id(s: &str) -> bool { pub(crate) fn validate_broadcast_shared_secret(s: &str) -> bool { let alphabet = base64::alphabet::URL_SAFE.as_str(); - s.chars().all(|c| alphabet.contains(c)) && s.len() == 43 + s.chars().all(|c| alphabet.contains(c)) && s.len() >= 43 && s.len() <= 100 } /// Function generates a Message-ID that can be used for a new outgoing message. From 21afbf9c493f5179e6f547d913a6b13c3d273e81 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 11:49:04 +0200 Subject: [PATCH 37/69] refactor: Use the same decode_name() function for the contact name, remove redundant check for grpid.is_some() If grpid is none, the group/brodacast name isn't used, anyways --- src/qr.rs | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/qr.rs b/src/qr.rs index d6f53954c5..d6b3378f1f 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -433,15 +433,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { None }; - let name = if let Some(encoded_name) = param.get("n") { - let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+` - match percent_decode_str(&encoded_name).decode_utf8() { - Ok(name) => name.to_string(), - Err(err) => bail!("Invalid name: {}", err), - } - } else { - "".to_string() - }; + let name = decode_name(¶m, "n")?.unwrap_or_default(); let invitenumber = param .get("i") @@ -456,8 +448,8 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .filter(|&s| validate_id(s)) .map(|s| s.to_string()); - let grpname = decode_chat_name(¶m, &grpid, "g")?; - let broadcast_name = decode_chat_name(¶m, &grpid, "b")?; + let grpname = decode_name(¶m, "g")?; + let broadcast_name = decode_name(¶m, "b")?; if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode.clone()) @@ -577,20 +569,12 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { } } -fn decode_chat_name( - param: &BTreeMap<&str, &str>, - grpid: &Option, - key: &str, -) -> Result> { - if grpid.is_some() { - if let Some(encoded_name) = param.get(key) { - let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+` - match percent_decode_str(&encoded_name).decode_utf8() { - Ok(name) => Ok(Some(name.to_string())), - Err(err) => bail!("Invalid group name: {}", err), - } - } else { - Ok(None) +fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result> { + if let Some(encoded_name) = param.get(key) { + let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+` + match percent_decode_str(&encoded_name).decode_utf8() { + Ok(name) => Ok(Some(name.to_string())), + Err(err) => bail!("Invalid QR param {key}: {err}"), } } else { Ok(None) From 63cdf7ebaee21db08b8f97d063ecd84348b0ee9a Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 16:21:04 +0200 Subject: [PATCH 38/69] refactor: It's not actually necessary for Alice to remember how the message was encrypted --- benches/benchmark_decrypting.rs | 4 +- src/decrypt.rs | 2 +- src/mimeparser.rs | 111 +++++++++++--------------------- src/pgp.rs | 14 ++-- src/securejoin.rs | 5 +- 5 files changed, 45 insertions(+), 91 deletions(-) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index f46cf7a7e9..47162e52ac 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -71,7 +71,7 @@ fn criterion_benchmark(c: &mut Criterion) { }); b.iter(|| { - let (mut msg, _) = + let mut msg = decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap(); let decrypted = msg.as_data_vec().unwrap(); @@ -101,7 +101,7 @@ fn criterion_benchmark(c: &mut Criterion) { }); b.iter(|| { - let (mut msg, _) = decrypt( + let mut msg = decrypt( encrypted.clone().into_bytes(), &[key_pair.secret.clone()], black_box(&secrets), diff --git a/src/decrypt.rs b/src/decrypt.rs index 48f8cd4a27..bf49fc6abd 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -19,7 +19,7 @@ pub fn try_decrypt<'a>( mail: &'a ParsedMail<'a>, private_keyring: &'a [SignedSecretKey], shared_secrets: &[String], -) -> Result, Option)>> { +) -> Result>> { let Some(encrypted_data_part) = get_encrypted_mime(mail) else { return Ok(None); }; diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 831efb4795..1c07671731 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -136,10 +136,6 @@ pub(crate) struct MimeMessage { /// Sender timestamp in secs since epoch. Allowed to be in the future due to unsynchronized /// clocks, but not too much. pub(crate) timestamp_sent: i64, - - /// How the message was encrypted (and now successfully decrypted): - /// The asymmetric key, an AUTH token, or a broadcast's shared secret. - pub(crate) was_encrypted_with: EncryptedWith, } #[derive(Debug, PartialEq)] @@ -222,25 +218,6 @@ pub enum SystemMessage { ChatE2ee = 50, } -#[derive(Debug)] -pub(crate) enum EncryptedWith { - AsymmetricKey, - BroadcastSecret(String), - AuthToken(String), - None, -} - -impl EncryptedWith { - pub(crate) fn auth_token(&self) -> Option<&str> { - match self { - EncryptedWith::AsymmetricKey => None, - EncryptedWith::BroadcastSecret(_) => None, - EncryptedWith::AuthToken(token) => Some(token), - EncryptedWith::None => None, - } - } -} - const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup"; impl MimeMessage { @@ -373,64 +350,51 @@ impl MimeMessage { }, ) .await?; - let num_broadcast_secrets = secrets.len(); secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?); - let (mail, is_encrypted, decrypted_with) = match tokio::task::block_in_place(|| { - try_decrypt(&mail, &private_keyring, &secrets) - }) { - Ok(Some((mut msg, index_of_secret))) => { - mail_raw = msg.as_data_vec().unwrap_or_default(); + let (mail, is_encrypted) = + match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) { + Ok(Some(mut msg)) => { + mail_raw = msg.as_data_vec().unwrap_or_default(); - let decrypted_mail = mailparse::parse_mail(&mail_raw)?; - if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { - info!( - context, - "decrypted message mime-body:\n{}", - String::from_utf8_lossy(&mail_raw), - ); - } - - decrypted_msg = Some(msg); + let decrypted_mail = mailparse::parse_mail(&mail_raw)?; + if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { + info!( + context, + "decrypted message mime-body:\n{}", + String::from_utf8_lossy(&mail_raw), + ); + } - timestamp_sent = Self::get_timestamp_sent( - &decrypted_mail.headers, - timestamp_sent, - timestamp_rcvd, - ); + decrypted_msg = Some(msg); - if let Some(protected_aheader_value) = decrypted_mail - .headers - .get_header_value(HeaderDef::Autocrypt) - { - aheader_value = Some(protected_aheader_value); - } + timestamp_sent = Self::get_timestamp_sent( + &decrypted_mail.headers, + timestamp_sent, + timestamp_rcvd, + ); - let decrypted_with = if let Some(index_of_secret) = index_of_secret { - let used_secret = secrets.into_iter().nth(index_of_secret).unwrap_or_default(); - if index_of_secret < num_broadcast_secrets { - EncryptedWith::BroadcastSecret(used_secret) - } else { - EncryptedWith::AuthToken(used_secret) + if let Some(protected_aheader_value) = decrypted_mail + .headers + .get_header_value(HeaderDef::Autocrypt) + { + aheader_value = Some(protected_aheader_value); } - } else { - EncryptedWith::AsymmetricKey - }; - (Ok(decrypted_mail), true, decrypted_with) - } - Ok(None) => { - mail_raw = Vec::new(); - decrypted_msg = None; - (Ok(mail), false, EncryptedWith::None) - } - Err(err) => { - mail_raw = Vec::new(); - decrypted_msg = None; - warn!(context, "decryption failed: {:#}", err); - (Err(err), false, EncryptedWith::None) - } - }; + (Ok(decrypted_mail), true) + } + Ok(None) => { + mail_raw = Vec::new(); + decrypted_msg = None; + (Ok(mail), false) + } + Err(err) => { + mail_raw = Vec::new(); + decrypted_msg = None; + warn!(context, "decryption failed: {:#}", err); + (Err(err), false) + } + }; let autocrypt_header = if !incoming { None @@ -602,7 +566,6 @@ impl MimeMessage { is_bot: None, timestamp_rcvd, timestamp_sent, - was_encrypted_with: decrypted_with, }; match partial { diff --git a/src/pgp.rs b/src/pgp.rs index 10183b4f0b..98e61e9de1 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -251,7 +251,7 @@ pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], shared_secrets: &[String], -) -> Result<(pgp::composed::Message<'static>, Option)> { +) -> Result> { let cursor = Cursor::new(ctext); let (msg, _headers) = Message::from_armor(cursor)?; @@ -277,12 +277,7 @@ pub fn decrypt( // remove one layer of compression let msg = msg.decompress()?; - let decrypted_with_secret = ring_result - .message_password - .iter() - .position(|&p| p == InnerRingResult::Ok); - - Ok((msg, decrypted_with_secret)) + Ok(msg) } /// Returns fingerprints @@ -418,7 +413,7 @@ mod tests { HashSet, Vec, )> { - let (mut msg, _) = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?; + let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?; let content = msg.as_data_vec()?; let ret_signature_fingerprints = valid_signature_fingerprints(&msg, public_keys_for_validation)?; @@ -622,14 +617,13 @@ mod tests { .await?; let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; - let (mut decrypted, index_of_secret) = decrypt( + let mut decrypted = decrypt( ctext.into(), &bob_private_keyring, &[shared_secret.to_string()], )?; assert_eq!(decrypted.as_data_vec()?, plain); - assert_eq!(index_of_secret, Some(0)); Ok(()) } diff --git a/src/securejoin.rs b/src/securejoin.rs index ec75182c99..85c3050421 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -387,10 +387,7 @@ pub(crate) async fn handle_securejoin_handshake( } // verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code, // or that the message was encrypted with the secret written to the QR code. - let auth = mime_message - .get_header(HeaderDef::SecureJoinAuth) - .or_else(|| mime_message.was_encrypted_with.auth_token()); - let Some(auth) = auth else { + let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else { warn!( context, "Ignoring {step} message because of missing auth code." From 7f3f4d541da77386998116a16ae27c41b35abaa5 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 16:26:04 +0200 Subject: [PATCH 39/69] clippy --- src/chat.rs | 8 ++++---- src/chat/chat_tests.rs | 22 +++++++++++----------- src/pgp.rs | 4 ++-- src/securejoin.rs | 1 - src/securejoin/securejoin_tests.rs | 4 ++-- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 875ac69f8f..75175268e6 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -3838,13 +3838,13 @@ pub(crate) async fn load_broadcast_shared_secret( context: &Context, chat_id: ChatId, ) -> Result> { - Ok(context + context .sql .query_get_value( "SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?", (chat_id,), ) - .await?) + .await } pub(crate) async fn save_broadcast_shared_secret( @@ -5184,7 +5184,7 @@ impl Context { } } - async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &String) -> Result { + async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &str) -> Result { Ok(match action { SyncAction::CreateOutBroadcast { chat_name, @@ -5193,7 +5193,7 @@ impl Context { create_broadcast_ex( self, Nosync, - grpid.clone(), + grpid.to_string(), chat_name.clone(), shared_secret.to_string(), ) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index a5773fc731..57597ad449 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2635,17 +2635,17 @@ async fn test_broadcast_change_name() -> Result<()> { let fiona = &tcm.fiona().await; tcm.section("Alice sends a message to Bob"); - let chat_alice = alice.create_chat(&bob).await; - send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?; + let chat_alice = alice.create_chat(bob).await; + send_text_msg(alice, chat_alice.id, "hi!".to_string()).await?; bob.recv_msg(&alice.pop_sent_msg().await).await; tcm.section("Bob sends a message to Alice"); - let chat_bob = bob.create_chat(&alice).await; - send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?; + let chat_bob = bob.create_chat(alice).await; + send_text_msg(bob, chat_bob.id, "ho!".to_string()).await?; let msg = alice.recv_msg(&bob.pop_sent_msg().await).await; assert!(msg.get_showpadlock()); - let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?; + let broadcast_id = create_broadcast(alice, "Channel".to_string()).await?; let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap(); tcm.section("Alice invites Bob to her channel"); @@ -2655,7 +2655,7 @@ async fn test_broadcast_change_name() -> Result<()> { { tcm.section("Alice changes the chat name"); - set_chat_name(&alice, broadcast_id, "My great broadcast").await?; + set_chat_name(alice, broadcast_id, "My great broadcast").await?; let sent = alice.pop_sent_msg().await; tcm.section("Bob receives the name-change system message"); @@ -2673,15 +2673,15 @@ async fn test_broadcast_change_name() -> Result<()> { { tcm.section("Alice changes the chat name again, but the system message is lost somehow"); - set_chat_name(&alice, broadcast_id, "Broadcast channel").await?; + set_chat_name(alice, broadcast_id, "Broadcast channel").await?; - let chat = Chat::load_from_db(&alice, broadcast_id).await?; + let chat = Chat::load_from_db(alice, broadcast_id).await?; assert_eq!(chat.typ, Chattype::OutBroadcast); assert_eq!(chat.name, "Broadcast channel"); assert!(!chat.is_self_talk()); tcm.section("Alice sends a text message 'ola!'"); - send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?; + send_text_msg(alice, broadcast_id, "ola!".to_string()).await?; let msg = alice.get_last_msg().await; assert_eq!(msg.chat_id, chat.id); } @@ -2703,7 +2703,7 @@ async fn test_broadcast_change_name() -> Result<()> { assert_eq!(msg.subject, "Re: Broadcast channel"); assert!(msg.get_showpadlock()); assert!(msg.get_override_sender_name().is_none()); - let chat = Chat::load_from_db(&bob, msg.chat_id).await?; + let chat = Chat::load_from_db(bob, msg.chat_id).await?; assert_eq!(chat.typ, Chattype::InBroadcast); assert_ne!(chat.id, chat_bob.id); assert_eq!(chat.name, "Broadcast channel"); @@ -2746,7 +2746,7 @@ async fn test_broadcast_multidev() -> Result<()> { set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?; let sent_msg = alice0.send_text(a0_broadcast_id, "hi").await; let msg = alice1.recv_msg(&sent_msg).await; - let a1_broadcast_id = get_chat_id_by_grpid(&alice1, &a0_broadcast_chat.grpid) + let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid) .await? .unwrap() .0; diff --git a/src/pgp.rs b/src/pgp.rs index 98e61e9de1..1f222320ed 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -8,7 +8,7 @@ use chrono::SubsecRound; use deltachat_contact_tools::EmailAddress; use pgp::armor::BlockType; use pgp::composed::{ - ArmorOptions, Deserializable, InnerRingResult, KeyType as PgpKeyType, Message, MessageBuilder, + ArmorOptions, Deserializable, KeyType as PgpKeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, StandaloneSignature, SubkeyParamsBuilder, TheRing, }; @@ -272,7 +272,7 @@ pub fn decrypt( session_keys: vec![], allow_legacy: false, }; - let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?; + let (msg, _ring_result) = msg.decrypt_the_ring(ring, true)?; // remove one layer of compression let msg = msg.decompress()?; diff --git a/src/securejoin.rs b/src/securejoin.rs index 85c3050421..8ba724b7ac 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -6,7 +6,6 @@ use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use crate::chat::{ self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid, - load_broadcast_shared_secret, }; use crate::chatlist_events; use crate::config::Config; diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index fb8306497c..ed59dc0213 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -857,8 +857,8 @@ async fn test_send_avatar_in_securejoin() -> Result<()> { //exec_securejoin_broadcast(&tcm, alice, bob).await; } - let alice_on_bob = bob.add_or_lookup_contact_no_key(&alice).await; - let avatar = alice_on_bob.get_profile_image(&bob).await?.unwrap(); + let alice_on_bob = bob.add_or_lookup_contact_no_key(alice).await; + let avatar = alice_on_bob.get_profile_image(bob).await?.unwrap(); assert_eq!( avatar.file_name().unwrap().to_str().unwrap(), AVATAR_64x64_DEDUPLICATED From d1769ed9abfbd04e9b4b876b9ed655077b00f322 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 16:45:20 +0200 Subject: [PATCH 40/69] test: Improve test_send_avatar_in_securejoin() --- src/securejoin/securejoin_tests.rs | 42 +++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index ed59dc0213..9d7555abff 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -834,8 +834,19 @@ async fn test_send_avatar_in_securejoin() -> Result<()> { let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap(); tcm.exec_securejoin_qr(scanner, scanned, &qr).await; } + async fn exec_securejoin_broadcast( + tcm: &TestContextManager, + scanner: &TestContext, + scanned: &TestContext, + ) { + let chat_id = chat::create_broadcast(scanned, "group".to_string()) + .await + .unwrap(); + let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(scanner, scanned, &qr).await; + } - for alice_scans in [true, false] { + for round in 0..6 { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; @@ -846,15 +857,26 @@ async fn test_send_avatar_in_securejoin() -> Result<()> { .set_config(Config::Selfavatar, Some(file.to_str().unwrap())) .await?; - if alice_scans { - tcm.execute_securejoin(alice, bob).await; - //exec_securejoin_group(&tcm, alice, bob).await; - //exec_securejoin_broadcast(&tcm, alice, bob).await; - // TODO also test these - } else { - tcm.execute_securejoin(bob, alice).await; - //exec_securejoin_group(&tcm, bob, alice).await; - //exec_securejoin_broadcast(&tcm, alice, bob).await; + match round { + 0 => { + tcm.execute_securejoin(alice, bob).await; + } + 1 => { + tcm.execute_securejoin(bob, alice).await; + } + 2 => { + exec_securejoin_group(&tcm, alice, bob).await; + } + 3 => { + exec_securejoin_group(&tcm, bob, alice).await; + } + 4 => { + exec_securejoin_broadcast(&tcm, alice, bob).await; + } + 5 => { + exec_securejoin_broadcast(&tcm, bob, alice).await; + } + _ => panic!(), } let alice_on_bob = bob.add_or_lookup_contact_no_key(alice).await; From af527722ca4a20cb4a16e29307b141c867dbf383 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 16:52:28 +0200 Subject: [PATCH 41/69] No clippy warnings anymore! --- benches/benchmark_decrypting.rs | 9 +++------ src/benchmark_internals.rs | 6 ++++++ src/e2ee.rs | 8 ++++---- src/pgp.rs | 7 +------ src/qr.rs | 13 +++++++++++-- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index 47162e52ac..d36f3b2c45 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; -use std::{hint::black_box, io::Write}; +use std::hint::black_box; use criterion::{Criterion, criterion_group, criterion_main}; +use deltachat::benchmark_internals::create_dummy_keypair; use deltachat::benchmark_internals::save_broadcast_shared_secret; use deltachat::{ Events, @@ -11,10 +11,7 @@ use deltachat::{ chat::ChatId, config::Config, context::Context, - imex::{ImexMode, imex}, - key, - pgp::{KeyPair, create_dummy_keypair, decrypt, encrypt_for_broadcast, pk_encrypt}, - receive_imf, + pgp::{KeyPair, decrypt, encrypt_for_broadcast, pk_encrypt}, stock_str::StockStrings, tools::create_broadcast_shared_secret_pub, }; diff --git a/src/benchmark_internals.rs b/src/benchmark_internals.rs index 423e0882e8..5b088a9aa0 100644 --- a/src/benchmark_internals.rs +++ b/src/benchmark_internals.rs @@ -1,6 +1,8 @@ //! Re-exports of internal functions needed for benchmarks. +#![allow(missing_docs)] // Not necessary to put a doc comment on the pub functions here use anyhow::Result; +use deltachat_contact_tools::EmailAddress; use std::collections::BTreeMap; use crate::chat::ChatId; @@ -32,3 +34,7 @@ pub async fn save_broadcast_shared_secret( ) -> Result<()> { crate::chat::save_broadcast_shared_secret(context, chat_id, secret).await } + +pub fn create_dummy_keypair(addr: &str) -> Result { + pgp::create_keypair(EmailAddress::new(addr)?) +} diff --git a/src/e2ee.rs b/src/e2ee.rs index deae246be3..4fbc4b013f 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -56,8 +56,8 @@ impl EncryptHelper { println!( "\nEncrypting pk:\n{}\n", - str::from_utf8(&raw_message).unwrap() - ); + String::from_utf8_lossy(&raw_message) + ); // TODO let ctext = pgp::pk_encrypt(raw_message, keyring, Some(sign_key), compress).await?; @@ -80,8 +80,8 @@ impl EncryptHelper { println!( "\nEncrypting symm:\n{}\n", - str::from_utf8(&raw_message).unwrap() - ); + String::from_utf8_lossy(&raw_message) + ); // TODO let ctext = pgp::encrypt_for_broadcast(raw_message, passphrase, sign_key, compress).await?; diff --git a/src/pgp.rs b/src/pgp.rs index 1f222320ed..349f0cbf7b 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -26,7 +26,7 @@ use crate::key::{DcKey, Fingerprint}; #[cfg(test)] pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt"; -pub const HEADER_SETUPCODE: &str = "passphrase-begin"; +pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin"; /// Preferred symmetric encryption algorithm. const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128; @@ -149,11 +149,6 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result { Ok(key_pair) } -#[cfg(feature = "internals")] -pub fn create_dummy_keypair(addr: &str) -> Result { - create_keypair(EmailAddress::new(addr)?) -} - /// Selects a subkey of the public key to use for encryption. /// /// Returns `None` if the public key cannot be used for encryption. diff --git a/src/qr.rs b/src/qr.rs index d6b3378f1f..86e9610db2 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -86,16 +86,25 @@ pub enum Qr { /// Ask whether to join the broadcast channel. AskJoinBroadcast { - // TODO document + /// The user-visible name of this broadcast channel broadcast_name: String, - // TODO not sure wheter it makes sense to call this grpid just because it's called like this in the db + /// A string of random characters, + /// uniquely identifying this broadcast channel in the database. + /// Called `grpid` for historic reasons: + /// The id of multi-user chats is always called `grpid` in the database + /// because groups were once the only multi-user chats. grpid: String, + /// The contact id of the inviter contact_id: ContactId, + /// The PGP fingerprint of the inviter fingerprint: Fingerprint, + /// The AUTH code from the secure-join protocol, + /// which is both used to encrypt the first message to the inviter + /// and to prove to the inviter that we saw the QR code. authcode: String, }, From 6bf5e872bf79d2ce04ca49937d06f1cc9be9f675 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 17:10:25 +0200 Subject: [PATCH 42/69] Use translatable message for broadcast-joining --- src/securejoin/bob.rs | 5 ++--- test-data/golden/test_broadcast_joining_golden_bob | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index fc07579f23..8e7099fbe0 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -164,9 +164,8 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul .await?; } - // TODO this message should be translatable: - let msg = "You were invited to join this channel. Waiting for the channel owner's device to reply…"; - chat::add_info_msg(context, joining_chat_id, msg, time()).await?; + let msg = stock_str::securejoin_wait(context).await; + chat::add_info_msg(context, joining_chat_id, &msg, time()).await?; Ok(joining_chat_id) } QrInvite::Contact { .. } => { diff --git a/test-data/golden/test_broadcast_joining_golden_bob b/test-data/golden/test_broadcast_joining_golden_bob index 2dec039420..c035544921 100644 --- a/test-data/golden/test_broadcast_joining_golden_bob +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -1,5 +1,5 @@ InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png -------------------------------------------------------------------------------- -Msg#11: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO] +Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] Msg#12🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO] -------------------------------------------------------------------------------- From c513afa2297d9db6165bf6b399da6f003bd6dc14 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 17:40:37 +0200 Subject: [PATCH 43/69] Add golden test that only one member-added message is shown for Bob --- src/chat/chat_tests.rs | 13 +++++++------ test-data/golden/test_sync_broadcast_bob | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 test-data/golden/test_sync_broadcast_bob diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 57597ad449..1bca324c99 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3879,14 +3879,11 @@ async fn test_sync_broadcast() -> Result<()> { .await .unwrap(); sync(alice0, alice1).await; // Sync QR code - tcm.exec_securejoin_qr_multi_device(bob, &[alice0, alice1], &qr) + let bob_broadcast_id = tcm + .exec_securejoin_qr_multi_device(bob, &[alice0, alice1], &qr) .await; - // This also imports Bob's key from the vCard. - // Otherwise it is possible that second device - // does not have Bob's key as only the fingerprint - // is transferred in the sync message. - let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id; + let a1b_contact_id = alice1.add_or_lookup_contact_no_key(bob).await.id; assert_eq!( get_chat_contacts(alice1, a1_broadcast_id).await?, vec![a1b_contact_id] @@ -3909,6 +3906,10 @@ async fn test_sync_broadcast() -> Result<()> { a0_broadcast_id.delete(alice0).await?; sync(alice0, alice1).await; alice1.assert_no_chat(a1_broadcast_id).await; + + bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob") + .await; + Ok(()) } diff --git a/test-data/golden/test_sync_broadcast_bob b/test-data/golden/test_sync_broadcast_bob new file mode 100644 index 0000000000..7aeec5a199 --- /dev/null +++ b/test-data/golden/test_sync_broadcast_bob @@ -0,0 +1,6 @@ +InBroadcast#Chat#11: Channel [1 member(s)] +-------------------------------------------------------------------------------- +Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] +Msg#12🔒: (Contact#Contact#10): Member Me added by alice@example.org. [FRESH][INFO] +Msg#13🔒: (Contact#Contact#10): hi [FRESH] +-------------------------------------------------------------------------------- From c5731f79db7c0db8546fd7380d522a436f97b800 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 7 Aug 2025 23:17:39 +0200 Subject: [PATCH 44/69] fix: Show only one member-added message for Bob --- deltachat-jsonrpc/src/api/types/chat_list.rs | 4 +- src/chat.rs | 5 +- src/receive_imf.rs | 50 ++++++++++++------- src/receive_imf/receive_imf_tests.rs | 2 +- src/securejoin/bob.rs | 6 ++- .../golden/test_broadcast_joining_golden_bob | 2 +- test-data/golden/test_sync_broadcast_bob | 4 +- 7 files changed, 45 insertions(+), 28 deletions(-) diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs index b5d31a7913..deaeeee20b 100644 --- a/deltachat-jsonrpc/src/api/types/chat_list.rs +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -129,7 +129,9 @@ pub(crate) async fn get_chat_list_item_by_id( let chat_contacts = get_chat_contacts(ctx, chat_id).await?; - let self_in_group = chat_contacts.contains(&ContactId::SELF); + let self_in_group = chat_contacts.contains(&ContactId::SELF) + || chat.get_type() == Chattype::OutBroadcast + || chat.get_type() == Chattype::Mailinglist; let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single { let contact = chat_contacts.first(); diff --git a/src/chat.rs b/src/chat.rs index 75175268e6..5f98c6d53d 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1737,8 +1737,9 @@ impl Chat { pub(crate) async fn is_self_in_chat(&self, context: &Context) -> Result { match self.typ { Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true), - Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await, - Chattype::InBroadcast => Ok(true), + Chattype::Group | Chattype::InBroadcast => { + is_contact_in_chat(context, self.id, ContactId::SELF).await + } } } diff --git a/src/receive_imf.rs b/src/receive_imf.rs index e4765a6359..e2ba9afd9f 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3071,9 +3071,7 @@ async fn apply_group_changes( if let Some(added_id) = added_id { if !added_ids.remove(&added_id) && !self_added { - // No-op "Member added" message. - // - // Trash it. + info!(context, "No-op 'Member added' message (TRASH)"); better_msg = Some(String::new()); } } @@ -3325,13 +3323,6 @@ async fn create_or_lookup_mailinglist_or_broadcast( ) })?; - chat::add_to_chat_contacts_table( - context, - mime_parser.timestamp_sent, - chat_id, - &[ContactId::SELF], - ) - .await?; if chattype == Chattype::InBroadcast { chat::add_to_chat_contacts_table( context, @@ -3566,19 +3557,40 @@ async fn apply_in_broadcast_changes( ) .await?; + if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { + if context.is_self_addr(added_addr).await? { + let msg; + + if chat.is_self_in_chat(context).await? { + // Self is already in the chat. + // Probably Alice has two devices and her second device added us again; + // just hide the message. + info!(context, "No-op broadcast 'Member added' message (TRASH)"); + msg = "".to_string(); + } else { + msg = stock_str::msg_add_member_local(context, ContactId::SELF, from_id).await; + } + + better_msg.get_or_insert(msg); + } + } + if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { - // The only member added/removed message that is ever sent is "I left.", - // so, this is the only case we need to handle here if from_id == ContactId::SELF { + // The only member added/removed message that is ever sent is "I left.", + // so, this is the only case we need to handle here better_msg .get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await); - } - } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - if context.is_self_addr(added_addr).await? { - better_msg.get_or_insert( - stock_str::msg_add_member_local(context, ContactId::SELF, from_id).await, - ); - } + } // TODO handle removed case + } else if !chat.is_self_in_chat(context).await? { + // Apparently, self is in the chat now, because we're receiving messages + chat::add_to_chat_contacts_table( + context, + mime_parser.timestamp_sent, + chat.id, + &[ContactId::SELF], + ) + .await?; } if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) { diff --git a/src/receive_imf/receive_imf_tests.rs b/src/receive_imf/receive_imf_tests.rs index 1a65ca8085..c85acd24b4 100644 --- a/src/receive_imf/receive_imf_tests.rs +++ b/src/receive_imf/receive_imf_tests.rs @@ -881,7 +881,7 @@ async fn test_github_mailing_list() -> Result<()> { Some("reply+elernshsetushoyseshetihseusaferuhsedtisneu@reply.github.com") ); assert_eq!(chat.name, "deltachat/deltachat-core-rust"); - assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 1); + assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 0); receive_imf(&t.ctx, GH_MAILINGLIST2.as_bytes(), false).await?; diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 8e7099fbe0..0c11e54a17 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -164,8 +164,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul .await?; } - let msg = stock_str::securejoin_wait(context).await; - chat::add_info_msg(context, joining_chat_id, &msg, time()).await?; + if !is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? { + let msg = stock_str::securejoin_wait(context).await; + chat::add_info_msg(context, joining_chat_id, &msg, time()).await?; + } Ok(joining_chat_id) } QrInvite::Contact { .. } => { diff --git a/test-data/golden/test_broadcast_joining_golden_bob b/test-data/golden/test_broadcast_joining_golden_bob index c035544921..71b6f1bda1 100644 --- a/test-data/golden/test_broadcast_joining_golden_bob +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -1,4 +1,4 @@ -InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png +InBroadcast#Chat#11: My Channel [2 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png -------------------------------------------------------------------------------- Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] Msg#12🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO] diff --git a/test-data/golden/test_sync_broadcast_bob b/test-data/golden/test_sync_broadcast_bob index 7aeec5a199..568dd86489 100644 --- a/test-data/golden/test_sync_broadcast_bob +++ b/test-data/golden/test_sync_broadcast_bob @@ -1,6 +1,6 @@ -InBroadcast#Chat#11: Channel [1 member(s)] +InBroadcast#Chat#11: Channel [2 member(s)] -------------------------------------------------------------------------------- Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] Msg#12🔒: (Contact#Contact#10): Member Me added by alice@example.org. [FRESH][INFO] -Msg#13🔒: (Contact#Contact#10): hi [FRESH] +Msg#14🔒: (Contact#Contact#10): hi [FRESH] -------------------------------------------------------------------------------- From 494cd39a3bc848ddfa96e7fd59708b1368dafb0d Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 8 Aug 2025 14:00:06 +0200 Subject: [PATCH 45/69] docs: Fix wrong comment on msg_del_member_local() --- src/stock_str.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stock_str.rs b/src/stock_str.rs index acc3099e0f..b625a35513 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -668,9 +668,9 @@ pub(crate) async fn msg_del_member_remote(context: &Context, removed_member_addr .replace1(whom) } -/// Stock string: `I added member %1$s.` or `Member %1$s removed by %2$s.`. +/// Stock string: `Member %1$s removed.`, `You removed member %1$s.` or `Member %1$s removed by %2$s.` /// -/// The `removed_member_addr` parameter should be an email address and is looked up in +/// The `removed_member` and `by_contact` parameter is looked up in /// the contacts to combine with the display name. pub(crate) async fn msg_del_member_local( context: &Context, From 44ae96468ffb8b0e856b4c9c4c3fa5e8cef24370 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 8 Aug 2025 15:00:32 +0200 Subject: [PATCH 46/69] Notify a removed member that they were removed --- src/chat.rs | 19 +++++- src/chat/chat_tests.rs | 17 ++++-- src/headerdef.rs | 1 + src/mimefactory.rs | 9 +++ src/receive_imf.rs | 75 ++++++++++++++---------- test-data/golden/test_sync_broadcast_bob | 3 +- 6 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 5f98c6d53d..f6d39b5ec4 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -31,6 +31,7 @@ use crate::debug_logging::maybe_set_logging_xdc; use crate::download::DownloadState; use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers}; use crate::events::EventType; +use crate::key::self_fingerprint; use crate::location; use crate::log::{LogExt, error, info, warn}; use crate::logged_debug_assert; @@ -4243,10 +4244,18 @@ pub async fn remove_contact_from_chat( // This allows to delete dangling references to deleted contacts // in case of the database becoming inconsistent due to a bug. if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? { - if chat.typ == Chattype::Group && chat.is_promoted() { + if chat.is_promoted() { let addr = contact.get_addr(); + let fingerprint = contact.fingerprint().map(|f| f.hex()); - let res = send_member_removal_msg(context, chat_id, contact_id, addr).await; + let res = send_member_removal_msg( + context, + chat_id, + contact_id, + addr, + fingerprint.as_deref(), + ) + .await; if contact_id == ContactId::SELF { res?; @@ -4270,7 +4279,9 @@ pub async fn remove_contact_from_chat( // For incoming broadcast channels, it's not possible to remove members, // but it's possible to leave: let self_addr = context.get_primary_self_addr().await?; - send_member_removal_msg(context, chat_id, contact_id, &self_addr).await?; + let fingerprint = self_fingerprint(context).await?; + send_member_removal_msg(context, chat_id, contact_id, &self_addr, Some(fingerprint)) + .await?; } else { bail!("Cannot remove members from non-group chats."); } @@ -4283,6 +4294,7 @@ async fn send_member_removal_msg( chat_id: ChatId, contact_id: ContactId, addr: &str, + fingerprint: Option<&str>, ) -> Result { let mut msg = Message::new(Viewtype::Text); @@ -4294,6 +4306,7 @@ async fn send_member_removal_msg( msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup); msg.param.set(Param::Arg, addr.to_lowercase()); + msg.param.set_optional(Param::Arg2, fingerprint); msg.param .set(Param::ContactAddedRemoved, contact_id.to_u32()); diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 1bca324c99..a73c2b8956 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3895,13 +3895,18 @@ async fn test_sync_broadcast() -> Result<()> { let msg = alice0.recv_msg(&sent_msg).await; assert_eq!(msg.chat_id, a0_broadcast_id); remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; - sync(alice0, alice1).await; + let sent = alice0.pop_sent_msg().await; + alice1.recv_msg(&sent).await; assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); - assert!( - get_past_chat_contacts(alice1, a1_broadcast_id) - .await? - .is_empty() - ); + // TODO do we want to make sure that there is no trace of a member? + // assert!( + // get_past_chat_contacts(alice1, a1_broadcast_id) + // .await? + // .is_empty() + // ); + bob.recv_msg(&sent).await; + let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?; + assert!(!bob_chat.is_self_in_chat(bob).await?); a0_broadcast_id.delete(alice0).await?; sync(alice0, alice1).await; diff --git a/src/headerdef.rs b/src/headerdef.rs index 0071a6da2b..a240b42202 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -63,6 +63,7 @@ pub enum HeaderDef { ChatUserAvatar, ChatVoiceMessage, ChatGroupMemberRemoved, + ChatGroupMemberRemovedFpr, ChatGroupMemberAdded, ChatContent, diff --git a/src/mimefactory.rs b/src/mimefactory.rs index dbc8c75026..4559e93d78 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1433,6 +1433,7 @@ impl MimeFactory { match command { SystemMessage::MemberRemovedFromGroup => { let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default(); + let fingerprint_to_remove = msg.param.get(Param::Arg2).unwrap_or_default(); if email_to_remove == context @@ -1453,6 +1454,14 @@ impl MimeFactory { .into(), )); } + + if !fingerprint_to_remove.is_empty() { + headers.push(( + "Chat-Group-Member-Removed-Fpr", + mail_builder::headers::raw::Raw::new(fingerprint_to_remove.to_string()) + .into(), + )); + } } SystemMessage::MemberAddedToGroup => { // TODO: lookup the contact by ID rather than email address. diff --git a/src/receive_imf.rs b/src/receive_imf.rs index e2ba9afd9f..25a3b1620e 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -15,8 +15,7 @@ use num_traits::FromPrimitive; use regex::Regex; use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table, - save_broadcast_shared_secret, + self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, save_broadcast_shared_secret, }; use crate::config::Config; use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails}; @@ -28,8 +27,8 @@ use crate::ephemeral::{Timer as EphemeralTimer, stock_ephemeral_timer_changed}; use crate::events::EventType; use crate::headerdef::{HeaderDef, HeaderDefMap}; use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table}; -use crate::key::self_fingerprint_opt; use crate::key::{DcKey, Fingerprint, SignedPublicKey}; +use crate::key::{self_fingerprint, self_fingerprint_opt}; use crate::log::LogExt; use crate::log::{info, warn}; use crate::logged_debug_assert; @@ -2905,15 +2904,13 @@ async fn apply_group_changes( } if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { - // TODO: if address "alice@example.org" is a member of the group twice, - // with old and new key, - // and someone (maybe Alice's new contact) just removed Alice's old contact, - // we may lookup the wrong contact because we only look up by the address. - // The result is that info message may contain the new Alice's display name - // rather than old display name. - // This could be fixed by looking up the contact with the highest - // `remove_timestamp` after applying Chat-Group-Member-Timestamps. - removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?; + if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) { + removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?; + } else { + // Removal message sent by a legacy Delta Chat client. + removed_id = + lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?; + } if let Some(id) = removed_id { better_msg = if id == from_id { silent = true; @@ -2930,6 +2927,8 @@ async fn apply_group_changes( // we may lookup the wrong contact. // This could be fixed by looking up the contact with // highest `add_timestamp` to disambiguate. + // Alternatively, this can be fixed by a header ChatGroupMemberAddedFpr, + // just like we have ChatGroupMemberRemovedFpr. // The result of the error is that info message // may contain display name of the wrong contact. let fingerprint = key.dc_fingerprint().hex(); @@ -3499,23 +3498,30 @@ async fn apply_out_broadcast_changes( ) .await?; - if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { - // The sender of the message left the broadcast channel - remove_from_chat_contacts_table(context, chat.id, from_id).await?; + if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) { + let removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?; + if removed_id == Some(from_id) { + // The sender of the message left the broadcast channel + chat::remove_from_chat_contacts_table(context, chat.id, from_id).await?; + + return Ok(GroupChangesInfo { + better_msg: Some("".to_string()), + added_removed_id: None, + silent: true, + extra_msgs: vec![], + }); + } else if from_id == ContactId::SELF { + if let Some(removed_id) = removed_id { + chat::remove_from_chat_contacts_table(context, chat.id, removed_id).await?; - return Ok(GroupChangesInfo { - better_msg: Some("".to_string()), - added_removed_id: None, - silent: true, - extra_msgs: vec![], - }); + better_msg.get_or_insert( + stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await, + ); + } + } } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - // TODO this may lookup the wrong contact if multiple contacts have the same email addr. - // We can send sync messages instead, - // lookup the fingerprint by gossip header (like it's done for groups right now), - // add a header ChatGroupMemberAddedFpr, - // or only handle addition on receival of Bob's request message and solve the problem in a different way for member-removed. - // --> link2xt said to probably handle addition on receival of Bob's request message, and to add a header ChatGroupMemberRemovedFpr. + // TODO this block can be removed, + // now that all of Alice's devices get to know about Bob joining via Bob's QR message. let contact = lookup_key_contact_by_address(context, added_addr, None).await?; if let Some(contact) = contact { better_msg.get_or_insert( @@ -3575,13 +3581,20 @@ async fn apply_in_broadcast_changes( } } - if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { + if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) { + // We are not supposed to receive a notification when someone else than self is removed: + ensure!(removed_fpr == self_fingerprint(context).await?); + if from_id == ContactId::SELF { - // The only member added/removed message that is ever sent is "I left.", - // so, this is the only case we need to handle here better_msg .get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await); - } // TODO handle removed case + } else { + better_msg.get_or_insert( + stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await, + ); + } + + chat::remove_from_chat_contacts_table(context, chat.id, ContactId::SELF).await?; } else if !chat.is_self_in_chat(context).await? { // Apparently, self is in the chat now, because we're receiving messages chat::add_to_chat_contacts_table( diff --git a/test-data/golden/test_sync_broadcast_bob b/test-data/golden/test_sync_broadcast_bob index 568dd86489..e0a31122bb 100644 --- a/test-data/golden/test_sync_broadcast_bob +++ b/test-data/golden/test_sync_broadcast_bob @@ -1,6 +1,7 @@ -InBroadcast#Chat#11: Channel [2 member(s)] +InBroadcast#Chat#11: Channel [1 member(s)] -------------------------------------------------------------------------------- Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] Msg#12🔒: (Contact#Contact#10): Member Me added by alice@example.org. [FRESH][INFO] Msg#14🔒: (Contact#Contact#10): hi [FRESH] +Msg#15🔒: (Contact#Contact#10): Member Me removed by alice@example.org. [FRESH][INFO] -------------------------------------------------------------------------------- From f321ed2a0338e720189834768f94c75d70968936 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 8 Aug 2025 15:16:02 +0200 Subject: [PATCH 47/69] Remove unnecessary TODO --- src/chat/chat_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index a73c2b8956..2ec4561f8a 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2874,7 +2874,10 @@ async fn test_broadcast_joining_golden() -> Result<()> { let file = alice.get_blobdir().join("avatar.png"); tokio::fs::write(&file, AVATAR_64x64_BYTES).await?; set_chat_profile_image(alice, alice_chat_id, file.to_str().unwrap()).await?; - alice.pop_sent_msg().await; // TODO check if Alice wrongly sends out a message here + // Because broadcasts are always 'promoted', + // set_chat_profile_image() sends out a message, + // which we need to pop: + alice.pop_sent_msg().await; let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await; From 209ad44db64f16264835c3cd8dfe2ce9e96b1e19 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 8 Aug 2025 15:49:48 +0200 Subject: [PATCH 48/69] fix: Don't show a weird 'Secure-Join: vb-request-v2 message' in Alice's 1:1 chat a recipient --- src/chat/chat_tests.rs | 12 +++++++++++- src/securejoin.rs | 8 +++----- .../test_broadcast_joining_golden_alice_direct | 4 ++++ 3 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 test-data/golden/test_broadcast_joining_golden_alice_direct diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 2ec4561f8a..a563c188dd 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2888,6 +2888,16 @@ async fn test_broadcast_joining_golden() -> Result<()> { bob.golden_test_chat(bob_chat_id, "test_broadcast_joining_golden_bob") .await; + let alice_bob_contact = alice.add_or_lookup_contact_id(bob).await; + let direct_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact) + .await? + .unwrap(); + // The 1:1 chat with Bob should not be visible to the user: + assert_eq!(direct_chat.blocked, Blocked::Yes); + alice + .golden_test_chat(direct_chat.id, "test_broadcast_joining_golden_alice_direct") + .await; + Ok(()) } @@ -3085,7 +3095,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); join_securejoin(bob0, &qr).await.unwrap(); let request = bob0.pop_sent_msg().await; - alice.recv_msg(&request).await; + alice.recv_msg_trash(&request).await; let answer = alice.pop_sent_msg().await; bob0.recv_msg(&answer).await; bob1.recv_msg(&answer).await; diff --git a/src/securejoin.rs b/src/securejoin.rs index 8ba724b7ac..692bf8056c 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -4,9 +4,7 @@ use anyhow::{Context as _, Error, Result, ensure}; use deltachat_contact_tools::ContactAddress; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; -use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid, -}; +use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid}; use crate::chatlist_events; use crate::config::Config; use crate::constants::{Blocked, Chattype, NON_ALPHANUMERIC_WITHOUT_DOT}; @@ -442,11 +440,11 @@ pub(crate) async fn handle_securejoin_handshake( .await?; inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 1000); - if step.starts_with("vb-") { + if step == "vb-request-v2" { // For broadcasts, we don't want to delete the message, // because the other device should also internally add the member // and see the key (because it won't see the member via autocrypt-gossip). - Ok(HandshakeMessage::Propagate) + Ok(HandshakeMessage::Ignore) } else { // IMAP-delete the message to avoid handling it by another device and adding the // member twice. Another device will know the member's key from Autocrypt-Gossip. diff --git a/test-data/golden/test_broadcast_joining_golden_alice_direct b/test-data/golden/test_broadcast_joining_golden_alice_direct new file mode 100644 index 0000000000..3195f9571f --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_alice_direct @@ -0,0 +1,4 @@ +Single#Chat#11: bob@example.net [KEY bob@example.net] 🛡️ +-------------------------------------------------------------------------------- +Msg#11: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO 🛡️] +-------------------------------------------------------------------------------- From 8a8ce8d1a9e1d8f7df0f846f656a208b9f106d7b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 8 Aug 2025 16:27:33 +0200 Subject: [PATCH 49/69] comments/naming: Make sure that I consistently use shared_secret --- benches/benchmark_decrypting.rs | 2 +- src/chat.rs | 2 +- src/decrypt.rs | 7 ++----- src/mimefactory.rs | 11 +++++------ src/pgp.rs | 24 ++++++++++++++---------- src/securejoin/bob.rs | 1 + 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index d36f3b2c45..8e5f5092f1 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -115,7 +115,7 @@ fn criterion_benchmark(c: &mut Criterion) { .map(|_| create_broadcast_shared_secret_pub()) .collect(); - // "secret" is the symmetric secret that was used to encrypt text_symmetrically_encrypted.eml: + // "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml: secrets[NUM_SECRETS / 2] = "secret".to_string(); let context = rt.block_on(async { diff --git a/src/chat.rs b/src/chat.rs index f6d39b5ec4..cbf525e277 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2940,7 +2940,7 @@ async fn prepare_send_msg( msg.param .get_bool(Param::ForcePlaintext) .unwrap_or_default() - // V2 securejoin messages are symmetrically encrypted, no need for the public key: + // V2 securejoin messages are symmetrically encrypted, no need for the public key: || msg.securejoin_step() == Some("vb-request-v2") } _ => false, diff --git a/src/decrypt.rs b/src/decrypt.rs index bf49fc6abd..060e873442 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -10,11 +10,8 @@ use crate::pgp; /// Tries to decrypt a message, but only if it is structured as an Autocrypt message. /// -/// If successful and the message is encrypted, returns a tuple of: -/// -/// - The decrypted and decompressed message -/// - If the message was symmetrically encrypted: -/// The index in `shared_secrets` of the secret used to decrypt the message. +/// If successful and the message is encrypted, +/// returns the decrypted and decompressed message. pub fn try_decrypt<'a>( mail: &'a ParsedMail<'a>, private_keyring: &'a [SignedSecretKey], diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 4559e93d78..78a9590c1c 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1180,7 +1180,7 @@ impl MimeFactory { Loaded::Mdn { .. } => true, }; - let symmetric_key: Option = match &self.loaded { + let shared_secret: Option = match &self.loaded { Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => { // TODO rather than setting Arg2, bob.rs could set a param `Param::SharedSecretForEncryption` or similar msg.param.get(Param::Arg2).map(|s| s.to_string()) @@ -1188,7 +1188,7 @@ impl MimeFactory { Loaded::Message { chat, msg } if should_encrypt_with_broadcast_secret(msg, chat) => { - // If there is no symmetric key yet + // If there is no shared secret yet // (because this is an old broadcast channel, // created before we had symmetric encryption), // we just encrypt asymmetrically. @@ -1200,11 +1200,10 @@ impl MimeFactory { _ => None, }; - let encrypted = if let Some(symmetric_key) = symmetric_key { - info!(context, "Symmetrically encrypting for broadcast channel."); - info!(context, "secret: {symmetric_key}"); // TODO + let encrypted = if let Some(shared_secret) = shared_secret { + info!(context, "Encrypting symmetrically."); encrypt_helper - .encrypt_for_broadcast(context, &symmetric_key, message, compress) + .encrypt_for_broadcast(context, &shared_secret, message, compress) .await? } else { // Asymmetric encryption diff --git a/src/pgp.rs b/src/pgp.rs index 349f0cbf7b..ad080a9fb9 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -238,10 +238,7 @@ pub fn pk_calc_signature( /// shared secrets used for symmetric encryption /// are passed in `shared_secrets`. /// -/// Returns a tuple of: -/// - The decrypted and decompressed message -/// - If the message was symmetrically encrypted: -/// The index in `shared_secrets` of the secret used to decrypt the message. +/// Returns the decrypted and decompressed message. pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], @@ -253,7 +250,13 @@ pub fn decrypt( let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect(); let empty_pw = Password::empty(); - // TODO it may degrade performance that we always try out all passwords here + // We always try out all passwords here, which is not great for performance. + // But benchmarking (see `benchmark_decrypting.rs`) + // showed that the performance penalty is acceptable. + // We could include a short (~2 character) identifier of the secret + // in + // (or just include the first 2 characters of the secret in clear-text) + // in order to let message_password: Vec = shared_secrets .iter() .map(|p| Password::from(p.as_str())) @@ -322,7 +325,7 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { tokio::task::spawn_blocking(move || { let mut rng = thread_rng(); let s2k = StringToKey::new_default(&mut rng); - let builder: MessageBuilder<'_> = MessageBuilder::from_bytes("", plain); + let builder = MessageBuilder::from_bytes("", plain); let mut builder = builder.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); builder.encrypt_with_password(s2k, &passphrase)?; @@ -333,14 +336,15 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { .await? } -/// Symmetric encryption. +/// Symmetrically encrypt the message to be sent into a broadcast channel. +/// `shared secret` is the secret that will be used for symmetric encryption. pub async fn encrypt_for_broadcast( plain: Vec, - passphrase: &str, + shared_secret: &str, private_key_for_signing: SignedSecretKey, compress: bool, ) -> Result { - let passphrase = Password::from(passphrase.to_string()); + let shared_secret = Password::from(shared_secret.to_string()); tokio::task::spawn_blocking(move || { let msg = MessageBuilder::from_bytes("", plain); @@ -357,7 +361,7 @@ pub async fn encrypt_for_broadcast( AeadAlgorithm::Ocb, ChunkSize::C8KiB, ); - msg.encrypt_with_password(&mut rng, s2k, &passphrase)?; + msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?; msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM); if compress { diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 0c11e54a17..069d8da94f 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -71,6 +71,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul let mut msg = Message { viewtype: Viewtype::Text, + // TODO I may want to make this generic also for group/contacts text: "Secure-Join: vb-request-v2".to_string(), hidden: true, ..Default::default() From 12e2a3b3481601a79f7648f66ee43ae93073a7b1 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 15:22:55 +0200 Subject: [PATCH 50/69] fix: Make sure that only the channel owner can write into the chat --- src/chat/chat_tests.rs | 114 +++++++++++++++++++++++++++++++++++++++++ src/mimefactory.rs | 1 + src/receive_imf.rs | 19 +++++++ 3 files changed, 134 insertions(+) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index a563c188dd..23e2194667 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use super::*; use crate::chatlist::get_archived_cnt; use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS}; @@ -3130,6 +3132,118 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { Ok(()) } +/// Test that only the owner of the broadcast channel +/// can send messages into the chat. +/// +/// To do so, we change Alice's public key on Bob's side, +/// so that she is supposed to appear as a new contact when we receive another message, +/// and check that she can't write into the channel. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_only_broadcast_owner_can_send_1() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + tcm.section("Alice creates broadcast channel and creates a QR code."); + let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + + tcm.section("Bob now scans the QR code sends the request message"); + let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap(); + let request = bob.pop_sent_msg().await; + alice.recv_msg_trash(&request).await; + + tcm.section("Alice answers"); + let answer = alice.pop_sent_msg().await; + + tcm.section("Change Alice's fingerprint for Bob, so that she is a different contact from Bob's point of view"); + let bob_alice_id = bob.add_or_lookup_contact_no_key(alice).await.id; + bob.sql + .execute( + "UPDATE contacts + SET fingerprint='1234567890123456789012345678901234567890' + WHERE id=?", + (bob_alice_id,), + ) + .await?; + + tcm.section("Bob receives an answer, but it ignored because of a fingerprint mismatch"); + bob.recv_msg(&answer).await; + assert!( + load_broadcast_shared_secret(bob, bob_broadcast_id) + .await? + .is_none() + ); + + Ok(()) +} + +/// Same as the previous test, but Alice's fingerprint is changed later, +/// so that we can check that until the fingerprint change, everything works fine. +/// +/// Also, this changes Alice's fingerprint in Alice's database, rather than Bob's database, +/// in order to test for the same thing in different ways. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_only_broadcast_owner_can_send_2() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &mut tcm.bob().await; + + tcm.section("Alice creates broadcast channel and creates a QR code."); + let alice_broadcast_id = create_broadcast(alice, "foo".to_string()).await?; + let qr = get_securejoin_qr(alice, Some(alice_broadcast_id)) + .await + .unwrap(); + + tcm.section("Bob now scans the QR code"); + let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap(); + let request = bob.pop_sent_msg().await; + alice.recv_msg_trash(&request).await; + let answer = alice.pop_sent_msg().await; + + tcm.section("Bob receives an answer, and processes it"); + let rcvd = bob.recv_msg(&answer).await; + assert!( + load_broadcast_shared_secret(bob, bob_broadcast_id) + .await? + .is_some() + ); + assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup); + + tcm.section("Alice sends a message, which still arrives fine"); + let sent = alice.send_text(alice_broadcast_id, "Hi").await; + let rcvd = bob.recv_msg(&sent).await; + assert_eq!(rcvd.text, "Hi"); + + tcm.section("Now, Alice's fingerprint changes"); + + alice.sql.execute("DELETE FROM keypairs", ()).await?; + alice + .sql + .execute("DELETE FROM config WHERE keyname='key_id'", ()) + .await?; + // Invalidate cached self fingerprint: + Arc::get_mut(&mut bob.ctx.inner) + .unwrap() + .self_fingerprint + .take(); + + tcm.section("Alice sends a message, which doesn't arrive fine"); + let sent = alice.send_text(alice_broadcast_id, "Hi").await; + let rcvd = bob.recv_msg(&sent).await; + assert_eq!( + rcvd.text, + "[Error: This message was not sent by the channel owner]" + ); + assert_eq!( + rcvd.error.unwrap(), + r#"Error: This message was not sent by the channel owner: +"Hi""# + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_encrypt_decrypt_broadcast() -> Result<()> { let mut tcm = TestContextManager::new(); diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 78a9590c1c..7dcdccf1bb 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -584,6 +584,7 @@ impl MimeFactory { || step == "vb-member-added" || step == "vc-contact-confirm" // TODO possibly add vb-member-added here + // TODO wait... for member-added messages, Param::Arg doesn't even contain the step, but the email } } diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 25a3b1620e..523c76768d 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1697,6 +1697,15 @@ async fn add_parts( part.error = Some(s); } } + + if chat.typ == Chattype::InBroadcast { + let s = stock_str::error(context, "This message was not sent by the channel owner") + .await; + if let Some(part) = mime_parser.parts.first_mut() { + part.error = Some(format!("{s}:\n\"{}\"", part.msg)); + } + mime_parser.replace_msg_by_error(&s); + } } } @@ -3550,6 +3559,16 @@ async fn apply_in_broadcast_changes( ) -> Result { ensure!(chat.typ == Chattype::InBroadcast); + if let Some(part) = mime_parser.parts.first() { + if let Some(error) = &part.error { + warn!( + context, + "Not applying broadcast changes from message with error: {error}" + ); + return Ok(GroupChangesInfo::default()); + } + } + let mut send_event_chat_modified = false; let mut better_msg = None; From 5e2fdd1ee35ad02f4e942efab86a51a2ebdfb565 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 15:32:01 +0200 Subject: [PATCH 51/69] feat: Rename vb-request-v2 -> vb-request-with-auth Turns out that Alice reacts to a request-v2 message in exactly the same way as to a request-with-auth message. So, no need to distinguish here. --- src/chat.rs | 2 +- src/mimefactory.rs | 8 ++++---- src/param.rs | 2 +- src/securejoin.rs | 8 ++++---- src/securejoin/bob.rs | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index cbf525e277..11d1015867 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2941,7 +2941,7 @@ async fn prepare_send_msg( .get_bool(Param::ForcePlaintext) .unwrap_or_default() // V2 securejoin messages are symmetrically encrypted, no need for the public key: - || msg.securejoin_step() == Some("vb-request-v2") + || msg.securejoin_step() == Some("vb-request-with-auth") } _ => false, }; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 7dcdccf1bb..f089639cb8 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -579,7 +579,7 @@ impl MimeFactory { // messages are auto-sent unlike usual unencrypted messages. step == "vg-request-with-auth" || step == "vc-request-with-auth" - || step == "vb-request-v2" + || step == "vb-request-with-auth" || step == "vg-member-added" || step == "vb-member-added" || step == "vc-contact-confirm" @@ -826,7 +826,7 @@ impl MimeFactory { } else if let Loaded::Message { msg, .. } = &self.loaded { if msg.param.get_cmd() == SystemMessage::SecurejoinMessage { let step = msg.param.get(Param::Arg).unwrap_or_default(); - if step != "vg-request" && step != "vc-request" && step != "vb-request-v2" { + if step != "vg-request" && step != "vc-request" && step != "vb-request-with-auth" { headers.push(( "Auto-Submitted", mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(), @@ -1563,7 +1563,7 @@ impl MimeFactory { headers.push(( if step == "vg-request-with-auth" || step == "vc-request-with-auth" - || step == "vb-request-v2" + || step == "vb-request-with-auth" { "Secure-Join-Auth" } else { @@ -1881,7 +1881,7 @@ fn hidden_recipients() -> Address<'static> { fn should_encrypt_with_auth_token(msg: &Message) -> bool { msg.param.get_cmd() == SystemMessage::SecurejoinMessage - && msg.param.get(Param::Arg).unwrap_or_default() == "vb-request-v2" + && msg.param.get(Param::Arg).unwrap_or_default() == "vb-request-with-auth" } fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool { diff --git a/src/param.rs b/src/param.rs index 6dcd1ada35..48e2577189 100644 --- a/src/param.rs +++ b/src/param.rs @@ -114,7 +114,7 @@ pub enum Param { /// /// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header. /// - /// For version two of the securejoin protocol (`vb-request-v2`), + /// For version two of the securejoin protocol (`vb-request-with-auth`), /// this is the Auth token used to encrypt the message. /// /// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced. diff --git a/src/securejoin.rs b/src/securejoin.rs index 692bf8056c..308550d856 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -288,7 +288,7 @@ pub(crate) async fn handle_securejoin_handshake( // -> or just ignore the problem for now - we will need to solve it for all messages anyways: https://github.com/chatmail/core/issues/7057 if !matches!( step, - "vg-request" | "vc-request" | "vb-request-v2" | "vb-member-added" + "vg-request" | "vc-request" | "vb-request-with-auth" | "vb-member-added" ) { let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); @@ -359,7 +359,7 @@ pub(crate) async fn handle_securejoin_handshake( ========================================================*/ bob::handle_auth_required(context, mime_message).await } - "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-v2" => { + "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => { /*========================================================== ==== Alice - the inviter side ==== ==== Steps 5+6 in "Setup verified contact" protocol ==== @@ -440,7 +440,7 @@ pub(crate) async fn handle_securejoin_handshake( .await?; inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 1000); - if step == "vb-request-v2" { + if step == "vb-request-with-auth" { // For broadcasts, we don't want to delete the message, // because the other device should also internally add the member // and see the key (because it won't see the member via autocrypt-gossip). @@ -594,7 +594,7 @@ pub(crate) async fn observe_securejoin_on_other_device( inviter_progress(context, contact_id, 1000); } - // TODO not sure if I should add vb-request-v2 here + // TODO not sure if I should add vb-request-with-auth here // Actually, I'm not even sure why vg-request-with-auth is here - why do we create a 1:1 chat?? if step == "vg-request-with-auth" || step == "vc-request-with-auth" { // This actually reflects what happens on the first device (which does the secure diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 069d8da94f..1b8306e3df 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -72,13 +72,13 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul let mut msg = Message { viewtype: Viewtype::Text, // TODO I may want to make this generic also for group/contacts - text: "Secure-Join: vb-request-v2".to_string(), + text: "Secure-Join: vb-request-with-auth".to_string(), hidden: true, ..Default::default() }; msg.param.set_cmd(SystemMessage::SecurejoinMessage); - msg.param.set(Param::Arg, "vb-request-v2"); + msg.param.set(Param::Arg, "vb-request-with-auth"); msg.param.set(Param::Arg2, invite.authcode()); msg.param.set_int(Param::GuaranteeE2ee, 1); let bob_fp = self_fingerprint(context).await?; @@ -357,7 +357,7 @@ pub(crate) async fn send_handshake_message( pub(crate) enum BobHandshakeMsg { /// vc-request or vg-request Request, - /// vc-request-with-auth, vg-request-with-auth, or vb-request-v2 + /// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth RequestWithAuth, } From bfedba9a4c9fa2be4055eaf5cd09571501b3fcc2 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 16:00:31 +0200 Subject: [PATCH 52/69] feat: Make reacting to v2 invites generic over the type of the invite (contact/group/broadcast) --- src/securejoin/bob.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 1b8306e3df..293dc4a6fd 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -69,22 +69,13 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul } info!(context, "Using fast securejoin with symmetric encryption"); - let mut msg = Message { - viewtype: Viewtype::Text, - // TODO I may want to make this generic also for group/contacts - text: "Secure-Join: vb-request-with-auth".to_string(), - hidden: true, - ..Default::default() - }; - msg.param.set_cmd(SystemMessage::SecurejoinMessage); - - msg.param.set(Param::Arg, "vb-request-with-auth"); - msg.param.set(Param::Arg2, invite.authcode()); - msg.param.set_int(Param::GuaranteeE2ee, 1); - let bob_fp = self_fingerprint(context).await?; - msg.param.set(Param::Arg3, bob_fp); - - chat::send_msg(context, private_chat_id, &mut msg).await?; + send_handshake_message( + context, + &invite, + private_chat_id, + BobHandshakeMsg::RequestWithAuth, + ) + .await?; context.emit_event(EventType::SecurejoinJoinerProgress { contact_id: invite.contact_id(), @@ -388,9 +379,7 @@ impl BobHandshakeMsg { Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", - QrInvite::Broadcast { .. } => { - panic!("There is no request-with-auth for broadcasts") - } // TODO remove panic + QrInvite::Broadcast { .. } => "vb-request-with-auth", }, } } From 49f1b376b7fc88a3c369b8520cbdff6b59f2a7be Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 16:29:34 +0200 Subject: [PATCH 53/69] bench: Improve benchmark_decrypting.rs benchmark --- benches/benchmark_decrypting.rs | 112 ++++++++++++------ ...ternals.rs => internals_for_benchmarks.rs} | 4 + src/lib.rs | 2 +- src/tools.rs | 5 - 4 files changed, 82 insertions(+), 41 deletions(-) rename src/{benchmark_internals.rs => internals_for_benchmarks.rs} (92%) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index 8e5f5092f1..27e739eaef 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -1,19 +1,45 @@ +//! Benchmarks for message decryption, +//! comparing decryption of symmetrically-encrypted messages +//! to decryption of asymmetrically-encrypted messages. +//! +//! Call with +//! +//! ```text +//! cargo bench --bench benchmark_decrypting --features="internals" +//! ``` +//! +//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark: +//! +//! ```text +//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message' +//! ``` +//! +//! You can also pass a substring. +//! So, you can run all 'Decrypt and parse' benchmarks with: +//! +//! ```text +//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt and parse' +//! ``` +//! +//! Symmetric decryption has to try out all known secrets, +//! You can benchmark this by adapting the `NUM_SECRETS` variable. + use std::hint::black_box; use criterion::{Criterion, criterion_group, criterion_main}; -use deltachat::benchmark_internals::create_dummy_keypair; -use deltachat::benchmark_internals::save_broadcast_shared_secret; +use deltachat::internals_for_benchmarks::create_broadcast_shared_secret; +use deltachat::internals_for_benchmarks::create_dummy_keypair; +use deltachat::internals_for_benchmarks::save_broadcast_shared_secret; use deltachat::{ Events, - benchmark_internals::key_from_asc, - benchmark_internals::parse_and_get_text, - benchmark_internals::store_self_keypair, chat::ChatId, config::Config, context::Context, + internals_for_benchmarks::key_from_asc, + internals_for_benchmarks::parse_and_get_text, + internals_for_benchmarks::store_self_keypair, pgp::{KeyPair, decrypt, encrypt_for_broadcast, pk_encrypt}, stock_str::StockStrings, - tools::create_broadcast_shared_secret_pub, }; use rand::{Rng, thread_rng}; use tempfile::tempdir; @@ -45,15 +71,17 @@ async fn create_context() -> Context { fn criterion_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("Decrypt"); + + // =========================================================================================== + // Benchmarks for decryption only, without any other parsing + // =========================================================================================== + group.sample_size(10); - group.bench_function("Decrypt symmetrically encrypted", |b| { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut plain: Vec = vec![0; 500]; - thread_rng().fill(&mut plain[..]); - let (secrets, encrypted) = rt.block_on(async { - let secrets: Vec = (0..NUM_SECRETS) - .map(|_| create_broadcast_shared_secret_pub()) - .collect(); + + group.bench_function("Decrypt a symmetrically encrypted message", |b| { + let plain = generate_plaintext(); + let secrets = generate_secrets(); + let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async { let secret = secrets[NUM_SECRETS / 2].clone(); let encrypted = encrypt_for_broadcast( plain.clone(), @@ -64,7 +92,7 @@ fn criterion_benchmark(c: &mut Criterion) { .await .unwrap(); - (secrets, encrypted) + encrypted }); b.iter(|| { @@ -75,16 +103,12 @@ fn criterion_benchmark(c: &mut Criterion) { assert_eq!(black_box(decrypted), plain); }); }); - group.bench_function("Decrypt pk encrypted", |b| { - // TODO code duplication with previous benchmark - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut plain: Vec = vec![0; 500]; - thread_rng().fill(&mut plain[..]); + + group.bench_function("Decrypt a public-key encrypted message", |b| { + let plain = generate_plaintext(); let key_pair = create_dummy_keypair("alice@example.org").unwrap(); - let (secrets, encrypted) = rt.block_on(async { - let secrets: Vec = (0..NUM_SECRETS) - .map(|_| create_broadcast_shared_secret_pub()) - .collect(); + let secrets = generate_secrets(); + let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async { let encrypted = pk_encrypt( plain.clone(), vec![black_box(key_pair.public.clone())], @@ -94,7 +118,7 @@ fn criterion_benchmark(c: &mut Criterion) { .await .unwrap(); - (secrets, encrypted) + encrypted }); b.iter(|| { @@ -110,12 +134,15 @@ fn criterion_benchmark(c: &mut Criterion) { }); }); + // =========================================================================================== + // Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf()) + // =========================================================================================== + let rt = tokio::runtime::Runtime::new().unwrap(); - let mut secrets: Vec = (0..NUM_SECRETS) - .map(|_| create_broadcast_shared_secret_pub()) - .collect(); + let mut secrets = generate_secrets(); - // "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml: + // "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml. + // Put it into the middle of our secrets: secrets[NUM_SECRETS / 2] = "secret".to_string(); let context = rt.block_on(async { @@ -128,36 +155,51 @@ fn criterion_benchmark(c: &mut Criterion) { context }); - group.bench_function("Receive a public-key encrypted message", |b| { + group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| { b.to_async(&rt).iter(|| { let ctx = context.clone(); async move { let text = parse_and_get_text( &ctx, - include_bytes!("../test-data/message/text_from_alice_encrypted.eml"), + include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"), ) .await .unwrap(); - assert_eq!(text, "hi"); + assert_eq!(text, "Symmetrically encrypted message"); } }); }); - group.bench_function("Receive a symmetrically encrypted message", |b| { + + group.bench_function("Decrypt and parse a public-key encrypted message", |b| { b.to_async(&rt).iter(|| { let ctx = context.clone(); async move { let text = parse_and_get_text( &ctx, - include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"), + include_bytes!("../test-data/message/text_from_alice_encrypted.eml"), ) .await .unwrap(); - assert_eq!(text, "Symmetrically encrypted message"); + assert_eq!(text, "hi"); } }); }); + group.finish(); } +fn generate_secrets() -> Vec { + let secrets: Vec = (0..NUM_SECRETS) + .map(|_| create_broadcast_shared_secret()) + .collect(); + secrets +} + +fn generate_plaintext() -> Vec { + let mut plain: Vec = vec![0; 500]; + thread_rng().fill(&mut plain[..]); + plain +} + criterion_group!(benches, criterion_benchmark); criterion_main!(benches); diff --git a/src/benchmark_internals.rs b/src/internals_for_benchmarks.rs similarity index 92% rename from src/benchmark_internals.rs rename to src/internals_for_benchmarks.rs index 5b088a9aa0..5ada6e0db7 100644 --- a/src/benchmark_internals.rs +++ b/src/internals_for_benchmarks.rs @@ -38,3 +38,7 @@ pub async fn save_broadcast_shared_secret( pub fn create_dummy_keypair(addr: &str) -> Result { pgp::create_keypair(EmailAddress::new(addr)?) } + +pub fn create_broadcast_shared_secret() -> String { + crate::tools::create_broadcast_shared_secret() +} diff --git a/src/lib.rs b/src/lib.rs index 16f5ce4ecd..af0c4d47ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,7 +112,7 @@ pub mod peer_channels; pub mod reaction; #[cfg(feature = "internals")] -pub mod benchmark_internals; +pub mod internals_for_benchmarks; /// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed. pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG"; diff --git a/src/tools.rs b/src/tools.rs index 4e26eb5ee9..a9b335d4c2 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -319,11 +319,6 @@ pub(crate) fn create_broadcast_shared_secret() -> String { res } -#[cfg(feature = "internals")] -pub fn create_broadcast_shared_secret_pub() -> String { - create_broadcast_shared_secret() -} - /// Returns true if given string is a valid ID. /// /// All IDs generated with `create_id()` should be considered valid. From dda2a575085337ac3ae3d644e10582897a7a9762 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 16:39:42 +0200 Subject: [PATCH 54/69] refactor: Remove small code duplication --- src/chat.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 11d1015867..ee1312ee2a 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -3774,6 +3774,10 @@ pub async fn create_broadcast(context: &Context, chat_name: String) -> Result Result<()> { info!(context, "Saving broadcast secret for chat {chat_id}"); - info!(context, "dbg the new secret for chat {chat_id} is {secret}"); context .sql - .execute( - "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) - ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret", - (chat_id, secret), - ) + .execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, secret)) .await?; Ok(()) From 39396ab2496919aa5ceb04fb403604a6d52a6e46 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 17:01:38 +0200 Subject: [PATCH 55/69] resolve some small TODOs --- benches/benchmark_decrypting.rs | 4 ++-- src/e2ee.rs | 21 +++++++-------------- src/mimefactory.rs | 2 +- src/pgp.rs | 7 ++++--- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index 27e739eaef..603c26a9ce 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -38,7 +38,7 @@ use deltachat::{ internals_for_benchmarks::key_from_asc, internals_for_benchmarks::parse_and_get_text, internals_for_benchmarks::store_self_keypair, - pgp::{KeyPair, decrypt, encrypt_for_broadcast, pk_encrypt}, + pgp::{KeyPair, decrypt, encrypt_symmetrically, pk_encrypt}, stock_str::StockStrings, }; use rand::{Rng, thread_rng}; @@ -83,7 +83,7 @@ fn criterion_benchmark(c: &mut Criterion) { let secrets = generate_secrets(); let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async { let secret = secrets[NUM_SECRETS / 2].clone(); - let encrypted = encrypt_for_broadcast( + let encrypted = encrypt_symmetrically( plain.clone(), black_box(&secret), create_dummy_keypair("alice@example.org").unwrap().secret, diff --git a/src/e2ee.rs b/src/e2ee.rs index 4fbc4b013f..cb01b7bfa4 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -54,21 +54,18 @@ impl EncryptHelper { let cursor = Cursor::new(&mut raw_message); mail_to_encrypt.clone().write_part(cursor).ok(); - println!( - "\nEncrypting pk:\n{}\n", - String::from_utf8_lossy(&raw_message) - ); // TODO - let ctext = pgp::pk_encrypt(raw_message, keyring, Some(sign_key), compress).await?; Ok(ctext) } - /// TODO documentation - pub async fn encrypt_for_broadcast( + /// Symmetrically encrypt the message to be sent into a broadcast channel, + /// or for version 2 of the Securejoin protocol. + /// `shared secret` is the secret that will be used for symmetric encryption. + pub async fn encrypt_symmetrically( self, context: &Context, - passphrase: &str, + shared_secret: &str, mail_to_encrypt: MimePart<'static>, compress: bool, ) -> Result { @@ -78,12 +75,8 @@ impl EncryptHelper { let cursor = Cursor::new(&mut raw_message); mail_to_encrypt.clone().write_part(cursor).ok(); - println!( - "\nEncrypting symm:\n{}\n", - String::from_utf8_lossy(&raw_message) - ); // TODO - - let ctext = pgp::encrypt_for_broadcast(raw_message, passphrase, sign_key, compress).await?; + let ctext = + pgp::encrypt_symmetrically(raw_message, shared_secret, sign_key, compress).await?; Ok(ctext) } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index f089639cb8..b376b53cd2 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1204,7 +1204,7 @@ impl MimeFactory { let encrypted = if let Some(shared_secret) = shared_secret { info!(context, "Encrypting symmetrically."); encrypt_helper - .encrypt_for_broadcast(context, &shared_secret, message, compress) + .encrypt_symmetrically(context, &shared_secret, message, compress) .await? } else { // Asymmetric encryption diff --git a/src/pgp.rs b/src/pgp.rs index ad080a9fb9..fc804db2af 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -336,9 +336,10 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { .await? } -/// Symmetrically encrypt the message to be sent into a broadcast channel. +/// Symmetrically encrypt the message to be sent into a broadcast channel, +/// or for version 2 of the Securejoin protocol. /// `shared secret` is the secret that will be used for symmetric encryption. -pub async fn encrypt_for_broadcast( +pub async fn encrypt_symmetrically( plain: Vec, shared_secret: &str, private_key_for_signing: SignedSecretKey, @@ -607,7 +608,7 @@ mod tests { let plain = Vec::from(b"this is the secret message"); let shared_secret = "shared secret"; - let ctext = encrypt_for_broadcast( + let ctext = encrypt_symmetrically( plain.clone(), shared_secret, load_self_secret_key(alice).await?, From fd2847b29b70a68064ad34a3723b52133d136134 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 11 Aug 2025 18:03:33 +0200 Subject: [PATCH 56/69] Resolve some small TODOs --- src/chat/chat_tests.rs | 23 +++++++++++++++++++++-- src/mimefactory.rs | 7 ++++--- src/param.rs | 4 ++-- src/qr.rs | 2 -- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 23e2194667..038991ed89 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -2890,8 +2890,8 @@ async fn test_broadcast_joining_golden() -> Result<()> { bob.golden_test_chat(bob_chat_id, "test_broadcast_joining_golden_bob") .await; - let alice_bob_contact = alice.add_or_lookup_contact_id(bob).await; - let direct_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact) + let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await; + let direct_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id) .await? .unwrap(); // The 1:1 chat with Bob should not be visible to the user: @@ -2900,6 +2900,25 @@ async fn test_broadcast_joining_golden() -> Result<()> { .golden_test_chat(direct_chat.id, "test_broadcast_joining_golden_alice_direct") .await; + assert_eq!( + alice_bob_contact + .get_verifier_id(alice) + .await? + .unwrap() + .unwrap(), + ContactId::SELF + ); + + let bob_alice_contact = bob.add_or_lookup_contact_no_key(alice).await; + assert_eq!( + bob_alice_contact + .get_verifier_id(bob) + .await? + .unwrap() + .unwrap(), + ContactId::SELF + ); + Ok(()) } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index b376b53cd2..f482c83811 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -580,11 +580,13 @@ impl MimeFactory { step == "vg-request-with-auth" || step == "vc-request-with-auth" || step == "vb-request-with-auth" + // Note that for "vg-member-added" and "vb-member-added", + // get_cmd() returns `MemberAddedToGroup` rather than `SecurejoinMessage`, + // so, it wouldn't actually be necessary to have them in the list here. + // Still, they are here for completeness. || step == "vg-member-added" || step == "vb-member-added" || step == "vc-contact-confirm" - // TODO possibly add vb-member-added here - // TODO wait... for member-added messages, Param::Arg doesn't even contain the step, but the email } } @@ -1183,7 +1185,6 @@ impl MimeFactory { let shared_secret: Option = match &self.loaded { Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => { - // TODO rather than setting Arg2, bob.rs could set a param `Param::SharedSecretForEncryption` or similar msg.param.get(Param::Arg2).map(|s| s.to_string()) } Loaded::Message { chat, msg } diff --git a/src/param.rs b/src/param.rs index 48e2577189..760a58d35f 100644 --- a/src/param.rs +++ b/src/param.rs @@ -99,10 +99,10 @@ pub enum Param { /// For Messages /// - /// For "MemberRemovedFromGroup" this is the email address + /// For "MemberRemovedFromGroup", this is the email address /// removed from the group. /// - /// For "MemberAddedToGroup" this is the email address added to the group. + /// For "MemberAddedToGroup", this is the email address added to the group. /// /// For securejoin messages, this is the step, /// which is put into the `Secure-Join` header. diff --git a/src/qr.rs b/src/qr.rs index 86e9610db2..766352febb 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -537,8 +537,6 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { (&addr, broadcast_name, grpid, authcode) { // This is a broadcast channel invite link. - // TODO code duplication with the previous block - // TODO at some point, we can mark this person as verified let addr = ContactAddress::new(addr)?; let (contact_id, _) = Contact::add_or_lookup_ex( context, From 0b365d3df2fd16451e856dc4025caa4ae4be6be6 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 12 Aug 2025 15:16:30 +0200 Subject: [PATCH 57/69] feat: Sync Alice's verification on Bob's side --- src/chat.rs | 46 ++++++++++++++++-------------------------- src/chat/chat_tests.rs | 25 +++++++++++++++++++++++ src/receive_imf.rs | 1 + src/securejoin.rs | 2 -- src/securejoin/bob.rs | 9 ++++++++- 5 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index ee1312ee2a..ff36ae217c 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -5089,10 +5089,8 @@ pub(crate) enum SyncAction { chat_name: String, shared_secret: String, }, - CreateInBroadcast { - chat_name: String, - shared_secret: String, - }, + /// Mark the contact with the given fingerprint as verified by self. + MarkVerified, Rename(String), /// Set chat contacts by their addresses. SetContacts(Vec), @@ -5148,6 +5146,14 @@ impl Context { SyncAction::Unblock => { return contact::set_blocked(self, Nosync, contact_id, false).await; } + SyncAction::MarkVerified => { + return contact::mark_contact_id_as_verified( + self, + contact_id, + ContactId::SELF, + ) + .await; + } _ => (), } ChatIdBlocked::get_for_contact(self, contact_id, Blocked::Request) @@ -5179,8 +5185,9 @@ impl Context { SyncAction::Accept => chat_id.accept_ex(self, Nosync).await, SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await, SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await, - SyncAction::CreateOutBroadcast { .. } | SyncAction::CreateInBroadcast { .. } => { - // Create action should have been handled by handle_sync_create_chat() already + SyncAction::CreateOutBroadcast { .. } | SyncAction::MarkVerified => { + // Create action should have been handled by handle_sync_create_chat() already. + // MarkVerified action should have been handled by mark_contact_id_as_verified() already. Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, @@ -5193,7 +5200,7 @@ impl Context { } async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &str) -> Result { - Ok(match action { + match action { SyncAction::CreateOutBroadcast { chat_name, shared_secret, @@ -5206,29 +5213,10 @@ impl Context { shared_secret.to_string(), ) .await?; - return Ok(true); - } - SyncAction::CreateInBroadcast { - chat_name, - shared_secret, - } => { - let chat_id = ChatId::create_multiuser_record( - self, - Chattype::InBroadcast, - grpid, - chat_name, - Blocked::Not, - ProtectionStatus::Unprotected, - None, - smeared_time(self), - ) - .await?; - save_broadcast_shared_secret(self, chat_id, shared_secret).await?; - - return Ok(true); + Ok(true) } - _ => false, - }) + _ => Ok(false), + } } /// Emits the appropriate `MsgsChanged` event. Should be called if the number of unnoticed diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 038991ed89..38674dc2d0 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3119,8 +3119,19 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { alice.recv_msg_trash(&request).await; let answer = alice.pop_sent_msg().await; bob0.recv_msg(&answer).await; + + // Sync Bob's verification of Alice: + sync(bob0, bob1).await; + // TODO uncommenting the next line creates a message "Can't decrypt outgoing messages, probably you're using DC on multiple devices without transferring your key" + // bob1.recv_msg(&request).await; bob1.recv_msg(&answer).await; + // The 1:1 chat should not be visible to the user on any of the devices. + // The contact should be marked as verified. + check_direct_chat_is_hidden_and_contact_is_verified(alice, bob0).await; + check_direct_chat_is_hidden_and_contact_is_verified(bob0, alice).await; + check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await; + tcm.section("Alice sends first message to broadcast."); let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; let bob0_hello = bob0.recv_msg(&sent_msg).await; @@ -3151,6 +3162,20 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { Ok(()) } +async fn check_direct_chat_is_hidden_and_contact_is_verified( + t: &TestContext, + contact: &TestContext, +) { + let contact = t.add_or_lookup_contact_no_key(contact).await; + if let Some(direct_chat) = ChatIdBlocked::lookup_by_contact(t, contact.id) + .await + .unwrap() + { + assert_eq!(direct_chat.blocked, Blocked::Yes); + } + assert!(contact.is_verified(t).await.unwrap()); +} + /// Test that only the owner of the broadcast channel /// can send messages into the chat. /// diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 523c76768d..ad36156d05 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3531,6 +3531,7 @@ async fn apply_out_broadcast_changes( } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { // TODO this block can be removed, // now that all of Alice's devices get to know about Bob joining via Bob's QR message. + // TODO test if this creates some problems with duplicate member-added messages on Alice's device let contact = lookup_key_contact_by_address(context, added_addr, None).await?; if let Some(contact) = contact { better_msg.get_or_insert( diff --git a/src/securejoin.rs b/src/securejoin.rs index 308550d856..a5cce0e44a 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -594,8 +594,6 @@ pub(crate) async fn observe_securejoin_on_other_device( inviter_progress(context, contact_id, 1000); } - // TODO not sure if I should add vb-request-with-auth here - // Actually, I'm not even sure why vg-request-with-auth is here - why do we create a 1:1 chat?? if step == "vg-request-with-auth" || step == "vc-request-with-auth" { // This actually reflects what happens on the first device (which does the secure // join) and causes a subsequent "vg-member-added" message to create an unblocked diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 293dc4a6fd..0106ba0783 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -10,7 +10,7 @@ use crate::contact::Origin; use crate::context::Context; use crate::events::EventType; use crate::key::self_fingerprint; -use crate::log::info; +use crate::log::{LogExt as _, info}; use crate::message::{Message, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::Param; @@ -81,6 +81,13 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul contact_id: invite.contact_id(), progress: JoinerProgress::RequestWithAuthSent.to_usize(), }); + + // Our second device won't be able to decrypt the outgoing message + // because it will be symmetrically encrypted with the AUTH token. + // So, we need to send a sync message: + let id = chat::SyncId::ContactFingerprint(invite.fingerprint().hex()); + let action = chat::SyncAction::MarkVerified; + chat::sync(context, id, action).await.log_err(context).ok(); } else { // Start the version 1 protocol and initialise the state. let has_key = context From 98a8e7314af9ac060d2dcaf00f9f973b9de26c58 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 15 Aug 2025 16:31:34 +0200 Subject: [PATCH 58/69] fix: Protect against DOS attacks via a message with many esks using expensive-to-compute s2k algos --- src/pgp.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 8 deletions(-) diff --git a/src/pgp.rs b/src/pgp.rs index fc804db2af..a5bf3e45d5 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashSet}; use std::io::{BufRead, Cursor}; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, bail}; use chrono::SubsecRound; use deltachat_contact_tools::EmailAddress; use pgp::armor::BlockType; @@ -242,7 +242,7 @@ pub fn pk_calc_signature( pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], - shared_secrets: &[String], + mut shared_secrets: &[String], ) -> Result> { let cursor = Cursor::new(ctext); let (msg, _headers) = Message::from_armor(cursor)?; @@ -250,13 +250,17 @@ pub fn decrypt( let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect(); let empty_pw = Password::empty(); + let try_symmetric_decryption = should_try_symmetric_decryption(&msg); + if try_symmetric_decryption.is_err() { + shared_secrets = &[]; + } + // We always try out all passwords here, which is not great for performance. // But benchmarking (see `benchmark_decrypting.rs`) // showed that the performance penalty is acceptable. - // We could include a short (~2 character) identifier of the secret - // in - // (or just include the first 2 characters of the secret in clear-text) - // in order to + // We could include a short (~2 character) identifier of the secret in cleartext + // (or just include the first 2 characters of the secret in cleartext) + // in order to narrow down the number of shared secrets that have to be tried out. let message_password: Vec = shared_secrets .iter() .map(|p| Password::from(p.as_str())) @@ -270,7 +274,19 @@ pub fn decrypt( session_keys: vec![], allow_legacy: false, }; - let (msg, _ring_result) = msg.decrypt_the_ring(ring, true)?; + + let res = msg.decrypt_the_ring(ring, true); + + let (msg, _ring_result) = match res { + Ok(it) => it, + Err(err) => { + if let Err(reason) = try_symmetric_decryption { + bail!("{err:#} (Note: symmetric decryption was not tried: {reason})") + } else { + bail!("{err:#}"); + } + } + }; // remove one layer of compression let msg = msg.decompress()?; @@ -278,6 +294,34 @@ pub fn decrypt( Ok(msg) } +/// Returns Ok(()) if we want to try symmetrically decrypting the message, +/// and Err with a reason if symmetric decryption should not be tried. +/// +/// A DOS attacker could send a message with a lot of encrypted session keys, +/// all of which use a very hard-to-compute string2key algorithm. +/// We would then try to decrypt all of the encrypted session keys +/// with all of the known shared secrets. +/// In order to prevent this, we do not try to symmetrically decrypt messages +/// that use a string2key algorithm other than 'Salted'. +fn should_try_symmetric_decryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> { + let Message::Encrypted { esk, .. } = msg else { + return Err("not encrypted"); + }; + + if esk.len() > 1 { + return Err("too many esks"); + } + + let [pgp::composed::Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..] else { + return Err("not symmetrically encrypted"); + }; + + match esk.s2k() { + Some(StringToKey::Salted { .. }) => Ok(()), + _ => Err("unsupported string2key algorithm"), + } +} + /// Returns fingerprints /// of all keys from the `public_keys_for_validation` keyring that /// have valid signatures there. @@ -339,6 +383,7 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { /// Symmetrically encrypt the message to be sent into a broadcast channel, /// or for version 2 of the Securejoin protocol. /// `shared secret` is the secret that will be used for symmetric encryption. +// TODO this name is veeery similar to `symm_encrypt()` pub async fn encrypt_symmetrically( plain: Vec, shared_secret: &str, @@ -356,6 +401,7 @@ pub async fn encrypt_symmetrically( hash_alg: HashAlgorithm::default(), salt, }; + // TODO ask whether it's actually good to use Seidp_v2 here let mut msg = msg.seipd_v2( &mut rng, SymmetricKeyAlgorithm::AES128, @@ -400,7 +446,7 @@ mod tests { use super::*; use crate::{ - key::load_self_secret_key, + key::{load_self_public_key, load_self_secret_key}, test_utils::{TestContextManager, alice_keypair, bob_keypair}, }; @@ -627,4 +673,75 @@ mod tests { Ok(()) } + + /// Test that we don't try to decrypt a message + /// that is symmetrically encrypted + /// with an expensive string2key algorithm + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_dont_decrypt_expensive_message() -> Result<()> { + let mut tcm = TestContextManager::new(); + let bob = &tcm.bob().await; + + let plain = Vec::from(b"this is the secret message"); + let shared_secret = "shared secret"; + + // Create a symmetrically encrypted message + // with an IteratedAndSalted string2key algorithm: + + let shared_secret_pw = Password::from(shared_secret.to_string()); + let msg = MessageBuilder::from_bytes("", plain); + let mut rng = thread_rng(); + let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted + + let mut msg = msg.seipd_v2( + &mut rng, + SymmetricKeyAlgorithm::AES128, + AeadAlgorithm::Ocb, + ChunkSize::C8KiB, + ); + msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?; + + let ctext = msg.to_armored_string(&mut rng, Default::default())?; + + // Trying to decrypt it should fail with a helpful error message: + + let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; + let error = decrypt( + ctext.into(), + &bob_private_keyring, + &[shared_secret.to_string()], + ) + .unwrap_err(); + + assert_eq!( + error.to_string(), + "missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)" + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_decryption_error_msg() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let plain = Vec::from(b"this is the secret message"); + let pk_for_encryption = load_self_public_key(alice).await?; + + // Encrypt a message, but only to self, not to Bob: + let ctext = pk_encrypt(plain, vec![pk_for_encryption], None, true).await?; + + // Trying to decrypt it should fail with an OK error message: + let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; + let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err(); + + assert_eq!( + error.to_string(), + "missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)" + ); + + Ok(()) + } } From 2a49f41c87bd60832d8fef8183c00c5a8a376118 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 16 Aug 2025 18:54:57 +0200 Subject: [PATCH 59/69] refactor: Rename to symm_encrypt_message() --- benches/benchmark_decrypting.rs | 4 ++-- src/e2ee.rs | 2 +- src/imex/key_transfer.rs | 2 +- src/pgp.rs | 13 ++++++------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs index 603c26a9ce..4f13a504e4 100644 --- a/benches/benchmark_decrypting.rs +++ b/benches/benchmark_decrypting.rs @@ -38,7 +38,7 @@ use deltachat::{ internals_for_benchmarks::key_from_asc, internals_for_benchmarks::parse_and_get_text, internals_for_benchmarks::store_self_keypair, - pgp::{KeyPair, decrypt, encrypt_symmetrically, pk_encrypt}, + pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message}, stock_str::StockStrings, }; use rand::{Rng, thread_rng}; @@ -83,7 +83,7 @@ fn criterion_benchmark(c: &mut Criterion) { let secrets = generate_secrets(); let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async { let secret = secrets[NUM_SECRETS / 2].clone(); - let encrypted = encrypt_symmetrically( + let encrypted = symm_encrypt_message( plain.clone(), black_box(&secret), create_dummy_keypair("alice@example.org").unwrap().secret, diff --git a/src/e2ee.rs b/src/e2ee.rs index cb01b7bfa4..69046de12b 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -76,7 +76,7 @@ impl EncryptHelper { mail_to_encrypt.clone().write_part(cursor).ok(); let ctext = - pgp::encrypt_symmetrically(raw_message, shared_secret, sign_key, compress).await?; + pgp::symm_encrypt_message(raw_message, shared_secret, sign_key, compress).await?; Ok(ctext) } diff --git a/src/imex/key_transfer.rs b/src/imex/key_transfer.rs index 2df4643919..84bce55988 100644 --- a/src/imex/key_transfer.rs +++ b/src/imex/key_transfer.rs @@ -98,7 +98,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result Some(("Autocrypt-Prefer-Encrypt", "mutual")), }; let private_key_asc = private_key.to_asc(ac_headers); - let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes()) + let encr = pgp::symm_encrypt_setup_file(passphrase, private_key_asc.into_bytes()) .await? .replace('\n', "\r\n"); diff --git a/src/pgp.rs b/src/pgp.rs index a5bf3e45d5..e8b78c139f 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -362,8 +362,8 @@ pub fn pk_validate( Ok(ret) } -/// Symmetric encryption. -pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { +/// Symmetric encryption for the autocrypt setup file. +pub async fn symm_encrypt_setup_file(passphrase: &str, plain: Vec) -> Result { let passphrase = Password::from(passphrase.to_string()); tokio::task::spawn_blocking(move || { @@ -380,11 +380,10 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { .await? } -/// Symmetrically encrypt the message to be sent into a broadcast channel, -/// or for version 2 of the Securejoin protocol. +/// Symmetrically encrypt the message. +/// This is used for broadcast channels and for version 2 of the Securejoin protocol. /// `shared secret` is the secret that will be used for symmetric encryption. -// TODO this name is veeery similar to `symm_encrypt()` -pub async fn encrypt_symmetrically( +pub async fn symm_encrypt_message( plain: Vec, shared_secret: &str, private_key_for_signing: SignedSecretKey, @@ -654,7 +653,7 @@ mod tests { let plain = Vec::from(b"this is the secret message"); let shared_secret = "shared secret"; - let ctext = encrypt_symmetrically( + let ctext = symm_encrypt_message( plain.clone(), shared_secret, load_self_secret_key(alice).await?, From 069bcbf82e689d13d207ed09d736233c991bf4fa Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 16 Aug 2025 18:58:58 +0200 Subject: [PATCH 60/69] fix: Remove panic!() call --- src/securejoin/bob.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 0106ba0783..11f8f7e06b 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -309,14 +309,14 @@ pub(crate) async fn send_handshake_message( ) -> Result<()> { let mut msg = Message { viewtype: Viewtype::Text, - text: step.body_text(invite), + text: step.body_text(invite)?, hidden: true, ..Default::default() }; msg.param.set_cmd(SystemMessage::SecurejoinMessage); // Sends the step in Secure-Join header. - msg.param.set(Param::Arg, step.securejoin_header(invite)); + msg.param.set(Param::Arg, step.securejoin_header(invite)?); match step { BobHandshakeMsg::Request => { @@ -365,8 +365,8 @@ impl BobHandshakeMsg { /// This text has no significance to the protocol, but would be visible if users see /// this email message directly, e.g. when accessing their email without using /// DeltaChat. - fn body_text(&self, invite: &QrInvite) -> String { - format!("Secure-Join: {}", self.securejoin_header(invite)) + fn body_text(&self, invite: &QrInvite) -> Result { + Ok(format!("Secure-Join: {}", self.securejoin_header(invite)?)) } /// Returns the `Secure-Join` header value. @@ -374,21 +374,22 @@ impl BobHandshakeMsg { /// This identifies the step this message is sending information about. Most protocol /// steps include additional information into other headers, see /// [`send_handshake_message`] for these. - fn securejoin_header(&self, invite: &QrInvite) -> &'static str { - match self { + fn securejoin_header(&self, invite: &QrInvite) -> Result<&'static str> { + let res = match self { Self::Request => match invite { QrInvite::Contact { .. } => "vc-request", QrInvite::Group { .. } => "vg-request", QrInvite::Broadcast { .. } => { - panic!("There is no request-with-auth for broadcasts") - } // TODO remove panic + bail!("There is no request-with-auth for broadcasts") + } }, Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", QrInvite::Broadcast { .. } => "vb-request-with-auth", }, - } + }; + Ok(res) } } From 04ac1b3b7c184726c62ec30795c78b1d73b43972 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 16 Aug 2025 19:00:09 +0200 Subject: [PATCH 61/69] Remove TODO --- src/sql/migrations.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index d794e2678d..2de6979d00 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1265,7 +1265,7 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); if dbversion < migration_version { sql.execute_migration( "CREATE TABLE broadcasts_shared_secrets( - chat_id INTEGER PRIMARY KEY NOT NULL, -- TODO we don't actually need the chat_id + chat_id INTEGER PRIMARY KEY NOT NULL, secret TEXT NOT NULL ) STRICT", migration_version, From 1cc34daa5cf83918232b5e16f79fce7ed44ced1f Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 16 Aug 2025 19:46:42 +0200 Subject: [PATCH 62/69] fix: Don't show wrong system message on Bob's second device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this, Bob's second device showed a system message "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages..." --- src/chat.rs | 5 ++++- src/chat/chat_tests.rs | 9 +++++++-- src/message.rs | 15 ++++++++------- src/mimefactory.rs | 3 +-- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index ff36ae217c..a215f25730 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2941,7 +2941,7 @@ async fn prepare_send_msg( .get_bool(Param::ForcePlaintext) .unwrap_or_default() // V2 securejoin messages are symmetrically encrypted, no need for the public key: - || msg.securejoin_step() == Some("vb-request-with-auth") + || msg.is_vb_request_with_auth() } _ => false, }; @@ -3036,6 +3036,9 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - if (context.get_config_bool(Config::BccSelf).await? || msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage) && (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty()) + // `vb-request-with-auth` messages are symmetrically encrypted + // with a secret which the other device doesn't have: + && !msg.is_vb_request_with_auth() { recipients.push(from); } diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 38674dc2d0..8872bffa50 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3116,14 +3116,19 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); join_securejoin(bob0, &qr).await.unwrap(); let request = bob0.pop_sent_msg().await; + + // Bob must send the message only to Alice, not to Self, + // because otherwise, his second device would show a device message + // "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. + // To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions." + assert_eq!(request.recipients, "alice@example.org"); + alice.recv_msg_trash(&request).await; let answer = alice.pop_sent_msg().await; bob0.recv_msg(&answer).await; // Sync Bob's verification of Alice: sync(bob0, bob1).await; - // TODO uncommenting the next line creates a message "Can't decrypt outgoing messages, probably you're using DC on multiple devices without transferring your key" - // bob1.recv_msg(&request).await; bob1.recv_msg(&answer).await; // The 1:1 chat should not be visible to the user on any of the devices. diff --git a/src/message.rs b/src/message.rs index 3acd9dd144..bf736a8afc 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1382,15 +1382,16 @@ impl Message { self.error.clone() } - // TODO this function could be used a lot more - /// If this is a secure-join message, - /// returns the current step, - /// which is put into the `Secure-Join` header. - pub(crate) fn securejoin_step(&self) -> Option<&str> { + /// Returns `true` if this message is a `vb-request-with-auth` SecureJoin message. + pub(crate) fn is_vb_request_with_auth(&self) -> bool { if self.param.get_cmd() == SystemMessage::SecurejoinMessage { - self.param.get(Param::Arg) + // CAVE: You can't check in the same way whether the message + // is a `v{g|b}-member-added` message, + // because for these messages, + // `param.get_cmd()` returns `SystemMessage::MemberAddedToGroup`. + self.param.get(Param::Arg) == Some("vb-request-with-auth") } else { - None + false } } } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index f482c83811..59aaa88152 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1881,8 +1881,7 @@ fn hidden_recipients() -> Address<'static> { } fn should_encrypt_with_auth_token(msg: &Message) -> bool { - msg.param.get_cmd() == SystemMessage::SecurejoinMessage - && msg.param.get(Param::Arg).unwrap_or_default() == "vb-request-with-auth" + msg.is_vb_request_with_auth() } fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool { From 6e07f8989dd66d8022cf0800dff40e7dac632664 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 16 Aug 2025 20:06:27 +0200 Subject: [PATCH 63/69] small refactoring --- src/receive_imf.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index ad36156d05..b434451288 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3511,14 +3511,9 @@ async fn apply_out_broadcast_changes( let removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?; if removed_id == Some(from_id) { // The sender of the message left the broadcast channel + // Silently remove them without notifying the user chat::remove_from_chat_contacts_table(context, chat.id, from_id).await?; - - return Ok(GroupChangesInfo { - better_msg: Some("".to_string()), - added_removed_id: None, - silent: true, - extra_msgs: vec![], - }); + better_msg = Some("".to_string()); } else if from_id == ContactId::SELF { if let Some(removed_id) = removed_id { chat::remove_from_chat_contacts_table(context, chat.id, removed_id).await?; From bd94d747186c5d2dade8a994adc06978657bc6f8 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 1 Sep 2025 07:51:46 +0200 Subject: [PATCH 64/69] test: Rename alice0, alice1 to alice1, alice2 in test_sync_muted() This makes the automatically generated "alice, alice2" context names correct --- src/chat/chat_tests.rs | 69 +++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 8872bffa50..f37667f240 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -4031,62 +4031,77 @@ async fn test_sync_muted() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sync_broadcast() -> Result<()> { let mut tcm = TestContextManager::new(); - let alice0 = &tcm.alice().await; let alice1 = &tcm.alice().await; - for a in [alice0, alice1] { + let alice2 = &tcm.alice().await; + for a in [alice1, alice2] { a.set_config_bool(Config::SyncMsgs, true).await?; } let bob = &tcm.bob().await; - let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id; + let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id; - let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?; - sync(alice0, alice1).await; - let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; - let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid) + tcm.section("Alice creates a channel on her first device"); + let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?; + + tcm.section("The channel syncs to her second device"); + sync(alice1, alice2).await; + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; + let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_broadcast_chat.grpid) .await? .unwrap() .0; - let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; - assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); - assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name()); - assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); + let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?; + assert_eq!(a2_broadcast_chat.get_type(), Chattype::OutBroadcast); + assert_eq!(a2_broadcast_chat.get_name(), a1_broadcast_chat.get_name()); + assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty()); - let qr = get_securejoin_qr(alice0, Some(a0_broadcast_id)) + tcm.section("Bob scans Alice's QR code, both of Alice's devices answer"); + let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id)) .await .unwrap(); - sync(alice0, alice1).await; // Sync QR code + sync(alice1, alice2).await; // Sync QR code let bob_broadcast_id = tcm - .exec_securejoin_qr_multi_device(bob, &[alice0, alice1], &qr) + .exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr) .await; - let a1b_contact_id = alice1.add_or_lookup_contact_no_key(bob).await.id; + let a2b_contact_id = alice2.add_or_lookup_contact_no_key(bob).await.id; assert_eq!( - get_chat_contacts(alice1, a1_broadcast_id).await?, - vec![a1b_contact_id] + get_chat_contacts(alice2, a2_broadcast_id).await?, + vec![a2b_contact_id] ); - let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await; + + tcm.section("Alice's second device sends a message to the channel"); + let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await; let msg = bob.recv_msg(&sent_msg).await; let chat = Chat::load_from_db(bob, msg.chat_id).await?; assert_eq!(chat.get_type(), Chattype::InBroadcast); - let msg = alice0.recv_msg(&sent_msg).await; - assert_eq!(msg.chat_id, a0_broadcast_id); - remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; - let sent = alice0.pop_sent_msg().await; - alice1.recv_msg(&sent).await; - assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); + let msg = alice1.recv_msg(&sent_msg).await; + assert_eq!(msg.chat_id, a1_broadcast_id); + + tcm.section("Alice's first device removes Bob"); + remove_contact_from_chat(alice1, a1_broadcast_id, a1b_contact_id).await?; + let sent = alice1.pop_sent_msg().await; + + tcm.section("Alice's second device receives the removal-message"); + alice2.recv_msg(&sent).await; + assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty()); // TODO do we want to make sure that there is no trace of a member? // assert!( // get_past_chat_contacts(alice1, a1_broadcast_id) // .await? // .is_empty() // ); + + tcm.section("Bob receives the removal-message"); bob.recv_msg(&sent).await; let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?; assert!(!bob_chat.is_self_in_chat(bob).await?); - a0_broadcast_id.delete(alice0).await?; - sync(alice0, alice1).await; - alice1.assert_no_chat(a1_broadcast_id).await; + tcm.section("Alice's first device deletes the chat"); + a1_broadcast_id.delete(alice1).await?; + sync(alice1, alice2).await; + alice2.assert_no_chat(a2_broadcast_id).await; + + // TODO test if Alice's second device shows duplicate member-added messages bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob") .await; From 2e1c249865b70d956fcc6d65b716a023f08b3924 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 1 Sep 2025 07:52:59 +0200 Subject: [PATCH 65/69] test: When a golden test fails, print some extra info --- src/test_utils.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test_utils.rs b/src/test_utils.rs index 5b82a991eb..00ecdb2c58 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -960,9 +960,15 @@ impl TestContext { .await .unwrap_or_else(|e| panic!("Error writing {filename:?}: {e}")); } else { + let green = Color::Green.normal(); + let red = Color::Red.normal(); assert_eq!( - actual, expected, - "To update the expected value, run `UPDATE_GOLDEN_TESTS=1 cargo test`" + actual, + expected, + "{} != {} on {}'s device.\nTo update the expected value, run with `UPDATE_GOLDEN_TESTS=1` environment variable", + red.paint("< actual chat content"), + green.paint("expected chat content >"), + self.name(), ); } } From 5101b3167623c3954dcdeff16591473acbbcd909 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 1 Sep 2025 08:15:37 +0200 Subject: [PATCH 66/69] test: Add golden test for Alice's side, too, in test_sync_broadcast --- src/chat/chat_tests.rs | 16 +++++++++++----- test-data/golden/test_sync_broadcast_alice | 6 ++++++ 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 test-data/golden/test_sync_broadcast_alice diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index f37667f240..83932cd66b 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -4096,16 +4096,22 @@ async fn test_sync_broadcast() -> Result<()> { let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?; assert!(!bob_chat.is_self_in_chat(bob).await?); + bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob") + .await; + + // Alice1 and Alice2 are supposed to show the chat in the same way: + alice1 + .golden_test_chat(a1_broadcast_id, "test_sync_broadcast_alice") + .await; + alice2 + .golden_test_chat(a2_broadcast_id, "test_sync_broadcast_alice") + .await; + tcm.section("Alice's first device deletes the chat"); a1_broadcast_id.delete(alice1).await?; sync(alice1, alice2).await; alice2.assert_no_chat(a2_broadcast_id).await; - // TODO test if Alice's second device shows duplicate member-added messages - - bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob") - .await; - Ok(()) } diff --git a/test-data/golden/test_sync_broadcast_alice b/test-data/golden/test_sync_broadcast_alice new file mode 100644 index 0000000000..fb56de7589 --- /dev/null +++ b/test-data/golden/test_sync_broadcast_alice @@ -0,0 +1,6 @@ +OutBroadcast#Chat#10: Channel [0 member(s)] +-------------------------------------------------------------------------------- +Msg#13🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √ +Msg#15🔒: Me (Contact#Contact#Self): hi √ +Msg#16🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √ +-------------------------------------------------------------------------------- From c36da1a4fd2f3f279828039e4469fe4c25489c42 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 1 Sep 2025 08:16:09 +0200 Subject: [PATCH 67/69] refactor: Remove superflous check for ChatGroupMemberAdded --- src/receive_imf.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index b434451288..9549fa48f9 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3523,17 +3523,10 @@ async fn apply_out_broadcast_changes( ); } } - } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - // TODO this block can be removed, - // now that all of Alice's devices get to know about Bob joining via Bob's QR message. - // TODO test if this creates some problems with duplicate member-added messages on Alice's device - let contact = lookup_key_contact_by_address(context, added_addr, None).await?; - if let Some(contact) = contact { - better_msg.get_or_insert( - stock_str::msg_add_member_local(context, contact, ContactId::UNDEFINED).await, - ); - } } + // No need to check for ChatGroupMemberAdded: + // The only way to add a member is by having them scan a QR code. + // All devices will receive Bob's vb-request-with-auth message and add him to the channel. if send_event_chat_modified { context.emit_event(EventType::ChatModified(chat.id)); From c0c96b9a173f2f9253a40155c08d1f01822b698c Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 1 Sep 2025 08:23:30 +0200 Subject: [PATCH 68/69] Remove outdated TODO --- src/receive_imf.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 9549fa48f9..e801e4b9da 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3491,7 +3491,6 @@ async fn apply_out_broadcast_changes( chat: &mut Chat, from_id: ContactId, ) -> Result { - // TODO code duplication with apply_in_broadcast_changes() ensure!(chat.typ == Chattype::OutBroadcast); let mut send_event_chat_modified = false; From 59b82eaaa3fec722b2d5fb4efec61f304047faef Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 1 Sep 2025 21:24:58 +0200 Subject: [PATCH 69/69] Resolve identity-misbinding TODO --- src/mimefactory.rs | 13 +++++++++---- src/securejoin.rs | 30 ++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 59aaa88152..7ba48a3627 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -182,7 +182,7 @@ impl MimeFactory { let now = time(); let chat = Chat::load_from_db(context, msg.chat_id).await?; let attach_profile_data = Self::should_attach_profile_data(&msg); - let undisclosed_recipients = chat.typ == Chattype::OutBroadcast; + let undisclosed_recipients = should_hide_recipients(&msg, &chat); let from_addr = context.get_primary_self_addr().await?; let config_displayname = context @@ -1100,7 +1100,7 @@ impl MimeFactory { match &self.loaded { Loaded::Message { chat, msg } => { - if chat.typ != Chattype::OutBroadcast { + if !should_hide_recipients(msg, chat) { for (addr, key) in &encryption_keys { let fingerprint = key.dc_fingerprint().hex(); let cmd = msg.param.get_cmd(); @@ -1885,12 +1885,17 @@ fn should_encrypt_with_auth_token(msg: &Message) -> bool { } fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool { - chat.is_any_broadcast() + chat.is_out_broadcast() && msg.param.get_cmd() != SystemMessage::SecurejoinMessage - // The member-added message in a broadcast must be asymmetrirally encrypted: + // The member-added message in a broadcast must be asymmetrically encrypted, + // because the newly-added member doesn't know the broadcast shared secret yet: && msg.param.get_cmd() != SystemMessage::MemberAddedToGroup } +fn should_hide_recipients(msg: &Message, chat: &Chat) -> bool { + should_encrypt_with_broadcast_secret(msg, chat) +} + fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool { should_encrypt_with_auth_token(msg) || should_encrypt_with_broadcast_secret(msg, chat) } diff --git a/src/securejoin.rs b/src/securejoin.rs index a5cce0e44a..63fe39990f 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -282,14 +282,28 @@ pub(crate) async fn handle_securejoin_handshake( info!(context, "Received secure-join message {step:?}."); - // TODO talk with link2xt about whether we need to protect against this identity-misbinding attack, - // and if so, how - // -> just put Alice's fingerprint into a header (can't put the gossip header bc we don't have this) - // -> or just ignore the problem for now - we will need to solve it for all messages anyways: https://github.com/chatmail/core/issues/7057 - if !matches!( - step, - "vg-request" | "vc-request" | "vb-request-with-auth" | "vb-member-added" - ) { + // Opportunistically protect against a theoretical 'surreptitious forwarding' attack: + // If Eve obtains a QR code from Alice and starts a securejoin with her, + // and also lets Bob scan a manipulated QR code, + // she could reencrypt the v*-request-with-auth message to Bob while maintaining the signature, + // and Bob would regard the message as valid. + // + // This attack is not actually relevant in any threat model, + // because if Eve can see Alice's QR code and have Bob scan a manipulated QR code, + // she can just do a classical MitM attack. + // + // Protecting all messages sent by Delta Chat against 'surreptitious forwarding' + // by checking the 'intended recipient fingerprint' + // will improve security (completely unrelated to the securejoin protocol) + // and is something we want to do in the future: + // https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding + if !matches!(step, "vg-request" | "vc-request" | "vb-request-with-auth") { + // We don't perform this check for `vb-request-with-auth`: + // Since the message is encrypted symmetrically, + // there are no gossip headers, + // so we can't easily do the same check as for asymmetrically encrypted secure-join messages. + // Because this check doesn't add protection in any threat model, + // we just skip it for vb-request-with-auth. let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); for (addr, key) in &mime_message.gossiped_keys {