Skip to content

[WIP] QR codes and symmetric encryption for broadcast channels #7042

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 58 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
3e11991
feat: Symmetric encryption. No decryption, no sharing of the secret, …
Hocuri Jul 7, 2025
c99ab4f
WIP: Start with decryption, and a test for it. Next TODO: SQL table m…
Hocuri Jul 7, 2025
04c8023
feat: Save the secret to encrypt and decrypt messages. Next: Send it …
Hocuri Jul 11, 2025
c1d1bf7
feat: Add create_broadcast_shared_secret()
Hocuri Jul 21, 2025
3aa227b
sync broadcast secret for multidevice
Hocuri Jul 21, 2025
7c6b52b
Make it compile
Hocuri Jul 21, 2025
2a2b54a
feat: Store symmetric key non-redundantly in the database
Hocuri Jul 21, 2025
461aa26
feat: Add broadcast QR type (todo: documentation)
Hocuri Jul 21, 2025
5c61e04
Adapt the rest of the code to the new QR code type
Hocuri Jul 23, 2025
9390cfc
test: Add test_send_avatar_in_securejoin
Hocuri Jul 25, 2025
810df62
Broadcast-securejoin is working!!
Hocuri Aug 1, 2025
34ebfbb
fix: make test_broadcast work, return an error when trying to add man…
Hocuri Aug 1, 2025
eeece9e
Make basic multi-device work on joiner side, fix test_only_minimal_da…
Hocuri Aug 1, 2025
dda48a7
fix: Make syncing of QR tokens work, make test_sync_broadcast pass
Hocuri Aug 1, 2025
98b4cad
make test_block_broadcast pass
Hocuri Aug 1, 2025
ab52eac
test: Fix test_broadcast_multidev
Hocuri Aug 1, 2025
dc2c912
test: Fix one panic in test_broadcasts_name_and_avatar, but there is …
Hocuri Aug 1, 2025
55b3a7f
fix: Make joining a channel work with multi-device, fix test_leave_br…
Hocuri Aug 1, 2025
f790e9f
test: fix test_leave_broadcast
Hocuri Aug 1, 2025
e8e9ddd
test: fix test_encrypt_decrypt_broadcast()
Hocuri Aug 4, 2025
86a7b68
fix: Actually send broadcast message to recipients, ALL TESTS PASS NO…
Hocuri Aug 4, 2025
e241af9
Add TODO
Hocuri Aug 4, 2025
99b24d4
fix: Let Alice send vb-member-added so that the chat is immediately s…
Hocuri Aug 4, 2025
f02d203
fix: Correct member-added info messages
Hocuri Aug 4, 2025
2bd7911
fix: Don't show a weird 'vb-request-with-auth' message when a subscri…
Hocuri Aug 4, 2025
bdc39cf
Add some print statements for debugging
Hocuri Aug 4, 2025
0620118
feat: Increase secret size to 256 bits of entropy
Hocuri Aug 4, 2025
16c902c
Remove unused and problematic ensure!
Hocuri Aug 4, 2025
0a0f747
Add benchmark for message decryption
Hocuri Aug 4, 2025
8e8a524
Speed up message decryption by not iterating in the s2k algorithm
Hocuri Aug 4, 2025
452c4cc
Add benchmark for message decryption
Hocuri Aug 5, 2025
05c8958
Improve TODOs
Hocuri Aug 6, 2025
a74d706
WIP, untested: Sending side of transferring the secret in member-adde…
Hocuri Aug 6, 2025
abb1d41
WIP, untested: Receiving side of passing broadcast secret in a message
Hocuri Aug 6, 2025
73d0dc2
feat: Transfer the broadcast secret in an encrypted message rather th…
Hocuri Aug 7, 2025
e63429f
Don't include the broadcast's shared secret in the QR code
Hocuri Aug 7, 2025
21afbf9
refactor: Use the same decode_name() function for the contact name, r…
Hocuri Aug 7, 2025
63cdf7e
refactor: It's not actually necessary for Alice to remember how the m…
Hocuri Aug 7, 2025
7f3f4d5
clippy
Hocuri Aug 7, 2025
d1769ed
test: Improve test_send_avatar_in_securejoin()
Hocuri Aug 7, 2025
af52772
No clippy warnings anymore!
Hocuri Aug 7, 2025
6bf5e87
Use translatable message for broadcast-joining
Hocuri Aug 7, 2025
c513afa
Add golden test that only one member-added message is shown for Bob
Hocuri Aug 7, 2025
c5731f7
fix: Show only one member-added message for Bob
Hocuri Aug 7, 2025
494cd39
docs: Fix wrong comment on msg_del_member_local()
Hocuri Aug 8, 2025
44ae964
Notify a removed member that they were removed
Hocuri Aug 8, 2025
f321ed2
Remove unnecessary TODO
Hocuri Aug 8, 2025
209ad44
fix: Don't show a weird 'Secure-Join: vb-request-v2 message' in Alice…
Hocuri Aug 8, 2025
8a8ce8d
comments/naming: Make sure that I consistently use shared_secret
Hocuri Aug 8, 2025
12e2a3b
fix: Make sure that only the channel owner can write into the chat
Hocuri Aug 11, 2025
5e2fdd1
feat: Rename vb-request-v2 -> vb-request-with-auth
Hocuri Aug 11, 2025
bfedba9
feat: Make reacting to v2 invites generic over the type of the invite…
Hocuri Aug 11, 2025
49f1b37
bench: Improve benchmark_decrypting.rs benchmark
Hocuri Aug 11, 2025
dda2a57
refactor: Remove small code duplication
Hocuri Aug 11, 2025
39396ab
resolve some small TODOs
Hocuri Aug 11, 2025
fd2847b
Resolve some small TODOs
Hocuri Aug 11, 2025
0b365d3
feat: Sync Alice's verification on Bob's side
Hocuri Aug 12, 2025
98a8e73
fix: Protect against DOS attacks via a message with many esks using e…
Hocuri Aug 15, 2025
File filter

