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

@@ -1,18 +1,16 @@
//! 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;
use crate::chat::{
self, ChatId, ProtectionStatus, is_contact_in_chat, save_broadcast_shared_secret,
};
use crate::chat::{self, ChatId, ProtectionStatus, is_contact_in_chat};
use crate::constants::{Blocked, Chattype};
use crate::contact::Origin;
use crate::context::Context;
use crate::events::EventType;
use crate::key::self_fingerprint;
use crate::log::{LogExt as _, info};
use crate::log::info;
use crate::message::{Message, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
@@ -51,63 +49,48 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
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));
if let QrInvite::Broadcast {
shared_secret,
grpid,
broadcast_name,
..
} = &invite
{
// TODO this causes some performance penalty because joining_chat_id is used again below,
// 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");
// The message has to be sent into the broadcast chat, rather than the 1:1 chat,
// so that it will be symmetrically encrypted
send_handshake_message(
context,
&invite,
broadcast_chat_id,
BobHandshakeMsg::RequestWithAuth,
)
.await?;
// Mark 1:1 chat as verified already.
chat_id
.set_protection(
context,
ProtectionStatus::Protected,
time(),
Some(invite.contact_id()),
)
.await?;
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id: invite.contact_id(),
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
});
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");
let mut msg = Message {
viewtype: Viewtype::Text,
text: "Secure-Join: vb-request-v2".to_string(),
hidden: true,
..Default::default()
};
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
msg.param.set(Param::Arg, "vb-request-v2");
msg.param.set(Param::Arg2, invite.authcode());
msg.param.set_int(Param::GuaranteeE2ee, 1);
let bob_fp = self_fingerprint(context).await?;
msg.param.set(Param::Arg3, bob_fp);
chat::send_msg(context, private_chat_id, &mut msg).await?;
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id: invite.contact_id(),
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
});
} else {
// Start the original (non-broadcast) protocol and initialise the state.
// Start the version 1 protocol and initialise the state.
let has_key = context
.sql
.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.
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,
@@ -140,9 +128,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?;
}
}
@@ -150,28 +139,26 @@ 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 { .. } => {
// TODO code duplication with previous block
let broadcast_chat_id = joining_chat_id(context, &invite, chat_id).await?;
if !is_contact_in_chat(context, broadcast_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(),
broadcast_chat_id,
joining_chat_id,
&[invite.contact_id()],
)
.await?;
@@ -179,8 +166,8 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
// TODO this message should be translatable:
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?;
Ok(broadcast_chat_id)
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
@@ -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.
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,
@@ -207,7 +194,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
)
.await?;
}
Ok(chat_id)
Ok(private_chat_id)
}
}
}
@@ -334,7 +321,7 @@ pub(crate) async fn send_handshake_message(
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 => {
@@ -368,7 +355,7 @@ pub(crate) async fn send_handshake_message(
pub(crate) enum BobHandshakeMsg {
/// vc-request or vg-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,
}
@@ -399,7 +386,9 @@ impl BobHandshakeMsg {
Self::RequestWithAuth => match invite {
QrInvite::Contact { .. } => "vc-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_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
invitenumber: Option<String>,
authcode: String,
},
Group {
@@ -26,7 +26,7 @@ pub enum QrInvite {
fingerprint: Fingerprint,
name: String,
grpid: String,
invitenumber: String,
invitenumber: Option<String>,
authcode: String,
},
Broadcast {
@@ -62,10 +62,12 @@ impl QrInvite {
}
/// 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::Broadcast { .. } => panic!("broadcast invite has no invite number"), // TODO panic
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => {
invitenumber.as_deref()
}
Self::Broadcast { .. } => None,
}
}
@@ -77,6 +79,17 @@ impl QrInvite {
| 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 {
@@ -92,7 +105,7 @@ impl TryFrom<Qr> for QrInvite {
} => Ok(QrInvite::Contact {
contact_id,
fingerprint,
invitenumber,
invitenumber: Some(invitenumber),
authcode,
}),
Qr::AskVerifyGroup {
@@ -107,7 +120,7 @@ impl TryFrom<Qr> for QrInvite {
fingerprint,
name: grpname,
grpid,
invitenumber,
invitenumber: Some(invitenumber),
authcode,
}),
Qr::AskJoinBroadcast {