Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ opt-level = 1
# Make anyhow `backtrace` feature useful.
# With `debug = 0` there are no line numbers in the backtrace
# produced with RUST_BACKTRACE=1.
debug = 1
debug = 'full'
opt-level = 0

[profile.fuzz]
Expand Down
45 changes: 34 additions & 11 deletions src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ use crate::smtp::send_msg_to_smtp;
use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid,
create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset,
smeared_time, time, truncate_msg_text,
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id,
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
gm2local_offset, smeared_time, time, truncate_msg_text,
};
use crate::webxdc::StatusUpdateSerial;
use crate::{chatlist_events, imap};
Expand Down Expand Up @@ -3685,14 +3685,16 @@ pub async fn create_group_chat(
/// Returns the created chat's id.
pub async fn create_broadcast(context: &Context, chat_name: String) -> Result<ChatId> {
let grpid = create_id();
create_broadcast_ex(context, Sync, grpid, chat_name).await
let secret = create_broadcast_shared_secret();
create_broadcast_ex(context, Sync, grpid, chat_name, secret).await
}

pub(crate) async fn create_broadcast_ex(
context: &Context,
sync: sync::Sync,
grpid: String,
chat_name: String,
secret: String,
) -> Result<ChatId> {
let row_id = {
let chat_name = &chat_name;
Expand All @@ -3710,17 +3712,28 @@ pub(crate) async fn create_broadcast_ex(
},
)?);
}
let mut param = Params::new();
// param.set(Param::Unpromoted, 1); // TODO broadcasts will just never be unpromoted for now
param.set(Param::SymmetricKey, &secret);
t.execute(
"INSERT INTO chats \
(type, name, grpid, param, created_timestamp) \
VALUES(?, ?, ?, \'U=1\', ?);",
VALUES(?, ?, ?, ?, ?);",
(
Chattype::OutBroadcast,
&chat_name,
&grpid,
param.to_string(),
create_smeared_timestamp(context),
),
)?;
let chat_id = t.last_insert_rowid();
// TODO code duplication of `INSERT INTO broadcasts_shared_secrets`
t.execute(
"INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?)
ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.chat_id",
(chat_id, &secret),
)?;
Ok(t.last_insert_rowid().try_into()?)
};
context.sql.transaction(trans_fn).await?
Expand All @@ -3732,7 +3745,7 @@ pub(crate) async fn create_broadcast_ex(

if sync.into() {
let id = SyncId::Grpid(grpid);
let action = SyncAction::CreateBroadcast(chat_name);
let action = SyncAction::CreateBroadcast { chat_name, secret };
self::sync(context, id, action).await.log_err(context).ok();
}

Expand Down Expand Up @@ -3918,7 +3931,7 @@ pub(crate) async fn add_contact_to_chat_ex(
}
add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?;
}
if chat.typ == Chattype::Group && chat.is_promoted() {
if chat.is_promoted() {
msg.viewtype = Viewtype::Text;

let contact_addr = contact.get_addr().to_lowercase();
Expand Down Expand Up @@ -4948,7 +4961,10 @@ pub(crate) enum SyncAction {
SetVisibility(ChatVisibility),
SetMuted(MuteDuration),
/// Create broadcast channel with the given name.
CreateBroadcast(String),
CreateBroadcast {
chat_name: String,
secret: String,
},
Rename(String),
/// Set chat contacts by their addresses.
SetContacts(Vec<String>),
Expand Down Expand Up @@ -5011,8 +5027,15 @@ impl Context {
.id
}
SyncId::Grpid(grpid) => {
if let SyncAction::CreateBroadcast(name) = action {
create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?;
if let SyncAction::CreateBroadcast { chat_name, secret } = action {
create_broadcast_ex(
self,
Nosync,
grpid.clone(),
chat_name.clone(),
secret.to_string(),
)
.await?;
return Ok(());
}
get_chat_id_by_grpid(self, grpid)
Expand All @@ -5035,7 +5058,7 @@ impl Context {
SyncAction::Accept => chat_id.accept_ex(self, Nosync).await,
SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await,
SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await,
SyncAction::CreateBroadcast(_) => {
SyncAction::CreateBroadcast { .. } => {
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
Expand Down
58 changes: 56 additions & 2 deletions src/chat/chat_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3036,6 +3036,45 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_encrypt_decrypt_broadcast_integration() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_without_secret = &tcm.bob().await;

let secret = "secret";

let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;

tcm.section("Create a broadcast channel with Bob, and send a message");
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;

let mut alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
alice_chat.param.set(Param::SymmetricKey, secret);
alice_chat.update_param(alice).await?;

// TODO the chat_id 10 is magical here:
bob.sql
.execute(
"INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (10, ?)",
(secret,),
)
.await?;

let sent = alice
.send_text(alice_chat_id, "Symmetrically encrypted message")
.await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.text, "Symmetrically encrypted message");

tcm.section("If Bob doesn't know the secret, he can't decrypt the message");
bob_without_secret.recv_msg_trash(&sent).await;

Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_for_contact_with_blocked() -> Result<()> {
let t = TestContext::new().await;
Expand Down Expand Up @@ -3746,7 +3785,9 @@ async fn test_sync_broadcast() -> Result<()> {
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 sent = alice0.pop_sent_msg().await;
let rcvd = alice1.recv_msg(&sent).await;
dbg!(rcvd); // TODO

// This also imports Bob's key from the vCard.
// Otherwise it is possible that second device
Expand Down Expand Up @@ -3788,12 +3829,25 @@ async fn test_sync_name() -> Result<()> {
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
sync(alice0, alice1).await;
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;

set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
sync(alice0, alice1).await;
//sync(alice0, alice1).await; // crash

let sent = alice0.pop_sent_msg().await;
let rcvd = alice1.recv_msg(&sent).await;
assert_eq!(rcvd.from_id, ContactId::SELF);
assert_eq!(rcvd.to_id, ContactId::SELF);
assert_eq!(
rcvd.text,
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
.await?
.unwrap()
.0;
assert_eq!(rcvd.chat_id, a1_broadcast_id);

let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
Expand Down
3 changes: 2 additions & 1 deletion src/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ use crate::pgp;
pub fn try_decrypt<'a>(
mail: &'a ParsedMail<'a>,
private_keyring: &'a [SignedSecretKey],
symmetric_secrets: &[String],
) -> Result<Option<::pgp::composed::Message<'static>>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None);
};

let data = encrypted_data_part.get_body_raw()?;
let msg = pgp::pk_decrypt(data, private_keyring)?;
let msg = pgp::decrypt(data, private_keyring, symmetric_secrets)?;

Ok(Some(msg))
}
Expand Down
19 changes: 19 additions & 0 deletions src/e2ee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ impl EncryptHelper {
Ok(ctext)
}

/// TODO documentation
pub async fn encrypt_for_broadcast(
self,
context: &Context,
passphrase: &str,
mail_to_encrypt: MimePart<'static>,
compress: bool,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;

let mut raw_message = Vec::new();
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();

let ctext = pgp::encrypt_for_broadcast(raw_message, passphrase, sign_key, compress).await?;

Ok(ctext)
}

/// Signs the passed-in `mail` using the private key from `context`.
/// Returns the payload and the signature.
pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result<String> {
Expand Down
4 changes: 4 additions & 0 deletions src/headerdef.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ 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.
/// This secret.
ChatBroadcastSecret,

/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
AutocryptGossip,
Expand Down
64 changes: 53 additions & 11 deletions src/mimefactory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ impl MimeFactory {
}
}

if let Loaded::Message { chat, .. } = &self.loaded {
if let Loaded::Message { msg, chat } = &self.loaded {
if chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast {
headers.push((
"List-ID",
Expand All @@ -799,6 +799,15 @@ impl MimeFactory {
))
.into(),
));

if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup {
if let Some(secret) = chat.param.get(Param::SymmetricKey) {
headers.push((
"Chat-Broadcast-Secret",
mail_builder::headers::text::Text::new(secret.to_string()).into(),
));
}
}
}
}

Expand Down Expand Up @@ -979,6 +988,15 @@ impl MimeFactory {
} else {
unprotected_headers.push(header.clone());
}
} else if header_name == "chat-broadcast-secret" {
if is_encrypted {
protected_headers.push(header.clone());
} else {
warn!(
context,
"Message is unnecrypted, not including broadcast secret"
);
}
} else if is_encrypted {
protected_headers.push(header.clone());

Expand Down Expand Up @@ -1117,18 +1135,43 @@ impl MimeFactory {
Loaded::Mdn { .. } => true,
};

// Encrypt to self unconditionally,
// even for a single-device setup.
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
let symmetric_key = match &self.loaded {
Loaded::Message { chat, .. } if chat.typ == Chattype::OutBroadcast => {
// If there is no symmetric key yet
// (because this is an old broadcast channel,
// created before we had symmetric encryption),
// we just encrypt asymmetrically.
// Symmetric encryption exists since 2025-08;
// some time after that, we can think about requiring everyone
// to switch to symmetrically-encrypted broadcast lists.
chat.param.get(Param::SymmetricKey)
}
_ => None,
};

let encrypted = if let Some(symmetric_key) = symmetric_key {
info!(context, "Symmetrically encrypting for broadcast channel.");
encrypt_helper
.encrypt_for_broadcast(context, symmetric_key, message, compress)
.await?
} else {
// Asymmetric encryption

// Encrypt to self unconditionally,
// even for a single-device setup.
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
encryption_keyring
.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));

encrypt_helper
.encrypt(context, encryption_keyring, message, compress)
.await?
};

// XXX: additional newline is needed
// to pass filtermail at
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>
let encrypted = encrypt_helper
.encrypt(context, encryption_keyring, message, compress)
.await?
+ "\n";
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
let encrypted = encrypted + "\n";

// Set the appropriate Content-Type for the outer message
MimePart::new(
Expand Down Expand Up @@ -1361,7 +1404,6 @@ impl MimeFactory {
}
}
SystemMessage::MemberAddedToGroup => {
ensure!(chat.typ != Chattype::OutBroadcast);
// TODO: lookup the contact by ID rather than email address.
// We are adding key-contacts, the cannot be looked up by address.
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
Expand Down
Loading
Loading