feat: Transfer the broadcast secret in an encrypted message rather than directly in the QR code

This commit is contained in:
Hocuri
2025-08-07 11:31:29 +02:00
parent e1abaebeb5
commit 738f6c1799
14 changed files with 265 additions and 134 deletions

View File

@@ -71,7 +71,7 @@ fn criterion_benchmark(c: &mut Criterion) {
}); });
b.iter(|| { b.iter(|| {
let mut msg = let (mut msg, _) =
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap(); decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
let decrypted = msg.as_data_vec().unwrap(); let decrypted = msg.as_data_vec().unwrap();
@@ -101,7 +101,7 @@ fn criterion_benchmark(c: &mut Criterion) {
}); });
b.iter(|| { b.iter(|| {
let mut msg = decrypt( let (mut msg, _) = decrypt(
encrypted.clone().into_bytes(), encrypted.clone().into_bytes(),
&[key_pair.secret.clone()], &[key_pair.secret.clone()],
black_box(&secrets), black_box(&secrets),

View File

@@ -2941,10 +2941,13 @@ async fn prepare_send_msg(
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
) )
} }
CantSendReason::MissingKey => msg CantSendReason::MissingKey => {
.param msg.param
.get_bool(Param::ForcePlaintext) .get_bool(Param::ForcePlaintext)
.unwrap_or_default(), .unwrap_or_default()
// V2 securejoin messages are symmetrically encrypted, no need for the public key:
|| msg.securejoin_step() == Some("vb-request-v2")
}
_ => false, _ => false,
}; };
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? { if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
@@ -3878,11 +3881,13 @@ pub(crate) async fn save_broadcast_shared_secret(
chat_id: ChatId, chat_id: ChatId,
secret: &str, secret: &str,
) -> Result<()> { ) -> Result<()> {
info!(context, "Saving broadcast secret for chat {chat_id}");
info!(context, "dbg the new secret for chat {chat_id} is {secret}");
context context
.sql .sql
.execute( .execute(
"INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?) "INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?)
ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.chat_id", ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret",
(chat_id, secret), (chat_id, secret),
) )
.await?; .await?;

View File

@@ -7,7 +7,7 @@ use crate::imex::{ImexMode, has_backup, imex};
use crate::message::{MessengerMessage, delete_msgs}; use crate::message::{MessengerMessage, delete_msgs};
use crate::mimeparser::{self, MimeMessage}; use crate::mimeparser::{self, MimeMessage};
use crate::receive_imf::receive_imf; use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr; use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::test_utils::{ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager, AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, sync, TimeShiftFalsePositiveNote, sync,
@@ -3094,8 +3094,12 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
tcm.section("Alice creates broadcast channel with Bob."); tcm.section("Alice creates broadcast channel with Bob.");
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?; let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob0, alice, &qr).await; join_securejoin(bob0, &qr).await.unwrap();
sync(bob0, bob1).await; let request = bob0.pop_sent_msg().await;
alice.recv_msg(&request).await;
let answer = alice.pop_sent_msg().await;
bob0.recv_msg(&answer).await;
bob1.recv_msg(&answer).await;
tcm.section("Alice sends first message to broadcast."); tcm.section("Alice sends first message to broadcast.");
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;

View File

@@ -10,18 +10,22 @@ use crate::pgp;
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message. /// 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 is encrypted, returns a tuple of:
///
/// - The decrypted and decompressed message
/// - If the message was symmetrically encrypted:
/// The index in `shared_secrets` of the secret used to decrypt the message.
pub fn try_decrypt<'a>( pub fn try_decrypt<'a>(
mail: &'a ParsedMail<'a>, mail: &'a ParsedMail<'a>,
private_keyring: &'a [SignedSecretKey], private_keyring: &'a [SignedSecretKey],
symmetric_secrets: &[String], shared_secrets: &[String],
) -> Result<Option<::pgp::composed::Message<'static>>> { ) -> Result<Option<(::pgp::composed::Message<'static>, Option<usize>)>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else { let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None); return Ok(None);
}; };
let data = encrypted_data_part.get_body_raw()?; let data = encrypted_data_part.get_body_raw()?;
let msg = pgp::decrypt(data, private_keyring, symmetric_secrets)?; let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
Ok(Some(msg)) Ok(Some(msg))
} }

