diff --git a/Cargo.toml b/Cargo.toml index d656d216d..f91e51e82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,6 +156,11 @@ name = "receive_emails" required-features = ["internals"] harness = false +[[bench]] +name = "decrypting" +required-features = ["internals"] +harness = false + [[bench]] name = "get_chat_msgs" harness = false diff --git a/benches/decrypting.rs b/benches/decrypting.rs new file mode 100644 index 000000000..d8cf90608 --- /dev/null +++ b/benches/decrypting.rs @@ -0,0 +1,200 @@ +//! Benchmarks for message decryption, +//! comparing decryption of symmetrically-encrypted messages +//! to decryption of asymmetrically-encrypted messages. +//! +//! Call with +//! +//! ```text +//! cargo bench --bench decrypting --features="internals" +//! ``` +//! +//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark: +//! +//! ```text +//! cargo bench --bench 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 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_benches::create_broadcast_secret; +use deltachat::internals_for_benches::create_dummy_keypair; +use deltachat::internals_for_benches::save_broadcast_secret; +use deltachat::{ + Events, + chat::ChatId, + config::Config, + context::Context, + internals_for_benches::key_from_asc, + internals_for_benches::parse_and_get_text, + internals_for_benches::store_self_keypair, + pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message}, + stock_str::StockStrings, +}; +use rand::{Rng, 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(); + 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(); + symm_encrypt_message( + plain.clone(), + create_dummy_keypair("alice@example.org").unwrap().secret, + black_box(&secret), + true, + ) + .await + .unwrap() + }); + + 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 { + pk_encrypt( + plain.clone(), + vec![black_box(key_pair.public.clone())], + Some(key_pair.secret.clone()), + true, + true, + ) + .await + .unwrap() + }); + + b.iter(|| { + let mut msg = decrypt( + encrypted.clone().into_bytes(), + std::slice::from_ref(&key_pair.secret), + 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_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_secret()) + .collect(); + secrets +} + +fn generate_plaintext() -> Vec { + let mut plain: Vec = vec![0; 500]; + rng().fill(&mut plain[..]); + plain +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 441e58351..45aa6762a 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -2563,6 +2563,7 @@ void dc_stop_ongoing_process (dc_context_t* context); #define DC_QR_ASK_VERIFYCONTACT 200 // id=contact #define DC_QR_ASK_VERIFYGROUP 202 // text1=groupname +#define DC_QR_ASK_VERIFYBROADCAST 204 // text1=broadcast name #define DC_QR_FPR_OK 210 // id=contact #define DC_QR_FPR_MISMATCH 220 // id=contact #define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint @@ -2595,8 +2596,9 @@ void dc_stop_ongoing_process (dc_context_t* context); * ask whether to verify the contact; * if so, start the protocol with dc_join_securejoin(). * - * - DC_QR_ASK_VERIFYGROUP with dc_lot_t::text1=Group name: - * ask whether to join the group; + * - DC_QR_ASK_VERIFYGROUP or DC_QR_ASK_VERIFYBROADCAST + * with dc_lot_t::text1=Group name: + * ask whether to join the chat; * if so, start the protocol with dc_join_securejoin(). * * - DC_QR_FPR_OK with dc_lot_t::id=Contact ID: @@ -2679,7 +2681,8 @@ dc_lot_t* dc_check_qr (dc_context_t* context, const char* * Get QR code text that will offer an Setup-Contact or Verified-Group invitation. * * The scanning device will pass the scanned content to dc_check_qr() then; - * if dc_check_qr() returns DC_QR_ASK_VERIFYCONTACT or DC_QR_ASK_VERIFYGROUP + * if dc_check_qr() returns + * DC_QR_ASK_VERIFYCONTACT, DC_QR_ASK_VERIFYGROUP or DC_QR_ASK_VERIFYBROADCAST * an out-of-band-verification can be joined using dc_join_securejoin() * * The returned text will also work as a normal https:-link, @@ -2720,7 +2723,7 @@ char* dc_get_securejoin_qr_svg (dc_context_t* context, uint32_ * Continue a Setup-Contact or Verified-Group-Invite protocol * started on another device with dc_get_securejoin_qr(). * This function is typically called when dc_check_qr() returns - * lot.state=DC_QR_ASK_VERIFYCONTACT or lot.state=DC_QR_ASK_VERIFYGROUP. + * lot.state=DC_QR_ASK_VERIFYCONTACT, lot.state=DC_QR_ASK_VERIFYGROUP or lot.state=DC_QR_ASK_VERIFYBROADCAST * * The function returns immediately and the handshake runs in background, * sending and receiving several messages. diff --git a/deltachat-ffi/src/lot.rs b/deltachat-ffi/src/lot.rs index 11b6cb405..fdea2c6d4 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 { name, .. } => Some(Cow::Borrowed(name)), Qr::FprOk { .. } => None, Qr::FprMismatch { .. } => None, Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)), @@ -98,6 +99,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, @@ -124,6 +126,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(), @@ -166,6 +169,9 @@ pub enum LotState { /// text1=groupname QrAskVerifyGroup = 202, + /// text1=broadcast_name + QrAskJoinBroadcast = 204, + /// id=contact QrFprOk = 210, diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 123762443..22853c925 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1030,7 +1030,7 @@ impl CommandApi { .await } - /// Create a new **broadcast channel** + /// Create a new, outgoing **broadcast channel** /// (called "Channel" in the UI). /// /// Broadcast channels are similar to groups on the sending device, diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 8270e0da6..b40c06784 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -35,6 +35,26 @@ pub enum QrObject { /// Authentication code. authcode: String, }, + /// Ask the user whether to join the broadcast channel. + AskJoinBroadcast { + /// The user-visible name of this broadcast channel + name: String, + /// A string of random characters, + /// uniquely identifying this broadcast channel across all databases/clients. + /// 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, + /// ID of the contact who owns the broadcast channel and created the QR code. + contact_id: u32, + /// Fingerprint of the broadcast channel owner's key as scanned from the QR code. + fingerprint: String, + + /// Invite number. + invitenumber: String, + /// Authentication code. + authcode: String, + }, /// Contact fingerprint is verified. /// /// Ask the user if they want to start chatting. @@ -208,6 +228,25 @@ impl From for QrObject { authcode, } } + Qr::AskJoinBroadcast { + name, + grpid, + contact_id, + fingerprint, + authcode, + invitenumber, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskJoinBroadcast { + name, + grpid, + contact_id, + fingerprint, + authcode, + invitenumber, + } + } Qr::FprOk { contact_id } => { let contact_id = contact_id.to_u32(); QrObject::FprOk { contact_id } diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index fd3f53dac..2e6941944 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -326,7 +326,7 @@ class Account: return Chat(self, self._rpc.create_group_chat(self.id, name, False)) def create_broadcast(self, name: str) -> Chat: - """Create a new **broadcast channel** + """Create a new, outgoing **broadcast channel** (called "Channel" in the UI). Broadcast channels are similar to groups on the sending device, diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/message.py b/deltachat-rpc-client/src/deltachat_rpc_client/message.py index c7cd378ba..a52a48b55 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/message.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/message.py @@ -93,6 +93,17 @@ class Message: if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id: break + def resend(self) -> None: + """Resend messages and make information available for newly added chat members. + Resending sends out the original message, however, recipients and webxdc-status may differ. + Clients that already have the original message can still ignore the resent message as + they have tracked the state by dedicated updates. + + Some messages cannot be resent, eg. info-messages, drafts, already pending messages, + or messages that are not sent by SELF. + """ + self._rpc.resend_messages(self.account.id, [self.id]) + @futuremethod def send_webxdc_realtime_advertisement(self): """Send an advertisement to join the realtime channel.""" diff --git a/deltachat-rpc-client/tests/test_securejoin.py b/deltachat-rpc-client/tests/test_securejoin.py index dfb9f0f19..6222a2a4e 100644 --- a/deltachat-rpc-client/tests/test_securejoin.py +++ b/deltachat-rpc-client/tests/test_securejoin.py @@ -3,6 +3,7 @@ import logging import pytest from deltachat_rpc_client import Chat, EventType, SpecialContactId +from deltachat_rpc_client.const import ChatType from deltachat_rpc_client.rpc import JsonRpcError @@ -109,6 +110,143 @@ def test_qr_securejoin(acfactory): fiona.wait_for_securejoin_joiner_success() +@pytest.mark.parametrize("all_devices_online", [True, False]) +def test_qr_securejoin_broadcast(acfactory, all_devices_online): + alice, bob, fiona = acfactory.get_online_accounts(3) + + alice2 = alice.clone() + bob2 = bob.clone() + + if all_devices_online: + alice2.start_io() + bob2.start_io() + + logging.info("===================== Alice creates a broadcast =====================") + alice_chat = alice.create_broadcast("Broadcast channel for everyone!") + snapshot = alice_chat.get_basic_snapshot() + assert not snapshot.is_unpromoted # Broadcast channels are never unpromoted + + logging.info("===================== Bob joins the broadcast =====================") + + qr_code = alice_chat.get_qr_code() + bob.secure_join(qr_code) + alice.wait_for_securejoin_inviter_success() + bob.wait_for_securejoin_joiner_success() + alice_chat.send_text("Hello everyone!") + + def get_broadcast(ac): + chat = ac.get_chatlist(query="Broadcast channel for everyone!")[0] + assert chat.get_basic_snapshot().name == "Broadcast channel for everyone!" + return chat + + def wait_for_broadcast_messages(ac): + chat = get_broadcast(ac) + + snapshot = ac.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == "You joined the channel." + assert snapshot.chat_id == chat.id + + snapshot = ac.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == "Hello everyone!" + assert snapshot.chat_id == chat.id + + def check_account(ac, contact, inviter_side, please_wait_info_msg=False): + # Check that the chat partner is verified. + contact_snapshot = contact.get_snapshot() + assert contact_snapshot.is_verified + + chat = get_broadcast(ac) + chat_msgs = chat.get_messages() + + if please_wait_info_msg: + first_msg = chat_msgs.pop(0).get_snapshot() + assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…" + assert first_msg.is_info + + encrypted_msg = chat_msgs[0].get_snapshot() + assert encrypted_msg.text == "Messages are end-to-end encrypted." + assert encrypted_msg.is_info + + member_added_msg = chat_msgs[1].get_snapshot() + if inviter_side: + assert member_added_msg.text == f"Member {contact_snapshot.display_name} added." + else: + assert member_added_msg.text == "You joined the channel." + assert member_added_msg.is_info + + hello_msg = chat_msgs[2].get_snapshot() + assert hello_msg.text == "Hello everyone!" + assert not hello_msg.is_info + assert hello_msg.show_padlock + assert hello_msg.error is None + + assert len(chat_msgs) == 3 + + chat_snapshot = chat.get_full_snapshot() + assert chat_snapshot.is_encrypted + assert chat_snapshot.name == "Broadcast channel for everyone!" + if inviter_side: + assert chat_snapshot.chat_type == ChatType.OUT_BROADCAST + else: + assert chat_snapshot.chat_type == ChatType.IN_BROADCAST + assert chat_snapshot.can_send == inviter_side + + chat_contacts = chat_snapshot.contact_ids + assert contact.id in chat_contacts + if inviter_side: + assert len(chat_contacts) == 1 + else: + assert len(chat_contacts) == 2 + assert SpecialContactId.SELF in chat_contacts + assert chat_snapshot.self_in_group + + wait_for_broadcast_messages(bob) + + check_account(alice, alice.create_contact(bob), inviter_side=True) + check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True) + + logging.info("===================== Test Alice's second device =====================") + + # Start second Alice device, if it wasn't started already. + alice2.start_io() + + while True: + msg_id = alice2.wait_for_msgs_changed_event().msg_id + if msg_id: + snapshot = alice2.get_message_by_id(msg_id).get_snapshot() + if snapshot.text == "Hello everyone!": + break + + check_account(alice2, alice2.create_contact(bob), inviter_side=True) + + logging.info("===================== Test Bob's second device =====================") + + # Start second Bob device, if it wasn't started already. + bob2.start_io() + bob2.wait_for_securejoin_joiner_success() + wait_for_broadcast_messages(bob2) + check_account(bob2, bob2.create_contact(alice), inviter_side=False) + + # The QR code token is synced, so alice2 must be able to handle join requests. + logging.info("===================== Fiona joins the group via alice2 =====================") + alice.stop_io() + fiona.secure_join(qr_code) + alice2.wait_for_securejoin_inviter_success() + fiona.wait_for_securejoin_joiner_success() + + snapshot = fiona.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == "You joined the channel." + + get_broadcast(alice2).get_messages()[2].resend() + snapshot = fiona.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == "Hello everyone!" + + check_account(fiona, fiona.create_contact(alice), inviter_side=False, please_wait_info_msg=True) + + # For Bob, the channel must not have changed: + check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True) + + def test_qr_securejoin_contact_request(acfactory) -> None: """Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode.""" alice, bob = acfactory.get_online_accounts(2) diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index 85f61ee18..5bbc32c21 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -11,7 +11,7 @@ from unittest.mock import MagicMock import pytest from deltachat_rpc_client import Contact, EventType, Message, events -from deltachat_rpc_client.const import ChatType, DownloadState, MessageState +from deltachat_rpc_client.const import DownloadState, MessageState from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS from deltachat_rpc_client.rpc import JsonRpcError @@ -930,34 +930,103 @@ def test_delete_deltachat_folder(acfactory, direct_imap): assert "DeltaChat" in ac1_direct_imap.list_folders() -def test_broadcast(acfactory): +@pytest.mark.parametrize("all_devices_online", [True, False]) +def test_leave_broadcast(acfactory, all_devices_online): alice, bob = acfactory.get_online_accounts(2) - alice_chat = alice.create_broadcast("My great channel") - snapshot = alice_chat.get_basic_snapshot() - assert snapshot.name == "My great channel" - assert snapshot.is_unpromoted - assert snapshot.is_encrypted - assert snapshot.chat_type == ChatType.OUT_BROADCAST + bob2 = bob.clone() - alice_contact_bob = alice.create_contact(bob, "Bob") - alice_chat.add_contact(alice_contact_bob) + if all_devices_online: + bob2.start_io() - alice_msg = alice_chat.send_message(text="hello").get_snapshot() - assert alice_msg.text == "hello" - assert alice_msg.show_padlock + logging.info("===================== Alice creates a broadcast =====================") + alice_chat = alice.create_broadcast("Broadcast channel for everyone!") - bob_msg = bob.wait_for_incoming_msg().get_snapshot() - assert bob_msg.text == "hello" - assert bob_msg.show_padlock - assert bob_msg.error is None + logging.info("===================== Bob joins the broadcast =====================") + qr_code = alice_chat.get_qr_code() + bob.secure_join(qr_code) + alice.wait_for_securejoin_inviter_success() + bob.wait_for_securejoin_joiner_success() - bob_chat = bob.get_chat_by_id(bob_msg.chat_id) - bob_chat_snapshot = bob_chat.get_basic_snapshot() - assert bob_chat_snapshot.name == "My great channel" - assert not bob_chat_snapshot.is_unpromoted - assert bob_chat_snapshot.is_encrypted - assert bob_chat_snapshot.chat_type == ChatType.IN_BROADCAST - assert bob_chat_snapshot.is_contact_request + alice_bob_contact = alice.create_contact(bob) + alice_contacts = alice_chat.get_contacts() + assert len(alice_contacts) == 1 # 1 recipient + assert alice_contacts[0].id == alice_bob_contact.id - assert not bob_chat.can_send() + member_added_msg = bob.wait_for_incoming_msg() + assert member_added_msg.get_snapshot().text == "You joined the channel." + + def get_broadcast(ac): + chat = ac.get_chatlist(query="Broadcast channel for everyone!")[0] + assert chat.get_basic_snapshot().name == "Broadcast channel for everyone!" + return chat + + def check_account(ac, contact, inviter_side, please_wait_info_msg=False): + chat = get_broadcast(ac) + contact_snapshot = contact.get_snapshot() + chat_msgs = chat.get_messages() + + if please_wait_info_msg: + first_msg = chat_msgs.pop(0).get_snapshot() + assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…" + assert first_msg.is_info + + encrypted_msg = chat_msgs.pop(0).get_snapshot() + assert encrypted_msg.text == "Messages are end-to-end encrypted." + assert encrypted_msg.is_info + + member_added_msg = chat_msgs.pop(0).get_snapshot() + if inviter_side: + assert member_added_msg.text == f"Member {contact_snapshot.display_name} added." + else: + assert member_added_msg.text == "You joined the channel." + assert member_added_msg.is_info + + if not inviter_side: + leave_msg = chat_msgs.pop(0).get_snapshot() + assert leave_msg.text == "You left the channel." + + assert len(chat_msgs) == 0 + + chat_snapshot = chat.get_full_snapshot() + + # On Alice's side, SELF is not in the list of contact ids + # because OutBroadcast chats never contain SELF in the list. + # On Bob's side, SELF is not in the list because he left. + if inviter_side: + assert len(chat_snapshot.contact_ids) == 0 + else: + assert chat_snapshot.contact_ids == [contact.id] + + logging.info("===================== Bob leaves the broadcast =====================") + bob_chat = get_broadcast(bob) + assert bob_chat.get_full_snapshot().self_in_group + assert len(bob_chat.get_contacts()) == 2 # Alice and Bob + + bob_chat.leave() + assert not bob_chat.get_full_snapshot().self_in_group + # After Bob left, only Alice will be left in Bob's memberlist + assert len(bob_chat.get_contacts()) == 1 + + check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True) + + logging.info("===================== Test Alice's device =====================") + while len(alice_chat.get_contacts()) != 0: # After Bob left, there will be 0 recipients + alice.wait_for_event(EventType.CHAT_MODIFIED) + + check_account(alice, alice.create_contact(bob), inviter_side=True) + + logging.info("===================== Test Bob's second device =====================") + # Start second Bob device, if it wasn't started already. + bob2.start_io() + + member_added_msg = bob2.wait_for_incoming_msg() + assert member_added_msg.get_snapshot().text == "You joined the channel." + + bob2_chat = get_broadcast(bob2) + + # After Bob left, only Alice will be left in Bob's memberlist + while len(bob2_chat.get_contacts()) != 1: + bob2.wait_for_event(EventType.CHAT_MODIFIED) + + check_account(bob2, bob2.create_contact(alice), inviter_side=False) diff --git a/src/chat.rs b/src/chat.rs index f375c7028..904bacb33 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -43,13 +43,15 @@ 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_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}; +pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3; + /// An chat item, such as a message or a marker. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ChatItem { @@ -304,7 +306,7 @@ impl ChatId { info!( context, - "Created group/mailinglist '{}' grpid={} as {}, blocked={}.", + "Created group/broadcast '{}' grpid={} as {}, blocked={}.", &grpname, grpid, chat_id, @@ -460,7 +462,11 @@ impl ChatId { } /// Adds message "Messages are end-to-end encrypted". - async fn add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> { + pub(crate) async fn add_encrypted_msg( + self, + context: &Context, + timestamp_sort: i64, + ) -> Result<()> { let text = stock_str::messages_e2e_encrypted(context).await; add_info_msg_with_cmd( context, @@ -1489,8 +1495,9 @@ impl Chat { pub 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 + } } } @@ -2568,8 +2575,9 @@ pub async fn is_contact_in_chat( ) -> Result { // this function works for group and for normal chats, however, it is more useful // for group chats. - // ContactId::SELF may be used to check, if the user itself is in a group - // chat (ContactId::SELF is not added to normal chats) + // ContactId::SELF may be used to check whether oneself + // is in a group or incoming broadcast chat + // (ContactId::SELF is not added to 1:1 chats or outgoing broadcast channels) let exists = context .sql @@ -2659,8 +2667,12 @@ async fn prepare_send_msg( // 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 @@ -3443,7 +3455,7 @@ pub(crate) async fn create_group_ex( Ok(chat_id) } -/// Create a new **broadcast channel** +/// Create a new, outgoing **broadcast channel** /// (called "Channel" in the UI). /// /// Broadcast channels are similar to groups on the sending device, @@ -3460,60 +3472,99 @@ pub(crate) 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_secret(); + create_out_broadcast_ex(context, Sync, grpid, chat_name, secret).await } -pub(crate) async fn create_broadcast_ex( +const SQL_INSERT_BROADCAST_SECRET: &str = + "INSERT INTO broadcast_secrets (chat_id, secret) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret"; + +pub(crate) async fn create_out_broadcast_ex( context: &Context, sync: sync::Sync, grpid: String, chat_name: String, + secret: String, ) -> Result { - let row_id = { - let chat_name = &chat_name; - let grpid = &grpid; - let trans_fn = |t: &mut rusqlite::Transaction| { - let cnt = t.execute("UPDATE chats SET name=? WHERE grpid=?", (chat_name, grpid))?; - ensure!(cnt <= 1, "{cnt} chats exist with grpid {grpid}"); - if cnt == 1 { - return Ok(t.query_row( - "SELECT id FROM chats WHERE grpid=? AND type=?", - (grpid, Chattype::OutBroadcast), - |row| { - let id: isize = row.get(0)?; - Ok(id) - }, - )?); - } - t.execute( - "INSERT INTO chats \ - (type, name, grpid, param, created_timestamp) \ - VALUES(?, ?, ?, \'U=1\', ?);", - ( - Chattype::OutBroadcast, - &chat_name, - &grpid, - create_smeared_timestamp(context), - ), - )?; - Ok(t.last_insert_rowid().try_into()?) - }; - context.sql.transaction(trans_fn).await? + let chat_name = sanitize_single_line(&chat_name); + if chat_name.is_empty() { + bail!("Invalid broadcast channel name: {chat_name}."); + } + + let timestamp = create_smeared_timestamp(context); + let trans_fn = |t: &mut rusqlite::Transaction| -> Result { + let cnt: u32 = t.query_row( + "SELECT COUNT(*) FROM chats WHERE grpid=?", + (&grpid,), + |row| row.get(0), + )?; + ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}"); + + t.execute( + "INSERT INTO chats + (type, name, grpid, created_timestamp) + VALUES(?, ?, ?, ?);", + (Chattype::OutBroadcast, &chat_name, &grpid, timestamp), + )?; + let chat_id = ChatId::new(t.last_insert_rowid().try_into()?); + + t.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, &secret))?; + Ok(chat_id) }; - let chat_id = ChatId::new(u32::try_from(row_id)?); + let chat_id = context.sql.transaction(trans_fn).await?; + chat_id.add_encrypted_msg(context, timestamp).await?; context.emit_msgs_changed_without_ids(); chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, chat_id); if sync.into() { let id = SyncId::Grpid(grpid); - let action = SyncAction::CreateBroadcast(chat_name); + let action = SyncAction::CreateOutBroadcast { chat_name, secret }; self::sync(context, id, action).await.log_err(context).ok(); } Ok(chat_id) } +pub(crate) async fn load_broadcast_secret( + context: &Context, + chat_id: ChatId, +) -> Result> { + context + .sql + .query_get_value( + "SELECT secret FROM broadcast_secrets WHERE chat_id=?", + (chat_id,), + ) + .await +} + +pub(crate) async fn save_broadcast_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(()) +} + +pub(crate) async fn delete_broadcast_secret(context: &Context, chat_id: ChatId) -> Result<()> { + info!(context, "Removing broadcast secret for chat {chat_id}"); + context + .sql + .execute("DELETE FROM broadcast_secrets WHERE chat_id=?", (chat_id,)) + .await?; + + Ok(()) +} + /// Set chat contacts in the `chats_contacts` table. pub(crate) async fn update_chat_contacts_table( context: &Context, @@ -3601,6 +3652,30 @@ pub(crate) async fn remove_from_chat_contacts_table( Ok(()) } +/// Removes a contact from the chat +/// without leaving a trace. +/// +/// Note that if we call this function, +/// and then receive a message from another device +/// that doesn't know that this this member was removed +/// then the group membership algorithm will wrongly re-add this member. +pub(crate) async fn remove_from_chat_contacts_table_without_trace( + context: &Context, + chat_id: ChatId, + contact_id: ContactId, +) -> Result<()> { + context + .sql + .execute( + "DELETE FROM chats_contacts + WHERE chat_id=? AND contact_id=?", + (chat_id, contact_id), + ) + .await?; + + Ok(()) +} + /// Adds a contact to the chat. /// If the group is promoted, also sends out a system message to all group members pub async fn add_contact_to_chat( @@ -3628,14 +3703,13 @@ 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, - "{chat_id} is not a group/broadcast where one can add members" + chat.typ == Chattype::Group || (from_handshake && chat.typ == Chattype::OutBroadcast), + "{chat_id} is not a group where one can add members", ); ensure!( Contact::real_exists_by_id(context, contact_id).await? || contact_id == ContactId::SELF, "invalid contact_id {contact_id} for adding to group" ); - 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." @@ -3679,21 +3753,35 @@ pub(crate) async fn add_contact_to_chat_ex( } } else { // else continue and send status mail - 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.typ == Chattype::OutBroadcast { + // The contact was added via a QR code rather than explicit user action, + // so it could be confusing to say 'You added member Alice'. + // And in a broadcast, SELF is the only one who can add members, + // so, no information is lost by just writing 'Member Alice added' instead. + 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()); + let fingerprint = contact.fingerprint().map(|f| f.hex()); + msg.param.set_optional(Param::Arg4, fingerprint); msg.param .set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32); + if chat.typ == Chattype::OutBroadcast { + let secret = load_broadcast_secret(context, chat_id) + .await? + .context("Failed to find broadcast shared secret")?; + msg.param.set(PARAM_BROADCAST_SECRET, secret); + } send_msg(context, chat_id, &mut msg).await?; sync = Nosync; @@ -3847,7 +3935,18 @@ pub async fn remove_contact_from_chat( ); let chat = Chat::load_from_db(context, chat_id).await?; - if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast { + if chat.typ == Chattype::InBroadcast { + ensure!( + contact_id == ContactId::SELF, + "Cannot remove other member from incoming broadcast channel" + ); + delete_broadcast_secret(context, chat_id).await?; + } + + if matches!( + chat.typ, + Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast + ) { if !chat.is_self_in_chat(context).await? { let err_msg = format!( "Cannot remove contact {contact_id} from chat {chat_id}: self not in group." @@ -3860,24 +3959,25 @@ pub async fn remove_contact_from_chat( if chat.is_promoted() { remove_from_chat_contacts_table(context, chat_id, contact_id).await?; } else { - context - .sql - .execute( - "DELETE FROM chats_contacts - WHERE chat_id=? AND contact_id=?", - (chat_id, contact_id), - ) - .await?; + remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?; } // We do not return an error if the contact does not exist in the database. // 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, contact_id, addr).await; + let res = send_member_removal_msg( + context, + &chat, + contact_id, + addr, + fingerprint.as_deref(), + ) + .await; if contact_id == ContactId::SELF { res?; @@ -3896,11 +3996,6 @@ pub async fn remove_contact_from_chat( chat.sync_contacts(context).await.log_err(context).ok(); } } - } else if chat.typ == Chattype::InBroadcast && contact_id == ContactId::SELF { - // 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, contact_id, &self_addr).await?; } else { bail!("Cannot remove members from non-group chats."); } @@ -3913,6 +4008,7 @@ async fn send_member_removal_msg( chat: &Chat, contact_id: ContactId, addr: &str, + fingerprint: Option<&str>, ) -> Result { let mut msg = Message::new(Viewtype::Text); @@ -3928,6 +4024,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::Arg4, fingerprint); msg.param .set(Param::ContactAddedRemoved, contact_id.to_u32()); @@ -4694,7 +4791,10 @@ pub(crate) enum SyncAction { SetVisibility(ChatVisibility), SetMuted(MuteDuration), /// Create broadcast channel with the given name. - CreateBroadcast(String), + CreateOutBroadcast { + chat_name: String, + secret: String, + }, /// Create encrypted group chat with the given name. CreateGroupEncrypted(String), Rename(String), @@ -4759,12 +4859,23 @@ impl Context { .id } SyncId::Grpid(grpid) => { - if let SyncAction::CreateBroadcast(name) = action { - create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?; - return Ok(()); - } else if let SyncAction::CreateGroupEncrypted(name) = action { - create_group_ex(self, Nosync, grpid.clone(), name).await?; - return Ok(()); + match action { + SyncAction::CreateOutBroadcast { chat_name, secret } => { + create_out_broadcast_ex( + self, + Nosync, + grpid.to_string(), + chat_name.clone(), + secret.to_string(), + ) + .await?; + return Ok(()); + } + SyncAction::CreateGroupEncrypted(name) => { + create_group_ex(self, Nosync, grpid.clone(), name).await?; + return Ok(()); + } + _ => {} } get_chat_id_by_grpid(self, grpid) .await? @@ -4786,7 +4897,8 @@ 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::CreateGroupEncrypted(..) => { + SyncAction::CreateOutBroadcast { .. } | SyncAction::CreateGroupEncrypted(..) => { + // Create action should have been handled above already. Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index e01718b55..0181c1b97 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, @@ -2261,7 +2264,8 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> { let group_id = create_group(&bob, "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; @@ -2623,45 +2627,171 @@ async fn test_can_send_group() -> Result<()> { Ok(()) } +/// Tests that in a broadcast channel, +/// the recipients can't see the identity of their fellow recipients. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_broadcast() -> 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; +async fn test_broadcast_members_cant_see_each_other() -> Result<()> { + fn contains(parsed: &MimeMessage, s: &str) -> bool { + assert_eq!(parsed.decrypting_failed, false); + let decoded_str = std::str::from_utf8(&parsed.decoded_data).unwrap(); + decoded_str.contains(s) + } - 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 mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let charlie = &tcm.charlie().await; - let chat_bob = bob.create_chat(&alice).await; - send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?; - let msg = alice.recv_msg(&bob.pop_sent_msg().await).await; - assert!(msg.get_showpadlock()); + tcm.section("Alice creates a channel, Bob joins."); + let alice_broadcast_id = create_broadcast(alice, "Channel".to_string()).await?; + let qr = get_securejoin_qr(alice, Some(alice_broadcast_id)) + .await + .unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; - // test broadcast channel - let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?; - add_contact_to_chat( - &alice, - broadcast_id, - get_chat_contacts(&alice, msg.chat_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?; + tcm.section("Charlie scans the QR code and sends request."); { - let chat = Chat::load_from_db(&alice, broadcast_id).await?; + join_securejoin(charlie, &qr).await.unwrap(); + + let request = charlie.pop_sent_msg().await; + assert_eq!(request.recipients, "alice@example.org charlie@example.net"); + + alice.recv_msg_trash(&request).await; + } + + tcm.section("Alice sends auth-required"); + { + let auth_required = alice.pop_sent_msg().await; + assert_eq!( + auth_required.recipients, + "charlie@example.net alice@example.org" + ); + let parsed = charlie.parse_msg(&auth_required).await; + assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some()); + assert!(contains(&parsed, "charlie@example.net")); + assert_eq!(contains(&parsed, "bob@example.net"), false); + + let parsed_by_bob = bob.parse_msg(&auth_required).await; + assert!(parsed_by_bob.decrypting_failed); + + charlie.recv_msg_trash(&auth_required).await; + } + + tcm.section("Charlie sends request-with-auth"); + { + let request_with_auth = charlie.pop_sent_msg().await; + assert_eq!( + request_with_auth.recipients, + "alice@example.org charlie@example.net" + ); + + alice.recv_msg_trash(&request_with_auth).await; + } + + tcm.section("Alice adds member"); + { + let member_added = alice.pop_sent_msg().await; + assert_eq!( + member_added.recipients, + "charlie@example.net alice@example.org" + ); + let parsed = charlie.parse_msg(&member_added).await; + assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some()); + assert!(contains(&parsed, "charlie@example.net")); + assert_eq!(contains(&parsed, "bob@example.net"), false); + + let parsed_by_bob = bob.parse_msg(&member_added).await; + assert!(parsed_by_bob.decrypting_failed); + + let rcvd = charlie.recv_msg(&member_added).await; + assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup); + } + + tcm.section("Alice sends into the channel."); + { + let hi_msg = alice.send_text(alice_broadcast_id, "hi").await; + let parsed = charlie.parse_msg(&hi_msg).await; + assert_eq!(parsed.header_exists(HeaderDef::AutocryptGossip), false); + assert_eq!(contains(&parsed, "charlie@example.net"), false); + assert_eq!(contains(&parsed, "bob@example.net"), false); + + let parsed_by_bob = bob.parse_msg(&hi_msg).await; + assert_eq!(parsed_by_bob.decrypting_failed, false); + } + + tcm.section("Alice removes Charlie. Bob must not see it."); + { + let alice_charlie_contact = alice.add_or_lookup_contact_id(charlie).await; + remove_contact_from_chat(alice, alice_broadcast_id, alice_charlie_contact).await?; + let member_removed = alice.pop_sent_msg().await; + assert_eq!( + member_removed.recipients, + "charlie@example.net alice@example.org" + ); + let parsed = charlie.parse_msg(&member_removed).await; + assert!(contains(&parsed, "charlie@example.net")); + assert_eq!(contains(&parsed, "bob@example.net"), false); + + let parsed_by_bob = bob.parse_msg(&member_removed).await; + assert!(parsed_by_bob.decrypting_failed); + + let rcvd = charlie.recv_msg(&member_removed).await; + assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberRemovedFromGroup); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_broadcast_change_name() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let fiona = &tcm.fiona().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; + + { + 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()); @@ -2674,25 +2804,24 @@ 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); + let chat_bob = bob.create_chat(alice).await; 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(()) @@ -2701,52 +2830,48 @@ async fn test_broadcast() -> Result<()> { /// - Alice has multiple devices /// - Alice creates a broadcast and sends a message into it /// - Alice's second device sees the broadcast -/// - Alice adds Bob to the broadcast -/// - Synchronization is only implemented via sync messages for now, -/// which are not enabled in tests by default, -/// so, Alice's second device doesn't see the change yet. -/// `test_sync_broadcast()` tests that synchronization works via sync messages. +/// - Alice's second device changes the name, +/// Alice's first device sees the name change #[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 mut tcm = TestContextManager::new(); + let alice1 = &tcm.alice().await; + let alice2 = &tcm.alice().await; + for a in &[alice1, alice2] { + a.set_config_bool(Config::SyncMsgs, true).await?; + } - 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 a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?; + sync(alice1, alice2).await; + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; + set_chat_name(alice1, a1_broadcast_id, "Broadcast channel 42").await?; + let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await; + let msg = alice2.recv_msg(&sent_msg).await; + let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_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?; - 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_eq!(msg.chat_id, a2_broadcast_id); + let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?; + assert_eq!(a2_broadcast_chat.get_type(), Chattype::OutBroadcast); + assert_eq!(a2_broadcast_chat.get_name(), "Broadcast channel 42"); + assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty()); - 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; - assert_eq!(msg.chat_id, a0_broadcast_id); - let a0_broadcast_chat = Chat::load_from_db(&alices[0], 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() - ); + SystemTime::shift(Duration::from_secs(10)); + + tcm.section("Alice2 changes the broadcast channel name"); + set_chat_name(alice2, a2_broadcast_id, "Broadcast channel 43").await?; + + tcm.section("Alice2 sends a message"); + let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await; + + tcm.section("Alice1 receives it"); + let msg = alice1.recv_msg(&sent_msg).await; + assert_eq!(msg.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 43"); + assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); Ok(()) } @@ -2764,7 +2889,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?; @@ -2772,14 +2896,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"); @@ -2837,6 +2962,70 @@ 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 private_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!(private_chat.blocked, Blocked::Yes); + alice + .golden_test_chat( + private_chat.id, + "test_broadcast_joining_golden_private_chat", + ) + .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 @@ -2848,11 +3037,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; @@ -2860,7 +3051,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); @@ -2914,11 +3105,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")); @@ -2963,12 +3156,13 @@ 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; let bob_msg = bob.recv_msg(&sent_msg).await; + let bob_chat_id = bob_msg.chat_id; assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 1); @@ -2979,15 +3173,24 @@ async fn test_leave_broadcast() -> Result<()> { // Shift the time so that we can later check the "Broadcast channel left" message's timestamp: SystemTime::shift(Duration::from_secs(60)); + assert_eq!( + load_broadcast_secret(bob, bob_chat_id).await?, + load_broadcast_secret(alice, alice_chat_id).await? + ); + tcm.section("Bob leaves the broadcast channel."); - let bob_chat_id = bob_msg.chat_id; bob_chat_id.accept(bob).await?; remove_contact_from_chat(bob, bob_chat_id, ContactId::SELF).await?; let leave_msg = bob.pop_sent_msg().await; alice.recv_msg_trash(&leave_msg).await; - assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 0); + assert!(get_chat_contacts(alice, alice_chat_id).await?.is_empty()); + assert!( + get_past_chat_contacts(alice, alice_chat_id) + .await? + .is_empty() + ); alice.emit_event(EventType::Test); alice @@ -3007,6 +3210,46 @@ async fn test_leave_broadcast() -> Result<()> { }) .await; + assert_eq!(load_broadcast_secret(bob, bob_chat_id).await?, None); + + Ok(()) +} + +/// Test removing Bob from the channel. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_remove_member_from_broadcast() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + tcm.section("Alice creates broadcast channel with Bob."); + let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; + + tcm.section("Alice sends first message to broadcast."); + let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; + let bob_msg = bob.recv_msg(&sent_msg).await; + let bob_chat_id = bob_msg.chat_id; + + assert_eq!( + load_broadcast_secret(bob, bob_chat_id).await?, + load_broadcast_secret(alice, alice_chat_id).await? + ); + + tcm.section("Alice removes Bob from the channel."); + let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; + remove_contact_from_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + + let remove_msg = alice.pop_sent_msg().await; + let rcvd = bob.recv_msg(&remove_msg).await; + assert_eq!(rcvd.text, "Member Me removed by alice@example.org."); + + let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?; + assert_eq!(bob_chat.is_self_in_chat(bob).await?, false); + + assert_eq!(load_broadcast_secret(bob, bob_chat_id).await?, None); + Ok(()) } @@ -3018,20 +3261,67 @@ 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; + assert_eq!(request.recipients, "alice@example.org bob@example.net"); + + alice.recv_msg_trash(&request).await; + let auth_required = alice.pop_sent_msg().await; + assert_eq!( + auth_required.recipients, + "bob@example.net alice@example.org" + ); + + bob0.recv_msg_trash(&auth_required).await; + let request_with_auth = bob0.pop_sent_msg().await; + assert_eq!( + request_with_auth.recipients, + "alice@example.org bob@example.net" + ); + + alice.recv_msg_trash(&request_with_auth).await; + let member_added = alice.pop_sent_msg().await; + assert_eq!(member_added.recipients, "bob@example.net alice@example.org"); + + tcm.section("Bob receives the member-added message answer, and processes it"); + let rcvd = bob0.recv_msg(&member_added).await; + assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup); + + tcm.section("Bob's second device also receives these messages"); + bob1.recv_msg_trash(&auth_required).await; + bob1.recv_msg_trash(&request_with_auth).await; + bob1.recv_msg(&member_added).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; + + // TODO: There is a known bug in `observe_securejoin_on_other_device()`: + // When Bob joins a group or broadcast with his first device, + // then a chat with Alice will pop up on his second device. + // When it's fixed, the 2 following lines can be replaced with + // `check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await;` + let bob1_alice_contact = bob1.add_or_lookup_contact_no_key(alice).await; + assert!(bob1_alice_contact.is_verified(bob1).await.unwrap()); tcm.section("Alice sends first message to broadcast."); let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; let bob0_hello = bob0.recv_msg(&sent_msg).await; + assert_eq!(bob0_hello.chat_blocked, Blocked::Not); let bob1_hello = bob1.recv_msg(&sent_msg).await; + assert_eq!(bob1_hello.chat_blocked, Blocked::Not); tcm.section("Bob leaves the broadcast channel with his first device."); let bob_chat_id = bob0_hello.chat_id; - bob_chat_id.accept(bob0).await?; remove_contact_from_chat(bob0, bob_chat_id, ContactId::SELF).await?; let leave_msg = bob0.pop_sent_msg().await; @@ -3051,6 +3341,233 @@ 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"); + let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap(); + let request = bob.pop_sent_msg().await; + alice.recv_msg_trash(&request).await; + let auth_required = alice.pop_sent_msg().await; + bob.recv_msg_trash(&auth_required).await; + let request_with_auth = bob.pop_sent_msg().await; + alice.recv_msg_trash(&request_with_auth).await; + let member_added = 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 shows it in 1:1 chat because of a fingerprint mismatch", + ); + let rcvd = bob.recv_msg(&member_added).await; + assert_eq!(rcvd.text, "I added member bob@example.net."); + + let bob_alice_chat_id = bob.get_chat(alice).await.id; + assert_eq!(rcvd.chat_id, bob_alice_chat_id); + + assert!( + load_broadcast_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 = &mut tcm.alice().await; + let bob = &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 = tcm.exec_securejoin_qr(bob, alice, &qr).await; + + assert!( + load_broadcast_secret(bob, bob_broadcast_id) + .await? + .is_some() + ); + + 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"); + assert_eq!(rcvd.chat_id, bob_broadcast_id); + + 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 alice.ctx.inner) + .unwrap() + .self_fingerprint + .take(); + + tcm.section( + "Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat", + ); + let sent = alice.send_text(alice_broadcast_id, "Hi").await; + let rcvd = bob.recv_msg(&sent).await; + assert_eq!(rcvd.text, "Hi"); + let bob_alice_chat_id = bob.get_chat(alice).await.id; + assert_eq!(rcvd.chat_id, bob_alice_chat_id); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sync_broadcast_avatar_and_name() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice1 = &tcm.alice().await; + let alice2 = &tcm.alice().await; + + alice1.set_config_bool(Config::SyncMsgs, true).await?; + alice2.set_config_bool(Config::SyncMsgs, true).await?; + + tcm.section("Alice1 creates broadcast channel."); + let a1_broadcast_id = create_broadcast(alice1, "foo".to_string()).await?; + + tcm.section("The channel syncs to her second device"); + sync(alice1, alice2).await; + + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; + let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_broadcast_chat.grpid) + .await? + .unwrap() + .0; + let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?; + assert_eq!(a2_broadcast_chat.get_name(), "foo".to_string()); + + set_chat_name(alice1, a1_broadcast_id, "New name").await?; + let sent = alice1.pop_sent_msg().await; + let rcvd = alice2.recv_msg(&sent).await; + assert_eq!(rcvd.chat_id, a2_broadcast_id); + assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged); + assert_eq!( + rcvd.text, + r#"You changed group name from "foo" to "New name"."# + ); + + let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?; + assert_eq!(a2_broadcast_chat.get_name(), "New name".to_string()); + + let file = alice2.get_blobdir().join("avatar.png"); + tokio::fs::write(&file, AVATAR_64x64_BYTES).await?; + set_chat_profile_image(alice2, a2_broadcast_id, file.to_str().unwrap()).await?; + + let sent = alice2.pop_sent_msg().await; + let rcvd = alice1.recv_msg(&sent).await; + assert_eq!(rcvd.chat_id, a1_broadcast_id); + assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupImageChanged); + assert_eq!(rcvd.text, "You changed the group image."); + + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; + let avatar = a1_broadcast_chat.get_profile_image(alice1).await?.unwrap(); + assert_eq!(avatar.file_name().unwrap(), AVATAR_64x64_DEDUPLICATED); + + 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_out_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, + None, + time(), + ) + .await?; + save_broadcast_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; @@ -3757,57 +4274,88 @@ async fn test_sync_muted() -> Result<()> { /// Tests that synchronizing broadcast channels via sync-messages works #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_sync_broadcast() -> Result<()> { +async fn test_sync_broadcast_and_send_message() -> Result<()> { let mut tcm = TestContextManager::new(); - let alice0 = &tcm.alice().await; let alice1 = &tcm.alice().await; - for a in [alice0, alice1] { + let alice2 = &tcm.alice().await; + for a in [alice1, alice2] { a.set_config_bool(Config::SyncMsgs, true).await?; } let bob = &tcm.bob().await; - let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id; + let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id; - let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?; - sync(alice0, alice1).await; - let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; - let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid) + tcm.section("Alice creates a channel on her first device"); + let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?; + + tcm.section("The channel syncs to her second device"); + sync(alice1, alice2).await; + let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; + let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_broadcast_chat.grpid) .await? .unwrap() .0; - let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; - assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); - assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name()); - assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); - add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; - sync(alice0, alice1).await; + let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?; + assert_eq!(a2_broadcast_chat.get_type(), Chattype::OutBroadcast); + assert_eq!(a2_broadcast_chat.get_name(), a1_broadcast_chat.get_name()); + assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty()); - // 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; + tcm.section("Bob scans Alice's QR code, both of Alice's devices answer"); + let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id)) + .await + .unwrap(); + sync(alice1, alice2).await; // Sync QR code + let bob_broadcast_id = tcm + .exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr) + .await; + + let a2b_contact_id = alice2.add_or_lookup_contact_no_key(bob).await.id; assert_eq!( - get_chat_contacts(alice1, a1_broadcast_id).await?, - vec![a1b_contact_id] + get_chat_contacts(alice2, a2_broadcast_id).await?, + vec![a2b_contact_id] ); - let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await; + + tcm.section("Alice's second device sends a message to the channel"); + let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await; let msg = bob.recv_msg(&sent_msg).await; let chat = Chat::load_from_db(bob, msg.chat_id).await?; assert_eq!(chat.get_type(), Chattype::InBroadcast); - let msg = alice0.recv_msg(&sent_msg).await; - assert_eq!(msg.chat_id, a0_broadcast_id); - remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; - sync(alice0, alice1).await; - assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); + let msg = alice1.recv_msg(&sent_msg).await; + assert_eq!(msg.chat_id, a1_broadcast_id); + + tcm.section("Alice's first device removes Bob"); + remove_contact_from_chat(alice1, a1_broadcast_id, a1b_contact_id).await?; + let sent = alice1.pop_sent_msg().await; + + tcm.section("Alice's second device receives the removal-message"); + alice2.recv_msg(&sent).await; + assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty()); assert!( - get_past_chat_contacts(alice1, a1_broadcast_id) + get_past_chat_contacts(alice2, a2_broadcast_id) .await? .is_empty() ); - a0_broadcast_id.delete(alice0).await?; - sync(alice0, alice1).await; - alice1.assert_no_chat(a1_broadcast_id).await; + tcm.section("Bob receives the removal-message"); + bob.recv_msg(&sent).await; + let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?; + assert!(!bob_chat.is_self_in_chat(bob).await?); + + bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob") + .await; + + // Alice1 and Alice2 are supposed to show the chat in the same way: + alice1 + .golden_test_chat(a1_broadcast_id, "test_sync_broadcast_alice1") + .await; + alice2 + .golden_test_chat(a2_broadcast_id, "test_sync_broadcast_alice2") + .await; + + tcm.section("Alice's first device deletes the chat"); + a1_broadcast_id.delete(alice1).await?; + sync(alice1, alice2).await; + alice2.assert_no_chat(a2_broadcast_id).await; + Ok(()) } @@ -3821,12 +4369,24 @@ 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; + + 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 8c3b9de15..4012a8f41 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 was 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 8a1bc318f..f3f1af778 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -66,6 +66,27 @@ impl EncryptHelper { Ok(ctext) } + /// Symmetrically encrypt the message. This is used for broadcast channels. + /// `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::symm_encrypt_message(raw_message, sign_key, shared_secret, 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 8102bcb81..32c2281b5 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -39,6 +39,8 @@ pub enum HeaderDef { /// Mailing list ID defined in [RFC 2919](https://tools.ietf.org/html/rfc2919). ListId, ListPost, + /// Mailing list id, belonging to a broadcast channel created by Delta Chat + ChatListId, /// List-Help header defined in [RFC 2369](https://datatracker.ietf.org/doc/html/rfc2369). ListHelp, @@ -63,7 +65,9 @@ pub enum HeaderDef { ChatUserAvatar, ChatVoiceMessage, ChatGroupMemberRemoved, + ChatGroupMemberRemovedFpr, ChatGroupMemberAdded, + ChatGroupMemberAddedFpr, ChatContent, /// Past members of the group. @@ -94,6 +98,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/imex/key_transfer.rs b/src/imex/key_transfer.rs index 8ba1b262b..017aec3e8 100644 --- a/src/imex/key_transfer.rs +++ b/src/imex/key_transfer.rs @@ -93,7 +93,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result Result { + key::SignedSecretKey::from_asc(data) +} + +pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> { + 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_secret(context: &Context, chat_id: ChatId, secret: &str) -> Result<()> { + crate::chat::save_broadcast_secret(context, chat_id, secret).await +} + +pub fn create_dummy_keypair(addr: &str) -> Result { + pgp::create_keypair(EmailAddress::new(addr)?) +} + +pub fn create_broadcast_secret() -> String { + crate::tools::create_broadcast_secret() +} diff --git a/src/lib.rs b/src/lib.rs index 341f03d69..27edc2809 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,7 +75,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; @@ -113,6 +116,9 @@ pub mod accounts; pub mod peer_channels; pub mod reaction; +#[cfg(feature = "internals")] +pub mod internals_for_benches; + /// 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/mimefactory.rs b/src/mimefactory.rs index 4d73a3e94..1ae0e8c93 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, format_err}; 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, PARAM_BROADCAST_SECRET, load_broadcast_secret}; use crate::config::Config; use crate::constants::ASM_SUBJECT; use crate::constants::{Chattype, DC_FROM_HANDSHAKE}; @@ -94,7 +94,7 @@ pub struct MimeFactory { /// to use for encryption. /// /// `None` if the message is not encrypted. - encryption_keys: Option>, + encryption_pubkeys: Option>, /// Vector of pairs of recipient name and address that goes into the `To` field. /// @@ -182,7 +182,7 @@ impl MimeFactory { let now = time(); let chat = Chat::load_from_db(context, msg.chat_id).await?; let attach_profile_data = Self::should_attach_profile_data(&msg); - let undisclosed_recipients = chat.typ == Chattype::OutBroadcast; + let undisclosed_recipients = should_hide_recipients(&msg, &chat); let from_addr = context.get_primary_self_addr().await?; let config_displayname = context @@ -208,14 +208,14 @@ impl MimeFactory { let mut recipient_ids = HashSet::new(); let mut req_mdn = false; - let encryption_keys; + let encryption_pubkeys; let self_fingerprint = self_fingerprint(context).await?; if chat.is_self_talk() { to.push((from_displayname.to_string(), from_addr.to_string())); - encryption_keys = if msg.param.get_bool(Param::ForcePlaintext).unwrap_or(false) { + encryption_pubkeys = if msg.param.get_bool(Param::ForcePlaintext).unwrap_or(false) { None } else { // Encrypt, but only to self. @@ -230,7 +230,7 @@ impl MimeFactory { recipients.push(list_post.to_string()); // Do not encrypt messages to mailing lists. - encryption_keys = None; + encryption_pubkeys = None; } else { let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup { msg.param.get(Param::Arg) @@ -290,6 +290,14 @@ impl MimeFactory { for row in rows { let (authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt) = row?; + // In a broadcast channel, only send member-added/removed messages + // to the affected member: + if let Some(fp) = must_have_only_one_recipient(&msg, &chat) { + if fp? != fingerprint { + continue; + } + } + let public_key_opt = if let Some(public_key_bytes) = &public_key_bytes_opt { Some(SignedPublicKey::from_slice(public_key_bytes)?) } else { @@ -329,7 +337,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 +358,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 +423,10 @@ impl MimeFactory { req_mdn = true; } - encryption_keys = if !is_encrypted { + encryption_pubkeys = if !is_encrypted { None + } else if should_encrypt_symmetrically(&msg, &chat) { + Some(Vec::new()) } else { if keys.is_empty() && !recipients.is_empty() { bail!("No recipient keys are available, cannot encrypt to {recipients:?}."); @@ -474,7 +484,7 @@ impl MimeFactory { sender_displayname, selfstatus, recipients, - encryption_keys, + encryption_pubkeys, to, past_members, member_fingerprints, @@ -503,7 +513,7 @@ impl MimeFactory { let timestamp = create_smeared_timestamp(context); let addr = contact.get_addr().to_string(); - let encryption_keys = if contact.is_key_contact() { + let encryption_pubkeys = if contact.is_key_contact() { if let Some(key) = contact.public_key(context).await? { Some(vec![(addr.clone(), key)]) } else { @@ -519,7 +529,7 @@ impl MimeFactory { sender_displayname: None, selfstatus: "".to_string(), recipients: vec![addr], - encryption_keys, + encryption_pubkeys, to: vec![("".to_string(), contact.get_addr().to_string())], past_members: vec![], member_fingerprints: vec![], @@ -560,6 +570,10 @@ impl MimeFactory { // messages are auto-sent unlike usual unencrypted messages. step == "vg-request-with-auth" || step == "vc-request-with-auth" + // Note that for "vg-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 == "vc-contact-confirm" } @@ -812,16 +826,25 @@ 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", + "Chat-List-ID", mail_builder::headers::text::Text::new(format!( "{} <{}>", chat.name, chat.grpid )) .into(), )); + + if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup { + if let Some(secret) = msg.param.get(PARAM_BROADCAST_SECRET) { + headers.push(( + "Chat-Broadcast-Secret", + mail_builder::headers::text::Text::new(secret.to_string()).into(), + )); + } + } } } @@ -872,7 +895,7 @@ impl MimeFactory { )); } - let is_encrypted = self.encryption_keys.is_some(); + let is_encrypted = self.encryption_pubkeys.is_some(); // Add ephemeral timer for non-MDN messages. // For MDNs it does not matter because they are not visible @@ -998,6 +1021,12 @@ impl MimeFactory { } else { unprotected_headers.push(header.clone()); } + } else if header_name == "chat-broadcast-secret" { + if is_encrypted { + protected_headers.push(header.clone()); + } else { + bail!("Message is unecrypted, cannot include broadcast secret"); + } } else if is_encrypted && header_name == "date" { protected_headers.push(header.clone()); @@ -1054,7 +1083,7 @@ impl MimeFactory { } } - let outer_message = if let Some(encryption_keys) = self.encryption_keys { + let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys { // Store protected headers in the inner message. let message = protected_headers .into_iter() @@ -1071,15 +1100,15 @@ impl MimeFactory { // Add gossip headers in chats with multiple recipients let multiple_recipients = - encryption_keys.len() > 1 || context.get_config_bool(Config::BccSelf).await?; + encryption_pubkeys.len() > 1 || context.get_config_bool(Config::BccSelf).await?; let gossip_period = context.get_config_i64(Config::GossipPeriod).await?; let now = time(); match &self.loaded { Loaded::Message { chat, msg } => { - if chat.typ != Chattype::OutBroadcast { - for (addr, key) in &encryption_keys { + if !should_hide_recipients(msg, chat) { + for (addr, key) in &encryption_pubkeys { let fingerprint = key.dc_fingerprint().hex(); let cmd = msg.param.get_cmd(); let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup @@ -1173,10 +1202,34 @@ 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 { chat, msg } + if should_encrypt_with_broadcast_secret(msg, chat) => + { + let secret = load_broadcast_secret(context, chat.id).await?; + if secret.is_none() { + // If there is no shared secret yet + // because this is an old broadcast channel, + // created before we had symmetric encryption, + // we show an error message. + let text = r#"The up to now "experimental channels feature" is about to become an officially supported one. By that, privacy will be improved, it will become faster, and less traffic will be consumed. + +As we do not guarantee feature-stability for such experiments, this means, that you will need to create the channel again. + +Here is what to do: + β€’ Create a new channel + β€’ Tap on the channel name + β€’ Tap on "QR Invite Code" + β€’ Have all recipients scan the QR code, or send them the link + +If you have any questions, please send an email to delta@merlinux.eu or ask at https://support.delta.chat/."#; + chat::add_info_msg(context, chat.id, text, time()).await?; + bail!(text); + } + secret + } + _ => None, + }; // Do not anonymize OpenPGP recipients. // @@ -1189,19 +1242,34 @@ impl MimeFactory { // once new core versions are sufficiently deployed. let anonymous_recipients = false; + let encrypted = if let Some(shared_secret) = shared_secret { + 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_pubkeys.iter().map(|(_addr, key)| (*key).clone())); + + encrypt_helper + .encrypt( + context, + encryption_keyring, + message, + compress, + anonymous_recipients, + ) + .await? + }; + // XXX: additional newline is needed // to pass filtermail at - // - let encrypted = encrypt_helper - .encrypt( - context, - encryption_keyring, - message, - compress, - anonymous_recipients, - ) - .await? - + "\n"; + // : + let encrypted = encrypted + "\n"; // Set the appropriate Content-Type for the outer message MimePart::new( @@ -1433,8 +1501,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::Arg4).unwrap_or_default(); if email_to_remove == context @@ -1455,12 +1523,19 @@ 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(); + let fingerprint_to_add = msg.param.get(Param::Arg4).unwrap_or_default(); + placeholdertext = Some(stock_str::msg_add_member_remote(context, email_to_add).await); @@ -1470,15 +1545,19 @@ impl MimeFactory { mail_builder::headers::raw::Raw::new(email_to_add.to_string()).into(), )); } + if !fingerprint_to_add.is_empty() { + headers.push(( + "Chat-Group-Member-Added-Fpr", + mail_builder::headers::raw::Raw::new(fingerprint_to_add.to_string()) + .into(), + )); + } 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 = "vg-member-added"; + 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(), )); } } @@ -1889,6 +1968,37 @@ fn hidden_recipients() -> Address<'static> { Address::new_group(Some("hidden-recipients".to_string()), Vec::new()) } +fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool { + chat.typ == Chattype::OutBroadcast && must_have_only_one_recipient(msg, chat).is_none() +} + +fn should_hide_recipients(msg: &Message, chat: &Chat) -> bool { + should_encrypt_with_broadcast_secret(msg, chat) +} + +fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool { + should_encrypt_with_broadcast_secret(msg, chat) +} + +/// Some messages sent into outgoing broadcast channels (member-added/member-removed) +/// should only go to a single recipient, +/// rather than all recipients. +/// This function returns the fingerprint of the recipient the message should be sent to. +fn must_have_only_one_recipient<'a>(msg: &'a Message, chat: &Chat) -> Option> { + if chat.typ == Chattype::OutBroadcast + && matches!( + msg.param.get_cmd(), + SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup + ) + { + let Some(fp) = msg.param.get(Param::Arg4) else { + return Some(Err(format_err!("Missing removed/added member"))); + }; + return Some(Ok(fp)); + } + None +} + 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 8c51f484f..69e8dee72 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -355,9 +355,13 @@ impl MimeMessage { let mail_raw; // Memory location for a possible decrypted message. let decrypted_msg; // Decrypted signed OpenPGP message. + let secrets: Vec = context + .sql + .query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| row.get(0)) + .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(); @@ -1567,6 +1571,8 @@ impl MimeMessage { // The message belongs to a mailing list and has a `ListId:`-header // that should be used to get a unique id. return Some(list_id); + } else if let Some(chat_list_id) = self.get_header(HeaderDef::ChatListId) { + return Some(chat_list_id); } else if let Some(sender) = self.get_header(HeaderDef::Sender) { // the `Sender:`-header alone is no indicator for mailing list // as also used for bot-impersonation via `set_override_sender_name()` diff --git a/src/param.rs b/src/param.rs index 1ea377100..16c10b033 100644 --- a/src/param.rs +++ b/src/param.rs @@ -99,19 +99,44 @@ pub enum Param { /// For Messages /// - /// For "MemberRemovedFromGroup" this is the email address - /// removed from the group. + /// For "MemberAddedToGroup" and "MemberRemovedFromGroup", + /// this is the email address added to / removed from the group. /// - /// For "MemberAddedToGroup" this is the email address added to the group. + /// For securejoin messages other than `vg-member-added`, 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 [`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. + /// + /// For call messages, this is the accept timestamp. 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. + /// + /// For "MemberAddedToGroup" and "MemberRemovedFromGroup", + /// this is the fingerprint added to / removed from the group. + /// + /// For call messages, this is the end timsetamp. Arg4 = b'H', /// For Messages diff --git a/src/pgp.rs b/src/pgp.rs index fab5f3c7b..f4988a96e 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::{ Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, 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_old::thread_rng; +use rand_old::{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; @@ -236,13 +237,17 @@ pub fn pk_calc_signature( Ok(sig.to_armored_string(ArmorOptions::default())?) } -/// Decrypts the message with keys from the private key keyring. +/// Decrypts the message: +/// - with keys from the private key keyring (passed in `private_keys_for_decryption`) +/// if the message was asymmetrically encrypted, +/// - with a shared secret/password (passed in `shared_secrets`), +/// if the message was symmetrically encrypted. /// -/// Receiver private keys are provided in -/// `private_keys_for_decryption`. -pub fn pk_decrypt( +/// 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)?; @@ -251,18 +256,41 @@ pub fn pk_decrypt( let empty_pw = Password::empty(); let decrypt_options = DecryptionOptions::new(); + let symmetric_encryption_res = check_symmetric_encryption(&msg); + if symmetric_encryption_res.is_err() { + shared_secrets = &[]; + } + + // We always try out all passwords here, + // but benchmarking (see `benches/decrypting.rs`) + // showed that the performance impact is negligible. + // We can improve this in the future if necessary. + 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![], decrypt_options, }; - 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) = symmetric_encryption_res { + bail!("{err:#} (Note: symmetric decryption was not tried: {reason})") + } else { + bail!("{err:#}"); + } + } + }; // remove one layer of compression let msg = msg.decompress()?; @@ -270,6 +298,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 check_symmetric_encryption(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. @@ -310,8 +366,8 @@ pub fn pk_validate( Ok(ret) } -/// Symmetric encryption. -pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { +/// Symmetric encryption for the autocrypt setup message (ASM). +pub async fn symm_encrypt_autocrypt_setup(passphrase: &str, plain: Vec) -> Result { let passphrase = Password::from(passphrase.to_string()); tokio::task::spawn_blocking(move || { @@ -328,6 +384,46 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec) -> Result { .await? } +/// Symmetrically encrypt the message. +/// This is used for broadcast channels and for version 2 of the Securejoin protocol. +/// `shared secret` is the secret that will be used for symmetric encryption. +pub async fn symm_encrypt_message( + plain: Vec, + private_key_for_signing: SignedSecretKey, + shared_secret: &str, + 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, + }; + 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, @@ -351,7 +447,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}, + }; use pgp::composed::Esk; use pgp::packet::PublicKeyEncryptedSessionKey; @@ -364,7 +463,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); @@ -560,6 +659,105 @@ mod tests { 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 = symm_encrypt_message( + plain.clone(), + load_self_secret_key(alice).await?, + shared_secret, + 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, 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(()) + } + /// Tests that recipient key IDs and fingerprints /// are omitted or replaced with wildcards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/qr.rs b/src/qr.rs index da2457822..4f1476bbd 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -85,6 +85,30 @@ pub enum Qr { authcode: String, }, + /// Ask whether to join the broadcast channel. + AskJoinBroadcast { + /// The user-visible name of this broadcast channel + name: String, + + /// A string of random characters, + /// uniquely identifying this broadcast channel across all databases/clients. + /// 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, + + /// ID of the contact who owns the channel and created the QR code. + contact_id: ContactId, + + /// Fingerprint of the contact's key as scanned from the QR code. + fingerprint: Fingerprint, + + /// Invite number. + invitenumber: String, + /// Authentication code. + authcode: String, + }, + /// Contact fingerprint is verified. /// /// Ask the user if they want to start chatting. @@ -371,6 +395,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&j=INVITENUMBER&s=AUTH` /// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR` async fn decode_openpgp(context: &Context, qr: &str) -> Result { let payload = qr @@ -407,18 +432,12 @@ 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") + // For historic reansons, broadcasts currently use j instead of i for the invitenumber: + .or_else(|| param.get("j")) .filter(|&s| validate_id(s)) .map(|s| s.to_string()); let authcode = param @@ -430,19 +449,8 @@ 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) { let addr = ContactAddress::new(addr)?; @@ -456,7 +464,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .await .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?; - if let (Some(grpid), Some(grpname)) = (grpid, grpname) { + if let (Some(grpid), Some(grpname)) = (grpid.clone(), grpname) { if context .is_self_addr(&addr) .await @@ -491,6 +499,15 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { authcode, }) } + } else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) { + Ok(Qr::AskJoinBroadcast { + name, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + }) } else if context.is_self_addr(&addr).await? { if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? { Ok(Qr::WithdrawVerifyContact { @@ -536,6 +553,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 4cabf7131..549f2bc8c 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -14,7 +14,7 @@ use mailparse::SingleInfo; use num_traits::FromPrimitive; use regex::Regex; -use crate::chat::{self, Chat, ChatId, ChatIdBlocked, remove_from_chat_contacts_table}; +use crate::chat::{self, Chat, ChatId, ChatIdBlocked, save_broadcast_secret}; use crate::config::Config; use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails}; use crate::contact::{self, Contact, ContactId, Origin, mark_contact_id_as_verified}; @@ -25,8 +25,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}; +use crate::key::{self_fingerprint, self_fingerprint_opt}; use crate::log::LogExt; use crate::log::{info, warn}; use crate::logged_debug_assert; @@ -43,7 +43,7 @@ use crate::simplify; use crate::stats::STATISTICS_BOT_EMAIL; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{self, buf_compress, remove_subject_prefix}; +use crate::tools::{self, buf_compress, remove_subject_prefix, validate_broadcast_secret}; use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location}; /// This is the struct that is returned after receiving one email (aka MIME message). @@ -320,7 +320,7 @@ async fn get_to_and_past_contact_ids( .await?; } } - ChatAssignment::Trash | ChatAssignment::MailingListOrBroadcast => { + ChatAssignment::Trash => { to_ids = Vec::new(); past_ids = Vec::new(); } @@ -385,7 +385,10 @@ async fn get_to_and_past_contact_ids( ) .await?; } - ChatAssignment::OneOneChat => { + // Sometimes, messages are sent just to a single recipient + // in a broadcast (e.g. securejoin messages). + // In this case, we need to look them up like in a 1:1 chat: + ChatAssignment::OneOneChat | ChatAssignment::MailingListOrBroadcast => { let pgp_to_ids = add_or_lookup_key_contacts( context, &mime_parser.recipients, @@ -681,12 +684,13 @@ pub(crate) async fn receive_imf_inner( handle_securejoin_handshake(context, &mut mime_parser, from_id) .await .context("error in Secure-Join message handling")? - } else { - let to_id = to_ids.first().copied().flatten().unwrap_or(ContactId::SELF); + } else if let Some(to_id) = to_ids.first().copied().flatten() { // handshake may mark contacts as verified and must be processed before chats are created observe_securejoin_on_other_device(context, &mime_parser, to_id) .await .context("error in Secure-Join watching")? + } else { + securejoin::HandshakeMessage::Propagate }; match res { @@ -1370,6 +1374,7 @@ async fn do_chat_assignment( create_or_lookup_mailinglist_or_broadcast( context, allow_creation, + create_blocked, mailinglist_header, from_id, mime_parser, @@ -1510,17 +1515,34 @@ async fn do_chat_assignment( // (it can't be a mailing list, since it's outgoing) if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { let listid = mailinglist_header_listid(mailinglist_header)?; - chat_id = Some( - if let Some((id, ..)) = chat::get_chat_id_by_grpid(context, &listid).await? + if let Some((id, ..)) = chat::get_chat_id_by_grpid(context, &listid).await? { + chat_id = Some(id); + } else { + // Looks like we missed the sync message that was creating this broadcast channel + let name = + compute_mailinglist_name(mailinglist_header, &listid, mime_parser); + if let Some(secret) = mime_parser + .get_header(HeaderDef::ChatBroadcastSecret) + .filter(|s| validate_broadcast_secret(s)) { - id - } else { chat_created = true; - let name = - compute_mailinglist_name(mailinglist_header, &listid, mime_parser); - chat::create_broadcast_ex(context, Nosync, listid, name).await? - }, - ); + chat_id = Some( + chat::create_out_broadcast_ex( + context, + Nosync, + listid, + name, + secret.to_string(), + ) + .await?, + ); + } else { + warn!( + context, + "Not creating outgoing broadcast with id {listid}, because secret is unknown" + ); + } + } } } ChatAssignment::AdHocGroup => { @@ -1614,8 +1636,8 @@ async fn add_parts( is_partial_download: Option, mut replace_msg_id: Option, prevent_rename: bool, - chat_id: ChatId, - chat_id_blocked: Blocked, + mut chat_id: ChatId, + mut chat_id_blocked: Blocked, is_dc_message: MessengerMessage, ) -> Result { let to_id = if mime_parser.incoming { @@ -1648,6 +1670,18 @@ async fn add_parts( for part in &mut mime_parser.parts { part.param.set(Param::OverrideSenderDisplayname, name); } + + if chat.typ == Chattype::InBroadcast { + warn!( + context, + "Not assigning msg '{rfc724_mid}' to broadcast {chat_id}: wrong sender: {from_id}." + ); + let direct_chat = + ChatIdBlocked::get_for_contact(context, from_id, Blocked::Request).await?; + chat_id = direct_chat.id; + chat_id_blocked = direct_chat.blocked; + chat = Chat::load_from_db(context, chat_id).await?; + } } } @@ -2790,20 +2824,18 @@ async fn apply_group_changes( !chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id); 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. if !is_from_in_chat { better_msg = Some(String::new()); - } else if let Some(id) = - lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await? + } else if let Some(removed_fpr) = + mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) { - removed_id = Some(id); + 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; Some(stock_str::msg_group_left_local(context, from_id).await) @@ -2819,8 +2851,8 @@ async fn apply_group_changes( } else if let Some(key) = mime_parser.gossiped_keys.get(added_addr) { // TODO: if gossiped keys contain the same address multiple times, // we may lookup the wrong contact. - // This could be fixed by looking up the contact with - // highest `add_timestamp` to disambiguate. + // This can be fixed by looking at ChatGroupMemberAddedFpr, + // just like we look at ChatGroupMemberRemovedFpr. // The result of the error is that info message // may contain display name of the wrong contact. let fingerprint = key.public_key.dc_fingerprint().hex(); @@ -2973,6 +3005,7 @@ async fn apply_group_changes( if !added_ids.remove(&added_id) && added_id != ContactId::SELF { // No-op "Member added" message. An exception is self-addition messages because they at // least must be shown when a chat is created on our side. + info!(context, "No-op 'Member added' message (TRASH)"); better_msg = Some(String::new()); } } @@ -3175,6 +3208,7 @@ fn mailinglist_header_listid(list_id_header: &str) -> Result { async fn create_or_lookup_mailinglist_or_broadcast( context: &Context, allow_creation: bool, + create_blocked: Blocked, list_id_header: &str, from_id: ContactId, mime_parser: &MimeMessage, @@ -3200,25 +3234,19 @@ async fn create_or_lookup_mailinglist_or_broadcast( }; if allow_creation { - // list does not exist but should be created + // Broadcast channel / mailinglist does not exist but should be created let param = mime_parser.list_post.as_ref().map(|list_post| { let mut p = Params::new(); p.set(Param::ListPost, list_post); p.to_string() }); - let is_bot = context.get_config_bool(Config::Bot).await?; - let blocked = if is_bot { - Blocked::Not - } else { - Blocked::Request - }; let chat_id = ChatId::create_multiuser_record( context, chattype, &listid, name, - blocked, + create_blocked, param, mime_parser.timestamp_sent, ) @@ -3230,13 +3258,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, @@ -3246,7 +3267,12 @@ async fn create_or_lookup_mailinglist_or_broadcast( ) .await?; } - Ok(Some((chat_id, blocked, true))) + + context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, chat_id); + + Ok(Some((chat_id, create_blocked, true))) } else { info!(context, "Creating list forbidden by caller."); Ok(None) @@ -3399,19 +3425,77 @@ async fn apply_out_broadcast_changes( ) -> Result { 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![], - }); + if from_id == ContactId::SELF { + apply_chat_name_and_avatar_changes( + context, + mime_parser, + from_id, + chat, + &mut send_event_chat_modified, + &mut better_msg, + ) + .await?; } - Ok(GroupChangesInfo::default()) + if let Some(added_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAddedFpr) { + if from_id == ContactId::SELF { + let added_id = lookup_key_contact_by_fingerprint(context, added_fpr).await?; + if let Some(added_id) = added_id { + if chat::is_contact_in_chat(context, chat.id, added_id).await? { + info!(context, "No-op broadcast addition (TRASH)"); + better_msg.get_or_insert("".to_string()); + } else { + chat::add_to_chat_contacts_table( + context, + mime_parser.timestamp_sent, + chat.id, + &[added_id], + ) + .await?; + let msg = + stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED) + .await; + better_msg.get_or_insert(msg); + send_event_chat_modified = true; + } + } else { + warn!(context, "Failed to find contact with fpr {added_fpr}"); + } + } + } else if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) { + send_event_chat_modified = true; + let removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?; + if removed_id == Some(from_id) { + // The sender of the message left the broadcast channel + // Silently remove them without notifying the user + chat::remove_from_chat_contacts_table_without_trace(context, chat.id, from_id).await?; + info!(context, "Broadcast leave message (TRASH)"); + better_msg = Some("".to_string()); + } else if from_id == ContactId::SELF { + if let Some(removed_id) = removed_id { + chat::remove_from_chat_contacts_table_without_trace(context, chat.id, removed_id) + .await?; + + better_msg.get_or_insert( + stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await, + ); + } + } + } + + 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( @@ -3422,6 +3506,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; @@ -3435,11 +3529,58 @@ 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)"); + "".to_string() + } else { + stock_str::msg_you_joined_broadcast(context).await + }; + + better_msg.get_or_insert(msg); + send_event_chat_modified = true; + } + } + + 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: + if removed_fpr != self_fingerprint(context).await? { + logged_debug_assert!(context, false, "Ignoring unexpected removal message"); + return Ok(GroupChangesInfo::default()); + } + chat::delete_broadcast_secret(context, chat.id).await?; + if from_id == ContactId::SELF { better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context).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_without_trace(context, chat.id, ContactId::SELF) + .await?; + send_event_chat_modified = true; + } else if !chat.is_self_in_chat(context).await? { + chat::add_to_chat_contacts_table( + context, + mime_parser.timestamp_sent, + chat.id, + &[ContactId::SELF], + ) + .await?; + send_event_chat_modified = true; + } + + if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) { + if validate_broadcast_secret(secret) { + save_broadcast_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 4b4bc2090..4e179bde0 100644 --- a/src/receive_imf/receive_imf_tests.rs +++ b/src/receive_imf/receive_imf_tests.rs @@ -882,7 +882,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 96b410a6f..d2b9337dc 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -46,14 +46,8 @@ fn inviter_progress( context: &Context, contact_id: ContactId, chat_id: ChatId, - is_group: bool, + chat_type: Chattype, ) -> Result<()> { - let chat_type = if is_group { - Chattype::Group - } else { - Chattype::Single - }; - // No other values are used. let progress = 1000; context.emit_event(EventType::SecurejoinInviterProgress { @@ -68,9 +62,9 @@ fn inviter_progress( /// 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 ==== @@ -78,12 +72,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 ); if chat.grpid.is_empty() { let err = format!("Can't generate QR code, chat {id} is a email thread"); @@ -127,24 +122,37 @@ 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, - ) + + let chat_name = chat.get_name(); + let chat_name_urlencoded = utf8_percent_encode(chat_name, NON_ALPHANUMERIC).to_string(); + if chat.typ == Chattype::OutBroadcast { + // For historic reansons, broadcasts currently use j instead of i for the invitenumber. + format!( + "https://i.delta.chat/#{}&a={}&b={}&x={}&j={}&s={}", + fingerprint.hex(), + self_addr_urlencoded, + &chat_name_urlencoded, + &chat.grpid, + &invitenumber, + &auth, + ) + } else { + format!( + "https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}", + fingerprint.hex(), + self_addr_urlencoded, + &chat_name_urlencoded, + &chat.grpid, + &invitenumber, + &auth, + ) + } } else { // parameters used: a=n=i=s= if sync_token { @@ -330,6 +338,21 @@ pub(crate) async fn handle_securejoin_handshake( info!(context, "Received secure-join message {step:?}."); + // Opportunistically protect against a theoretical 'surreptitious forwarding' attack: + // If Eve obtains a QR code from Alice and starts a securejoin with her, + // and also lets Bob scan a manipulated QR code, + // she could reencrypt the v*-request-with-auth message to Bob while maintaining the signature, + // and Bob would regard the message as valid. + // + // This attack is not actually relevant in any threat model, + // because if Eve can see Alice's QR code and have Bob scan a manipulated QR code, + // she can just do a classical MitM attack. + // + // Protecting all messages sent by Delta Chat against 'surreptitious forwarding' + // by checking the 'intended recipient fingerprint' + // will improve security (completely unrelated to the securejoin protocol) + // and is something we want to do in the future: + // https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding if !matches!(step, "vg-request" | "vc-request") { let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); @@ -450,7 +473,7 @@ pub(crate) async fn handle_securejoin_handshake( ); return Ok(HandshakeMessage::Ignore); }; - let group_chat_id = match grpid.as_str() { + let joining_chat_id = match grpid.as_str() { "" => None, id => { let Some((chat_id, ..)) = get_chat_id_by_grpid(context, id).await? else { @@ -486,12 +509,13 @@ pub(crate) async fn handle_securejoin_handshake( ChatId::create_for_contact(context, contact_id).await?; } context.emit_event(EventType::ContactsChanged(Some(contact_id))); - if let Some(group_chat_id) = group_chat_id { + if let Some(joining_chat_id) = joining_chat_id { // Join group. - chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true) + chat::add_contact_to_chat_ex(context, Nosync, joining_chat_id, contact_id, true) .await?; - let is_group = true; - inviter_progress(context, contact_id, group_chat_id, is_group)?; + let chat = Chat::load_from_db(context, joining_chat_id).await?; + + inviter_progress(context, contact_id, joining_chat_id, chat.typ)?; // 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) @@ -502,8 +526,7 @@ pub(crate) async fn handle_securejoin_handshake( .await .context("failed sending vc-contact-confirm message")?; - let is_group = false; - inviter_progress(context, contact_id, chat_id, is_group)?; + inviter_progress(context, contact_id, chat_id, Chattype::Single)?; Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed) } } @@ -621,9 +644,16 @@ pub(crate) async fn observe_securejoin_on_other_device( mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?; if step == "vg-member-added" || step == "vc-contact-confirm" { - let is_group = mime_message + let chat_type = if mime_message .get_header(HeaderDef::ChatGroupMemberAdded) - .is_some(); + .is_none() + { + Chattype::Single + } else if mime_message.get_header(HeaderDef::ListId).is_some() { + Chattype::OutBroadcast + } else { + Chattype::Group + }; // We don't know the chat ID // as we may not know about the group yet. @@ -633,7 +663,7 @@ pub(crate) async fn observe_securejoin_on_other_device( // and tests which don't care about the chat ID, // so we pass invalid chat ID here. let chat_id = ChatId::new(0); - inviter_progress(context, contact_id, chat_id, is_group)?; + inviter_progress(context, contact_id, chat_id, chat_type)?; } if step == "vg-request-with-auth" || step == "vc-request-with-auth" { diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index f276e8e02..4a0d67b10 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -17,7 +17,7 @@ use crate::param::Param; use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint}; use crate::stock_str; use crate::sync::Sync::*; -use crate::tools::{create_smeared_timestamp, time}; +use crate::tools::{smeared_time, time}; /// Starts the securejoin protocol with the QR `invite`. /// @@ -47,10 +47,14 @@ 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()))?; ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?; context.emit_event(EventType::ContactsChanged(None)); @@ -65,15 +69,15 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul ) .await?; - // `group_chat_id` is `Some` if group chat + // `joining_chat_id` is `Some` if group chat // already exists and we are in the chat. - let group_chat_id = match invite { - QrInvite::Group { ref grpid, .. } => { - if let Some((group_chat_id, _blocked)) = + let joining_chat_id = match invite { + QrInvite::Group { ref grpid, .. } | QrInvite::Broadcast { ref grpid, .. } => { + if let Some((joining_chat_id, _blocked)) = chat::get_chat_id_by_grpid(context, grpid).await? { - if is_contact_in_chat(context, group_chat_id, ContactId::SELF).await? { - Some(group_chat_id) + if is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? { + Some(joining_chat_id) } else { None } @@ -84,7 +88,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul QrInvite::Contact { .. } => None, }; - if let Some(group_chat_id) = group_chat_id { + if let Some(joining_chat_id) = joining_chat_id { // If QR code is a group invite // and we are already in the chat, // nothing needs to be done. @@ -93,44 +97,72 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul contact_id: invite.contact_id(), progress: JoinerProgress::Succeeded.to_usize(), }); - return Ok(group_chat_id); + return Ok(joining_chat_id); } else if has_key && verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()) .await? { // 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?; context.emit_event(EventType::SecurejoinJoinerProgress { contact_id: invite.contact_id(), 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?; } } match invite { 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? { + let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?; + // We created the group already, now we need to add Alice to the group. + // The group will only become usable once the protocol is finished. + 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 { .. } => { + let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?; + // We created the broadcast channel already, now we need to add Alice to it. + 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 we were not in the broadcast channel before, show a 'please wait' info message. + // Since we don't have any specific stock string for this, + // use the generic `Establishing guaranteed end-to-end encryption, please wait…` + 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 @@ -139,13 +171,13 @@ 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?; 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, @@ -155,7 +187,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul None, ) .await?; - Ok(chat_id) + Ok(private_chat_id) } } } @@ -216,10 +248,10 @@ 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. + // so only show it when joining a group and not for a 1:1 chat or broadcast channel. let contact_id = invite.contact_id(); let msg = stock_str::secure_join_replies(context, contact_id).await; let chat_id = joining_chat_id(context, &invite, chat_id).await?; @@ -326,10 +358,12 @@ impl BobHandshakeMsg { Self::Request => match invite { QrInvite::Contact { .. } => "vc-request", QrInvite::Group { .. } => "vg-request", + QrInvite::Broadcast { .. } => "vg-request", }, Self::RequestWithAuth => match invite { QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth", + QrInvite::Broadcast { .. } => "vg-request-with-auth", }, } } @@ -349,8 +383,14 @@ 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 { 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, _blocked)) => { chat_id.unblock_ex(context, Nosync).await?; chat_id @@ -358,17 +398,17 @@ async fn joining_chat_id( None => { ChatId::create_multiuser_record( context, - Chattype::Group, + chattype, grpid, name, Blocked::Not, 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 023d6875b..4bb3b71e1 100644 --- a/src/securejoin/qrinvite.rs +++ b/src/securejoin/qrinvite.rs @@ -29,6 +29,14 @@ pub enum QrInvite { invitenumber: String, authcode: String, }, + Broadcast { + contact_id: ContactId, + fingerprint: Fingerprint, + name: String, + grpid: String, + invitenumber: String, + authcode: String, + }, } impl QrInvite { @@ -38,28 +46,36 @@ 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 { match self { - Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber, + Self::Contact { invitenumber, .. } + | Self::Group { invitenumber, .. } + | Self::Broadcast { invitenumber, .. } => invitenumber, } } /// 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, } } } @@ -95,6 +111,21 @@ impl TryFrom for QrInvite { invitenumber, authcode, }), + Qr::AskJoinBroadcast { + name, + grpid, + contact_id, + fingerprint, + authcode, + invitenumber, + } => Ok(QrInvite::Broadcast { + name, + grpid, + contact_id, + fingerprint, + authcode, + invitenumber, + }), _ => bail!("Unsupported QR type"), } } diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index 67e0acb0d..9b8197a04 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -11,7 +11,8 @@ use crate::mimeparser::{GossipedKey, SystemMessage}; 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; @@ -843,6 +844,73 @@ 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(scanned, "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(()) +} + /// Tests that scanning a QR code week later /// allows Bob to establish a contact with Alice, /// but does not mark Bob as verified for Alice. diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 224dccfcb..f2e0bbb29 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1327,6 +1327,18 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); .await?; } + inc_and_check(&mut migration_version, 138)?; + if dbversion < migration_version { + sql.execute_migration( + "CREATE TABLE broadcast_secrets( + chat_id INTEGER PRIMARY KEY NOT NULL, + secret TEXT NOT NULL + ) STRICT", + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? diff --git a/src/stats.rs b/src/stats.rs index 00f272dbb..bad0ca6ab 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -156,8 +156,9 @@ struct JoinedInvite { already_verified: bool, /// The type of the invite: /// "contact" for 1:1 invites that setup a verified contact, - /// "group" for invites that invite to a group - /// and also perform the contact verification 'along the way'. + /// "group" for invites that invite to a group, + /// "broadcast" for invites that invite to a broadcast channel. + /// The invite also performs the contact verification 'along the way'. typ: String, } @@ -838,6 +839,7 @@ pub(crate) async fn count_securejoin_invite(context: &Context, invite: &QrInvite let typ = match invite { QrInvite::Contact { .. } => "contact", QrInvite::Group { .. } => "group", + QrInvite::Broadcast { .. } => "broadcast", }; context diff --git a/src/stats/stats_tests.rs b/src/stats/stats_tests.rs index ab1ab9c29..5fcbeb677 100644 --- a/src/stats/stats_tests.rs +++ b/src/stats/stats_tests.rs @@ -407,6 +407,7 @@ async fn test_stats_securejoin_invites() -> Result<()> { let bob = &tcm.bob().await; let charlie = &tcm.charlie().await; alice.set_config_bool(Config::StatsSending, true).await?; + let _first_sent_stats = alice.pop_sent_msg().await; let mut expected = vec![]; diff --git a/src/stock_str.rs b/src/stock_str.rs index b23f95138..82f931927 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -432,6 +432,9 @@ https://delta.chat/donate"))] #[strum(props(fallback = "Scan to join channel %1$s"))] SecureJoinBrodcastQRDescription = 201, + #[strum(props(fallback = "You joined the channel."))] + MsgYouJoinedBroadcast = 202, + #[strum(props( fallback = "The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!" ))] @@ -732,6 +735,11 @@ pub(crate) async fn msg_you_left_broadcast(context: &Context) -> String { translated(context, StockMessage::MsgYouLeftBroadcast).await } +/// Stock string: `You joined the channel.` +pub(crate) async fn msg_you_joined_broadcast(context: &Context) -> String { + translated(context, StockMessage::MsgYouJoinedBroadcast).await +} + /// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`. pub(crate) async fn msg_reacted( context: &Context, diff --git a/src/test_utils.rs b/src/test_utils.rs index fdbddddf7..683e39e58 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -226,24 +226,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 the 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; } } @@ -1005,9 +1036,15 @@ impl TestContext { .await .unwrap_or_else(|e| panic!("Error writing {filename:?}: {e}")); } else { + let green = Color::Green.normal(); + let red = Color::Red.normal(); assert_eq!( - actual, expected, - "To update the expected value, run `UPDATE_GOLDEN_TESTS=1 cargo test`" + actual, + expected, + "{} != {} on {}'s device.\nTo update the expected value, run with `UPDATE_GOLDEN_TESTS=1` environment variable", + red.paint("actual chat content (shown in red)"), + green.paint("expected chat content (shown in green)"), + self.name(), ); } } diff --git a/src/tools.rs b/src/tools.rs index 3c5c7c4ec..5b8e2530d 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -296,6 +296,23 @@ 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. +/// 258 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_secret() -> String { + // ThreadRng implements CryptoRng trait and is supposed to be cryptographically secure. + // Generate 264 random bits. + let mut arr = [0u8; 33]; + rand::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. @@ -304,6 +321,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_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 000000000..260726c62 --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_alice @@ -0,0 +1,6 @@ +OutBroadcast#Chat#1001: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png +-------------------------------------------------------------------------------- +Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#1002πŸ”’: Me (Contact#Contact#Self): You changed the group image. [INFO] √ +Msg#1006πŸ”’: Me (Contact#Contact#Self): Member bob@example.net added. [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 000000000..45e761195 --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_bob @@ -0,0 +1,6 @@ +InBroadcast#Chat#2002: My Channel [2 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png +-------------------------------------------------------------------------------- +Msg#2004: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] +Msg#2003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#2007πŸ”’: (Contact#Contact#2001): You joined the channel. [FRESH][INFO] +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_broadcast_joining_golden_private_chat b/test-data/golden/test_broadcast_joining_golden_private_chat new file mode 100644 index 000000000..d4a1739d4 --- /dev/null +++ b/test-data/golden/test_broadcast_joining_golden_private_chat @@ -0,0 +1,4 @@ +Single#Chat#1002: bob@example.net [KEY bob@example.net] +-------------------------------------------------------------------------------- +Msg#1003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_sync_broadcast_alice1 b/test-data/golden/test_sync_broadcast_alice1 new file mode 100644 index 000000000..d0d42cc22 --- /dev/null +++ b/test-data/golden/test_sync_broadcast_alice1 @@ -0,0 +1,7 @@ +OutBroadcast#Chat#1001: Channel [0 member(s)] +-------------------------------------------------------------------------------- +Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#1007πŸ”’: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √ +Msg#1009πŸ”’: Me (Contact#Contact#Self): hi √ +Msg#1010πŸ”’: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √ +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_sync_broadcast_alice2 b/test-data/golden/test_sync_broadcast_alice2 new file mode 100644 index 000000000..ef143e0d8 --- /dev/null +++ b/test-data/golden/test_sync_broadcast_alice2 @@ -0,0 +1,7 @@ +OutBroadcast#Chat#1001: Channel [0 member(s)] +-------------------------------------------------------------------------------- +Msg#1002: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#1007πŸ”’: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √ +Msg#1009πŸ”’: Me (Contact#Contact#Self): hi √ +Msg#1010πŸ”’: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √ +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_sync_broadcast_bob b/test-data/golden/test_sync_broadcast_bob new file mode 100644 index 000000000..ce0c2b1dc --- /dev/null +++ b/test-data/golden/test_sync_broadcast_bob @@ -0,0 +1,8 @@ +InBroadcast#Chat#2002: Channel [1 member(s)] +-------------------------------------------------------------------------------- +Msg#2004: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO] +Msg#2003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#2008πŸ”’: (Contact#Contact#2001): You joined the channel. [FRESH][INFO] +Msg#2010πŸ”’: (Contact#Contact#2001): hi [FRESH] +Msg#2011πŸ”’: (Contact#Contact#2001): 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 000000000..6e7952e91 --- /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 000000000..44406e7d4 --- /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-- +