mirror of
https://github.com/chatmail/core.git
synced 2026-04-20 15:06:30 +03:00
Add a stock string `%1$s invited you to join this channel.\n\nWaiting
for the device of %2$s to reply…`, which is shown when a user starts to
join a channel.
I did _not_ add an equivalent to `%1$s replied, waiting for being added
to the group…`, which is shown when vg-auth-required was received. I
don't think that this would add any information that's interesting to
the user, other than 'something is happening, hang on'. And the more
text on the screen, the less likely that anyone reads it. But if others
think differently, we can also add it.
With this PR, joining a channel looks like this:
```
Msg#2003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#2004: info (Contact#Contact#Info): Alice invited you to join this channel.
Waiting for the device of Alice to reply… [NOTICED][INFO]
Msg#2007🔒: (Contact#Contact#2001): You joined the channel. [FRESH][INFO]
```
417 lines
15 KiB
Rust
417 lines
15 KiB
Rust
//! Bob's side of SecureJoin handling, the joiner-side.
|
|
|
|
use anyhow::{Context as _, Result};
|
|
|
|
use super::HandshakeMessage;
|
|
use super::qrinvite::QrInvite;
|
|
use crate::chat::{self, ChatId, 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::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::{smeared_time, time};
|
|
|
|
/// Starts the securejoin protocol with the QR `invite`.
|
|
///
|
|
/// This will try to start the securejoin protocol for the given QR `invite`.
|
|
///
|
|
/// If Bob already has Alice's key, he sends `AUTH` token
|
|
/// and forgets about the invite.
|
|
/// If Bob does not yet have Alice's key, he sends `vc-request`
|
|
/// or `vg-request` message and stores a row in the `bobstate` table
|
|
/// so he can check Alice's key against the fingerprint
|
|
/// and send `AUTH` token later.
|
|
///
|
|
/// This function takes care of handling multiple concurrent joins and handling errors while
|
|
/// starting the protocol.
|
|
///
|
|
/// # Bob - the joiner's side
|
|
/// ## Step 2 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1
|
|
/// chat with Alice, for a SecureJoin QR this is the group chat.
|
|
pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Result<ChatId> {
|
|
// A 1:1 chat is needed to send messages to Alice. When joining a group this chat is
|
|
// hidden, if a user starts sending messages in it it will be unhidden in
|
|
// receive_imf.
|
|
let hidden = match invite {
|
|
QrInvite::Contact { .. } => Blocked::Not,
|
|
QrInvite::Group { .. } => Blocked::Yes,
|
|
QrInvite::Broadcast { .. } => Blocked::Yes,
|
|
};
|
|
|
|
// The 1:1 chat with the inviter
|
|
let private_chat_id =
|
|
ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
|
|
.await
|
|
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
|
|
|
|
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
|
|
context.emit_event(EventType::ContactsChanged(None));
|
|
|
|
// Now start the protocol and initialise the state.
|
|
{
|
|
let has_key = context
|
|
.sql
|
|
.exists(
|
|
"SELECT COUNT(*) FROM public_keys WHERE fingerprint=?",
|
|
(invite.fingerprint().hex(),),
|
|
)
|
|
.await?;
|
|
|
|
// `joining_chat_id` is `Some` if group chat
|
|
// already exists and we are in the chat.
|
|
let joining_chat_id = match invite {
|
|
QrInvite::Group { ref grpid, .. } | QrInvite::Broadcast { ref grpid, .. } => {
|
|
if let Some((joining_chat_id, _blocked)) =
|
|
chat::get_chat_id_by_grpid(context, grpid).await?
|
|
{
|
|
if is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? {
|
|
Some(joining_chat_id)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
QrInvite::Contact { .. } => None,
|
|
};
|
|
|
|
if let Some(joining_chat_id) = joining_chat_id {
|
|
// If QR code is a group invite
|
|
// and we are already in the chat,
|
|
// nothing needs to be done.
|
|
// Even if Alice is not verified, we don't send anything.
|
|
context.emit_event(EventType::SecurejoinJoinerProgress {
|
|
contact_id: invite.contact_id(),
|
|
progress: JoinerProgress::Succeeded.to_usize(),
|
|
});
|
|
return Ok(joining_chat_id);
|
|
} else if has_key
|
|
&& verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id())
|
|
.await?
|
|
{
|
|
// The scanned fingerprint matches Alice's key, we can proceed to step 4b.
|
|
info!(context, "Taking securejoin protocol shortcut");
|
|
send_handshake_message(
|
|
context,
|
|
&invite,
|
|
private_chat_id,
|
|
BobHandshakeMsg::RequestWithAuth,
|
|
)
|
|
.await?;
|
|
|
|
context.emit_event(EventType::SecurejoinJoinerProgress {
|
|
contact_id: invite.contact_id(),
|
|
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
|
|
});
|
|
} else {
|
|
send_handshake_message(context, &invite, private_chat_id, BobHandshakeMsg::Request)
|
|
.await?;
|
|
|
|
insert_new_db_entry(context, invite.clone(), private_chat_id).await?;
|
|
}
|
|
}
|
|
|
|
match invite {
|
|
QrInvite::Group { .. } => {
|
|
let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?;
|
|
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
|
|
chat::add_info_msg(context, joining_chat_id, &msg).await?;
|
|
Ok(joining_chat_id)
|
|
}
|
|
QrInvite::Broadcast { .. } => {
|
|
let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?;
|
|
// We created the broadcast channel already, now we need to add Alice to it.
|
|
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
|
|
chat::add_to_chat_contacts_table(
|
|
context,
|
|
time(),
|
|
joining_chat_id,
|
|
&[invite.contact_id()],
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// If we were not in the broadcast channel before, show a 'please wait' info message.
|
|
if !is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? {
|
|
let msg =
|
|
stock_str::secure_join_broadcast_started(context, invite.contact_id()).await;
|
|
chat::add_info_msg(context, joining_chat_id, &msg).await?;
|
|
}
|
|
Ok(joining_chat_id)
|
|
}
|
|
QrInvite::Contact { .. } => {
|
|
// For setup-contact the BobState already ensured the 1:1 chat exists because it
|
|
// uses it to send the handshake messages.
|
|
chat::add_info_msg_with_cmd(
|
|
context,
|
|
private_chat_id,
|
|
&stock_str::securejoin_wait(context).await,
|
|
SystemMessage::SecurejoinWait,
|
|
None,
|
|
time(),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
Ok(private_chat_id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inserts a new entry in the bobstate table.
|
|
///
|
|
/// Returns the ID of the newly inserted entry.
|
|
async fn insert_new_db_entry(context: &Context, invite: QrInvite, chat_id: ChatId) -> Result<i64> {
|
|
context
|
|
.sql
|
|
.insert(
|
|
"INSERT INTO bobstate (invite, next_step, chat_id) VALUES (?, ?, ?);",
|
|
(invite, 0, chat_id),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Handles `vc-auth-required` and `vg-auth-required` handshake messages.
|
|
///
|
|
/// # Bob - the joiner's side
|
|
/// ## Step 4 in the "Setup Contact protocol"
|
|
pub(super) async fn handle_auth_required(
|
|
context: &Context,
|
|
message: &MimeMessage,
|
|
) -> Result<HandshakeMessage> {
|
|
// Load all Bob states that expect `vc-auth-required` or `vg-auth-required`.
|
|
let bob_states = context
|
|
.sql
|
|
.query_map_vec("SELECT id, invite, chat_id FROM bobstate", (), |row| {
|
|
let row_id: i64 = row.get(0)?;
|
|
let invite: QrInvite = row.get(1)?;
|
|
let chat_id: ChatId = row.get(2)?;
|
|
Ok((row_id, invite, chat_id))
|
|
})
|
|
.await?;
|
|
|
|
info!(
|
|
context,
|
|
"Bob Step 4 - handling {{vc,vg}}-auth-required message."
|
|
);
|
|
|
|
let mut auth_sent = false;
|
|
for (bobstate_row_id, invite, chat_id) in bob_states {
|
|
if !encrypted_and_signed(context, message, invite.fingerprint()) {
|
|
continue;
|
|
}
|
|
|
|
if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
|
|
{
|
|
continue;
|
|
}
|
|
|
|
info!(context, "Fingerprint verified.",);
|
|
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth).await?;
|
|
context
|
|
.sql
|
|
.execute("DELETE FROM bobstate WHERE id=?", (bobstate_row_id,))
|
|
.await?;
|
|
|
|
match invite {
|
|
QrInvite::Contact { .. } | QrInvite::Broadcast { .. } => {}
|
|
QrInvite::Group { .. } => {
|
|
// The message reads "Alice replied, waiting to be added to the group…",
|
|
// so only show it when joining a group and not for a 1:1 chat or broadcast channel.
|
|
let contact_id = invite.contact_id();
|
|
let msg = stock_str::secure_join_replies(context, contact_id).await;
|
|
let chat_id = joining_chat_id(context, &invite, chat_id).await?;
|
|
chat::add_info_msg(context, chat_id, &msg).await?;
|
|
}
|
|
}
|
|
|
|
context.emit_event(EventType::SecurejoinJoinerProgress {
|
|
contact_id: invite.contact_id(),
|
|
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
|
|
});
|
|
|
|
auth_sent = true;
|
|
}
|
|
|
|
if auth_sent {
|
|
// Delete the message from IMAP server.
|
|
Ok(HandshakeMessage::Done)
|
|
} else {
|
|
// We have not found any corresponding AUTH codes,
|
|
// maybe another Bob device has scanned the QR code.
|
|
// Leave the message on IMAP server and let the other device
|
|
// process it.
|
|
Ok(HandshakeMessage::Ignore)
|
|
}
|
|
}
|
|
|
|
/// Sends the requested handshake message to Alice.
|
|
pub(crate) async fn send_handshake_message(
|
|
context: &Context,
|
|
invite: &QrInvite,
|
|
chat_id: ChatId,
|
|
step: BobHandshakeMsg,
|
|
) -> Result<()> {
|
|
let mut msg = Message {
|
|
viewtype: Viewtype::Text,
|
|
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));
|
|
|
|
match step {
|
|
BobHandshakeMsg::Request => {
|
|
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
|
msg.param.set(Param::Arg2, invite.invitenumber());
|
|
msg.force_plaintext();
|
|
}
|
|
BobHandshakeMsg::RequestWithAuth => {
|
|
// Sends the Secure-Join-Auth header in mimefactory.rs.
|
|
msg.param.set(Param::Arg2, invite.authcode());
|
|
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
|
|
|
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
|
|
let bob_fp = self_fingerprint(context).await?;
|
|
msg.param.set(Param::Arg3, bob_fp);
|
|
|
|
// Sends the grpid in the Secure-Join-Group header.
|
|
//
|
|
// `Secure-Join-Group` header is deprecated,
|
|
// but old Delta Chat core requires that Alice receives it.
|
|
//
|
|
// Previous Delta Chat core also sent `Secure-Join-Group` header
|
|
// in `vg-request` messages,
|
|
// but it was not used on the receiver.
|
|
if let QrInvite::Group { grpid, .. } = invite {
|
|
msg.param.set(Param::Arg4, grpid);
|
|
}
|
|
}
|
|
};
|
|
|
|
chat::send_msg(context, chat_id, &mut msg).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Identifies the SecureJoin handshake messages Bob can send.
|
|
pub(crate) enum BobHandshakeMsg {
|
|
/// vc-request or vg-request
|
|
Request,
|
|
/// vc-request-with-auth or vg-request-with-auth
|
|
RequestWithAuth,
|
|
}
|
|
|
|
impl BobHandshakeMsg {
|
|
/// Returns the text to send in the body of the handshake message.
|
|
///
|
|
/// 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))
|
|
}
|
|
|
|
/// Returns the `Secure-Join` header value.
|
|
///
|
|
/// 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 {
|
|
Self::Request => match invite {
|
|
QrInvite::Contact { .. } => "vc-request",
|
|
QrInvite::Group { .. } => "vg-request",
|
|
QrInvite::Broadcast { .. } => "vg-request",
|
|
},
|
|
Self::RequestWithAuth => match invite {
|
|
QrInvite::Contact { .. } => "vc-request-with-auth",
|
|
QrInvite::Group { .. } => "vg-request-with-auth",
|
|
QrInvite::Broadcast { .. } => "vg-request-with-auth",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the [`ChatId`] of the chat being joined.
|
|
///
|
|
/// This is the chat in which you want to notify the user as well.
|
|
///
|
|
/// When joining a group this is the [`ChatId`] of the group chat, when verifying a
|
|
/// contact this is the [`ChatId`] of the 1:1 chat.
|
|
/// The group chat will be created if it does not yet exist.
|
|
async fn joining_chat_id(
|
|
context: &Context,
|
|
invite: &QrInvite,
|
|
alice_chat_id: ChatId,
|
|
) -> Result<ChatId> {
|
|
match invite {
|
|
QrInvite::Contact { .. } => Ok(alice_chat_id),
|
|
QrInvite::Group { grpid, name, .. } | QrInvite::Broadcast { name, grpid, .. } => {
|
|
let chattype = if matches!(invite, QrInvite::Group { .. }) {
|
|
Chattype::Group
|
|
} else {
|
|
Chattype::InBroadcast
|
|
};
|
|
|
|
let chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
|
|
Some((chat_id, _blocked)) => {
|
|
chat_id.unblock_ex(context, Nosync).await?;
|
|
chat_id
|
|
}
|
|
None => {
|
|
ChatId::create_multiuser_record(
|
|
context,
|
|
chattype,
|
|
grpid,
|
|
name,
|
|
Blocked::Not,
|
|
None,
|
|
smeared_time(context),
|
|
)
|
|
.await?
|
|
}
|
|
};
|
|
Ok(chat_id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Progress updates for [`EventType::SecurejoinJoinerProgress`].
|
|
///
|
|
/// This has an `From<JoinerProgress> for usize` impl yielding numbers between 0 and a 1000
|
|
/// which can be shown as a progress bar.
|
|
pub(crate) enum JoinerProgress {
|
|
/// vg-vc-request-with-auth sent.
|
|
///
|
|
/// Typically shows as "alice@addr verified, introducing myself."
|
|
RequestWithAuthSent,
|
|
/// Completed securejoin.
|
|
Succeeded,
|
|
}
|
|
|
|
impl JoinerProgress {
|
|
#[expect(clippy::wrong_self_convention)]
|
|
pub(crate) fn to_usize(self) -> usize {
|
|
match self {
|
|
JoinerProgress::RequestWithAuthSent => 400,
|
|
JoinerProgress::Succeeded => 1000,
|
|
}
|
|
}
|
|
}
|