View File

@@ -1385,6 +1385,18 @@ impl Message {
pub fn error(&self) -> Option<String> { pub fn error(&self) -> Option<String> {
self.error.clone() self.error.clone()
} }
// TODO this function could be used a lot more
/// If this is a secure-join message,
/// returns the current step,
/// which is put into the `Secure-Join` header.
pub(crate) fn securejoin_step(&self) -> Option<&str> {
if self.param.get_cmd() == SystemMessage::SecurejoinMessage {
self.param.get(Param::Arg)
} else {
None
}
}
} }
/// State of the message. /// State of the message.

View File

@@ -329,7 +329,7 @@ impl MimeFactory {
if let Some(public_key) = public_key_opt { if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key)) keys.push((addr.clone(), public_key))
} else if id != ContactId::SELF && !chat.is_any_broadcast() { } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone()); missing_key_addresses.insert(addr.clone());
if is_encrypted { if is_encrypted {
warn!(context, "Missing key for {addr}"); warn!(context, "Missing key for {addr}");
@@ -350,7 +350,7 @@ impl MimeFactory {
if let Some(public_key) = public_key_opt { if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key)) keys.push((addr.clone(), public_key))
} else if id != ContactId::SELF && !chat.is_any_broadcast() { } else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone()); missing_key_addresses.insert(addr.clone());
if is_encrypted { if is_encrypted {
warn!(context, "Missing key for {addr}"); warn!(context, "Missing key for {addr}");
@@ -430,7 +430,7 @@ impl MimeFactory {
encryption_keys = if !is_encrypted { encryption_keys = if !is_encrypted {
None None
} else if chat.is_out_broadcast() { } else if should_encrypt_symmetrically(&msg, &chat) {
// Encrypt, but only symmetrically, not with the public keys. // Encrypt, but only symmetrically, not with the public keys.
Some(Vec::new()) Some(Vec::new())
} else { } else {
@@ -579,7 +579,7 @@ impl MimeFactory {
// messages are auto-sent unlike usual unencrypted messages. // messages are auto-sent unlike usual unencrypted messages.
step == "vg-request-with-auth" step == "vg-request-with-auth"
|| step == "vc-request-with-auth" || step == "vc-request-with-auth"
|| step == "vb-request-with-auth" || step == "vb-request-v2"
|| step == "vg-member-added" || step == "vg-member-added"
|| step == "vb-member-added" || step == "vb-member-added"
|| step == "vc-contact-confirm" || step == "vc-contact-confirm"
@@ -825,7 +825,7 @@ impl MimeFactory {
} else if let Loaded::Message { msg, .. } = &self.loaded { } else if let Loaded::Message { msg, .. } = &self.loaded {
if msg.param.get_cmd() == SystemMessage::SecurejoinMessage { if msg.param.get_cmd() == SystemMessage::SecurejoinMessage {
let step = msg.param.get(Param::Arg).unwrap_or_default(); 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-v2" {
headers.push(( headers.push((
"Auto-Submitted", "Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(), mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
@@ -1181,7 +1181,13 @@ impl MimeFactory {
}; };
let symmetric_key: Option<String> = match &self.loaded { let symmetric_key: Option<String> = match &self.loaded {
Loaded::Message { chat, .. } if chat.is_any_broadcast() => { Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => {
// TODO rather than setting Arg2, bob.rs could set a param `Param::SharedSecretForEncryption` or similar
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 symmetric key yet // If there is no symmetric key yet
// (because this is an old broadcast channel, // (because this is an old broadcast channel,
// created before we had symmetric encryption), // created before we had symmetric encryption),
@@ -1196,6 +1202,7 @@ impl MimeFactory {
let encrypted = if let Some(symmetric_key) = symmetric_key { let encrypted = if let Some(symmetric_key) = symmetric_key {
info!(context, "Symmetrically encrypting for broadcast channel."); info!(context, "Symmetrically encrypting for broadcast channel.");
info!(context, "secret: {symmetric_key}"); // TODO
encrypt_helper encrypt_helper
.encrypt_for_broadcast(context, &symmetric_key, message, compress) .encrypt_for_broadcast(context, &symmetric_key, message, compress)
.await? .await?
@@ -1547,7 +1554,7 @@ impl MimeFactory {
headers.push(( headers.push((
if step == "vg-request-with-auth" if step == "vg-request-with-auth"
|| step == "vc-request-with-auth" || step == "vc-request-with-auth"
|| step == "vb-request-with-auth" || step == "vb-request-v2"
{ {
"Secure-Join-Auth" "Secure-Join-Auth"
} else { } else {
@@ -1898,6 +1905,22 @@ fn hidden_recipients() -> Address<'static> {
Address::new_group(Some("hidden-recipients".to_string()), Vec::new()) Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
} }
fn should_encrypt_with_auth_token(msg: &Message) -> bool {
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
&& msg.param.get(Param::Arg).unwrap_or_default() == "vb-request-v2"
}
fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool {
chat.is_any_broadcast()
&& msg.param.get_cmd() != SystemMessage::SecurejoinMessage
// The member-added message in a broadcast must be asymmetrirally encrypted:
&& msg.param.get_cmd() != SystemMessage::MemberAddedToGroup
}
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>> { async fn build_body_file(context: &Context, msg: &Message) -> Result<MimePart<'static>> {
let file_name = msg.get_filename().context("msg has no file")?; let file_name = msg.get_filename().context("msg has no file")?;
let blob = msg let blob = msg

View File

@@ -18,7 +18,6 @@ use crate::authres::handle_authres;
use crate::blob::BlobObject; use crate::blob::BlobObject;
use crate::chat::ChatId; use crate::chat::ChatId;
use crate::config::Config; use crate::config::Config;
use crate::constants;
use crate::contact::ContactId; use crate::contact::ContactId;
use crate::context::Context; use crate::context::Context;
use crate::decrypt::{try_decrypt, validate_detached_signature}; 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, get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
}; };
use crate::{chatlist_events, location, stock_str, tools}; use crate::{chatlist_events, location, stock_str, tools};
use crate::{constants, token};
/// A parsed MIME message. /// A parsed MIME message.
/// ///
@@ -136,6 +136,10 @@ pub(crate) struct MimeMessage {
/// Sender timestamp in secs since epoch. Allowed to be in the future due to unsynchronized /// Sender timestamp in secs since epoch. Allowed to be in the future due to unsynchronized
/// clocks, but not too much. /// clocks, but not too much.
pub(crate) timestamp_sent: i64, pub(crate) timestamp_sent: i64,
/// How the message was encrypted (and now successfully decrypted):
/// The asymmetric key, an AUTH token, or a broadcast's shared secret.
pub(crate) was_encrypted_with: EncryptedWith,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
@@ -234,6 +238,25 @@ pub enum SystemMessage {
CallEnded = 67, CallEnded = 67,
} }
#[derive(Debug)]
pub(crate) enum EncryptedWith {
AsymmetricKey,
BroadcastSecret(String),
AuthToken(String),
None,
}
impl EncryptedWith {
pub(crate) fn auth_token(&self) -> Option<&str> {
match self {
EncryptedWith::AsymmetricKey => None,
EncryptedWith::BroadcastSecret(_) => None,
EncryptedWith::AuthToken(token) => Some(token),
EncryptedWith::None => None,
}
}
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup"; const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
impl MimeMessage { impl MimeMessage {
@@ -354,7 +377,7 @@ impl MimeMessage {
let mail_raw; // Memory location for a possible decrypted message. let mail_raw; // Memory location for a possible decrypted message.
let decrypted_msg; // Decrypted signed OpenPGP message. let decrypted_msg; // Decrypted signed OpenPGP message.
let symmetric_secrets: Vec<String> = context let mut secrets: Vec<String> = context
.sql .sql
.query_map( .query_map(
"SELECT secret FROM broadcasts_shared_secrets", "SELECT secret FROM broadcasts_shared_secrets",
@@ -366,11 +389,13 @@ impl MimeMessage {
}, },
) )
.await?; .await?;
let num_broadcast_secrets = secrets.len();
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
let (mail, is_encrypted) = match tokio::task::block_in_place(|| { let (mail, is_encrypted, decrypted_with) = match tokio::task::block_in_place(|| {
try_decrypt(&mail, &private_keyring, &symmetric_secrets) try_decrypt(&mail, &private_keyring, &secrets)
}) { }) {
Ok(Some(mut msg)) => { Ok(Some((mut msg, index_of_secret))) => {
mail_raw = msg.as_data_vec().unwrap_or_default(); mail_raw = msg.as_data_vec().unwrap_or_default();
let decrypted_mail = mailparse::parse_mail(&mail_raw)?; let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
@@ -397,18 +422,29 @@ impl MimeMessage {
aheader_value = Some(protected_aheader_value); aheader_value = Some(protected_aheader_value);
} }
(Ok(decrypted_mail), true) let decrypted_with = if let Some(index_of_secret) = index_of_secret {
let used_secret = secrets.into_iter().nth(index_of_secret).unwrap_or_default();
if index_of_secret < num_broadcast_secrets {
EncryptedWith::BroadcastSecret(used_secret)
} else {
EncryptedWith::AuthToken(used_secret)
}
} else {
EncryptedWith::AsymmetricKey
};
(Ok(decrypted_mail), true, decrypted_with)
} }
Ok(None) => { Ok(None) => {
mail_raw = Vec::new(); mail_raw = Vec::new();
decrypted_msg = None; decrypted_msg = None;
(Ok(mail), false) (Ok(mail), false, EncryptedWith::None)
} }
Err(err) => { Err(err) => {
mail_raw = Vec::new(); mail_raw = Vec::new();
decrypted_msg = None; decrypted_msg = None;
warn!(context, "decryption failed: {:#}", err); warn!(context, "decryption failed: {:#}", err);
(Err(err), false) (Err(err), false, EncryptedWith::None)
} }
}; };
@@ -620,6 +656,7 @@ impl MimeMessage {
is_bot: None, is_bot: None,
timestamp_rcvd, timestamp_rcvd,
timestamp_sent, timestamp_sent,
was_encrypted_with: decrypted_with,
}; };
match partial { match partial {

View File

@@ -103,6 +103,9 @@ pub enum Param {
/// removed from the group. /// 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', Arg = b'E',
/// For Messages /// For Messages
@@ -111,6 +114,9 @@ pub enum Param {
/// ///
/// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header. /// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header.
/// ///
/// For version two of the securejoin protocol (`vb-request-v2`),
/// this is the Auth token used to encrypt the message.
///
/// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced. /// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced.
/// ///
/// For [`SystemMessage::MemberAddedToGroup`], /// For [`SystemMessage::MemberAddedToGroup`],

View File

@@ -8,7 +8,7 @@ use chrono::SubsecRound;
use deltachat_contact_tools::EmailAddress; use deltachat_contact_tools::EmailAddress;
use pgp::armor::BlockType; use pgp::armor::BlockType;
use pgp::composed::{ use pgp::composed::{
ArmorOptions, Deserializable, KeyType as PgpKeyType, Message, MessageBuilder, ArmorOptions, Deserializable, InnerRingResult, KeyType as PgpKeyType, Message, MessageBuilder,
SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
StandaloneSignature, SubkeyParamsBuilder, TheRing, StandaloneSignature, SubkeyParamsBuilder, TheRing,
}; };
@@ -239,13 +239,19 @@ pub fn pk_calc_signature(
/// Decrypts the message with keys from the private key keyring. /// Decrypts the message with keys from the private key keyring.
/// ///
/// Receiver private keys are provided in /// Receiver private keys are passed in `private_keys_for_decryption`,
/// `private_keys_for_decryption`. /// shared secrets used for symmetric encryption
/// are passed in `shared_secrets`.
///
/// Returns a tuple of:
/// - The decrypted and decompressed message
/// - If the message was symmetrically encrypted:
/// The index in `shared_secrets` of the secret used to decrypt the message.
pub fn decrypt( pub fn decrypt(
ctext: Vec<u8>, ctext: Vec<u8>,
private_keys_for_decryption: &[SignedSecretKey], private_keys_for_decryption: &[SignedSecretKey],
symmetric_secrets: &[String], shared_secrets: &[String],
) -> Result<pgp::composed::Message<'static>> { ) -> Result<(pgp::composed::Message<'static>, Option<usize>)> {
let cursor = Cursor::new(ctext); let cursor = Cursor::new(ctext);
let (msg, _headers) = Message::from_armor(cursor)?; let (msg, _headers) = Message::from_armor(cursor)?;
@@ -253,7 +259,7 @@ pub fn decrypt(
let empty_pw = Password::empty(); let empty_pw = Password::empty();
// TODO it may degrade performance that we always try out all passwords here // TODO it may degrade performance that we always try out all passwords here
let message_password: Vec<Password> = symmetric_secrets let message_password: Vec<Password> = shared_secrets
.iter() .iter()
.map(|p| Password::from(p.as_str())) .map(|p| Password::from(p.as_str()))
.collect(); .collect();
@@ -266,12 +272,17 @@ pub fn decrypt(
session_keys: vec![], session_keys: vec![],
allow_legacy: false, allow_legacy: false,
}; };
let (msg, _ring_result) = msg.decrypt_the_ring(ring, true)?; let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?;
// remove one layer of compression // remove one layer of compression
let msg = msg.decompress()?; let msg = msg.decompress()?;
Ok(msg) let decrypted_with_secret = ring_result
.message_password
.iter()
.position(|&p| p == InnerRingResult::Ok);
Ok((msg, decrypted_with_secret))
} }
/// Returns fingerprints /// Returns fingerprints
@@ -407,7 +418,7 @@ mod tests {
HashSet<Fingerprint>, HashSet<Fingerprint>,
Vec<u8>, Vec<u8>,
)> { )> {
let mut msg = 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 content = msg.as_data_vec()?;
let ret_signature_fingerprints = let ret_signature_fingerprints =
valid_signature_fingerprints(&msg, public_keys_for_validation); valid_signature_fingerprints(&msg, public_keys_for_validation);
@@ -611,13 +622,14 @@ mod tests {
.await?; .await?;
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let mut decrypted = decrypt( let (mut decrypted, index_of_secret) = decrypt(
ctext.into(), ctext.into(),
&bob_private_keyring, &bob_private_keyring,
&[shared_secret.to_string()], &[shared_secret.to_string()],
)?; )?;
assert_eq!(decrypted.as_data_vec()?, plain); assert_eq!(decrypted.as_data_vec()?, plain);
assert_eq!(index_of_secret, Some(0));
Ok(()) Ok(())
} }

View File

@@ -296,7 +296,7 @@ pub(crate) async fn handle_securejoin_handshake(
// -> or just ignore the problem for now - we will need to solve it for all messages anyways: https://github.com/chatmail/core/issues/7057 // -> or just ignore the problem for now - we will need to solve it for all messages anyways: https://github.com/chatmail/core/issues/7057
if !matches!( if !matches!(
step, step,
"vg-request" | "vc-request" | "vb-request-with-auth" | "vb-member-added" "vg-request" | "vc-request" | "vb-request-v2" | "vb-member-added"
) { ) {
let mut self_found = false; let mut self_found = false;
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
@@ -367,7 +367,7 @@ pub(crate) async fn handle_securejoin_handshake(
========================================================*/ ========================================================*/
bob::handle_auth_required(context, mime_message).await bob::handle_auth_required(context, mime_message).await
} }
"vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => { "vg-request-with-auth" | "vc-request-with-auth" | "vb-request-v2" => {
/*========================================================== /*==========================================================
==== Alice - the inviter side ==== ==== Alice - the inviter side ====
==== Steps 5+6 in "Setup verified contact" protocol ==== ==== Steps 5+6 in "Setup verified contact" protocol ====
@@ -390,8 +390,12 @@ pub(crate) async fn handle_securejoin_handshake(
); );
return Ok(HandshakeMessage::Ignore); 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,
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else { // or that the message was encrypted with the secret written to the QR code.
let auth = mime_message
.get_header(HeaderDef::SecureJoinAuth)
.or_else(|| mime_message.was_encrypted_with.auth_token());
let Some(auth) = auth else {
warn!( warn!(
context, context,
"Ignoring {step} message because of missing auth code." "Ignoring {step} message because of missing auth code."
@@ -447,9 +451,16 @@ pub(crate) async fn handle_securejoin_handshake(
.await?; .await?;
inviter_progress(context, contact_id, 800); inviter_progress(context, contact_id, 800);
inviter_progress(context, contact_id, 1000); inviter_progress(context, contact_id, 1000);
if step.starts_with("vb-") {
// 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::Propagate)
} else {
// IMAP-delete the message to avoid handling it by another device and adding the // 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. // member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done) Ok(HandshakeMessage::Done)
}
} else { } else {
// Setup verified contact. // Setup verified contact.
secure_connection_established( secure_connection_established(
@@ -594,7 +605,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
inviter_progress(context, contact_id, 1000); inviter_progress(context, contact_id, 1000);
} }
// TODO not sure if I should ad vb-request-with-auth here // TODO not sure if I should add vb-request-v2 here
// Actually, I'm not even sure why vg-request-with-auth is here - why do we create a 1:1 chat?? // Actually, I'm not even sure why vg-request-with-auth is here - why do we create a 1:1 chat??
if step == "vg-request-with-auth" || step == "vc-request-with-auth" { if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
// This actually reflects what happens on the first device (which does the secure // This actually reflects what happens on the first device (which does the secure

View File

@@ -1,18 +1,16 @@
//! Bob's side of SecureJoin handling, the joiner-side. //! 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::HandshakeMessage;
use super::qrinvite::QrInvite; use super::qrinvite::QrInvite;
use crate::chat::{ use crate::chat::{self, ChatId, ProtectionStatus, is_contact_in_chat};
self, ChatId, ProtectionStatus, is_contact_in_chat, save_broadcast_shared_secret,
};
use crate::constants::{Blocked, Chattype}; use crate::constants::{Blocked, Chattype};
use crate::contact::Origin; use crate::contact::Origin;
use crate::context::Context; use crate::context::Context;
use crate::events::EventType; use crate::events::EventType;
use crate::key::self_fingerprint; use crate::key::self_fingerprint;
use crate::log::{LogExt as _, info}; use crate::log::info;
use crate::message::{Message, Viewtype}; use crate::message::{Message, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param; use crate::param::Param;
@@ -51,63 +49,48 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
QrInvite::Group { .. } => Blocked::Yes, QrInvite::Group { .. } => Blocked::Yes,
QrInvite::Broadcast { .. } => Blocked::Yes, QrInvite::Broadcast { .. } => Blocked::Yes,
}; };
let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
// The 1:1 chat with the inviter
let private_chat_id =
ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
.await .await
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?; .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?; ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
context.emit_event(EventType::ContactsChanged(None)); context.emit_event(EventType::ContactsChanged(None));
if let QrInvite::Broadcast { if invite.is_v2() {
shared_secret, if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
grpid,
broadcast_name,
..
} = &invite
{ {
// TODO this causes some performance penalty because joining_chat_id is used again below, bail!("V2 protocol failed because of fingerprint mismatch");
// but maybe it's fine }
let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?;
save_broadcast_shared_secret(context, broadcast_chat_id, shared_secret).await?;
let id = chat::SyncId::Grpid(grpid.to_string());
let action = chat::SyncAction::CreateInBroadcast {
chat_name: broadcast_name.to_string(),
shared_secret: shared_secret.to_string(),
};
chat::sync(context, id, action).await.log_err(context).ok();
if verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await? {
info!(context, "Using fast securejoin with symmetric encryption"); info!(context, "Using fast securejoin with symmetric encryption");
// The message has to be sent into the broadcast chat, rather than the 1:1 chat, let mut msg = Message {
// so that it will be symmetrically encrypted viewtype: Viewtype::Text,
send_handshake_message( text: "Secure-Join: vb-request-v2".to_string(),
context, hidden: true,
&invite, ..Default::default()
broadcast_chat_id, };
BobHandshakeMsg::RequestWithAuth, msg.param.set_cmd(SystemMessage::SecurejoinMessage);
)
.await?;
// Mark 1:1 chat as verified already. msg.param.set(Param::Arg, "vb-request-v2");
chat_id msg.param.set(Param::Arg2, invite.authcode());
.set_protection( msg.param.set_int(Param::GuaranteeE2ee, 1);
context, let bob_fp = self_fingerprint(context).await?;
ProtectionStatus::Protected, msg.param.set(Param::Arg3, bob_fp);
time(),
Some(invite.contact_id()), chat::send_msg(context, private_chat_id, &mut msg).await?;
)
.await?;
context.emit_event(EventType::SecurejoinJoinerProgress { context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id: invite.contact_id(), contact_id: invite.contact_id(),
progress: JoinerProgress::RequestWithAuthSent.to_usize(), progress: JoinerProgress::RequestWithAuthSent.to_usize(),
}); });
}
} else { } else {
// Start the original (non-broadcast) protocol and initialise the state. // Start the version 1 protocol and initialise the state.
let has_key = context let has_key = context
.sql .sql
.exists( .exists(
@@ -122,11 +105,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. // The scanned fingerprint matches Alice's key, we can proceed to step 4b.
info!(context, "Taking securejoin protocol shortcut"); info!(context, "Taking securejoin protocol shortcut");
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth) send_handshake_message(
context,
&invite,
private_chat_id,
BobHandshakeMsg::RequestWithAuth,
)
.await?; .await?;
// Mark 1:1 chat as verified already. // Mark 1:1 chat as verified already.
chat_id private_chat_id
.set_protection( .set_protection(
context, context,
ProtectionStatus::Protected, ProtectionStatus::Protected,
@@ -140,9 +128,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
progress: JoinerProgress::RequestWithAuthSent.to_usize(), progress: JoinerProgress::RequestWithAuthSent.to_usize(),
}); });
} else { } 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?;
} }
} }
@@ -150,28 +139,26 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
QrInvite::Group { .. } => { QrInvite::Group { .. } => {
// For a secure-join we need to create the group and add the contact. The group will // 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. // 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, joining_chat_id, invite.contact_id()).await? {
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
chat::add_to_chat_contacts_table( chat::add_to_chat_contacts_table(
context, context,
time(), time(),
group_chat_id, joining_chat_id,
&[invite.contact_id()], &[invite.contact_id()],
) )
.await?; .await?;
} }
let msg = stock_str::secure_join_started(context, 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?; chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
Ok(group_chat_id) Ok(joining_chat_id)
} }
QrInvite::Broadcast { .. } => { QrInvite::Broadcast { .. } => {
// TODO code duplication with previous block // TODO code duplication with previous block
let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?; if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
if !is_contact_in_chat(context, broadcast_chat_id, invite.contact_id()).await? {
chat::add_to_chat_contacts_table( chat::add_to_chat_contacts_table(
context, context,
time(), time(),
broadcast_chat_id, joining_chat_id,
&[invite.contact_id()], &[invite.contact_id()],
) )
.await?; .await?;
@@ -179,8 +166,8 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
// TODO this message should be translatable: // TODO this message should be translatable:
let msg = "You were invited to join this channel. Waiting for the channel owner's device to reply…"; let msg = "You were invited to join this channel. Waiting for the channel owner's device to reply…";
chat::add_info_msg(context, broadcast_chat_id, msg, time()).await?; chat::add_info_msg(context, joining_chat_id, msg, time()).await?;
Ok(broadcast_chat_id) Ok(joining_chat_id)
} }
QrInvite::Contact { .. } => { QrInvite::Contact { .. } => {
// For setup-contact the BobState already ensured the 1:1 chat exists because it // For setup-contact the BobState already ensured the 1:1 chat exists because it
@@ -189,14 +176,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. // race with its change, we don't add our message below the protection message.
let sort_to_bottom = true; let sort_to_bottom = true;
let (received, incoming) = (false, false); 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) .calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming)
.await?; .await?;
if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected { if private_chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
let ts_start = time(); let ts_start = time();
chat::add_info_msg_with_cmd( chat::add_info_msg_with_cmd(
context, context,
chat_id, private_chat_id,
&stock_str::securejoin_wait(context).await, &stock_str::securejoin_wait(context).await,
SystemMessage::SecurejoinWait, SystemMessage::SecurejoinWait,
ts_sort, ts_sort,
@@ -207,7 +194,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
) )
.await?; .await?;
} }
Ok(chat_id) Ok(private_chat_id)
} }
} }
} }
@@ -334,7 +321,7 @@ pub(crate) async fn send_handshake_message(
match step { match step {
BobHandshakeMsg::Request => { BobHandshakeMsg::Request => {
// Sends the Secure-Join-Invitenumber header in mimefactory.rs. // 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(); msg.force_plaintext();
} }
BobHandshakeMsg::RequestWithAuth => { BobHandshakeMsg::RequestWithAuth => {
@@ -368,7 +355,7 @@ pub(crate) async fn send_handshake_message(
pub(crate) enum BobHandshakeMsg { pub(crate) enum BobHandshakeMsg {
/// vc-request or vg-request /// vc-request or vg-request
Request, Request,
/// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth /// vc-request-with-auth, vg-request-with-auth, or vb-request-v2
RequestWithAuth, RequestWithAuth,
} }
@@ -399,7 +386,9 @@ impl BobHandshakeMsg {
Self::RequestWithAuth => match invite { Self::RequestWithAuth => match invite {
QrInvite::Contact { .. } => "vc-request-with-auth", QrInvite::Contact { .. } => "vc-request-with-auth",
QrInvite::Group { .. } => "vg-request-with-auth", QrInvite::Group { .. } => "vg-request-with-auth",
QrInvite::Broadcast { .. } => "vb-request-with-auth", QrInvite::Broadcast { .. } => {
panic!("There is no request-with-auth for broadcasts")
} // TODO remove panic
}, },
} }
} }

View File

@@ -18,7 +18,7 @@ pub enum QrInvite {
Contact { Contact {
contact_id: ContactId, contact_id: ContactId,
fingerprint: Fingerprint, fingerprint: Fingerprint,
invitenumber: String, invitenumber: Option<String>,
authcode: String, authcode: String,
}, },
Group { Group {
@@ -26,7 +26,7 @@ pub enum QrInvite {
fingerprint: Fingerprint, fingerprint: Fingerprint,
name: String, name: String,
grpid: String, grpid: String,
invitenumber: String, invitenumber: Option<String>,
authcode: String, authcode: String,
}, },
Broadcast { Broadcast {
@@ -62,10 +62,12 @@ impl QrInvite {
} }
/// The `INVITENUMBER` of the setup-contact/secure-join protocol. /// The `INVITENUMBER` of the setup-contact/secure-join protocol.
pub fn invitenumber(&self) -> &str { pub fn invitenumber(&self) -> Option<&str> {
match self { match self {
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber, Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => {
Self::Broadcast { .. } => panic!("broadcast invite has no invite number"), // TODO panic invitenumber.as_deref()
}
Self::Broadcast { .. } => None,
} }
} }
@@ -77,6 +79,17 @@ impl QrInvite {
| Self::Broadcast { authcode, .. } => 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 { impl TryFrom<Qr> for QrInvite {
@@ -92,7 +105,7 @@ impl TryFrom<Qr> for QrInvite {
} => Ok(QrInvite::Contact { } => Ok(QrInvite::Contact {
contact_id, contact_id,
fingerprint, fingerprint,
invitenumber, invitenumber: Some(invitenumber),
authcode, authcode,
}), }),
Qr::AskVerifyGroup { Qr::AskVerifyGroup {
@@ -107,7 +120,7 @@ impl TryFrom<Qr> for QrInvite {
fingerprint, fingerprint,
name: grpname, name: grpname,
grpid, grpid,
invitenumber, invitenumber: Some(invitenumber),
authcode, authcode,
}), }),
Qr::AskJoinBroadcast { Qr::AskJoinBroadcast {

View File

@@ -61,6 +61,21 @@ pub async fn lookup(
.await .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( pub async fn lookup_or_new(
context: &Context, context: &Context,
namespace: Namespace, namespace: Namespace,

View File

@@ -1,5 +1,5 @@
InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png InBroadcast#Chat#11: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
Msg#12: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO] Msg#11: info (Contact#Contact#Info): You were invited to join this channel. Waiting for the channel owner's device to reply… [NOTICED][INFO]
Msg#13🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO] Msg#12🔒: (Contact#Contact#10): Member Me added by Alice. [FRESH][INFO]
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------