Filter by extension

Filter by extension


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

[profile.fuzz]
Expand Down Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions benches/benchmark_decrypting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! Benchmarks for message decryption,
//! comparing decryption of symmetrically-encrypted messages
//! to decryption of asymmetrically-encrypted messages.
//!
//! Call with
//!
//! ```text
//! cargo bench --bench benchmark_decrypting --features="internals"
//! ```
//!
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
//!
//! ```text
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
//! ```
//!
//! You can also pass a substring.
//! So, you can run all 'Decrypt and parse' benchmarks with:
//!
//! ```text
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt and parse'
//! ```
//!
//! Symmetric decryption has to try out all known secrets,
//! You can benchmark this by adapting the `NUM_SECRETS` variable.

use std::hint::black_box;

use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::internals_for_benchmarks::create_broadcast_shared_secret;
use deltachat::internals_for_benchmarks::create_dummy_keypair;
use deltachat::internals_for_benchmarks::save_broadcast_shared_secret;
use deltachat::{
Events,
chat::ChatId,
config::Config,
context::Context,
internals_for_benchmarks::key_from_asc,
internals_for_benchmarks::parse_and_get_text,
internals_for_benchmarks::store_self_keypair,
pgp::{KeyPair, decrypt, encrypt_symmetrically, pk_encrypt},
stock_str::StockStrings,
};
use rand::{Rng, thread_rng};
use tempfile::tempdir;

const NUM_SECRETS: usize = 500;

async fn create_context() -> Context {
let dir = tempdir().unwrap();
let dbfile = dir.path().join("db.sqlite");
let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new())
.await
.unwrap();

context
.set_config(Config::ConfiguredAddr, Some("[email protected]"))
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc"))
.unwrap()
.0;
let public = secret.signed_public_key();
let key_pair = KeyPair { public, secret };
store_self_keypair(&context, &key_pair)
.await
.expect("Failed to save key");

context
}

fn criterion_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("Decrypt");

// ===========================================================================================
// Benchmarks for decryption only, without any other parsing
// ===========================================================================================

group.sample_size(10);

group.bench_function("Decrypt a symmetrically encrypted message", |b| {
let plain = generate_plaintext();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
let secret = secrets[NUM_SECRETS / 2].clone();
let encrypted = encrypt_symmetrically(
plain.clone(),
black_box(&secret),
create_dummy_keypair("[email protected]").unwrap().secret,
true,
)
.await
.unwrap();

encrypted
});

b.iter(|| {
let mut msg =
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
let decrypted = msg.as_data_vec().unwrap();

assert_eq!(black_box(decrypted), plain);
});
});

group.bench_function("Decrypt a public-key encrypted message", |b| {
let plain = generate_plaintext();
let key_pair = create_dummy_keypair("[email protected]").unwrap();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
let encrypted = pk_encrypt(
plain.clone(),
vec![black_box(key_pair.public.clone())],
Some(key_pair.secret.clone()),
true,
)
.await
.unwrap();

encrypted
});

b.iter(|| {
let mut msg = decrypt(
encrypted.clone().into_bytes(),
&[key_pair.secret.clone()],
black_box(&secrets),
)
.unwrap();
let decrypted = msg.as_data_vec().unwrap();

assert_eq!(black_box(decrypted), plain);
});
});

// ===========================================================================================
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
// ===========================================================================================

let rt = tokio::runtime::Runtime::new().unwrap();
let mut secrets = generate_secrets();

// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
// Put it into the middle of our secrets:
secrets[NUM_SECRETS / 2] = "secret".to_string();

let context = rt.block_on(async {
let context = create_context().await;
for (i, secret) in secrets.iter().enumerate() {
save_broadcast_shared_secret(&context, ChatId::new(10 + i as u32), &secret)
.await
.unwrap();
}
context
});

group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(text, "Symmetrically encrypted message");
}
});
});

group.bench_function("Decrypt and parse a public-key encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_from_alice_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(text, "hi");
}
});
});

group.finish();
}

fn generate_secrets() -> Vec<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);
6 changes: 6 additions & 0 deletions deltachat-ffi/src/lot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -169,6 +172,9 @@ pub enum LotState {
/// text1=groupname
QrAskVerifyGroup = 202,

/// text1=broadcast_name
QrAskJoinBroadcast = 204,

/// id=contact
QrFprOk = 210,

Expand Down
4 changes: 3 additions & 1 deletion deltachat-jsonrpc/src/api/types/chat_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ pub(crate) async fn get_chat_list_item_by_id(

let chat_contacts = get_chat_contacts(ctx, chat_id).await?;

let self_in_group = chat_contacts.contains(&ContactId::SELF);
let self_in_group = chat_contacts.contains(&ContactId::SELF)
|| chat.get_type() == Chattype::OutBroadcast
|| chat.get_type() == Chattype::Mailinglist;

let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single {
let contact = chat_contacts.first();
Expand Down
30 changes: 30 additions & 0 deletions deltachat-jsonrpc/src/api/types/qr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
/// Chat name.
broadcast_name: String,
/// Group ID.
grpid: String,
/// ID of the contact.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,

authcode: String,
},
/// Contact fingerprint is verified.
///
/// Ask the user if they want to start chatting.
Expand Down Expand Up @@ -207,6 +220,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 }
Expand Down
Loading
Loading