diff --git a/Cargo.toml b/Cargo.toml index f907070c6d..bc17e7a7c7 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] @@ -157,6 +157,11 @@ name = "receive_emails" required-features = ["internals"] harness = false +[[bench]] +name = "benchmark_decrypting" +required-features = ["internals"] +harness = false + [[bench]] name = "get_chat_msgs" harness = false diff --git a/benches/benchmark_decrypting.rs b/benches/benchmark_decrypting.rs new file mode 100644 index 0000000000..603c26a9ce --- /dev/null +++ b/benches/benchmark_decrypting.rs @@ -0,0 +1,205 @@ +//! 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::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, + 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_symmetrically, pk_encrypt}, + stock_str::StockStrings, +}; +use rand::{Rng, thread_rng}; +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"); + + // =========================================================================================== + // Benchmarks for decryption only, without any other parsing + // =========================================================================================== + + group.sample_size(10); + + 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_symmetrically( + plain.clone(), + black_box(&secret), + create_dummy_keypair("alice@example.org").unwrap().secret, + true, + ) + .await + .unwrap(); + + 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 a public-key encrypted message", |b| { + let plain = generate_plaintext(); + let key_pair = create_dummy_keypair("alice@example.org").unwrap(); + 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())], + Some(key_pair.secret.clone()), + true, + ) + .await + .unwrap(); + + 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); + }); + }); + + // =========================================================================================== + // Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf()) + // =========================================================================================== + + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut secrets = generate_secrets(); + + // "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 { + 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("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_symmetrically_encrypted.eml"), + ) + .await + .unwrap(); + assert_eq!(text, "Symmetrically encrypted message"); + } + }); + }); + + 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_from_alice_encrypted.eml"), + ) + .await + .unwrap(); + 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/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/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/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 61d8141f76..7082a73817 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -34,6 +34,19 @@ 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, + + authcode: String, + }, /// Contact fingerprint is verified. /// /// Ask the user if they want to start chatting. @@ -207,6 +220,23 @@ impl From for QrObject { authcode, } } + Qr::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + authcode, + } + } Qr::FprOk { contact_id } => { let contact_id = contact_id.to_u32(); QrObject::FprOk { contact_id } diff --git a/src/chat.rs b/src/chat.rs index d717460744..ff36ae217c 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; @@ -43,9 +44,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}; @@ -1646,6 +1647,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. @@ -1725,8 +1738,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(false), + Chattype::Group | Chattype::InBroadcast => { + is_contact_in_chat(context, self.id, ContactId::SELF).await + } } } @@ -2909,18 +2923,26 @@ 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 + .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") } - CantSendReason::MissingKey => msg - .param - .get_bool(Param::ForcePlaintext) - .unwrap_or_default(), _ => false, }; if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? { @@ -3748,14 +3770,20 @@ 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 } +const SQL_INSERT_BROADCAST_SECRET: &str = + "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret"; + pub(crate) async fn create_broadcast_ex( context: &Context, sync: sync::Sync, grpid: String, chat_name: String, + secret: String, ) -> Result { let row_id = { let chat_name = &chat_name; @@ -3775,8 +3803,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, @@ -3784,6 +3812,8 @@ pub(crate) async fn create_broadcast_ex( create_smeared_timestamp(context), ), )?; + let chat_id = t.last_insert_rowid(); + t.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, &secret))?; Ok(t.last_insert_rowid().try_into()?) }; context.sql.transaction(trans_fn).await? @@ -3795,13 +3825,43 @@ pub(crate) async fn create_broadcast_ex( if sync.into() { let id = SyncId::Grpid(grpid); - let action = SyncAction::CreateBroadcast(chat_name); + let action = SyncAction::CreateOutBroadcast { + chat_name, + shared_secret: secret, + }; self::sync(context, id, action).await.log_err(context).ok(); } Ok(chat_id) } +pub(crate) async fn load_broadcast_shared_secret( + context: &Context, + chat_id: ChatId, +) -> Result> { + 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, + secret: &str, +) -> Result<()> { + info!(context, "Saving broadcast secret for chat {chat_id}"); + context + .sql + .execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, secret)) + .await?; + + Ok(()) +} + /// Set chat contacts in the `chats_contacts` table. pub(crate) async fn update_chat_contacts_table( context: &Context, @@ -3916,8 +3976,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 || (from_handshake && chat.typ == Chattype::OutBroadcast), + "{} is not a group where one can add members", chat_id ); ensure!( @@ -3925,11 +3985,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.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" @@ -3976,21 +4031,32 @@ 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.typ == Chattype::Group && chat.is_promoted() { + if chat.is_promoted() { 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 no useful 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()); 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; @@ -4172,10 +4238,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?; @@ -4199,7 +4273,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."); } @@ -4212,6 +4288,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); @@ -4223,6 +4300,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()); @@ -5007,7 +5085,12 @@ pub(crate) enum SyncAction { SetVisibility(ChatVisibility), SetMuted(MuteDuration), /// Create broadcast channel with the given name. - CreateBroadcast(String), + CreateOutBroadcast { + 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), @@ -5063,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) @@ -5070,8 +5161,8 @@ impl Context { .id } SyncId::Grpid(grpid) => { - if let SyncAction::CreateBroadcast(name) = action { - create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?; + let handled = self.handle_sync_create_chat(action, grpid).await?; + if handled { return Ok(()); } get_chat_id_by_grpid(self, grpid) @@ -5094,7 +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::CreateBroadcast(_) => { + 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, @@ -5106,6 +5199,26 @@ impl Context { } } + async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &str) -> Result { + match action { + SyncAction::CreateOutBroadcast { + chat_name, + shared_secret, + } => { + create_broadcast_ex( + self, + Nosync, + grpid.to_string(), + chat_name.clone(), + shared_secret.to_string(), + ) + .await?; + Ok(true) + } + _ => Ok(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 3a7ca45de7..38674dc2d0 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}; @@ -7,6 +9,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, join_securejoin}; use crate::test_utils::{ AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync, @@ -2262,7 +2265,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; @@ -2625,44 +2629,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; - let chat_alice = alice.create_chat(&bob).await; - send_text_msg(&alice, chat_alice.id, "hi!".to_string()).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; - let chat_bob = bob.create_chat(&alice).await; - send_text_msg(&bob, chat_bob.id, "ho!".to_string()).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 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"); + tcm.exec_securejoin_qr(bob, alice, &qr).await; + tcm.section("Alice invites Fiona to her channel"); + tcm.exec_securejoin_qr(fiona, alice, &qr).await; + { - let chat = Chat::load_from_db(&alice, broadcast_id).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()); - send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?; + 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()); @@ -2675,25 +2702,23 @@ 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?; + 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"); 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(()) @@ -2709,45 +2734,43 @@ async fn test_broadcast() -> 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(()) } @@ -2765,7 +2788,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?; @@ -2773,14 +2795,15 @@ 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); 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"); @@ -2838,6 +2861,67 @@ 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?; + // 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; + + 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; + + 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: + assert_eq!(direct_chat.blocked, Blocked::Yes); + alice + .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(()) +} + /// - Create a broadcast channel /// - Block it /// - Check that the broadcast channel appears in the list of blocked contacts @@ -2849,11 +2933,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; @@ -2861,7 +2947,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); @@ -2915,11 +3001,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")); @@ -2964,8 +3052,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; @@ -3019,11 +3107,30 @@ 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?; - 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(); + join_securejoin(bob0, &qr).await.unwrap(); + let request = bob0.pop_sent_msg().await; + 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; @@ -3055,6 +3162,180 @@ 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. +/// +/// 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(); + 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; + + tcm.section("Create a broadcast channel with Bob, and send a message"); + let alice_chat_id = create_broadcast_ex( + alice, + Sync, + "My Channel".to_string(), + grpid.to_string(), + secret.to_string(), + ) + .await?; + add_to_chat_contacts_table(alice, time(), alice_chat_id, &[alice_bob_contact_id]).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") + .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; @@ -3764,14 +4045,16 @@ 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; - // 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 qr = get_securejoin_qr(alice0, Some(a0_broadcast_id)) + .await + .unwrap(); + sync(alice0, alice1).await; // Sync QR code + let bob_broadcast_id = tcm + .exec_securejoin_qr_multi_device(bob, &[alice0, alice1], &qr) + .await; + + 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] @@ -3783,17 +4066,26 @@ 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; alice1.assert_no_chat(a1_broadcast_id).await; + + bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob") + .await; + Ok(()) } @@ -3807,12 +4099,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/decrypt.rs b/src/decrypt.rs index 8c3b9de150..060e873442 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -10,17 +10,19 @@ 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 the decrypted and decompressed message. pub fn try_decrypt<'a>( mail: &'a ParsedMail<'a>, private_keyring: &'a [SignedSecretKey], + shared_secrets: &[String], ) -> 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, shared_secrets)?; Ok(Some(msg)) } diff --git a/src/e2ee.rs b/src/e2ee.rs index 9968c22457..cb01b7bfa4 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -59,6 +59,28 @@ impl EncryptHelper { Ok(ctext) } + /// 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, + shared_secret: &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_symmetrically(raw_message, shared_secret, 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/headerdef.rs b/src/headerdef.rs index 330a4d9ba0..a240b42202 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -63,6 +63,7 @@ pub enum HeaderDef { ChatUserAvatar, ChatVoiceMessage, ChatGroupMemberRemoved, + ChatGroupMemberRemovedFpr, ChatGroupMemberAdded, ChatContent, @@ -93,6 +94,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/internals_for_benchmarks.rs b/src/internals_for_benchmarks.rs new file mode 100644 index 0000000000..5ada6e0db7 --- /dev/null +++ b/src/internals_for_benchmarks.rs @@ -0,0 +1,44 @@ +//! 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; +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 +} + +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 3c6402cbfe..af0c4d47ed 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; @@ -108,6 +111,9 @@ pub mod accounts; pub mod peer_channels; pub mod reaction; +#[cfg(feature = "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/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 0d971bf243..f482c83811 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; @@ -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}; @@ -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 { + } 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 { + } 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}"); @@ -415,8 +415,24 @@ impl MimeFactory { req_mdn = true; } + // 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 + } else if should_encrypt_symmetrically(&msg, &chat) { + // Encrypt, but only symmetrically, not with the public keys. + Some(Vec::new()) } else { if keys.is_empty() && !recipients.is_empty() { bail!( @@ -563,7 +579,13 @@ 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" + // 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" } } @@ -806,7 +828,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-with-auth" { headers.push(( "Auto-Submitted", mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(), @@ -815,7 +837,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", @@ -825,6 +847,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(), + )); + } + } } } @@ -1005,6 +1036,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()); @@ -1143,18 +1183,48 @@ 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 shared_secret: Option = match &self.loaded { + Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => { + 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 shared secret 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. + load_broadcast_shared_secret(context, chat.id).await? + } + _ => None, + }; + + let encrypted = if let Some(shared_secret) = shared_secret { + info!(context, "Encrypting symmetrically."); + encrypt_helper + .encrypt_symmetrically(context, &shared_secret, 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( @@ -1363,8 +1433,8 @@ impl MimeFactory { match command { SystemMessage::MemberRemovedFromGroup => { - ensure!(chat.typ != Chattype::OutBroadcast); 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 @@ -1385,9 +1455,16 @@ 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 => { - 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(); @@ -1401,14 +1478,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(), )); } } @@ -1484,7 +1562,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" @@ -1799,6 +1880,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-with-auth" +} + +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 1036cbb06b..1c07671731 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. /// @@ -338,9 +338,22 @@ impl MimeMessage { let mail_raw; // Memory location for a possible decrypted message. let decrypted_msg; // Decrypted signed OpenPGP message. + let mut secrets: Vec = context + .sql + .query_map( + "SELECT secret FROM broadcasts_shared_secrets", + (), + |row| row.get(0), + |rows| { + rows.collect::, _>>() + .map_err(Into::into) + }, + ) + .await?; + 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)) { + 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(); diff --git a/src/param.rs b/src/param.rs index 9e0433a256..760a58d35f 100644 --- a/src/param.rs +++ b/src/param.rs @@ -99,19 +99,42 @@ 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. 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 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. + /// + /// 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 diff --git a/src/pgp.rs b/src/pgp.rs index e00a41310b..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; @@ -12,12 +12,13 @@ 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; 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}; @@ -25,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; @@ -233,11 +234,15 @@ 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`. -pub fn pk_decrypt( +/// Receiver private keys are passed in `private_keys_for_decryption`, +/// shared secrets used for symmetric encryption +/// are passed in `shared_secrets`. +/// +/// Returns the decrypted and decompressed message. +pub fn decrypt( ctext: Vec, private_keys_for_decryption: &[SignedSecretKey], + mut shared_secrets: &[String], ) -> Result> { let cursor = Cursor::new(ctext); let (msg, _headers) = Message::from_armor(cursor)?; @@ -245,18 +250,43 @@ pub fn pk_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 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())) + .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, }; - 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 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()?; @@ -264,6 +294,34 @@ pub fn pk_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. @@ -322,6 +380,48 @@ 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. +/// `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, + private_key_for_signing: SignedSecretKey, + compress: bool, +) -> Result { + let shared_secret = Password::from(shared_secret.to_string()); + + tokio::task::spawn_blocking(move || { + 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, + }; + // TODO ask whether it's actually good to use Seidp_v2 here + let mut msg = msg.seipd_v2( + &mut rng, + SymmetricKeyAlgorithm::AES128, + AeadAlgorithm::Ocb, + ChunkSize::C8KiB, + ); + msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?; + + 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())?; + + Ok(encoded_msg) + }) + .await? +} + /// Symmetric decryption. pub async fn symm_decrypt( passphrase: &str, @@ -345,7 +445,10 @@ mod tests { use tokio::sync::OnceCell; use super::*; - use crate::test_utils::{alice_keypair, bob_keypair}; + use crate::{ + key::{load_self_public_key, load_self_secret_key}, + test_utils::{TestContextManager, alice_keypair, bob_keypair}, + }; fn pk_decrypt_and_validate<'a>( ctext: &'a [u8], @@ -356,7 +459,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)?; @@ -542,4 +645,103 @@ 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_symmetrically( + plain.clone(), + shared_secret, + load_self_secret_key(alice).await?, + true, + ) + .await?; + + let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; + let mut decrypted = decrypt( + ctext.into(), + &bob_private_keyring, + &[shared_secret.to_string()], + )?; + + assert_eq!(decrypted.as_data_vec()?, plain); + + 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(()) + } } diff --git a/src/qr.rs b/src/qr.rs index 6453188033..766352febb 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -84,6 +84,30 @@ pub enum Qr { authcode: String, }, + /// Ask whether to join the broadcast channel. + AskJoinBroadcast { + /// The user-visible name of this broadcast channel + broadcast_name: String, + + /// 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, + }, + /// Contact fingerprint is verified. /// /// Ask the user if they want to start chatting. @@ -381,6 +405,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&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 @@ -417,15 +442,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") @@ -440,21 +457,12 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .filter(|&s| validate_id(s)) .map(|s| s.to_string()); - let grpname = if grpid.is_some() { - if let Some(encoded_name) = param.get("g") { - let encoded_name = encoded_name.replace('+', "%20"); // 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_name(¶m, "g")?; + let broadcast_name = decode_name(¶m, "b")?; - 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, @@ -525,6 +533,28 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { authcode, }) } + } else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(authcode)) = + (&addr, broadcast_name, grpid, authcode) + { + // This is a broadcast channel invite link. + 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, + authcode, + }) } else if let Some(addr) = addr { let fingerprint = fingerprint.hex(); let (contact_id, _) = @@ -546,6 +576,18 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { } } +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) + } +} + /// 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 980fddead8..ad36156d05 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -15,7 +15,7 @@ use num_traits::FromPrimitive; use regex::Regex; use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table, + 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}; @@ -27,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; @@ -44,7 +44,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, 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}; @@ -1559,7 +1562,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? }, ); } @@ -1693,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); + } } } @@ -2900,15 +2913,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; @@ -2925,6 +2936,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(); @@ -3066,9 +3079,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()); } } @@ -3320,13 +3331,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, @@ -3487,21 +3491,65 @@ 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); - 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?; + let mut send_event_chat_modified = false; + let mut better_msg = None; - return Ok(GroupChangesInfo { - better_msg: Some("".to_string()), - added_removed_id: None, - silent: true, - extra_msgs: vec![], - }); + 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_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?; + + 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 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, + ); + } } - 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( @@ -3512,6 +3560,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; @@ -3525,12 +3583,54 @@ async fn apply_in_broadcast_changes( ) .await?; - 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 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_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 { better_msg .get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await); + } 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( + context, + mime_parser.timestamp_sent, + chat.id, + &[ContactId::SELF], + ) + .await?; + } + + if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) { + if validate_broadcast_shared_secret(secret) { + save_broadcast_shared_secret(context, chat.id, secret).await?; + } else { + warn!(context, "Not saving invalid broadcast secret"); } } 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.rs b/src/securejoin.rs index 4cba5407ea..a5cce0e44a 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -46,9 +46,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 +56,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 +94,40 @@ 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; } - format!( - "https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}", - fingerprint.hex(), - self_addr_urlencoded, - &group_name_urlencoded, - &chat.grpid, - &invitenumber, - &auth, - ) + + if chat.typ == Chattype::OutBroadcast { + let broadcast_name = chat.get_name(); + let broadcast_name_urlencoded = + utf8_percent_encode(broadcast_name, NON_ALPHANUMERIC).to_string(); + format!( + "https://i.delta.chat/#{}&a={}&b={}&x={}&s={}", + fingerprint.hex(), + self_addr_urlencoded, + &broadcast_name_urlencoded, + &chat.grpid, + &auth, + ) + } 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(); + 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 +282,14 @@ 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 + // -> 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" + ) { 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 +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" => { + "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => { /*========================================================== ==== Alice - the inviter side ==== ==== Steps 5+6 in "Setup verified contact" protocol ==== @@ -360,7 +382,8 @@ 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 + // 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 Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else { warn!( context, @@ -398,7 +421,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))); @@ -412,13 +435,21 @@ 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?; 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 == "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). + 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. + Ok(HandshakeMessage::Done) + } } else { // Setup verified contact. secure_connection_established( @@ -447,7 +478,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!( @@ -499,6 +530,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, @@ -514,7 +546,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); }; @@ -554,7 +590,7 @@ 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); } @@ -565,7 +601,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/securejoin/bob.rs b/src/securejoin/bob.rs index 5392f94692..0106ba0783 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -1,6 +1,6 @@ //! 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; @@ -10,14 +10,14 @@ 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; 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`. /// @@ -47,16 +47,49 @@ 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 - .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)); - // Now start the protocol and initialise the state. - { + 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"); + + send_handshake_message( + context, + &invite, + private_chat_id, + BobHandshakeMsg::RequestWithAuth, + ) + .await?; + + context.emit_event(EventType::SecurejoinJoinerProgress { + 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 .sql .exists( @@ -71,11 +104,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, @@ -89,9 +127,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?; } } @@ -99,19 +138,36 @@ 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 + if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? { + chat::add_to_chat_contacts_table( + context, + time(), + joining_chat_id, + &[invite.contact_id()], + ) + .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 { .. } => { // For setup-contact the BobState already ensured the 1:1 chat exists because it @@ -120,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, @@ -138,7 +194,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul ) .await?; } - Ok(chat_id) + Ok(private_chat_id) } } } @@ -204,7 +260,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. @@ -265,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 => { @@ -299,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, } @@ -323,10 +379,14 @@ impl BobHandshakeMsg { 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 }, Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", + QrInvite::Broadcast { .. } => "vb-request-with-auth", }, } } @@ -346,8 +406,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 @@ -355,18 +426,18 @@ async fn joining_chat_id( None => { ChatId::create_multiuser_record( context, - Chattype::Group, + chattype, grpid, name, Blocked::Not, ProtectionStatus::Unprotected, // protection is added later as needed None, - create_smeared_timestamp(context), + smeared_time(context), ) .await? } }; - Ok(group_chat_id) + Ok(chat_id) } } } diff --git a/src/securejoin/qrinvite.rs b/src/securejoin/qrinvite.rs index 023d6875b6..0010ffc81a 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,14 @@ pub enum QrInvite { fingerprint: Fingerprint, name: String, grpid: String, - invitenumber: String, + invitenumber: Option, + authcode: String, + }, + Broadcast { + contact_id: ContactId, + fingerprint: Fingerprint, + broadcast_name: String, + grpid: String, authcode: String, }, } @@ -38,30 +45,50 @@ 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, } } /// 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::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => { + invitenumber.as_deref() + } + Self::Broadcast { .. } => None, } } /// 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::Contact { authcode, .. } + | Self::Group { authcode, .. } + | 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 { @@ -77,7 +104,7 @@ impl TryFrom for QrInvite { } => Ok(QrInvite::Contact { contact_id, fingerprint, - invitenumber, + invitenumber: Some(invitenumber), authcode, }), Qr::AskVerifyGroup { @@ -92,10 +119,23 @@ impl TryFrom for QrInvite { fingerprint, name: grpname, grpid, - invitenumber, + invitenumber: Some(invitenumber), + authcode, + }), + Qr::AskJoinBroadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, + authcode, + } => Ok(QrInvite::Broadcast { + broadcast_name, + grpid, + contact_id, + fingerprint, authcode, }), - _ => bail!("Unsupported QR type"), + _ => bail!("Unsupported QR type: {qr:?}"), } } } diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index b5f3fcd84a..9d7555abff 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,72 @@ 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; + } + 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 round in 0..6 { + 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?; + + 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; + let avatar = alice_on_bob.get_profile_image(bob).await?.unwrap(); + assert_eq!( + avatar.file_name().unwrap().to_str().unwrap(), + AVATAR_64x64_DEDUPLICATED + ); + } + + Ok(()) +} diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 4a7c40e911..d794e2678d 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1261,6 +1261,18 @@ 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 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 .get_raw_config_int(VERSION_CFG) .await? 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, 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(); } diff --git a/src/test_utils.rs b/src/test_utils.rs index 573ef65df3..5b82a991eb 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -219,24 +219,55 @@ 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, - scanner: &TestContext, - scanned: &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 `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 `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 { - 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 { - 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; } } 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/src/tools.rs b/src/tools.rs index 59cca8d158..a9b335d4c2 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 && s.len() <= 100 +} + /// 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 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..46128b029e --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_alice @@ -0,0 +1,5 @@ +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): Member bob@example.net added. [INFO] √ +-------------------------------------------------------------------------------- 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 πŸ›‘οΈ] +-------------------------------------------------------------------------------- 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..71b6f1bda1 --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000000..e0a31122bb --- /dev/null +++ b/test-data/golden/test_sync_broadcast_bob @@ -0,0 +1,7 @@ +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] +-------------------------------------------------------------------------------- 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-- +