feat!: QR codes and symmetric encryption for broadcast channels (#7268)

Follow-up for https://github.com/chatmail/core/pull/7042, part of
https://github.com/chatmail/core/issues/6884.

This will make it possible to create invite-QR codes for broadcast
channels, and make them symmetrically end-to-end encrypted.

- [x] Go through all the changes in #7042, and check which ones I still
need, and revert all other changes
- [x] Use the classical Securejoin protocol, rather than the new 2-step
protocol
- [x] Make the Rust tests pass
- [x] Make the Python tests pass
- [x] Fix TODOs in the code
- [x] Test it, and fix any bugs I find
- [x] I found a bug when exporting all profiles at once fails sometimes,
though this bug is unrelated to channels:
https://github.com/chatmail/core/issues/7281
- [x] Do a self-review (i.e. read all changes, and check if I see some
things that should be changed)
- [x] Have this PR reviewed and merged
- [ ] Open an issue for "TODO: There is a known bug in the securejoin
protocol"
- [ ] Create an issue that outlines how we can improve the Securejoin
protocol in the future (I don't have the time to do this right now, but
want to do it sometime in winter)
- [ ] Write a guide for UIs how to adapt to the changes (see
https://github.com/deltachat/deltachat-android/pull/3886)

## Backwards compatibility

This is not very backwards compatible:
- Trying to join a symmetrically-encrypted broadcast channel with an old
device will fail
- If you joined a symmetrically-encrypted broadcast channel with one
device, and use an old core on the other device, then the other device
will show a mostly empty chat (except for two device messages)
- If you created a broadcast channel in the past, then you will get an
error message when trying to send into the channel:

> 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/.


## The symmetric encryption

Symmetric encryption uses a shared secret. Currently, we use AES128 for
encryption everywhere in Delta Chat, so, this is what I'm using for
broadcast channels (though it wouldn't be hard to switch to AES256).

The secret shared between all members of a broadcast channel has 258
bits of entropy (see `fn create_broadcast_shared_secret` in the code).

Since the shared secrets have more entropy than the AES session keys,
it's not necessary to have a hard-to-compute string2key algorithm, so,
I'm using the string2key algorithm `salted`. This is fast enough that
Delta Chat can just try out all known shared secrets. [^1] In order to
prevent DOS attacks, Delta Chat will not attempt to decrypt with a
string2key algorithm other than `salted` [^2].

## The "Securejoin" protocol that adds members to the channel after they
scanned a QR code

This PR uses the classical securejoin protocol, the same that is also
used for group and 1:1 invitations.

The messages sent back and forth are called `vg-request`,
`vg-auth-required`, `vg-request-with-auth`, and `vg-member-added`. I
considered using the `vc-` prefix, because from a protocol-POV, the
distinction between `vc-` and `vg-` isn't important (as @link2xt pointed
out in an in-person discussion), but
1. it would be weird if groups used `vg-` while broadcasts and 1:1 chats
used `vc-`,
2. we don't have a `vc-member-added` message yet, so, this would mean
one more different kind of message
3. we anyways want to switch to a new securejoin protocol soon, which
will be a backwards incompatible change with a transition phase. When we
do this change, we can make everything `vc-`.



[^1]: In a symmetrically encrypted message, it's not visible which
secret was used to encrypt without trying out all secrets. If this does
turn out to be too slow in the future, then we can remember which secret
was used more recently, and and try the most recent secret first. If
this is still too slow, then we can assign a short, non-unique (~2
characters) id to every shared secret, and send it in cleartext. The
receiving Delta Chat will then only try out shared secrets with this id.
Of course, this would leak a little bit of metadata in cleartext, so, I
would like to avoid it.
[^2]: 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. Delta Chat would then try to decrypt all of the encrypted
session keys with all of the known shared secrets. In order to prevent
this, as I said, Delta Chat will not attempt to decrypt with a
string2key algorithm other than `salted`

BREAKING CHANGE: A new QR type AskJoinBroadcast; cloning a broadcast
channel is no longer possible; manually adding a member to a broadcast
channel is no longer possible (only by having them scan a QR code)
This commit is contained in:
Hocuri
2025-11-03 21:02:13 +01:00
committed by GitHub
parent 997e8216bf
commit 5034449009
43 changed files with 2635 additions and 475 deletions

View File

@@ -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

200
benches/decrypting.rs Normal file
View File

@@ -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<String> {
let secrets: Vec<String> = (0..NUM_SECRETS)
.map(|_| create_broadcast_secret())
.collect();
secrets
}
fn generate_plaintext() -> Vec<u8> {
let mut plain: Vec<u8> = vec![0; 500];
rng().fill(&mut plain[..]);
plain
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<Qr> 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 }

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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<bool> {
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<bool> {
// 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<ChatId> {
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<ChatId> {
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<ChatId> {
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<Option<String>> {
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<MsgId> {
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,

File diff suppressed because it is too large Load Diff

View File

@@ -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<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, shared_secrets)?;
Ok(Some(msg))
}

View File

@@ -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<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::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<String> {

View File

@@ -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,

View File

@@ -93,7 +93,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
let private_key = load_self_secret_key(context).await?;
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
let private_key_asc = private_key.to_asc(ac_headers);
let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes())
let encr = pgp::symm_encrypt_autocrypt_setup(passphrase, private_key_asc.into_bytes())
.await?
.replace('\n', "\r\n");

View File

@@ -0,0 +1,38 @@
//! Re-exports of `pub(crate)` functions that are needed for benchmarks.
#![allow(missing_docs)] // Not necessary to put a doc comment on the pub functions here
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use crate::chat::ChatId;
use crate::context::Context;
use crate::key;
use crate::key::DcKey;
use crate::mimeparser::MimeMessage;
use crate::pgp;
use crate::pgp::KeyPair;
pub fn key_from_asc(data: &str) -> Result<key::SignedSecretKey> {
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<String> {
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<KeyPair> {
pgp::create_keypair(EmailAddress::new(addr)?)
}
pub fn create_broadcast_secret() -> String {
crate::tools::create_broadcast_secret()
}

View File

@@ -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";

View File

@@ -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<Vec<(String, SignedPublicKey)>>,
encryption_pubkeys: Option<Vec<(String, SignedPublicKey)>>,
/// 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<String> = 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
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>
let encrypted = encrypt_helper
.encrypt(
context,
encryption_keyring,
message,
compress,
anonymous_recipients,
)
.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(
@@ -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<Result<&'a str>> {
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<MimePart<'static>> {
let file_name = msg.get_filename().context("msg has no file")?;
let blob = msg

View File

@@ -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<String> = 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()`

View File

@@ -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

View File

@@ -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<u8>,
private_keys_for_decryption: &[SignedSecretKey],
mut shared_secrets: &[String],
) -> Result<pgp::composed::Message<'static>> {
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<Password> = 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<u8>) -> Result<String> {
/// Symmetric encryption for the autocrypt setup message (ASM).
pub async fn symm_encrypt_autocrypt_setup(passphrase: &str, plain: Vec<u8>) -> Result<String> {
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<u8>) -> Result<String> {
.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<u8>,
private_key_for_signing: SignedSecretKey,
shared_secret: &str,
compress: bool,
) -> Result<String> {
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<T: BufRead + std::fmt::Debug + 'static + Send>(
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<Fingerprint>,
Vec<u8>,
)> {
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)]

View File

@@ -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<String> {
/// 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<Qr> {
let payload = qr
@@ -407,18 +432,12 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
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(&param, "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<Qr> {
.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(&param, "g")?;
let broadcast_name = decode_name(&param, "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<Qr> {
.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<Qr> {
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<Qr> {
}
}
fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
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<Qr> {
let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);

View File

@@ -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<u32>,
mut replace_msg_id: Option<MsgId>,
prevent_rename: bool,
chat_id: ChatId,
chat_id_blocked: Blocked,
mut chat_id: ChatId,
mut chat_id_blocked: Blocked,
is_dc_message: MessengerMessage,
) -> Result<ReceivedMsg> {
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<String> {
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<GroupChangesInfo> {
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<GroupChangesInfo> {
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");
}
}

View File

@@ -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?;

View File

@@ -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<ChatId>) -> Result<String> {
/// 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<ChatId>) -> Result<String> {
/*=======================================================
==== 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<ChatId>) -> 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<ChatId>) -> 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" {

View File

@@ -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<ChatId> {
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)
}
}
}

View File

@@ -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<Qr> 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"),
}
}

View File

@@ -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.

View File

@@ -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?

View File

@@ -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

View File

@@ -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![];

View File

@@ -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,

View File

@@ -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(),
);
}
}

View File

@@ -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

View File

@@ -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] √
--------------------------------------------------------------------------------

View File

@@ -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]
--------------------------------------------------------------------------------

View File

@@ -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]
--------------------------------------------------------------------------------

View File

@@ -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] √
--------------------------------------------------------------------------------

View File

@@ -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] √
--------------------------------------------------------------------------------

View File

@@ -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]
--------------------------------------------------------------------------------

View File

@@ -0,0 +1,87 @@
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="1858da4ad2d3c8a9_43c3a6383df12469_9f050814a95fd805"
MIME-Version: 1.0
From: <alice@example.org>
To: <bob@example.net>
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--

View File

@@ -0,0 +1,56 @@
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="1858db5a667e9817_308656fa7edcc46c_c8ec5a6c260b51bc"
MIME-Version: 1.0
From: <alice@example.org>
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--