mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 23:52:11 +03:00
Compare commits
102 Commits
v2.29.0
...
hoc/channe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380f6e2786 | ||
|
|
43d65cb012 | ||
|
|
8fda2dee52 | ||
|
|
640d81094a | ||
|
|
c5b5d8020b | ||
|
|
dfc969e3c0 | ||
|
|
45bed57055 | ||
|
|
9914233683 | ||
|
|
cc54c68b29 | ||
|
|
e8fff886a0 | ||
|
|
632dd28e5b | ||
|
|
ac98289728 | ||
|
|
23c04c2134 | ||
|
|
6cd499ebc1 | ||
|
|
abd091db4c | ||
|
|
a5d9d43d47 | ||
|
|
dca184f72c | ||
|
|
d967bff702 | ||
|
|
de10f31a3a | ||
|
|
dd11364bef | ||
|
|
3a8a6f6949 | ||
|
|
557702ea74 | ||
|
|
fc52c8de05 | ||
|
|
4c068e835b | ||
|
|
18c84e838c | ||
|
|
f8a46fe3cf | ||
|
|
302059cd63 | ||
|
|
ae4b0fdb4e | ||
|
|
b5a54aa6cf | ||
|
|
01d9acbf6a | ||
|
|
60e4899b3a | ||
|
|
8eb5fc528f | ||
|
|
286f913f6e | ||
|
|
6e68eb1c5d | ||
|
|
153ced7141 | ||
|
|
4a9af2b600 | ||
|
|
0c25646ac2 | ||
|
|
019da70c8a | ||
|
|
19159c905f | ||
|
|
51a36d23a2 | ||
|
|
f7844e97c2 | ||
|
|
a3d1e3bc89 | ||
|
|
dc5237f530 | ||
|
|
f66f6f3e92 | ||
|
|
9b49386bc8 | ||
|
|
40f4eea049 | ||
|
|
00ba559562 | ||
|
|
3a648698ee | ||
|
|
61e0d14eed | ||
|
|
2efbbcc669 | ||
|
|
479a5632fb | ||
|
|
9dc590cb35 | ||
|
|
956519cd98 | ||
|
|
90d4856a1c | ||
|
|
792c05fc3e | ||
|
|
3cf7746ceb | ||
|
|
0acc34a882 | ||
|
|
378896eca3 | ||
|
|
265ac4e30b | ||
|
|
8d89dcc65f | ||
|
|
a858709301 | ||
|
|
3d5e97eced | ||
|
|
5da6ca1ec4 | ||
|
|
58d0fd39b5 | ||
|
|
40e3c34f59 | ||
|
|
1377a77ea8 | ||
|
|
db32f1142c | ||
|
|
738f6c1799 | ||
|
|
e1abaebeb5 | ||
|
|
0978a46ab6 | ||
|
|
410048a9e1 | ||
|
|
72336ebb8a | ||
|
|
fca8948e4c | ||
|
|
d431f2ebd3 | ||
|
|
ad0e3179dd | ||
|
|
494ad63a73 | ||
|
|
13bbcbeb0e | ||
|
|
a14b53e3ca | ||
|
|
9474fbff56 | ||
|
|
c4001cc3ff | ||
|
|
548f5a454c | ||
|
|
91110147c3 | ||
|
|
6012595f1a | ||
|
|
504b2d691d | ||
|
|
7e191f6cf9 | ||
|
|
37f6da1cc9 | ||
|
|
df2693f307 | ||
|
|
cdd280a2d3 | ||
|
|
6bb714a6e5 | ||
|
|
b276eda1a2 | ||
|
|
9c747b4cb0 | ||
|
|
326deab025 | ||
|
|
24561cd256 | ||
|
|
5da7e45b2b | ||
|
|
3389e93820 | ||
|
|
789b923bb8 | ||
|
|
547f750073 | ||
|
|
382023de11 | ||
|
|
3781a35989 | ||
|
|
8653fdbd8e | ||
|
|
47bf4da1fe | ||
|
|
ec2056f5e2 |
@@ -157,6 +157,11 @@ name = "receive_emails"
|
||||
required-features = ["internals"]
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "benchmark_decrypting"
|
||||
required-features = ["internals"]
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "get_chat_msgs"
|
||||
harness = false
|
||||
|
||||
199
benches/benchmark_decrypting.rs
Normal file
199
benches/benchmark_decrypting.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Benchmarks for message decryption,
|
||||
//! comparing decryption of symmetrically-encrypted messages
|
||||
//! to decryption of asymmetrically-encrypted messages.
|
||||
//!
|
||||
//! Call with
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench benchmark_decrypting --features="internals"
|
||||
//! ```
|
||||
//!
|
||||
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
|
||||
//! ```
|
||||
//!
|
||||
//! You can also pass a substring.
|
||||
//! So, you can run all 'Decrypt and parse' benchmarks with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt and parse'
|
||||
//! ```
|
||||
//!
|
||||
//! Symmetric decryption has to try out all known secrets,
|
||||
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
|
||||
|
||||
use std::hint::black_box;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use deltachat::internals_for_benchmarks::create_broadcast_shared_secret;
|
||||
use deltachat::internals_for_benchmarks::create_dummy_keypair;
|
||||
use deltachat::internals_for_benchmarks::save_broadcast_shared_secret;
|
||||
use deltachat::{
|
||||
Events,
|
||||
chat::ChatId,
|
||||
config::Config,
|
||||
context::Context,
|
||||
internals_for_benchmarks::key_from_asc,
|
||||
internals_for_benchmarks::parse_and_get_text,
|
||||
internals_for_benchmarks::store_self_keypair,
|
||||
pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message},
|
||||
stock_str::StockStrings,
|
||||
};
|
||||
use rand::{Rng, thread_rng};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const NUM_SECRETS: usize = 500;
|
||||
|
||||
async fn create_context() -> Context {
|
||||
let dir = tempdir().unwrap();
|
||||
let dbfile = dir.path().join("db.sqlite");
|
||||
let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
context
|
||||
.set_config(Config::ConfiguredAddr, Some("bob@example.net"))
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
|
||||
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(),
|
||||
black_box(&secret),
|
||||
create_dummy_keypair("alice@example.org").unwrap().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,
|
||||
)
|
||||
.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_shared_secret(&context, ChatId::new(10 + i as u32), secret)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
context
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| {
|
||||
b.to_async(&rt).iter(|| {
|
||||
let ctx = context.clone();
|
||||
async move {
|
||||
let text = parse_and_get_text(
|
||||
&ctx,
|
||||
include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "Symmetrically encrypted message");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt and parse a public-key encrypted message", |b| {
|
||||
b.to_async(&rt).iter(|| {
|
||||
let ctx = context.clone();
|
||||
async move {
|
||||
let text = parse_and_get_text(
|
||||
&ctx,
|
||||
include_bytes!("../test-data/message/text_from_alice_encrypted.eml"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "hi");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn generate_secrets() -> Vec<String> {
|
||||
let secrets: Vec<String> = (0..NUM_SECRETS)
|
||||
.map(|_| create_broadcast_shared_secret())
|
||||
.collect();
|
||||
secrets
|
||||
}
|
||||
|
||||
fn generate_plaintext() -> Vec<u8> {
|
||||
let mut plain: Vec<u8> = vec![0; 500];
|
||||
thread_rng().fill(&mut plain[..]);
|
||||
plain
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
@@ -45,6 +45,7 @@ impl Lot {
|
||||
Self::Qr(qr) => match qr {
|
||||
Qr::AskVerifyContact { .. } => None,
|
||||
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
|
||||
Qr::AskJoinBroadcast { broadcast_name, .. } => Some(Cow::Borrowed(broadcast_name)),
|
||||
Qr::FprOk { .. } => None,
|
||||
Qr::FprMismatch { .. } => None,
|
||||
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
|
||||
@@ -99,6 +100,7 @@ impl Lot {
|
||||
Self::Qr(qr) => match qr {
|
||||
Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact,
|
||||
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
|
||||
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
|
||||
Qr::FprOk { .. } => LotState::QrFprOk,
|
||||
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
|
||||
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
|
||||
@@ -126,6 +128,7 @@ impl Lot {
|
||||
Self::Qr(qr) => match qr {
|
||||
Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(),
|
||||
Qr::AskVerifyGroup { .. } => Default::default(),
|
||||
Qr::AskJoinBroadcast { .. } => Default::default(),
|
||||
Qr::FprOk { contact_id } => contact_id.to_u32(),
|
||||
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
|
||||
Qr::FprWithoutAddr { .. } => Default::default(),
|
||||
@@ -169,6 +172,9 @@ pub enum LotState {
|
||||
/// text1=groupname
|
||||
QrAskVerifyGroup = 202,
|
||||
|
||||
/// text1=broadcast_name
|
||||
QrAskJoinBroadcast = 204,
|
||||
|
||||
/// id=contact
|
||||
QrFprOk = 210,
|
||||
|
||||
|
||||
@@ -999,7 +999,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,
|
||||
|
||||
@@ -34,6 +34,23 @@ pub enum QrObject {
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
},
|
||||
/// Ask the user whether to join the broadcast channel.
|
||||
AskJoinBroadcast {
|
||||
/// Chat name.
|
||||
broadcast_name: String,
|
||||
/// A string of random characters,
|
||||
/// uniquely identifying this broadcast channel in the database.
|
||||
/// Called `grpid` for historic reasons:
|
||||
/// The id of multi-user chats is always called `grpid` in the database
|
||||
/// because groups were once the only multi-user chats.
|
||||
grpid: String,
|
||||
/// ID of the contact who owns the channel and created the QR code.
|
||||
contact_id: u32,
|
||||
/// Fingerprint of the contact's key as scanned from the QR code.
|
||||
fingerprint: String,
|
||||
|
||||
authcode: String,
|
||||
},
|
||||
/// Contact fingerprint is verified.
|
||||
///
|
||||
/// Ask the user if they want to start chatting.
|
||||
@@ -207,6 +224,23 @@ impl From<Qr> for QrObject {
|
||||
authcode,
|
||||
}
|
||||
}
|
||||
Qr::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
QrObject::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
}
|
||||
}
|
||||
Qr::FprOk { contact_id } => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
QrObject::FprOk { contact_id }
|
||||
|
||||
@@ -324,7 +324,7 @@ class Account:
|
||||
return Chat(self, self._rpc.create_group_chat(self.id, name, protect))
|
||||
|
||||
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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -112,6 +113,132 @@ def test_qr_securejoin(acfactory, protect):
|
||||
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 wait_for_group_messages(ac):
|
||||
snapshot = ac.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
snapshot = ac.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == "Hello everyone!"
|
||||
|
||||
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 = ac.get_chatlist()[0]
|
||||
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 == f"Member Me added by {contact_snapshot.display_name}."
|
||||
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_group_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()
|
||||
alice2.wait_for_securejoin_inviter_success()
|
||||
|
||||
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_group_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 == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
alice2.get_chatlist()[0].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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -876,34 +876,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 == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
def get_broadcast(ac):
|
||||
chat = ac.get_chatlist()[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 == f"Member Me added by {contact_snapshot.display_name}."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
if not inviter_side:
|
||||
leave_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert leave_msg.text == "You left."
|
||||
|
||||
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 == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
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)
|
||||
|
||||
288
src/chat.rs
288
src/chat.rs
@@ -43,9 +43,9 @@ use crate::smtp::send_msg_to_smtp;
|
||||
use crate::stock_str;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::{
|
||||
IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid,
|
||||
create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset,
|
||||
smeared_time, time, truncate_msg_text,
|
||||
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id,
|
||||
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
|
||||
gm2local_offset, smeared_time, time, truncate_msg_text,
|
||||
};
|
||||
use crate::webxdc::StatusUpdateSerial;
|
||||
use crate::{chatlist_events, imap};
|
||||
@@ -600,11 +600,23 @@ impl ChatId {
|
||||
|| chat.is_device_talk()
|
||||
|| chat.is_self_talk()
|
||||
|| (!chat.can_send(context).await? && !chat.is_contact_request())
|
||||
// For chattype InBrodacast, the info message is added when the member-added message is received
|
||||
// by directly calling add_encrypted_msg()
|
||||
|| chat.typ == Chattype::InBroadcast
|
||||
|| chat.blocked == Blocked::Yes
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.add_encrypted_msg(context, timestamp_sort).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1725,8 +1737,9 @@ impl Chat {
|
||||
pub(crate) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,8 +2846,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
|
||||
@@ -2924,13 +2938,20 @@ 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
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default()
|
||||
// "vb-request-with-auth" is symmetrically encrypted, no need for the public key:
|
||||
|| msg.is_vb_request_with_auth()
|
||||
}
|
||||
CantSendReason::MissingKey => msg
|
||||
.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default(),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
|
||||
@@ -3032,6 +3053,9 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
if (context.get_config_bool(Config::BccSelf).await?
|
||||
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
|
||||
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
|
||||
// `vb-request-with-auth` messages are symmetrically encrypted
|
||||
// with a secret which the other device doesn't have:
|
||||
&& !msg.is_vb_request_with_auth()
|
||||
{
|
||||
recipients.push(from);
|
||||
}
|
||||
@@ -3763,7 +3787,7 @@ pub 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,
|
||||
@@ -3780,60 +3804,92 @@ pub 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_shared_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 broadcasts_shared_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.maybe_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,
|
||||
shared_secret: secret,
|
||||
};
|
||||
self::sync(context, id, action).await.log_err(context).ok();
|
||||
}
|
||||
|
||||
Ok(chat_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_broadcast_shared_secret(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
) -> Result<Option<String>> {
|
||||
context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?",
|
||||
(chat_id,),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_broadcast_shared_secret(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
secret: &str,
|
||||
) -> Result<()> {
|
||||
info!(context, "Saving broadcast secret for chat {chat_id}");
|
||||
context
|
||||
.sql
|
||||
.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, secret))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set chat contacts in the `chats_contacts` table.
|
||||
pub(crate) async fn update_chat_contacts_table(
|
||||
context: &Context,
|
||||
@@ -3921,6 +3977,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(
|
||||
@@ -3948,8 +4028,8 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
// this also makes sure, no contacts are added to special or normal chats
|
||||
let mut chat = Chat::load_from_db(context, chat_id).await?;
|
||||
ensure!(
|
||||
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
|
||||
"{} is not a group/broadcast where one can add members",
|
||||
chat.typ == Chattype::Group || (from_handshake && chat.typ == Chattype::OutBroadcast),
|
||||
"{} is not a group where one can add members",
|
||||
chat_id
|
||||
);
|
||||
ensure!(
|
||||
@@ -3957,7 +4037,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
"invalid contact_id {} for adding to group",
|
||||
contact_id
|
||||
);
|
||||
ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed");
|
||||
ensure!(
|
||||
chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF,
|
||||
"Cannot add SELF to broadcast channel."
|
||||
@@ -4008,21 +4087,33 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
if is_contact_in_chat(context, chat_id, contact_id).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?;
|
||||
}
|
||||
if chat.typ == Chattype::Group && chat.is_promoted() {
|
||||
if chat.is_promoted() {
|
||||
msg.viewtype = Viewtype::Text;
|
||||
|
||||
let contact_addr = contact.get_addr().to_lowercase();
|
||||
msg.text = stock_str::msg_add_member_local(context, contact.id, ContactId::SELF).await;
|
||||
let added_by = if from_handshake && chat.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());
|
||||
msg.param
|
||||
.set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
|
||||
if chat.typ == Chattype::OutBroadcast {
|
||||
let secret = load_broadcast_shared_secret(context, chat_id)
|
||||
.await?
|
||||
.context("Failed to find broadcast shared secret")?;
|
||||
msg.param.set(Param::Arg3, secret);
|
||||
}
|
||||
send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
sync = Nosync;
|
||||
@@ -4177,7 +4268,17 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
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."
|
||||
@@ -4190,24 +4291,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_id, contact_id, addr).await;
|
||||
let res = send_member_removal_msg(
|
||||
context,
|
||||
chat_id,
|
||||
contact_id,
|
||||
addr,
|
||||
fingerprint.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if contact_id == ContactId::SELF {
|
||||
res?;
|
||||
@@ -4227,11 +4329,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_id, contact_id, &self_addr).await?;
|
||||
} else {
|
||||
bail!("Cannot remove members from non-group chats.");
|
||||
}
|
||||
@@ -4244,6 +4341,7 @@ async fn send_member_removal_msg(
|
||||
chat_id: ChatId,
|
||||
contact_id: ContactId,
|
||||
addr: &str,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<MsgId> {
|
||||
let mut msg = Message::new(Viewtype::Text);
|
||||
|
||||
@@ -4255,6 +4353,7 @@ async fn send_member_removal_msg(
|
||||
|
||||
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
|
||||
msg.param.set(Param::Arg, addr.to_lowercase());
|
||||
msg.param.set_optional(Param::Arg2, fingerprint);
|
||||
msg.param
|
||||
.set(Param::ContactAddedRemoved, contact_id.to_u32());
|
||||
|
||||
@@ -5039,7 +5138,12 @@ pub(crate) enum SyncAction {
|
||||
SetVisibility(ChatVisibility),
|
||||
SetMuted(MuteDuration),
|
||||
/// Create broadcast channel with the given name.
|
||||
CreateBroadcast(String),
|
||||
CreateOutBroadcast {
|
||||
chat_name: String,
|
||||
shared_secret: String,
|
||||
},
|
||||
/// Mark the contact with the given fingerprint as verified by self.
|
||||
MarkVerified,
|
||||
Rename(String),
|
||||
/// Set chat contacts by their addresses.
|
||||
SetContacts(Vec<String>),
|
||||
@@ -5095,6 +5199,16 @@ impl Context {
|
||||
SyncAction::Unblock => {
|
||||
return contact::set_blocked(self, Nosync, contact_id, false).await;
|
||||
}
|
||||
SyncAction::MarkVerified => {
|
||||
ContactId::scaleup_origin(self, &[contact_id], Origin::SecurejoinJoined)
|
||||
.await?;
|
||||
return contact::mark_contact_id_as_verified(
|
||||
self,
|
||||
contact_id,
|
||||
Some(ContactId::SELF),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
ChatIdBlocked::get_for_contact(self, contact_id, Blocked::Request)
|
||||
@@ -5102,8 +5216,8 @@ impl Context {
|
||||
.id
|
||||
}
|
||||
SyncId::Grpid(grpid) => {
|
||||
if let SyncAction::CreateBroadcast(name) = action {
|
||||
create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?;
|
||||
let handled = self.handle_sync_create_chat(action, grpid).await?;
|
||||
if handled {
|
||||
return Ok(());
|
||||
}
|
||||
get_chat_id_by_grpid(self, grpid)
|
||||
@@ -5126,7 +5240,9 @@ impl Context {
|
||||
SyncAction::Accept => chat_id.accept_ex(self, Nosync).await,
|
||||
SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await,
|
||||
SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await,
|
||||
SyncAction::CreateBroadcast(_) => {
|
||||
SyncAction::CreateOutBroadcast { .. } | SyncAction::MarkVerified => {
|
||||
// Create action should have been handled by handle_sync_create_chat() already.
|
||||
// MarkVerified action should have been handled by mark_contact_id_as_verified() already.
|
||||
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
|
||||
}
|
||||
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
|
||||
@@ -5138,6 +5254,26 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &str) -> Result<bool> {
|
||||
match action {
|
||||
SyncAction::CreateOutBroadcast {
|
||||
chat_name,
|
||||
shared_secret,
|
||||
} => {
|
||||
create_out_broadcast_ex(
|
||||
self,
|
||||
Nosync,
|
||||
grpid.to_string(),
|
||||
chat_name.clone(),
|
||||
shared_secret.to_string(),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the appropriate `MsgsChanged` event. Should be called if the number of unnoticed
|
||||
/// archived chats could decrease. In general we don't want to make an extra db query to know if
|
||||
/// a noticed chat is archived. Emitting events should be cheap, a false-positive `MsgsChanged`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::chatlist::get_archived_cnt;
|
||||
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
|
||||
@@ -7,6 +9,7 @@ use crate::imex::{ImexMode, has_backup, imex};
|
||||
use crate::message::{MessengerMessage, delete_msgs};
|
||||
use crate::mimeparser::{self, MimeMessage};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::securejoin::{get_securejoin_qr, join_securejoin};
|
||||
use crate::test_utils::{
|
||||
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
|
||||
TimeShiftFalsePositiveNote, sync,
|
||||
@@ -2276,7 +2279,8 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
|
||||
let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?;
|
||||
add_contact_to_chat(&bob, group_id, charlie_id).await?;
|
||||
let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?;
|
||||
add_contact_to_chat(&bob, broadcast_id, charlie_id).await?;
|
||||
let qr = get_securejoin_qr(&bob, Some(broadcast_id)).await?;
|
||||
tcm.exec_securejoin_qr(&charlie, &bob, &qr).await;
|
||||
for chat_id in &[single_id, group_id, broadcast_id] {
|
||||
forward_msgs(&bob, &[orig_msg.id], *chat_id).await?;
|
||||
let sent_msg = bob.pop_sent_msg().await;
|
||||
@@ -2639,44 +2643,67 @@ async fn test_can_send_group() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast() -> Result<()> {
|
||||
async fn test_broadcast_change_name() -> Result<()> {
|
||||
// create two context, send two messages so both know the other
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let fiona = TestContext::new_fiona().await;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let fiona = &tcm.fiona().await;
|
||||
|
||||
let chat_alice = alice.create_chat(&bob).await;
|
||||
send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?;
|
||||
tcm.section("Alice sends a message to Bob");
|
||||
let chat_alice = alice.create_chat(bob).await;
|
||||
send_text_msg(alice, chat_alice.id, "hi!".to_string()).await?;
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
|
||||
let chat_bob = bob.create_chat(&alice).await;
|
||||
send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?;
|
||||
tcm.section("Bob sends a message to Alice");
|
||||
let chat_bob = bob.create_chat(alice).await;
|
||||
send_text_msg(bob, chat_bob.id, "ho!".to_string()).await?;
|
||||
let msg = alice.recv_msg(&bob.pop_sent_msg().await).await;
|
||||
assert!(msg.get_showpadlock());
|
||||
|
||||
// test broadcast channel
|
||||
let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?;
|
||||
add_contact_to_chat(
|
||||
&alice,
|
||||
broadcast_id,
|
||||
get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await;
|
||||
add_contact_to_chat(&alice, broadcast_id, fiona_contact_id).await?;
|
||||
set_chat_name(&alice, broadcast_id, "Broadcast channel").await?;
|
||||
let broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
|
||||
|
||||
tcm.section("Alice invites Bob to her channel");
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
tcm.section("Alice invites Fiona to her channel");
|
||||
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
|
||||
|
||||
{
|
||||
let chat = Chat::load_from_db(&alice, broadcast_id).await?;
|
||||
tcm.section("Alice changes the chat name");
|
||||
set_chat_name(alice, broadcast_id, "My great broadcast").await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Bob receives the name-change system message");
|
||||
let msg = bob.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
let bob_chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "My great broadcast");
|
||||
|
||||
tcm.section("Fiona receives the name-change system message");
|
||||
let msg = fiona.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
let fiona_chat = Chat::load_from_db(fiona, msg.chat_id).await?;
|
||||
assert_eq!(fiona_chat.name, "My great broadcast");
|
||||
}
|
||||
|
||||
{
|
||||
tcm.section("Alice changes the chat name again, but the system message is lost somehow");
|
||||
set_chat_name(alice, broadcast_id, "Broadcast channel").await?;
|
||||
|
||||
let chat = Chat::load_from_db(alice, broadcast_id).await?;
|
||||
assert_eq!(chat.typ, Chattype::OutBroadcast);
|
||||
assert_eq!(chat.name, "Broadcast channel");
|
||||
assert!(!chat.is_self_talk());
|
||||
|
||||
send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?;
|
||||
tcm.section("Alice sends a text message 'ola!'");
|
||||
send_text_msg(alice, broadcast_id, "ola!".to_string()).await?;
|
||||
let msg = alice.get_last_msg().await;
|
||||
assert_eq!(msg.chat_id, chat.id);
|
||||
}
|
||||
|
||||
{
|
||||
tcm.section("Bob receives the 'ola!' message");
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
let msg = bob.parse_msg(&sent_msg).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -2689,25 +2716,23 @@ async fn test_broadcast() -> Result<()> {
|
||||
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.get_text(), "ola!");
|
||||
assert_eq!(msg.subject, "Broadcast channel");
|
||||
assert_eq!(msg.subject, "Re: Broadcast channel");
|
||||
assert!(msg.get_showpadlock());
|
||||
assert!(msg.get_override_sender_name().is_none());
|
||||
let chat = Chat::load_from_db(&bob, msg.chat_id).await?;
|
||||
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(chat.typ, Chattype::InBroadcast);
|
||||
assert_ne!(chat.id, chat_bob.id);
|
||||
assert_eq!(chat.name, "Broadcast channel");
|
||||
assert!(!chat.is_self_talk());
|
||||
}
|
||||
|
||||
{
|
||||
// Alice changes the name:
|
||||
set_chat_name(&alice, broadcast_id, "My great broadcast").await?;
|
||||
let sent = alice.send_text(broadcast_id, "I changed the title!").await;
|
||||
|
||||
let msg = bob.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
let bob_chat = Chat::load_from_db(&bob, msg.chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "My great broadcast");
|
||||
tcm.section("Fiona receives the 'ola!' message");
|
||||
let msg = fiona.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.get_text(), "ola!");
|
||||
assert!(msg.get_showpadlock());
|
||||
assert!(msg.get_override_sender_name().is_none());
|
||||
let chat = Chat::load_from_db(fiona, msg.chat_id).await?;
|
||||
assert_eq!(chat.typ, Chattype::InBroadcast);
|
||||
assert_eq!(chat.name, "Broadcast channel");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2723,45 +2748,43 @@ async fn test_broadcast() -> Result<()> {
|
||||
/// `test_sync_broadcast()` tests that synchronization works via sync messages.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_multidev() -> Result<()> {
|
||||
let alices = [
|
||||
TestContext::new_alice().await,
|
||||
TestContext::new_alice().await,
|
||||
];
|
||||
let bob = TestContext::new_bob().await;
|
||||
let a1b_contact_id = alices[1].add_or_lookup_contact(&bob).await.id;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice0 = &tcm.alice().await;
|
||||
let alice1 = &tcm.alice().await;
|
||||
for a in &[alice0, alice1] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let a0_broadcast_id = create_broadcast(&alices[0], "Channel".to_string()).await?;
|
||||
let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?;
|
||||
set_chat_name(&alices[0], a0_broadcast_id, "Broadcast channel 42").await?;
|
||||
let sent_msg = alices[0].send_text(a0_broadcast_id, "hi").await;
|
||||
let msg = alices[1].recv_msg(&sent_msg).await;
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(&alices[1], &a0_broadcast_chat.grpid)
|
||||
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
|
||||
let sent_msg = alice0.send_text(a0_broadcast_id, "hi").await;
|
||||
let msg = alice1.recv_msg(&sent_msg).await;
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(msg.chat_id, a1_broadcast_id);
|
||||
let a1_broadcast_chat = Chat::load_from_db(&alices[1], a1_broadcast_id).await?;
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
|
||||
assert!(
|
||||
get_chat_contacts(&alices[1], a1_broadcast_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
|
||||
|
||||
add_contact_to_chat(&alices[1], a1_broadcast_id, a1b_contact_id).await?;
|
||||
set_chat_name(&alices[1], a1_broadcast_id, "Broadcast channel 43").await?;
|
||||
let sent_msg = alices[1].send_text(a1_broadcast_id, "hi").await;
|
||||
let msg = alices[0].recv_msg(&sent_msg).await;
|
||||
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
|
||||
|
||||
set_chat_name(alice1, a1_broadcast_id, "Broadcast channel 43").await?;
|
||||
let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await;
|
||||
let msg = alice0.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.chat_id, a0_broadcast_id);
|
||||
let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
assert_eq!(a0_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a0_broadcast_chat.get_name(), "Broadcast channel 42");
|
||||
assert!(
|
||||
get_chat_contacts(&alices[0], a0_broadcast_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
assert!(get_chat_contacts(alice0, a0_broadcast_id).await?.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2779,7 +2802,6 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
alice.set_config(Config::Displayname, Some("Alice")).await?;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
@@ -2787,14 +2809,15 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
assert_eq!(alice_chat.typ, Chattype::OutBroadcast);
|
||||
|
||||
let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
|
||||
assert_eq!(alice_chat.is_promoted(), false);
|
||||
assert_eq!(alice_chat.is_promoted(), true); // Broadcast channels are never unpromoted
|
||||
let sent = alice.send_text(alice_chat_id, "Hi nobody").await;
|
||||
let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
|
||||
assert_eq!(alice_chat.is_promoted(), true);
|
||||
assert_eq!(sent.recipients, "alice@example.org");
|
||||
|
||||
tcm.section("Add a contact to the chat and send a message");
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
let sent = alice.send_text(alice_chat_id, "Hi somebody").await;
|
||||
|
||||
assert_eq!(sent.recipients, "bob@example.net alice@example.org");
|
||||
@@ -2852,6 +2875,67 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that directly after broadcast-securejoin,
|
||||
/// the brodacast is shown correctly on both devices.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_joining_golden() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
alice.set_config(Config::Displayname, Some("Alice")).await?;
|
||||
|
||||
tcm.section("Create a broadcast channel with an avatar");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
let file = alice.get_blobdir().join("avatar.png");
|
||||
tokio::fs::write(&file, AVATAR_64x64_BYTES).await?;
|
||||
set_chat_profile_image(alice, alice_chat_id, file.to_str().unwrap()).await?;
|
||||
// Because broadcasts are always 'promoted',
|
||||
// set_chat_profile_image() sends out a message,
|
||||
// which we need to pop:
|
||||
alice.pop_sent_msg().await;
|
||||
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
alice
|
||||
.golden_test_chat(alice_chat_id, "test_broadcast_joining_golden_alice")
|
||||
.await;
|
||||
bob.golden_test_chat(bob_chat_id, "test_broadcast_joining_golden_bob")
|
||||
.await;
|
||||
|
||||
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
|
||||
let direct_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.unwrap();
|
||||
// The 1:1 chat with Bob should not be visible to the user:
|
||||
assert_eq!(direct_chat.blocked, Blocked::Yes);
|
||||
alice
|
||||
.golden_test_chat(direct_chat.id, "test_broadcast_joining_golden_alice_direct")
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
alice_bob_contact
|
||||
.get_verifier_id(alice)
|
||||
.await?
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
ContactId::SELF
|
||||
);
|
||||
|
||||
let bob_alice_contact = bob.add_or_lookup_contact_no_key(alice).await;
|
||||
assert_eq!(
|
||||
bob_alice_contact
|
||||
.get_verifier_id(bob)
|
||||
.await?
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
ContactId::SELF
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// - Create a broadcast channel
|
||||
/// - Block it
|
||||
/// - Check that the broadcast channel appears in the list of blocked contacts
|
||||
@@ -2863,11 +2947,13 @@ async fn test_block_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let sent = alice.send_text(alice_chat_id, "Hi somebody").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
|
||||
@@ -2875,7 +2961,7 @@ async fn test_block_broadcast() -> Result<()> {
|
||||
assert_eq!(chats.len(), 1);
|
||||
assert_eq!(chats.get_chat_id(0)?, rcvd.chat_id);
|
||||
|
||||
assert_eq!(rcvd.chat_blocked, Blocked::Request);
|
||||
assert_eq!(rcvd.chat_blocked, Blocked::Not);
|
||||
let blocked = Contact::get_all_blocked(bob).await.unwrap();
|
||||
assert_eq!(blocked.len(), 0);
|
||||
|
||||
@@ -2929,11 +3015,13 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let mut sent = alice.send_text(alice_chat_id, "Hi somebody").await;
|
||||
|
||||
assert!(!sent.payload.contains("List-ID"));
|
||||
@@ -2978,8 +3066,8 @@ async fn test_leave_broadcast() -> Result<()> {
|
||||
|
||||
tcm.section("Alice creates broadcast channel with Bob.");
|
||||
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let bob_contact = alice.add_or_lookup_contact(bob).await.id;
|
||||
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
tcm.section("Alice sends first message to broadcast.");
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
@@ -3002,7 +3090,12 @@ async fn test_leave_broadcast() -> Result<()> {
|
||||
let leave_msg = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_trash(&leave_msg).await;
|
||||
|
||||
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 0);
|
||||
assert!(get_chat_contacts(alice, alice_chat_id).await?.is_empty());
|
||||
assert!(
|
||||
get_past_chat_contacts(alice, alice_chat_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
alice.emit_event(EventType::Test);
|
||||
alice
|
||||
@@ -3033,11 +3126,35 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
let bob0 = &tcm.bob().await;
|
||||
let bob1 = &tcm.bob().await;
|
||||
for b in [bob0, bob1] {
|
||||
b.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
tcm.section("Alice creates broadcast channel with Bob.");
|
||||
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let bob_contact = alice.add_or_lookup_contact(bob0).await.id;
|
||||
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
join_securejoin(bob0, &qr).await.unwrap();
|
||||
let request = bob0.pop_sent_msg().await;
|
||||
|
||||
// Bob must send the message only to Alice, not to Self,
|
||||
// because otherwise, his second device would show a device message
|
||||
// "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages.
|
||||
// To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions."
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let answer = alice.pop_sent_msg().await;
|
||||
bob0.recv_msg(&answer).await;
|
||||
|
||||
// Sync Bob's verification of Alice:
|
||||
sync(bob0, bob1).await;
|
||||
bob1.recv_msg(&answer).await;
|
||||
|
||||
// The 1:1 chat should not be visible to the user on any of the devices.
|
||||
// The contact should be marked as verified.
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(alice, bob0).await;
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(bob0, alice).await;
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await;
|
||||
|
||||
tcm.section("Alice sends first message to broadcast.");
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
@@ -3069,6 +3186,180 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_direct_chat_is_hidden_and_contact_is_verified(
|
||||
t: &TestContext,
|
||||
contact: &TestContext,
|
||||
) {
|
||||
let contact = t.add_or_lookup_contact_no_key(contact).await;
|
||||
if let Some(direct_chat) = ChatIdBlocked::lookup_by_contact(t, contact.id)
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
assert_eq!(direct_chat.blocked, Blocked::Yes);
|
||||
}
|
||||
assert!(contact.is_verified(t).await.unwrap());
|
||||
}
|
||||
|
||||
/// Test that only the owner of the broadcast channel
|
||||
/// can send messages into the chat.
|
||||
///
|
||||
/// To do so, we change Alice's public key on Bob's side,
|
||||
/// so that she is supposed to appear as a new contact when we receive another message,
|
||||
/// and check that she can't write into the channel.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.section("Alice creates broadcast channel and creates a QR code.");
|
||||
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
|
||||
tcm.section("Bob now scans the QR code sends the request message");
|
||||
let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap();
|
||||
let request = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_trash(&request).await;
|
||||
|
||||
tcm.section("Alice answers");
|
||||
let answer = alice.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Change Alice's fingerprint for Bob, so that she is a different contact from Bob's point of view");
|
||||
let bob_alice_id = bob.add_or_lookup_contact_no_key(alice).await.id;
|
||||
bob.sql
|
||||
.execute(
|
||||
"UPDATE contacts
|
||||
SET fingerprint='1234567890123456789012345678901234567890'
|
||||
WHERE id=?",
|
||||
(bob_alice_id,),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tcm.section("Bob receives an answer, but it ignored because of a fingerprint mismatch");
|
||||
bob.recv_msg(&answer).await;
|
||||
assert!(
|
||||
load_broadcast_shared_secret(bob, bob_broadcast_id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Same as the previous test, but Alice's fingerprint is changed later,
|
||||
/// so that we can check that until the fingerprint change, everything works fine.
|
||||
///
|
||||
/// Also, this changes Alice's fingerprint in Alice's database, rather than Bob's database,
|
||||
/// in order to test for the same thing in different ways.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &mut tcm.bob().await;
|
||||
|
||||
tcm.section("Alice creates broadcast channel and creates a QR code.");
|
||||
let alice_broadcast_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tcm.section("Bob now scans the QR code");
|
||||
let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap();
|
||||
let request = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let answer = alice.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Bob receives an answer, and processes it");
|
||||
let rcvd = bob.recv_msg(&answer).await;
|
||||
assert!(
|
||||
load_broadcast_shared_secret(bob, bob_broadcast_id)
|
||||
.await?
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
|
||||
|
||||
tcm.section("Alice sends a message, which still arrives fine");
|
||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "Hi");
|
||||
|
||||
tcm.section("Now, Alice's fingerprint changes");
|
||||
|
||||
alice.sql.execute("DELETE FROM keypairs", ()).await?;
|
||||
alice
|
||||
.sql
|
||||
.execute("DELETE FROM config WHERE keyname='key_id'", ())
|
||||
.await?;
|
||||
// Invalidate cached self fingerprint:
|
||||
Arc::get_mut(&mut bob.ctx.inner)
|
||||
.unwrap()
|
||||
.self_fingerprint
|
||||
.take();
|
||||
|
||||
tcm.section("Alice sends a message, which doesn't arrive fine");
|
||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
"[Error: This message was not sent by the channel owner]"
|
||||
);
|
||||
assert_eq!(
|
||||
rcvd.error.unwrap(),
|
||||
r#"Error: This message was not sent by the channel owner:
|
||||
"Hi""#
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let bob_without_secret = &tcm.bob().await;
|
||||
|
||||
let secret = "secret";
|
||||
let grpid = "grpid";
|
||||
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_out_broadcast_ex(
|
||||
alice,
|
||||
Sync,
|
||||
"My Channel".to_string(),
|
||||
grpid.to_string(),
|
||||
secret.to_string(),
|
||||
)
|
||||
.await?;
|
||||
add_to_chat_contacts_table(alice, time(), alice_chat_id, &[alice_bob_contact_id]).await?;
|
||||
|
||||
let bob_chat_id = ChatId::create_multiuser_record(
|
||||
bob,
|
||||
Chattype::InBroadcast,
|
||||
grpid,
|
||||
"My Channel",
|
||||
Blocked::Not,
|
||||
ProtectionStatus::Unprotected,
|
||||
None,
|
||||
time(),
|
||||
)
|
||||
.await?;
|
||||
save_broadcast_shared_secret(bob, bob_chat_id, secret).await?;
|
||||
|
||||
let sent = alice
|
||||
.send_text(alice_chat_id, "Symmetrically encrypted message")
|
||||
.await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "Symmetrically encrypted message");
|
||||
|
||||
tcm.section("If Bob doesn't know the secret, he can't decrypt the message");
|
||||
bob_without_secret.recv_msg_trash(&sent).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_create_for_contact_with_blocked() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
@@ -3783,55 +4074,86 @@ async fn test_sync_muted() -> Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_sync_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice0 = &tcm.alice().await;
|
||||
let alice1 = &tcm.alice().await;
|
||||
for a in [alice0, alice1] {
|
||||
let alice2 = &tcm.alice().await;
|
||||
for a in [alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
let bob = &tcm.bob().await;
|
||||
let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id;
|
||||
let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id;
|
||||
|
||||
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
tcm.section("Alice creates a channel on her first device");
|
||||
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
|
||||
|
||||
tcm.section("The channel syncs to her second device");
|
||||
sync(alice1, alice2).await;
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_broadcast_chat.grpid)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name());
|
||||
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
|
||||
add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
|
||||
assert_eq!(a2_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a2_broadcast_chat.get_name(), a1_broadcast_chat.get_name());
|
||||
assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty());
|
||||
|
||||
// This also imports Bob's key from the vCard.
|
||||
// Otherwise it is possible that second device
|
||||
// does not have Bob's key as only the fingerprint
|
||||
// is transferred in the sync message.
|
||||
let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id;
|
||||
tcm.section("Bob scans Alice's QR code, both of Alice's devices answer");
|
||||
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
sync(alice1, alice2).await; // Sync QR code
|
||||
let bob_broadcast_id = tcm
|
||||
.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr)
|
||||
.await;
|
||||
|
||||
let a2b_contact_id = alice2.add_or_lookup_contact_no_key(bob).await.id;
|
||||
assert_eq!(
|
||||
get_chat_contacts(alice1, a1_broadcast_id).await?,
|
||||
vec![a1b_contact_id]
|
||||
get_chat_contacts(alice2, a2_broadcast_id).await?,
|
||||
vec![a2b_contact_id]
|
||||
);
|
||||
let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await;
|
||||
|
||||
tcm.section("Alice's second device sends a message to the channel");
|
||||
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(chat.get_type(), Chattype::InBroadcast);
|
||||
let msg = alice0.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.chat_id, a0_broadcast_id);
|
||||
remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?;
|
||||
sync(alice0, alice1).await;
|
||||
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
|
||||
let msg = alice1.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.chat_id, a1_broadcast_id);
|
||||
|
||||
tcm.section("Alice's first device removes Bob");
|
||||
remove_contact_from_chat(alice1, a1_broadcast_id, a1b_contact_id).await?;
|
||||
let sent = alice1.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Alice's second device receives the removal-message");
|
||||
alice2.recv_msg(&sent).await;
|
||||
assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty());
|
||||
assert!(
|
||||
get_past_chat_contacts(alice1, a1_broadcast_id)
|
||||
get_past_chat_contacts(alice2, a2_broadcast_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
a0_broadcast_id.delete(alice0).await?;
|
||||
sync(alice0, alice1).await;
|
||||
alice1.assert_no_chat(a1_broadcast_id).await;
|
||||
tcm.section("Bob receives the removal-message");
|
||||
bob.recv_msg(&sent).await;
|
||||
let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?;
|
||||
assert!(!bob_chat.is_self_in_chat(bob).await?);
|
||||
|
||||
bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob")
|
||||
.await;
|
||||
|
||||
// Alice1 and Alice2 are supposed to show the chat in the same way:
|
||||
alice1
|
||||
.golden_test_chat(a1_broadcast_id, "test_sync_broadcast_alice1")
|
||||
.await;
|
||||
alice2
|
||||
.golden_test_chat(a2_broadcast_id, "test_sync_broadcast_alice2")
|
||||
.await;
|
||||
|
||||
tcm.section("Alice's first device deletes the chat");
|
||||
a1_broadcast_id.delete(alice1).await?;
|
||||
sync(alice1, alice2).await;
|
||||
alice2.assert_no_chat(a2_broadcast_id).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3845,12 +4167,25 @@ async fn test_sync_name() -> Result<()> {
|
||||
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
|
||||
set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
|
||||
sync(alice0, alice1).await;
|
||||
//sync(alice0, alice1).await; // crash
|
||||
|
||||
let sent = alice0.pop_sent_msg().await;
|
||||
let rcvd = alice1.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.from_id, ContactId::SELF);
|
||||
assert_eq!(rcvd.to_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
|
||||
);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(rcvd.chat_id, a1_broadcast_id);
|
||||
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
22
src/e2ee.rs
22
src/e2ee.rs
@@ -58,6 +58,28 @@ impl EncryptHelper {
|
||||
Ok(ctext)
|
||||
}
|
||||
|
||||
/// Symmetrically encrypt the message to be sent into a broadcast channel,
|
||||
/// or for version 2 of the Securejoin protocol.
|
||||
/// `shared secret` is the secret that will be used for symmetric encryption.
|
||||
pub async fn encrypt_symmetrically(
|
||||
self,
|
||||
context: &Context,
|
||||
shared_secret: &str,
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
compress: bool,
|
||||
) -> Result<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, shared_secret, sign_key, compress).await?;
|
||||
|
||||
Ok(ctext)
|
||||
}
|
||||
|
||||
/// Signs the passed-in `mail` using the private key from `context`.
|
||||
/// Returns the payload and the signature.
|
||||
pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result<String> {
|
||||
|
||||
@@ -63,6 +63,7 @@ pub enum HeaderDef {
|
||||
ChatUserAvatar,
|
||||
ChatVoiceMessage,
|
||||
ChatGroupMemberRemoved,
|
||||
ChatGroupMemberRemovedFpr,
|
||||
ChatGroupMemberAdded,
|
||||
ChatContent,
|
||||
|
||||
@@ -94,6 +95,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,
|
||||
|
||||
@@ -95,7 +95,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_setup_file(passphrase, private_key_asc.into_bytes())
|
||||
.await?
|
||||
.replace('\n', "\r\n");
|
||||
|
||||
|
||||
42
src/internals_for_benchmarks.rs
Normal file
42
src/internals_for_benchmarks.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Re-exports of internal functions needed for benchmarks.
|
||||
#![allow(missing_docs)] // Not necessary to put a doc comment on the pub functions here
|
||||
|
||||
use anyhow::Result;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
|
||||
use 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_shared_secret(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
secret: &str,
|
||||
) -> Result<()> {
|
||||
crate::chat::save_broadcast_shared_secret(context, chat_id, secret).await
|
||||
}
|
||||
|
||||
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
|
||||
pgp::create_keypair(EmailAddress::new(addr)?)
|
||||
}
|
||||
|
||||
pub fn create_broadcast_shared_secret() -> String {
|
||||
crate::tools::create_broadcast_shared_secret()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -109,6 +112,9 @@ pub mod accounts;
|
||||
pub mod peer_channels;
|
||||
pub mod reaction;
|
||||
|
||||
#[cfg(feature = "internals")]
|
||||
pub mod internals_for_benchmarks;
|
||||
|
||||
/// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed.
|
||||
pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG";
|
||||
|
||||
|
||||
@@ -1383,6 +1383,19 @@ impl Message {
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.clone()
|
||||
}
|
||||
|
||||
/// Returns `true` if this message is a `vb-request-with-auth` SecureJoin message.
|
||||
pub(crate) fn is_vb_request_with_auth(&self) -> bool {
|
||||
if self.param.get_cmd() == SystemMessage::SecurejoinMessage {
|
||||
// CAVE: You can't check in the same way whether the message
|
||||
// is a `v{g|b}-member-added` message,
|
||||
// because for these messages,
|
||||
// `param.get_cmd()` returns `SystemMessage::MemberAddedToGroup`.
|
||||
self.param.get(Param::Arg) == Some("vb-request-with-auth")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the message.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use base64::Engine as _;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use deltachat_contact_tools::sanitize_bidi_characters;
|
||||
@@ -15,7 +15,7 @@ use tokio::fs;
|
||||
|
||||
use crate::aheader::{Aheader, EncryptPreference};
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::{self, Chat};
|
||||
use crate::chat::{self, Chat, load_broadcast_shared_secret};
|
||||
use crate::config::Config;
|
||||
use crate::constants::ASM_SUBJECT;
|
||||
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
|
||||
@@ -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
|
||||
@@ -329,7 +329,7 @@ impl MimeFactory {
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF {
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
@@ -350,7 +350,7 @@ impl MimeFactory {
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF {
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
@@ -415,8 +415,24 @@ impl MimeFactory {
|
||||
req_mdn = true;
|
||||
}
|
||||
|
||||
// If undisclosed_recipients, and this is a member-added/removed message,
|
||||
// only send to the added/removed member
|
||||
if undisclosed_recipients
|
||||
&& matches!(
|
||||
msg.param.get_cmd(),
|
||||
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
|
||||
)
|
||||
{
|
||||
if let Some(member) = msg.param.get(Param::Arg) {
|
||||
recipients.retain(|addr| addr == member);
|
||||
}
|
||||
}
|
||||
|
||||
encryption_keys = if !is_encrypted {
|
||||
None
|
||||
} else if should_encrypt_symmetrically(&msg, &chat) {
|
||||
// Encrypt, but only symmetrically, not with the public keys.
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
if keys.is_empty() && !recipients.is_empty() {
|
||||
bail!(
|
||||
@@ -563,7 +579,13 @@ impl MimeFactory {
|
||||
// messages are auto-sent unlike usual unencrypted messages.
|
||||
step == "vg-request-with-auth"
|
||||
|| step == "vc-request-with-auth"
|
||||
|| step == "vb-request-with-auth"
|
||||
// Note that for "vg-member-added" and "vb-member-added",
|
||||
// get_cmd() returns `MemberAddedToGroup` rather than `SecurejoinMessage`,
|
||||
// so, it wouldn't actually be necessary to have them in the list here.
|
||||
// Still, they are here for completeness.
|
||||
|| step == "vg-member-added"
|
||||
|| step == "vb-member-added"
|
||||
|| step == "vc-contact-confirm"
|
||||
}
|
||||
}
|
||||
@@ -806,7 +828,7 @@ impl MimeFactory {
|
||||
} else if let Loaded::Message { msg, .. } = &self.loaded {
|
||||
if msg.param.get_cmd() == SystemMessage::SecurejoinMessage {
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
if step != "vg-request" && step != "vc-request" {
|
||||
if step != "vg-request" && step != "vc-request" && step != "vb-request-with-auth" {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
|
||||
@@ -815,7 +837,7 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
if let Loaded::Message { chat, .. } = &self.loaded {
|
||||
if let Loaded::Message { msg, chat } = &self.loaded {
|
||||
if chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast {
|
||||
headers.push((
|
||||
"List-ID",
|
||||
@@ -825,6 +847,15 @@ impl MimeFactory {
|
||||
))
|
||||
.into(),
|
||||
));
|
||||
|
||||
if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup {
|
||||
if let Some(secret) = msg.param.get(Param::Arg3) {
|
||||
headers.push((
|
||||
"Chat-Broadcast-Secret",
|
||||
mail_builder::headers::text::Text::new(secret.to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +1036,15 @@ impl MimeFactory {
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
} else {
|
||||
warn!(
|
||||
context,
|
||||
"Message is unnecrypted, not including broadcast secret"
|
||||
);
|
||||
}
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
@@ -1060,7 +1100,7 @@ impl MimeFactory {
|
||||
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
if chat.typ != Chattype::OutBroadcast {
|
||||
if !should_hide_recipients(msg, chat) {
|
||||
for (addr, key) in &encryption_keys {
|
||||
let fingerprint = key.dc_fingerprint().hex();
|
||||
let cmd = msg.param.get_cmd();
|
||||
@@ -1144,18 +1184,48 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => true,
|
||||
};
|
||||
|
||||
// Encrypt to self unconditionally,
|
||||
// even for a single-device setup.
|
||||
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
|
||||
encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
|
||||
let shared_secret: Option<String> = match &self.loaded {
|
||||
Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => {
|
||||
msg.param.get(Param::Arg2).map(|s| s.to_string())
|
||||
}
|
||||
Loaded::Message { chat, msg }
|
||||
if should_encrypt_with_broadcast_secret(msg, chat) =>
|
||||
{
|
||||
// If there is no shared secret yet
|
||||
// (because this is an old broadcast channel,
|
||||
// created before we had symmetric encryption),
|
||||
// we just encrypt asymmetrically.
|
||||
// Symmetric encryption exists since 2025-08;
|
||||
// some time after that, we can think about requiring everyone
|
||||
// to switch to symmetrically-encrypted broadcast lists.
|
||||
load_broadcast_shared_secret(context, chat.id).await?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let encrypted = if let Some(shared_secret) = shared_secret {
|
||||
info!(context, "Encrypting symmetrically.");
|
||||
encrypt_helper
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress)
|
||||
.await?
|
||||
} else {
|
||||
// Asymmetric encryption
|
||||
|
||||
// Encrypt to self unconditionally,
|
||||
// even for a single-device setup.
|
||||
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
|
||||
encryption_keyring
|
||||
.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
|
||||
|
||||
encrypt_helper
|
||||
.encrypt(context, encryption_keyring, message, compress)
|
||||
.await?
|
||||
};
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>
|
||||
let encrypted = encrypt_helper
|
||||
.encrypt(context, encryption_keyring, message, compress)
|
||||
.await?
|
||||
+ "\n";
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
@@ -1364,8 +1434,8 @@ impl MimeFactory {
|
||||
|
||||
match command {
|
||||
SystemMessage::MemberRemovedFromGroup => {
|
||||
ensure!(chat.typ != Chattype::OutBroadcast);
|
||||
let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
let fingerprint_to_remove = msg.param.get(Param::Arg2).unwrap_or_default();
|
||||
|
||||
if email_to_remove
|
||||
== context
|
||||
@@ -1386,9 +1456,16 @@ impl MimeFactory {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !fingerprint_to_remove.is_empty() {
|
||||
headers.push((
|
||||
"Chat-Group-Member-Removed-Fpr",
|
||||
mail_builder::headers::raw::Raw::new(fingerprint_to_remove.to_string())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
SystemMessage::MemberAddedToGroup => {
|
||||
ensure!(chat.typ != Chattype::OutBroadcast);
|
||||
// TODO: lookup the contact by ID rather than email address.
|
||||
// We are adding key-contacts, the cannot be looked up by address.
|
||||
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
@@ -1402,14 +1479,15 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
if 0 != msg.param.get_int(Param::Arg2).unwrap_or_default() & DC_FROM_HANDSHAKE {
|
||||
info!(
|
||||
context,
|
||||
"Sending secure-join message {:?}.", "vg-member-added",
|
||||
);
|
||||
let step = match chat.typ {
|
||||
Chattype::Group => "vg-member-added",
|
||||
Chattype::OutBroadcast => "vb-member-added",
|
||||
_ => bail!("Wrong chattype {}", chat.typ),
|
||||
};
|
||||
info!(context, "Sending secure-join message {:?}.", step,);
|
||||
headers.push((
|
||||
"Secure-Join",
|
||||
mail_builder::headers::raw::Raw::new("vg-member-added".to_string())
|
||||
.into(),
|
||||
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1485,7 +1563,10 @@ impl MimeFactory {
|
||||
let param2 = msg.param.get(Param::Arg2).unwrap_or_default();
|
||||
if !param2.is_empty() {
|
||||
headers.push((
|
||||
if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
|
||||
if step == "vg-request-with-auth"
|
||||
|| step == "vc-request-with-auth"
|
||||
|| step == "vb-request-with-auth"
|
||||
{
|
||||
"Secure-Join-Auth"
|
||||
} else {
|
||||
"Secure-Join-Invitenumber"
|
||||
@@ -1834,6 +1915,29 @@ fn hidden_recipients() -> Address<'static> {
|
||||
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
|
||||
}
|
||||
|
||||
fn should_encrypt_with_auth_token(msg: &Message) -> bool {
|
||||
msg.is_vb_request_with_auth()
|
||||
}
|
||||
|
||||
fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool {
|
||||
chat.typ == Chattype::OutBroadcast
|
||||
// The only `SystemMessage::SecurejoinMessage` that is ever sent into a broadcast,
|
||||
// which is `vb-request-with-auth`,
|
||||
// should be encrypted with the AUTH token rather than the broadcast secret.
|
||||
&& msg.param.get_cmd() != SystemMessage::SecurejoinMessage
|
||||
// The member-added message in a broadcast must be asymmetrically encrypted,
|
||||
// because the newly-added member doesn't know the broadcast shared secret yet:
|
||||
&& msg.param.get_cmd() != SystemMessage::MemberAddedToGroup
|
||||
}
|
||||
|
||||
fn should_hide_recipients(msg: &Message, chat: &Chat) -> bool {
|
||||
should_encrypt_with_broadcast_secret(msg, chat)
|
||||
}
|
||||
|
||||
fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool {
|
||||
should_encrypt_with_auth_token(msg) || should_encrypt_with_broadcast_secret(msg, chat)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::authres::handle_authres;
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::ChatId;
|
||||
use crate::config::Config;
|
||||
use crate::constants;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::decrypt::{try_decrypt, validate_detached_signature};
|
||||
@@ -35,6 +34,7 @@ use crate::tools::{
|
||||
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
|
||||
};
|
||||
use crate::{chatlist_events, location, stock_str, tools};
|
||||
use crate::{constants, token};
|
||||
|
||||
/// Public key extracted from `Autocrypt-Gossip`
|
||||
/// header with associated information.
|
||||
@@ -355,9 +355,22 @@ impl MimeMessage {
|
||||
|
||||
let mail_raw; // Memory location for a possible decrypted message.
|
||||
let decrypted_msg; // Decrypted signed OpenPGP message.
|
||||
let mut secrets: Vec<String> = context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT secret FROM broadcasts_shared_secrets",
|
||||
(),
|
||||
|row| row.get(0),
|
||||
|rows| {
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
|
||||
|
||||
let (mail, is_encrypted) =
|
||||
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring)) {
|
||||
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) {
|
||||
Ok(Some(mut msg)) => {
|
||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||
|
||||
@@ -1589,6 +1602,15 @@ impl MimeMessage {
|
||||
.is_some_and(|part| part.typ == Viewtype::Call)
|
||||
}
|
||||
|
||||
pub fn replace_msg_by_error(&mut self, error_msg: &str) {
|
||||
self.is_system_message = SystemMessage::Unknown;
|
||||
if let Some(part) = self.parts.first_mut() {
|
||||
part.typ = Viewtype::Text;
|
||||
part.msg = format!("[{error_msg}]");
|
||||
self.parts.truncate(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_rfc724_mid(&self) -> Option<String> {
|
||||
self.get_header(HeaderDef::MessageId)
|
||||
.and_then(|msgid| parse_message_id(msgid).ok())
|
||||
|
||||
31
src/param.rs
31
src/param.rs
@@ -99,19 +99,42 @@ pub enum Param {
|
||||
|
||||
/// For Messages
|
||||
///
|
||||
/// For "MemberRemovedFromGroup" this is the email address
|
||||
/// For "MemberRemovedFromGroup", this is the email address
|
||||
/// removed from the group.
|
||||
///
|
||||
/// For "MemberAddedToGroup" this is the email address added to the group.
|
||||
/// For "MemberAddedToGroup", this is the email address added to the group.
|
||||
///
|
||||
/// For securejoin messages, this is the step,
|
||||
/// which is put into the `Secure-Join` header.
|
||||
Arg = b'E',
|
||||
|
||||
/// For Messages
|
||||
///
|
||||
/// For `BobHandshakeMsg::Request`, this is the `Secure-Join-Invitenumber` header.
|
||||
///
|
||||
/// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header.
|
||||
///
|
||||
/// For version two of the securejoin protocol (`vb-request-with-auth`),
|
||||
/// this is the Auth token used to encrypt the message.
|
||||
///
|
||||
/// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced.
|
||||
///
|
||||
/// For [`SystemMessage::MemberAddedToGroup`],
|
||||
/// this is '1' if it was added because of a securejoin-handshake, and '0' otherwise.
|
||||
Arg2 = b'F',
|
||||
|
||||
/// `Secure-Join-Fingerprint` header for `{vc,vg}-request-with-auth` messages.
|
||||
/// For Messages
|
||||
///
|
||||
/// For `BobHandshakeMsg::RequestWithAuth`,
|
||||
/// this contains the `Secure-Join-Fingerprint` header.
|
||||
///
|
||||
/// For [`SystemMessage::MemberAddedToGroup`] that add to a broadcast channel,
|
||||
/// this contains the broadcast channel's shared secret.
|
||||
Arg3 = b'G',
|
||||
|
||||
/// Deprecated `Secure-Join-Group` header for messages.
|
||||
/// For Messages
|
||||
///
|
||||
/// Deprecated `Secure-Join-Group` header for `BobHandshakeMsg::RequestWithAuth` messages.
|
||||
Arg4 = b'H',
|
||||
|
||||
/// For Messages
|
||||
|
||||
234
src/pgp.rs
234
src/pgp.rs
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{BufRead, Cursor};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use chrono::SubsecRound;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use pgp::armor::BlockType;
|
||||
@@ -12,12 +12,13 @@ use pgp::composed::{
|
||||
SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
|
||||
StandaloneSignature, SubkeyParamsBuilder, TheRing,
|
||||
};
|
||||
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
||||
use pgp::crypto::ecc_curve::ECCCurve;
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{CompressionAlgorithm, KeyDetails, Password, PublicKeyTrait, StringToKey};
|
||||
use rand::thread_rng;
|
||||
use rand::{Rng as _, thread_rng};
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
@@ -25,7 +26,7 @@ use crate::key::{DcKey, Fingerprint};
|
||||
#[cfg(test)]
|
||||
pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt";
|
||||
|
||||
pub const HEADER_SETUPCODE: &str = "passphrase-begin";
|
||||
pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin";
|
||||
|
||||
/// Preferred symmetric encryption algorithm.
|
||||
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
|
||||
@@ -231,13 +232,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)?;
|
||||
@@ -245,18 +250,43 @@ pub fn pk_decrypt(
|
||||
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
|
||||
let empty_pw = Password::empty();
|
||||
|
||||
let try_symmetric_decryption = should_try_symmetric_decryption(&msg);
|
||||
if try_symmetric_decryption.is_err() {
|
||||
shared_secrets = &[];
|
||||
}
|
||||
|
||||
// We always try out all passwords here, which is not great for performance.
|
||||
// But benchmarking (see `benchmark_decrypting.rs`)
|
||||
// showed that the performance penalty is acceptable.
|
||||
// We could include a short (~2 character) identifier of the secret in cleartext
|
||||
// (or just include the first 2 characters of the secret in cleartext)
|
||||
// in order to narrow down the number of shared secrets that have to be tried out.
|
||||
let message_password: Vec<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![],
|
||||
allow_legacy: false,
|
||||
};
|
||||
let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?;
|
||||
anyhow::ensure!(
|
||||
!ring_result.secret_keys.is_empty(),
|
||||
"decryption failed, no matching secret keys"
|
||||
);
|
||||
|
||||
let res = msg.decrypt_the_ring(ring, true);
|
||||
|
||||
let (msg, _ring_result) = match res {
|
||||
Ok(it) => it,
|
||||
Err(err) => {
|
||||
if let Err(reason) = try_symmetric_decryption {
|
||||
bail!("{err:#} (Note: symmetric decryption was not tried: {reason})")
|
||||
} else {
|
||||
bail!("{err:#}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// remove one layer of compression
|
||||
let msg = msg.decompress()?;
|
||||
@@ -264,6 +294,34 @@ pub fn pk_decrypt(
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
||||
/// and Err with a reason if symmetric decryption should not be tried.
|
||||
///
|
||||
/// A DOS attacker could send a message with a lot of encrypted session keys,
|
||||
/// all of which use a very hard-to-compute string2key algorithm.
|
||||
/// We would then try to decrypt all of the encrypted session keys
|
||||
/// with all of the known shared secrets.
|
||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||
/// that use a string2key algorithm other than 'Salted'.
|
||||
fn should_try_symmetric_decryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> {
|
||||
let Message::Encrypted { esk, .. } = msg else {
|
||||
return Err("not encrypted");
|
||||
};
|
||||
|
||||
if esk.len() > 1 {
|
||||
return Err("too many esks");
|
||||
}
|
||||
|
||||
let [pgp::composed::Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..] else {
|
||||
return Err("not symmetrically encrypted");
|
||||
};
|
||||
|
||||
match esk.s2k() {
|
||||
Some(StringToKey::Salted { .. }) => Ok(()),
|
||||
_ => Err("unsupported string2key algorithm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns fingerprints
|
||||
/// of all keys from the `public_keys_for_validation` keyring that
|
||||
/// have valid signatures there.
|
||||
@@ -304,8 +362,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 file.
|
||||
pub async fn symm_encrypt_setup_file(passphrase: &str, plain: Vec<u8>) -> Result<String> {
|
||||
let passphrase = Password::from(passphrase.to_string());
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -322,6 +380,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>,
|
||||
shared_secret: &str,
|
||||
private_key_for_signing: SignedSecretKey,
|
||||
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,
|
||||
@@ -345,7 +443,10 @@ mod tests {
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use super::*;
|
||||
use crate::test_utils::{alice_keypair, bob_keypair};
|
||||
use crate::{
|
||||
key::{load_self_public_key, load_self_secret_key},
|
||||
test_utils::{TestContextManager, alice_keypair, bob_keypair},
|
||||
};
|
||||
|
||||
fn pk_decrypt_and_validate<'a>(
|
||||
ctext: &'a [u8],
|
||||
@@ -356,7 +457,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);
|
||||
@@ -542,4 +643,103 @@ mod tests {
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let shared_secret = "shared secret";
|
||||
let ctext = symm_encrypt_message(
|
||||
plain.clone(),
|
||||
shared_secret,
|
||||
load_self_secret_key(alice).await?,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let mut decrypted = decrypt(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
)?;
|
||||
|
||||
assert_eq!(decrypted.as_data_vec()?, plain);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that we don't try to decrypt a message
|
||||
/// that is symmetrically encrypted
|
||||
/// with an expensive string2key algorithm
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_decrypt_expensive_message() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let shared_secret = "shared secret";
|
||||
|
||||
// Create a symmetrically encrypted message
|
||||
// with an IteratedAndSalted string2key algorithm:
|
||||
|
||||
let shared_secret_pw = Password::from(shared_secret.to_string());
|
||||
let msg = MessageBuilder::from_bytes("", plain);
|
||||
let mut rng = thread_rng();
|
||||
let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted
|
||||
|
||||
let mut msg = msg.seipd_v2(
|
||||
&mut rng,
|
||||
SymmetricKeyAlgorithm::AES128,
|
||||
AeadAlgorithm::Ocb,
|
||||
ChunkSize::C8KiB,
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
|
||||
|
||||
let ctext = msg.to_armored_string(&mut rng, Default::default())?;
|
||||
|
||||
// Trying to decrypt it should fail with a helpful error message:
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decryption_error_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let pk_for_encryption = load_self_public_key(alice).await?;
|
||||
|
||||
// Encrypt a message, but only to self, not to Bob:
|
||||
let ctext = pk_encrypt(plain, vec![pk_for_encryption], None, true).await?;
|
||||
|
||||
// Trying to decrypt it should fail with an OK error message:
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
88
src/qr.rs
88
src/qr.rs
@@ -84,6 +84,30 @@ pub enum Qr {
|
||||
authcode: String,
|
||||
},
|
||||
|
||||
/// Ask whether to join the broadcast channel.
|
||||
AskJoinBroadcast {
|
||||
/// The user-visible name of this broadcast channel
|
||||
broadcast_name: String,
|
||||
|
||||
/// A string of random characters,
|
||||
/// uniquely identifying this broadcast channel in the database.
|
||||
/// Called `grpid` for historic reasons:
|
||||
/// The id of multi-user chats is always called `grpid` in the database
|
||||
/// because groups were once the only multi-user chats.
|
||||
grpid: String,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// The AUTH code from the secure-join protocol,
|
||||
/// which is both used to encrypt the first message to the inviter
|
||||
/// and to prove to the inviter that we saw the QR code.
|
||||
authcode: String,
|
||||
},
|
||||
|
||||
/// Contact fingerprint is verified.
|
||||
///
|
||||
/// Ask the user if they want to start chatting.
|
||||
@@ -381,6 +405,7 @@ pub fn format_backup(qr: &Qr) -> Result<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&s=AUTH`
|
||||
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR`
|
||||
async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
let payload = qr
|
||||
@@ -417,15 +442,7 @@ 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(¶m, "n")?.unwrap_or_default();
|
||||
|
||||
let invitenumber = param
|
||||
.get("i")
|
||||
@@ -440,21 +457,12 @@ 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(¶m, "g")?;
|
||||
let broadcast_name = decode_name(¶m, "b")?;
|
||||
|
||||
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
|
||||
if let (Some(addr), Some(invitenumber), Some(authcode)) =
|
||||
(&addr, invitenumber, authcode.clone())
|
||||
{
|
||||
let addr = ContactAddress::new(addr)?;
|
||||
let (contact_id, _) = Contact::add_or_lookup_ex(
|
||||
context,
|
||||
@@ -525,6 +533,28 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
authcode,
|
||||
})
|
||||
}
|
||||
} else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(authcode)) =
|
||||
(&addr, broadcast_name, grpid, authcode)
|
||||
{
|
||||
// This is a broadcast channel invite link.
|
||||
let addr = ContactAddress::new(addr)?;
|
||||
let (contact_id, _) = Contact::add_or_lookup_ex(
|
||||
context,
|
||||
&name,
|
||||
&addr,
|
||||
&fingerprint.hex(),
|
||||
Origin::UnhandledSecurejoinQrScan,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
|
||||
|
||||
Ok(Qr::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
})
|
||||
} else if let Some(addr) = addr {
|
||||
let fingerprint = fingerprint.hex();
|
||||
let (contact_id, _) =
|
||||
@@ -546,6 +576,18 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<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);
|
||||
|
||||
@@ -15,7 +15,7 @@ use num_traits::FromPrimitive;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::chat::{
|
||||
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table,
|
||||
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, save_broadcast_shared_secret,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
|
||||
@@ -27,8 +27,8 @@ use crate::ephemeral::{Timer as EphemeralTimer, stock_ephemeral_timer_changed};
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table};
|
||||
use crate::key::self_fingerprint_opt;
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
use crate::key::{self_fingerprint, self_fingerprint_opt};
|
||||
use crate::log::LogExt;
|
||||
use crate::log::{info, warn};
|
||||
use crate::logged_debug_assert;
|
||||
@@ -44,7 +44,10 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on
|
||||
use crate::simplify;
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{self, buf_compress, remove_subject_prefix};
|
||||
use crate::tools::{
|
||||
self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix,
|
||||
validate_broadcast_shared_secret,
|
||||
};
|
||||
use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location};
|
||||
use crate::{contact, imap};
|
||||
|
||||
@@ -1389,6 +1392,7 @@ async fn do_chat_assignment(
|
||||
create_or_lookup_mailinglist_or_broadcast(
|
||||
context,
|
||||
allow_creation,
|
||||
create_blocked,
|
||||
mailinglist_header,
|
||||
from_id,
|
||||
mime_parser,
|
||||
@@ -1573,7 +1577,9 @@ async fn do_chat_assignment(
|
||||
} else {
|
||||
let name =
|
||||
compute_mailinglist_name(mailinglist_header, &listid, mime_parser);
|
||||
chat::create_broadcast_ex(context, Nosync, listid, name).await?
|
||||
let secret = create_broadcast_shared_secret();
|
||||
chat::create_out_broadcast_ex(context, Nosync, listid, name, secret)
|
||||
.await?
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1701,6 +1707,15 @@ async fn add_parts(
|
||||
for part in &mut mime_parser.parts {
|
||||
part.param.set(Param::OverrideSenderDisplayname, name);
|
||||
}
|
||||
|
||||
if chat.typ == Chattype::InBroadcast {
|
||||
let s = stock_str::error(context, "This message was not sent by the channel owner")
|
||||
.await;
|
||||
if let Some(part) = mime_parser.parts.first_mut() {
|
||||
part.error = Some(format!("{s}:\n\"{}\"", part.msg));
|
||||
}
|
||||
mime_parser.replace_msg_by_error(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2899,15 +2914,13 @@ async fn apply_group_changes(
|
||||
}
|
||||
|
||||
if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
|
||||
// TODO: if address "alice@example.org" is a member of the group twice,
|
||||
// with old and new key,
|
||||
// and someone (maybe Alice's new contact) just removed Alice's old contact,
|
||||
// we may lookup the wrong contact because we only look up by the address.
|
||||
// The result is that info message may contain the new Alice's display name
|
||||
// rather than old display name.
|
||||
// This could be fixed by looking up the contact with the highest
|
||||
// `remove_timestamp` after applying Chat-Group-Member-Timestamps.
|
||||
removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?;
|
||||
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
|
||||
removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?;
|
||||
} else {
|
||||
// Removal message sent by a legacy Delta Chat client.
|
||||
removed_id =
|
||||
lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?;
|
||||
}
|
||||
if let Some(id) = removed_id {
|
||||
better_msg = if id == from_id {
|
||||
silent = true;
|
||||
@@ -2924,6 +2937,8 @@ async fn apply_group_changes(
|
||||
// we may lookup the wrong contact.
|
||||
// This could be fixed by looking up the contact with
|
||||
// highest `add_timestamp` to disambiguate.
|
||||
// Alternatively, this can be fixed by a header ChatGroupMemberAddedFpr,
|
||||
// just like we have ChatGroupMemberRemovedFpr.
|
||||
// The result of the error is that info message
|
||||
// may contain display name of the wrong contact.
|
||||
let fingerprint = key.public_key.dc_fingerprint().hex();
|
||||
@@ -3065,9 +3080,7 @@ async fn apply_group_changes(
|
||||
|
||||
if let Some(added_id) = added_id {
|
||||
if !added_ids.remove(&added_id) && !self_added {
|
||||
// No-op "Member added" message.
|
||||
//
|
||||
// Trash it.
|
||||
info!(context, "No-op 'Member added' message (TRASH)");
|
||||
better_msg = Some(String::new());
|
||||
}
|
||||
}
|
||||
@@ -3263,6 +3276,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,
|
||||
@@ -3295,18 +3309,12 @@ async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
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,
|
||||
ProtectionStatus::Unprotected,
|
||||
param,
|
||||
mime_parser.timestamp_sent,
|
||||
@@ -3319,13 +3327,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,
|
||||
@@ -3335,7 +3336,12 @@ async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(Some((chat_id, blocked)))
|
||||
|
||||
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)))
|
||||
} else {
|
||||
info!(context, "Creating list forbidden by caller.");
|
||||
Ok(None)
|
||||
@@ -3488,19 +3494,53 @@ 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![],
|
||||
});
|
||||
apply_chat_name_and_avatar_changes(
|
||||
context,
|
||||
mime_parser,
|
||||
from_id,
|
||||
chat,
|
||||
&mut send_event_chat_modified,
|
||||
&mut better_msg,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No need to check for ChatGroupMemberAdded:
|
||||
// The only way to add a member is by having them scan a QR code.
|
||||
// All devices will receive Bob's vb-request-with-auth message and add him to the channel.
|
||||
|
||||
Ok(GroupChangesInfo::default())
|
||||
if send_event_chat_modified {
|
||||
context.emit_event(EventType::ChatModified(chat.id));
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat.id);
|
||||
}
|
||||
Ok(GroupChangesInfo {
|
||||
better_msg,
|
||||
added_removed_id: None,
|
||||
silent: false,
|
||||
extra_msgs: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_in_broadcast_changes(
|
||||
@@ -3511,6 +3551,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;
|
||||
|
||||
@@ -3524,12 +3574,61 @@ async fn apply_in_broadcast_changes(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
|
||||
// The only member added/removed message that is ever sent is "I left.",
|
||||
// so, this is the only case we need to handle here
|
||||
if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) {
|
||||
if context.is_self_addr(added_addr).await? {
|
||||
let msg;
|
||||
|
||||
if chat.is_self_in_chat(context).await? {
|
||||
// Self is already in the chat.
|
||||
// Probably Alice has two devices and her second device added us again;
|
||||
// just hide the message.
|
||||
info!(context, "No-op broadcast 'Member added' message (TRASH)");
|
||||
msg = "".to_string();
|
||||
} else {
|
||||
chat.id
|
||||
.add_encrypted_msg(context, mime_parser.timestamp_sent)
|
||||
.await?;
|
||||
msg = stock_str::msg_add_member_local(context, ContactId::SELF, from_id).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:
|
||||
ensure!(removed_fpr == self_fingerprint(context).await?);
|
||||
|
||||
if from_id == ContactId::SELF {
|
||||
better_msg
|
||||
.get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await);
|
||||
} else {
|
||||
better_msg.get_or_insert(
|
||||
stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await,
|
||||
);
|
||||
}
|
||||
|
||||
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, ContactId::SELF)
|
||||
.await?;
|
||||
send_event_chat_modified = true;
|
||||
} else if !chat.is_self_in_chat(context).await? {
|
||||
// Apparently, self is in the chat now, because we're receiving messages
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
chat.id,
|
||||
&[ContactId::SELF],
|
||||
)
|
||||
.await?;
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
|
||||
if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) {
|
||||
if validate_broadcast_shared_secret(secret) {
|
||||
save_broadcast_shared_secret(context, chat.id, secret).await?;
|
||||
} else {
|
||||
warn!(context, "Not saving invalid broadcast secret");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -881,7 +881,7 @@ async fn test_github_mailing_list() -> Result<()> {
|
||||
Some("reply+elernshsetushoyseshetihseusaferuhsedtisneu@reply.github.com")
|
||||
);
|
||||
assert_eq!(chat.name, "deltachat/deltachat-core-rust");
|
||||
assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 1);
|
||||
assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 0);
|
||||
|
||||
receive_imf(&t.ctx, GH_MAILINGLIST2.as_bytes(), false).await?;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ fn inviter_progress(
|
||||
let chat_type = match step.get(..3) {
|
||||
Some("vc-") => Chattype::Single,
|
||||
Some("vg-") => Chattype::Group,
|
||||
Some("vb-") => Chattype::OutBroadcast,
|
||||
_ => bail!("Unknown securejoin step {step}"),
|
||||
};
|
||||
context.emit_event(EventType::SecurejoinInviterProgress {
|
||||
@@ -59,9 +60,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 ====
|
||||
@@ -69,12 +70,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");
|
||||
@@ -107,24 +109,40 @@ 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,
|
||||
)
|
||||
|
||||
if chat.typ == Chattype::OutBroadcast {
|
||||
let broadcast_name = chat.get_name();
|
||||
let broadcast_name_urlencoded =
|
||||
utf8_percent_encode(broadcast_name, NON_ALPHANUMERIC).to_string();
|
||||
format!(
|
||||
"https://i.delta.chat/#{}&a={}&b={}&x={}&s={}",
|
||||
fingerprint.hex(),
|
||||
self_addr_urlencoded,
|
||||
&broadcast_name_urlencoded,
|
||||
&chat.grpid,
|
||||
&auth,
|
||||
)
|
||||
} else {
|
||||
// parameters used: a=g=x=i=s=
|
||||
let group_name = chat.get_name();
|
||||
let group_name_urlencoded =
|
||||
utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
|
||||
format!(
|
||||
"https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}",
|
||||
fingerprint.hex(),
|
||||
self_addr_urlencoded,
|
||||
&group_name_urlencoded,
|
||||
&chat.grpid,
|
||||
&invitenumber,
|
||||
&auth,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// parameters used: a=n=i=s=
|
||||
if sync_token {
|
||||
@@ -279,9 +297,28 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
|
||||
info!(context, "Received secure-join message {step:?}.");
|
||||
|
||||
let join_vg = step.starts_with("vg-");
|
||||
|
||||
if !matches!(step, "vg-request" | "vc-request") {
|
||||
// Opportunistically protect against a theoretical 'surreptitious forwarding' attack:
|
||||
// If Eve obtains a QR code from Alice and starts a securejoin with her,
|
||||
// and also lets Bob scan a manipulated QR code,
|
||||
// she could reencrypt the v*-request-with-auth message to Bob while maintaining the signature,
|
||||
// and Bob would regard the message as valid.
|
||||
//
|
||||
// This attack is not actually relevant in any threat model,
|
||||
// because if Eve can see Alice's QR code and have Bob scan a manipulated QR code,
|
||||
// she can just do a classical MitM attack.
|
||||
//
|
||||
// Protecting all messages sent by Delta Chat against 'surreptitious forwarding'
|
||||
// by checking the 'intended recipient fingerprint'
|
||||
// will improve security (completely unrelated to the securejoin protocol)
|
||||
// and is something we want to do in the future:
|
||||
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
|
||||
if !matches!(step, "vg-request" | "vc-request" | "vb-request-with-auth") {
|
||||
// We don't perform this check for `vb-request-with-auth`:
|
||||
// Since the message is encrypted symmetrically,
|
||||
// there are no gossip headers,
|
||||
// so we can't easily do the same check as for asymmetrically encrypted secure-join messages.
|
||||
// Because this check doesn't add protection in any threat model,
|
||||
// we just skip it for vb-request-with-auth.
|
||||
let mut self_found = false;
|
||||
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
|
||||
for (addr, key) in &mime_message.gossiped_keys {
|
||||
@@ -353,7 +390,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
========================================================*/
|
||||
bob::handle_auth_required(context, mime_message).await
|
||||
}
|
||||
"vg-request-with-auth" | "vc-request-with-auth" => {
|
||||
"vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => {
|
||||
/*==========================================================
|
||||
==== Alice - the inviter side ====
|
||||
==== Steps 5+6 in "Setup verified contact" protocol ====
|
||||
@@ -376,7 +413,8 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
);
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code
|
||||
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code,
|
||||
// or that the message was encrypted with the secret written to the QR code.
|
||||
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
|
||||
warn!(
|
||||
context,
|
||||
@@ -414,7 +452,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
|
||||
// for setup-contact, make Alice's one-to-one chat with Bob visible
|
||||
// (secure-join-information are shown in the group chat)
|
||||
if !join_vg {
|
||||
if step.starts_with("vc-") {
|
||||
ChatId::create_for_contact(context, contact_id).await?;
|
||||
}
|
||||
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
||||
@@ -428,13 +466,21 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
mime_message.timestamp_sent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true)
|
||||
.await?;
|
||||
inviter_progress(context, contact_id, step, 800)?;
|
||||
inviter_progress(context, contact_id, step, 1000)?;
|
||||
// IMAP-delete the message to avoid handling it by another device and adding the
|
||||
// member twice. Another device will know the member's key from Autocrypt-Gossip.
|
||||
Ok(HandshakeMessage::Done)
|
||||
if step == "vb-request-with-auth" {
|
||||
// For broadcasts, we don't want to delete the message,
|
||||
// because the other device should also internally add the member
|
||||
// and see the key (because it won't see the member via autocrypt-gossip).
|
||||
Ok(HandshakeMessage::Ignore)
|
||||
} else {
|
||||
// IMAP-delete the message to avoid handling it by another device and adding the
|
||||
// member twice. Another device will know the member's key from Autocrypt-Gossip.
|
||||
Ok(HandshakeMessage::Done)
|
||||
}
|
||||
} else {
|
||||
// Setup verified contact.
|
||||
secure_connection_established(
|
||||
@@ -463,7 +509,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
});
|
||||
Ok(HandshakeMessage::Ignore)
|
||||
}
|
||||
"vg-member-added" => {
|
||||
"vg-member-added" | "vb-member-added" => {
|
||||
let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded)
|
||||
else {
|
||||
warn!(
|
||||
@@ -532,6 +578,13 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
step,
|
||||
"vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm"
|
||||
) {
|
||||
// `vb-request-with-auth` can be ignored
|
||||
// because we wouldn't be able to decrypt the message
|
||||
// (it's symmetrically encrypted with the AUTH token, which only the scanning device knows);
|
||||
// instead, the verification is transferred via a `MarkVerified` sync message.
|
||||
// `vb-member-added` can be ignored
|
||||
// because all devices receive the `vb-request-with-auth` message
|
||||
// and mark Bob as verified because of this.
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bob's side of SecureJoin handling, the joiner-side.
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use super::HandshakeMessage;
|
||||
use super::qrinvite::QrInvite;
|
||||
@@ -10,14 +10,14 @@ use crate::contact::Origin;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::log::info;
|
||||
use crate::log::{LogExt as _, info};
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::Param;
|
||||
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{create_smeared_timestamp, time};
|
||||
use crate::tools::{smeared_time, time};
|
||||
|
||||
/// Starts the securejoin protocol with the QR `invite`.
|
||||
///
|
||||
@@ -47,16 +47,49 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
let hidden = match invite {
|
||||
QrInvite::Contact { .. } => Blocked::Not,
|
||||
QrInvite::Group { .. } => Blocked::Yes,
|
||||
QrInvite::Broadcast { .. } => Blocked::Yes,
|
||||
};
|
||||
let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
|
||||
.await
|
||||
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
|
||||
|
||||
// The 1:1 chat with the inviter
|
||||
let private_chat_id =
|
||||
ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
|
||||
.await
|
||||
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
|
||||
|
||||
// The chat id of the 1:1 chat, group or broadcast that is being joined
|
||||
let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?;
|
||||
|
||||
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
|
||||
// Now start the protocol and initialise the state.
|
||||
{
|
||||
if invite.is_v2() {
|
||||
if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
|
||||
{
|
||||
bail!("V2 protocol failed because of fingerprint mismatch");
|
||||
}
|
||||
info!(context, "Using fast securejoin with symmetric encryption");
|
||||
|
||||
send_handshake_message(
|
||||
context,
|
||||
&invite,
|
||||
private_chat_id,
|
||||
BobHandshakeMsg::RequestWithAuth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
context.emit_event(EventType::SecurejoinJoinerProgress {
|
||||
contact_id: invite.contact_id(),
|
||||
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
|
||||
});
|
||||
|
||||
// Our second device won't be able to decrypt the outgoing message
|
||||
// because it will be symmetrically encrypted with the AUTH token.
|
||||
// So, we need to send a sync message:
|
||||
let id = chat::SyncId::ContactFingerprint(invite.fingerprint().hex());
|
||||
let action = chat::SyncAction::MarkVerified;
|
||||
chat::sync(context, id, action).await.log_err(context).ok();
|
||||
} else {
|
||||
// Start the version 1 protocol and initialise the state.
|
||||
let has_key = context
|
||||
.sql
|
||||
.exists(
|
||||
@@ -71,11 +104,16 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
{
|
||||
// The scanned fingerprint matches Alice's key, we can proceed to step 4b.
|
||||
info!(context, "Taking securejoin protocol shortcut");
|
||||
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth)
|
||||
.await?;
|
||||
send_handshake_message(
|
||||
context,
|
||||
&invite,
|
||||
private_chat_id,
|
||||
BobHandshakeMsg::RequestWithAuth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Mark 1:1 chat as verified already.
|
||||
chat_id
|
||||
private_chat_id
|
||||
.set_protection(
|
||||
context,
|
||||
ProtectionStatus::Protected,
|
||||
@@ -89,9 +127,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
|
||||
});
|
||||
} else {
|
||||
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
|
||||
send_handshake_message(context, &invite, private_chat_id, BobHandshakeMsg::Request)
|
||||
.await?;
|
||||
|
||||
insert_new_db_entry(context, invite.clone(), chat_id).await?;
|
||||
insert_new_db_entry(context, invite.clone(), private_chat_id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,19 +138,35 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
QrInvite::Group { .. } => {
|
||||
// For a secure-join we need to create the group and add the contact. The group will
|
||||
// only become usable once the protocol is finished.
|
||||
let group_chat_id = joining_chat_id(context, &invite, chat_id).await?;
|
||||
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
|
||||
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
time(),
|
||||
group_chat_id,
|
||||
joining_chat_id,
|
||||
&[invite.contact_id()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
|
||||
chat::add_info_msg(context, group_chat_id, &msg, time()).await?;
|
||||
Ok(group_chat_id)
|
||||
chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
|
||||
Ok(joining_chat_id)
|
||||
}
|
||||
QrInvite::Broadcast { .. } => {
|
||||
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
time(),
|
||||
joining_chat_id,
|
||||
&[invite.contact_id()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? {
|
||||
let msg = stock_str::securejoin_wait(context).await;
|
||||
chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
|
||||
}
|
||||
Ok(joining_chat_id)
|
||||
}
|
||||
QrInvite::Contact { .. } => {
|
||||
// For setup-contact the BobState already ensured the 1:1 chat exists because it
|
||||
@@ -120,14 +175,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
// race with its change, we don't add our message below the protection message.
|
||||
let sort_to_bottom = true;
|
||||
let (received, incoming) = (false, false);
|
||||
let ts_sort = chat_id
|
||||
let ts_sort = private_chat_id
|
||||
.calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming)
|
||||
.await?;
|
||||
if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
|
||||
if private_chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
|
||||
let ts_start = time();
|
||||
chat::add_info_msg_with_cmd(
|
||||
context,
|
||||
chat_id,
|
||||
private_chat_id,
|
||||
&stock_str::securejoin_wait(context).await,
|
||||
SystemMessage::SecurejoinWait,
|
||||
ts_sort,
|
||||
@@ -138,7 +193,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(chat_id)
|
||||
Ok(private_chat_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +259,7 @@ pub(super) async fn handle_auth_required(
|
||||
.await?;
|
||||
|
||||
match invite {
|
||||
QrInvite::Contact { .. } => {}
|
||||
QrInvite::Contact { .. } | QrInvite::Broadcast { .. } => {}
|
||||
QrInvite::Group { .. } => {
|
||||
// The message reads "Alice replied, waiting to be added to the group…",
|
||||
// so only show it on secure-join and not on setup-contact.
|
||||
@@ -253,19 +308,19 @@ pub(crate) async fn send_handshake_message(
|
||||
) -> Result<()> {
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: step.body_text(invite),
|
||||
text: step.body_text(invite)?,
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
|
||||
|
||||
// Sends the step in Secure-Join header.
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite));
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite)?);
|
||||
|
||||
match step {
|
||||
BobHandshakeMsg::Request => {
|
||||
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.invitenumber());
|
||||
msg.param.set_optional(Param::Arg2, invite.invitenumber());
|
||||
msg.force_plaintext();
|
||||
}
|
||||
BobHandshakeMsg::RequestWithAuth => {
|
||||
@@ -299,7 +354,7 @@ pub(crate) async fn send_handshake_message(
|
||||
pub(crate) enum BobHandshakeMsg {
|
||||
/// vc-request or vg-request
|
||||
Request,
|
||||
/// vc-request-with-auth or vg-request-with-auth
|
||||
/// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth
|
||||
RequestWithAuth,
|
||||
}
|
||||
|
||||
@@ -309,8 +364,8 @@ impl BobHandshakeMsg {
|
||||
/// This text has no significance to the protocol, but would be visible if users see
|
||||
/// this email message directly, e.g. when accessing their email without using
|
||||
/// DeltaChat.
|
||||
fn body_text(&self, invite: &QrInvite) -> String {
|
||||
format!("Secure-Join: {}", self.securejoin_header(invite))
|
||||
fn body_text(&self, invite: &QrInvite) -> Result<String> {
|
||||
Ok(format!("Secure-Join: {}", self.securejoin_header(invite)?))
|
||||
}
|
||||
|
||||
/// Returns the `Secure-Join` header value.
|
||||
@@ -318,17 +373,22 @@ impl BobHandshakeMsg {
|
||||
/// This identifies the step this message is sending information about. Most protocol
|
||||
/// steps include additional information into other headers, see
|
||||
/// [`send_handshake_message`] for these.
|
||||
fn securejoin_header(&self, invite: &QrInvite) -> &'static str {
|
||||
match self {
|
||||
fn securejoin_header(&self, invite: &QrInvite) -> Result<&'static str> {
|
||||
let res = match self {
|
||||
Self::Request => match invite {
|
||||
QrInvite::Contact { .. } => "vc-request",
|
||||
QrInvite::Group { .. } => "vg-request",
|
||||
QrInvite::Broadcast { .. } => {
|
||||
bail!("There is no request-with-auth for broadcasts")
|
||||
}
|
||||
},
|
||||
Self::RequestWithAuth => match invite {
|
||||
QrInvite::Contact { .. } => "vc-request-with-auth",
|
||||
QrInvite::Group { .. } => "vg-request-with-auth",
|
||||
QrInvite::Broadcast { .. } => "vb-request-with-auth",
|
||||
},
|
||||
}
|
||||
};
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,8 +406,19 @@ 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 {
|
||||
broadcast_name: name,
|
||||
grpid,
|
||||
..
|
||||
} => {
|
||||
let chattype = if matches!(invite, QrInvite::Group { .. }) {
|
||||
Chattype::Group
|
||||
} else {
|
||||
Chattype::InBroadcast
|
||||
};
|
||||
|
||||
let chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
|
||||
Some((chat_id, _protected, _blocked)) => {
|
||||
chat_id.unblock_ex(context, Nosync).await?;
|
||||
chat_id
|
||||
@@ -355,18 +426,18 @@ async fn joining_chat_id(
|
||||
None => {
|
||||
ChatId::create_multiuser_record(
|
||||
context,
|
||||
Chattype::Group,
|
||||
chattype,
|
||||
grpid,
|
||||
name,
|
||||
Blocked::Not,
|
||||
ProtectionStatus::Unprotected, // protection is added later as needed
|
||||
None,
|
||||
create_smeared_timestamp(context),
|
||||
smeared_time(context),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(group_chat_id)
|
||||
Ok(chat_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub enum QrInvite {
|
||||
Contact {
|
||||
contact_id: ContactId,
|
||||
fingerprint: Fingerprint,
|
||||
invitenumber: String,
|
||||
invitenumber: Option<String>,
|
||||
authcode: String,
|
||||
},
|
||||
Group {
|
||||
@@ -26,7 +26,14 @@ pub enum QrInvite {
|
||||
fingerprint: Fingerprint,
|
||||
name: String,
|
||||
grpid: String,
|
||||
invitenumber: String,
|
||||
invitenumber: Option<String>,
|
||||
authcode: String,
|
||||
},
|
||||
Broadcast {
|
||||
contact_id: ContactId,
|
||||
fingerprint: Fingerprint,
|
||||
broadcast_name: String,
|
||||
grpid: String,
|
||||
authcode: String,
|
||||
},
|
||||
}
|
||||
@@ -38,30 +45,50 @@ impl QrInvite {
|
||||
/// translated to a contact ID.
|
||||
pub fn contact_id(&self) -> ContactId {
|
||||
match self {
|
||||
Self::Contact { contact_id, .. } | Self::Group { contact_id, .. } => *contact_id,
|
||||
Self::Contact { contact_id, .. }
|
||||
| Self::Group { contact_id, .. }
|
||||
| Self::Broadcast { contact_id, .. } => *contact_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fingerprint of the inviter.
|
||||
pub fn fingerprint(&self) -> &Fingerprint {
|
||||
match self {
|
||||
Self::Contact { fingerprint, .. } | Self::Group { fingerprint, .. } => fingerprint,
|
||||
Self::Contact { fingerprint, .. }
|
||||
| Self::Group { fingerprint, .. }
|
||||
| Self::Broadcast { fingerprint, .. } => fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `INVITENUMBER` of the setup-contact/secure-join protocol.
|
||||
pub fn invitenumber(&self) -> &str {
|
||||
pub fn invitenumber(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber,
|
||||
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => {
|
||||
invitenumber.as_deref()
|
||||
}
|
||||
Self::Broadcast { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `AUTH` code of the setup-contact/secure-join protocol.
|
||||
pub fn authcode(&self) -> &str {
|
||||
match self {
|
||||
Self::Contact { authcode, .. } | Self::Group { authcode, .. } => authcode,
|
||||
Self::Contact { authcode, .. }
|
||||
| Self::Group { authcode, .. }
|
||||
| Self::Broadcast { authcode, .. } => authcode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this QR code uses the faster "version 2" protocol,
|
||||
/// where the first message from Bob to Alice is symmetrically encrypted
|
||||
/// with the AUTH code.
|
||||
/// We may decide in the future to backwards-compatibly mark QR codes as V2,
|
||||
/// but for now, everything without an invite number
|
||||
/// is definitely V2,
|
||||
/// because the invite number is needed for V1.
|
||||
pub(crate) fn is_v2(&self) -> bool {
|
||||
self.invitenumber().is_none()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Qr> for QrInvite {
|
||||
@@ -77,7 +104,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
} => Ok(QrInvite::Contact {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
invitenumber: Some(invitenumber),
|
||||
authcode,
|
||||
}),
|
||||
Qr::AskVerifyGroup {
|
||||
@@ -92,10 +119,23 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
name: grpname,
|
||||
grpid,
|
||||
invitenumber,
|
||||
invitenumber: Some(invitenumber),
|
||||
authcode,
|
||||
}),
|
||||
_ => bail!("Unsupported QR type"),
|
||||
Qr::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
} => Ok(QrInvite::Broadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
}),
|
||||
_ => bail!("Unsupported QR type: {qr:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use crate::mimeparser::GossipedKey;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::stock_str::{self, messages_e2e_encrypted};
|
||||
use crate::test_utils::{
|
||||
TestContext, TestContextManager, TimeShiftFalsePositiveNote, get_chat_msg,
|
||||
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
|
||||
TimeShiftFalsePositiveNote, get_chat_msg,
|
||||
};
|
||||
use crate::tools::SystemTime;
|
||||
use std::time::Duration;
|
||||
@@ -869,3 +870,72 @@ async fn test_wrong_auth_token() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_send_avatar_in_securejoin() -> Result<()> {
|
||||
async fn exec_securejoin_group(
|
||||
tcm: &TestContextManager,
|
||||
scanner: &TestContext,
|
||||
scanned: &TestContext,
|
||||
) {
|
||||
let chat_id = chat::create_group_chat(scanned, ProtectionStatus::Protected, "group")
|
||||
.await
|
||||
.unwrap();
|
||||
let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(scanner, scanned, &qr).await;
|
||||
}
|
||||
async fn exec_securejoin_broadcast(
|
||||
tcm: &TestContextManager,
|
||||
scanner: &TestContext,
|
||||
scanned: &TestContext,
|
||||
) {
|
||||
let chat_id = chat::create_broadcast(scanned, "group".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(scanner, scanned, &qr).await;
|
||||
}
|
||||
|
||||
for round in 0..6 {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let file = alice.dir.path().join("avatar.png");
|
||||
tokio::fs::write(&file, AVATAR_64x64_BYTES).await?;
|
||||
alice
|
||||
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
|
||||
.await?;
|
||||
|
||||
match round {
|
||||
0 => {
|
||||
tcm.execute_securejoin(alice, bob).await;
|
||||
}
|
||||
1 => {
|
||||
tcm.execute_securejoin(bob, alice).await;
|
||||
}
|
||||
2 => {
|
||||
exec_securejoin_group(&tcm, alice, bob).await;
|
||||
}
|
||||
3 => {
|
||||
exec_securejoin_group(&tcm, bob, alice).await;
|
||||
}
|
||||
4 => {
|
||||
exec_securejoin_broadcast(&tcm, alice, bob).await;
|
||||
}
|
||||
5 => {
|
||||
exec_securejoin_broadcast(&tcm, bob, alice).await;
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
|
||||
let alice_on_bob = bob.add_or_lookup_contact_no_key(alice).await;
|
||||
let avatar = alice_on_bob.get_profile_image(bob).await?.unwrap();
|
||||
assert_eq!(
|
||||
avatar.file_name().unwrap().to_str().unwrap(),
|
||||
AVATAR_64x64_DEDUPLICATED
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1261,6 +1261,18 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 134)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration(
|
||||
"CREATE TABLE broadcasts_shared_secrets(
|
||||
chat_id INTEGER PRIMARY KEY NOT NULL,
|
||||
secret TEXT NOT NULL
|
||||
) STRICT",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
@@ -665,9 +665,9 @@ pub(crate) async fn msg_del_member_remote(context: &Context, removed_member_addr
|
||||
.replace1(whom)
|
||||
}
|
||||
|
||||
/// Stock string: `I added member %1$s.` or `Member %1$s removed by %2$s.`.
|
||||
/// Stock string: `Member %1$s removed.`, `You removed member %1$s.` or `Member %1$s removed by %2$s.`
|
||||
///
|
||||
/// The `removed_member_addr` parameter should be an email address and is looked up in
|
||||
/// The `removed_member` and `by_contact` parameter is looked up in
|
||||
/// the contacts to combine with the display name.
|
||||
pub(crate) async fn msg_del_member_local(
|
||||
context: &Context,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! # Synchronize items between devices.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use mail_builder::mime::MimePart;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -270,6 +270,7 @@ impl Context {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
.with_context(|| format!("Sync data {:?}", item.data))
|
||||
.log_err(self)
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -222,24 +222,55 @@ impl TestContextManager {
|
||||
self.exec_securejoin_qr(scanner, scanned, &qr).await
|
||||
}
|
||||
|
||||
/// Executes SecureJoin initiated by `scanner` scanning `qr` generated by `scanned`.
|
||||
/// Executes SecureJoin initiated by `joiner` scanning `qr` generated by `inviter`.
|
||||
///
|
||||
/// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1
|
||||
/// chat with `scanned`, for a SecureJoin QR this is the group chat.
|
||||
/// chat with `inviter`, for a SecureJoin QR this is the group chat.
|
||||
pub async fn exec_securejoin_qr(
|
||||
&self,
|
||||
scanner: &TestContext,
|
||||
scanned: &TestContext,
|
||||
joiner: &TestContext,
|
||||
inviter: &TestContext,
|
||||
qr: &str,
|
||||
) -> ChatId {
|
||||
let chat_id = join_securejoin(&scanner.ctx, qr).await.unwrap();
|
||||
self.exec_securejoin_qr_multi_device(joiner, &[inviter], qr)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Executes SecureJoin initiated by `joiner`
|
||||
/// scanning `qr` generated by one of the `inviters` devices.
|
||||
/// All of the `inviters` devices will get the messages and send replies.
|
||||
///
|
||||
/// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1
|
||||
/// chat with `inviter`, for a SecureJoin QR this is the group chat.
|
||||
pub async fn exec_securejoin_qr_multi_device(
|
||||
&self,
|
||||
joiner: &TestContext,
|
||||
inviters: &[&TestContext],
|
||||
qr: &str,
|
||||
) -> ChatId {
|
||||
assert!(joiner.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
for inviter in inviters {
|
||||
assert!(inviter.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
}
|
||||
|
||||
let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap();
|
||||
|
||||
loop {
|
||||
if let Some(sent) = scanner.pop_sent_msg_opt(Duration::ZERO).await {
|
||||
scanned.recv_msg_opt(&sent).await;
|
||||
} else if let Some(sent) = scanned.pop_sent_msg_opt(Duration::ZERO).await {
|
||||
scanner.recv_msg_opt(&sent).await;
|
||||
} else {
|
||||
let mut something_sent = false;
|
||||
if let Some(sent) = joiner.pop_sent_msg_opt(Duration::ZERO).await {
|
||||
for inviter in inviters {
|
||||
inviter.recv_msg_opt(&sent).await;
|
||||
}
|
||||
something_sent = true;
|
||||
}
|
||||
for inviter in inviters {
|
||||
if let Some(sent) = inviter.pop_sent_msg_opt(Duration::ZERO).await {
|
||||
joiner.recv_msg_opt(&sent).await;
|
||||
something_sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !something_sent {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -953,9 +984,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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
15
src/token.rs
15
src/token.rs
@@ -61,6 +61,21 @@ pub async fn lookup(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lookup_all(context: &Context, namespace: Namespace) -> Result<Vec<String>> {
|
||||
context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT token FROM tokens WHERE namespc=? ORDER BY timestamp DESC LIMIT 1",
|
||||
(namespace,),
|
||||
|row| row.get(0),
|
||||
|rows| {
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lookup_or_new(
|
||||
context: &Context,
|
||||
namespace: Namespace,
|
||||
|
||||
24
src/tools.rs
24
src/tools.rs
@@ -300,6 +300,25 @@ pub(crate) fn create_id() -> String {
|
||||
base64::engine::general_purpose::URL_SAFE.encode(arr)
|
||||
}
|
||||
|
||||
/// Generate a shared secret for a broadcast channel, consisting of 43 characters.
|
||||
///
|
||||
/// The string generated by this function has 258 bits of entropy
|
||||
/// and is returned as 43 Base64 characters, each containing 6 bits of entropy.
|
||||
/// 256 is chosen because we may switch to AES-256 keys in the future,
|
||||
/// and so that the shared secret definitely won't be the weak spot.
|
||||
pub(crate) fn create_broadcast_shared_secret() -> String {
|
||||
// ThreadRng implements CryptoRng trait and is supposed to be cryptographically secure.
|
||||
let mut rng = thread_rng();
|
||||
|
||||
// Generate 264 random bits.
|
||||
let mut arr = [0u8; 33];
|
||||
rng.fill(&mut arr[..]);
|
||||
|
||||
let mut res = base64::engine::general_purpose::URL_SAFE.encode(arr);
|
||||
res.truncate(43);
|
||||
res
|
||||
}
|
||||
|
||||
/// Returns true if given string is a valid ID.
|
||||
///
|
||||
/// All IDs generated with `create_id()` should be considered valid.
|
||||
@@ -308,6 +327,11 @@ pub(crate) fn validate_id(s: &str) -> bool {
|
||||
s.chars().all(|c| alphabet.contains(c)) && s.len() > 10 && s.len() <= 32
|
||||
}
|
||||
|
||||
pub(crate) fn validate_broadcast_shared_secret(s: &str) -> bool {
|
||||
let alphabet = base64::alphabet::URL_SAFE.as_str();
|
||||
s.chars().all(|c| alphabet.contains(c)) && s.len() >= 43 && s.len() <= 100
|
||||
}
|
||||
|
||||
/// Function generates a Message-ID that can be used for a new outgoing message.
|
||||
/// - this function is called for all outgoing messages.
|
||||
/// - the message ID should be globally unique
|
||||
|
||||
6
test-data/golden/test_broadcast_joining_golden_alice
Normal file
6
test-data/golden/test_broadcast_joining_golden_alice
Normal file
@@ -0,0 +1,6 @@
|
||||
OutBroadcast#Chat#10: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#11🔒: Me (Contact#Contact#Self): You changed the group image. [INFO] √
|
||||
Msg#13🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -0,0 +1,4 @@
|
||||
Single#Chat#11: bob@example.net [KEY bob@example.net] 🛡️
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#12: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO 🛡️]
|
||||
--------------------------------------------------------------------------------
|
||||
6
test-data/golden/test_broadcast_joining_golden_bob
Normal file
6
test-data/golden/test_broadcast_joining_golden_bob
Normal file
@@ -0,0 +1,6 @@
|
||||
InBroadcast#Chat#11: My Channel [2 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO]
|
||||
Msg#12: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#13🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO]
|
||||
--------------------------------------------------------------------------------
|
||||
7
test-data/golden/test_sync_broadcast_alice1
Normal file
7
test-data/golden/test_sync_broadcast_alice1
Normal file
@@ -0,0 +1,7 @@
|
||||
OutBroadcast#Chat#10: Channel [0 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#14🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#16🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#17🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
7
test-data/golden/test_sync_broadcast_alice2
Normal file
7
test-data/golden/test_sync_broadcast_alice2
Normal file
@@ -0,0 +1,7 @@
|
||||
OutBroadcast#Chat#10: Channel [0 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#11: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#14🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#16🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#17🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
8
test-data/golden/test_sync_broadcast_bob
Normal file
8
test-data/golden/test_sync_broadcast_bob
Normal file
@@ -0,0 +1,8 @@
|
||||
InBroadcast#Chat#11: Channel [1 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#11: info (Contact#Contact#Info): Establishing guaranteed end-to-end encryption, please wait… [NOTICED][INFO]
|
||||
Msg#12: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#13🔒: (Contact#Contact#10): Member Me added by alice@example.org. [FRESH][INFO]
|
||||
Msg#15🔒: (Contact#Contact#10): hi [FRESH]
|
||||
Msg#16🔒: (Contact#Contact#10): Member Me removed by alice@example.org. [FRESH][INFO]
|
||||
--------------------------------------------------------------------------------
|
||||
87
test-data/message/text_from_alice_encrypted.eml
Normal file
87
test-data/message/text_from_alice_encrypted.eml
Normal 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--
|
||||
|
||||
56
test-data/message/text_symmetrically_encrypted.eml
Normal file
56
test-data/message/text_symmetrically_encrypted.eml
Normal 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--
|
||||
|
||||
Reference in New Issue
Block a user