Skip to content

Commit

Permalink
feat: Make one-to-one chats read-only the first seconds of a SecureJo…
Browse files Browse the repository at this point in the history
…in (#5512)
  • Loading branch information
iequidoo committed May 10, 2024
1 parent 8a4dff2 commit 3d02125
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 23 deletions.
10 changes: 10 additions & 0 deletions deltachat-jsonrpc/src/api/types/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,14 @@ pub enum SystemMessageType {
LocationOnly,
InvalidUnencryptedMail,

/// 1:1 chats info message telling that SecureJoin has started and the user should wait for it
/// to complete.
SecurejoinWait,

/// 1:1 chats info message telling that SecureJoin is still running, but the user may already
/// send messages.
SecurejoinWaitTimeout,

/// Chat ephemeral message timer is changed.
EphemeralTimerChanged,

Expand Down Expand Up @@ -382,6 +390,8 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::WebxdcStatusUpdate => SystemMessageType::WebxdcStatusUpdate,
SystemMessage::WebxdcInfoMessage => SystemMessageType::WebxdcInfoMessage,
SystemMessage::InvalidUnencryptedMail => SystemMessageType::InvalidUnencryptedMail,
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
}
}
}
Expand Down
113 changes: 112 additions & 1 deletion src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use deltachat_contact_tools::{strip_rtlo_characters, ContactAddress};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use strum_macros::EnumIter;
use tokio::task;

use crate::aheader::EncryptPreference;
use crate::blob::BlobObject;
Expand All @@ -38,6 +39,7 @@ use crate::mimeparser::SystemMessage;
use crate::param::{Param, Params};
use crate::peerstate::Peerstate;
use crate::receive_imf::ReceivedMsg;
use crate::securejoin::BobState;
use crate::smtp::send_msg_to_smtp;
use crate::sql;
use crate::stock_str;
Expand Down Expand Up @@ -126,6 +128,10 @@ pub(crate) enum CantSendReason {

/// Not a member of the chat.
NotAMember,

/// Temporary state for 1:1 chats while SecureJoin is in progress, after a timeout sending
/// messages (incl. unencrypted if we don't yet know the contact's pubkey) is allowed.
SecurejoinWait,
}

impl fmt::Display for CantSendReason {
Expand All @@ -145,6 +151,7 @@ impl fmt::Display for CantSendReason {
write!(f, "mailing list does not have a know post address")
}
Self::NotAMember => write!(f, "not a member of the chat"),
Self::SecurejoinWait => write!(f, "awaiting SecureJoin for 1:1 chat"),
}
}
}
Expand Down Expand Up @@ -1407,6 +1414,18 @@ impl ChatId {

Ok(sort_timestamp)
}

/// Spawns a task checking after a timeout whether the SecureJoin has finished for the 1:1 chat
/// and otherwise notifying the user accordingly.
pub(crate) fn spawn_securejoin_wait(self, context: &Context, timeout: u64) {
let context = context.clone();
task::spawn(async move {
tokio::time::sleep(Duration::from_secs(timeout)).await;
let chat = Chat::load_from_db(&context, self).await?;
chat.check_securejoin_wait(&context, 0).await?;
Result::<()>::Ok(())
});
}
}

impl std::fmt::Display for ChatId {
Expand Down Expand Up @@ -1586,6 +1605,12 @@ impl Chat {
Some(ReadOnlyMailingList)
} else if !self.is_self_in_chat(context).await? {
Some(NotAMember)
} else if self
.check_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT)
.await?
> 0
{
Some(SecurejoinWait)
} else {
None
};
Expand All @@ -1599,6 +1624,70 @@ impl Chat {
Ok(self.why_cant_send(context).await?.is_none())
}

/// Returns the remaining timeout for the 1:1 chat in-progress SecureJoin.
///
/// If the timeout has expired, notifies the user that sending messages is possible. See also
/// [`CantSendReason::SecurejoinWait`].
pub(crate) async fn check_securejoin_wait(
&self,
context: &Context,
timeout: u64,
) -> Result<u64> {
if self.typ != Chattype::Single || self.protected != ProtectionStatus::Unprotected {
return Ok(0);
}
let (mut param0, mut param1) = (Params::new(), Params::new());
param0.set_cmd(SystemMessage::SecurejoinWait);
param1.set_cmd(SystemMessage::SecurejoinWaitTimeout);
let (param0, param1) = (param0.to_string(), param1.to_string());
let Some((param, ts_sort, ts_start)) = context
.sql
.query_row_optional(
"SELECT param, timestamp, timestamp_sent FROM msgs WHERE id=\
(SELECT MAX(id) FROM msgs WHERE chat_id=? AND param IN (?, ?))",
(self.id, &param0, &param1),
|row| {
let param: String = row.get(0)?;
let ts_sort: i64 = row.get(1)?;
let ts_start: i64 = row.get(2)?;
Ok((param, ts_sort, ts_start))
},
)
.await?
else {
return Ok(0);
};
if param == param1 {
return Ok(0);
}
let now = time();
// Don't await SecureJoin if the clock was set back.
if ts_start <= now {
let timeout = ts_start
.saturating_add(timeout.try_into()?)
.saturating_sub(now);
if timeout > 0 {
return Ok(timeout as u64);
}
}
add_info_msg_with_cmd(
context,
self.id,
&stock_str::securejoin_wait_timeout(context).await,
SystemMessage::SecurejoinWaitTimeout,
// Use the sort timestamp of the "please wait" message, this way the added message is
// very unlikely to be sorted below the protection message if the SecureJoin finishes in
// parallel.
ts_sort,
Some(now),
None,
None,
)
.await?;
context.emit_event(EventType::ChatModified(self.id));
Ok(0)
}

/// Checks if the user is part of a chat
/// and has basically the permissions to edit the chat therefore.
/// The function does not check if the chat type allows editing of concrete elements.
Expand Down Expand Up @@ -2330,6 +2419,26 @@ pub(crate) async fn update_special_chat_names(context: &Context) -> Result<()> {
Ok(())
}

/// Checks if there is a 1:1 chat in-progress SecureJoin for Bob and, if necessary, schedules a task
/// unblocking the chat and notifying the user accordingly.
pub(crate) async fn resume_securejoin_wait(context: &Context) -> Result<()> {
let Some(bobstate) = BobState::from_db(&context.sql).await? else {
return Ok(());
};
if !bobstate.in_progress() {
return Ok(());
}
let chat_id = bobstate.alice_chat();
let chat = Chat::load_from_db(context, chat_id).await?;
let timeout = chat
.check_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT)
.await?;
if timeout > 0 {
chat_id.spawn_securejoin_wait(context, timeout);
}
Ok(())
}

/// Handle a [`ChatId`] and its [`Blocked`] status at once.
///
/// This struct is an optimisation to read a [`ChatId`] and its [`Blocked`] status at once
Expand Down Expand Up @@ -2583,7 +2692,9 @@ async fn prepare_msg_common(
if let Some(reason) = chat.why_cant_send(context).await? {
if matches!(
reason,
CantSendReason::ProtectionBroken | CantSendReason::ContactRequest
CantSendReason::ProtectionBroken
| CantSendReason::ContactRequest
| CantSendReason::SecurejoinWait
) && msg.param.get_cmd() == SystemMessage::SecurejoinMessage
{
// Send out the message, the securejoin message is supposed to repair the verification.
Expand Down
5 changes: 5 additions & 0 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ pub(crate) const DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT: u64 = 12 * 60 * 60;
/// in the group membership consistency algo to reject outdated membership changes.
pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;

/// How long a 1:1 chat can't be used for sending while the SecureJoin is in progress. This should
/// be 10-20 seconds so that we are reasonably sure that the app remains active and receiving also
/// on mobile devices. See also [`crate::chat::CantSendReason::SecurejoinWait`].
pub(crate) const SECUREJOIN_WAIT_TIMEOUT: u64 = 15 * 60;

#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;
Expand Down
8 changes: 8 additions & 0 deletions src/mimeparser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ pub enum SystemMessage {
/// which is sent by chatmail servers.
InvalidUnencryptedMail = 13,

/// 1:1 chats info message telling that SecureJoin has started and the user should wait for it
/// to complete.
SecurejoinWait = 14,

/// 1:1 chats info message telling that SecureJoin is still running, but the user may already
/// send messages.
SecurejoinWaitTimeout = 15,

/// Self-sent-message that contains only json used for multi-device-sync;
/// if possible, we attach that to other messages as for locations.
MultiDeviceSync = 20,
Expand Down
12 changes: 7 additions & 5 deletions src/securejoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ mod bob;
mod bobstate;
mod qrinvite;

use bobstate::BobState;
pub(crate) use bobstate::BobState;
use qrinvite::QrInvite;

use crate::token::Namespace;
Expand Down Expand Up @@ -764,7 +764,7 @@ mod tests {
use crate::constants::Chattype;
use crate::imex::{imex, ImexMode};
use crate::receive_imf::receive_imf;
use crate::stock_str::chat_protection_enabled;
use crate::stock_str::{self, chat_protection_enabled};
use crate::test_utils::get_chat_msg;
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
Expand Down Expand Up @@ -990,10 +990,12 @@ mod tests {

// Check Bob got the verified message in his 1:1 chat.
let chat = bob.create_chat(&alice).await;
let msg = get_chat_msg(&bob, chat.get_id(), 0, 1).await;
let msg = get_chat_msg(&bob, chat.get_id(), 0, 2).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&bob).await;
assert_eq!(msg.get_text(), expected_text);
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
let msg = get_chat_msg(&bob, chat.get_id(), 1, 2).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
Expand Down
24 changes: 21 additions & 3 deletions src/securejoin/bob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ use super::bobstate::{BobHandshakeStage, BobState};
use super::qrinvite::QrInvite;
use super::HandshakeMessage;
use crate::chat::{is_contact_in_chat, ChatId, ProtectionStatus};
use crate::constants::{Blocked, Chattype};
use crate::constants::{self, Blocked, Chattype};
use crate::contact::Contact;
use crate::context::Context;
use crate::events::EventType;
use crate::mimeparser::MimeMessage;
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::sync::Sync::*;
use crate::tools::{create_smeared_timestamp, time};
use crate::{chat, stock_str};
Expand Down Expand Up @@ -69,7 +69,25 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
QrInvite::Contact { .. } => {
// For setup-contact the BobState already ensured the 1:1 chat exists because it
// uses it to send the handshake messages.
Ok(state.alice_chat())
let chat_id = state.alice_chat();
let sort_to_bottom = true;
let ts_start = time();
let ts_sort = chat_id
.calc_sort_timestamp(context, ts_start, sort_to_bottom, false)
.await?;
chat::add_info_msg_with_cmd(
context,
chat_id,
&stock_str::securejoin_wait(context).await,
SystemMessage::SecurejoinWait,
ts_sort,
Some(ts_start),
None,
None,
)
.await?;
chat_id.spawn_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT);
Ok(chat_id)
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/securejoin/bobstate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ impl BobState {
async fn send_handshake_message(&self, context: &Context, step: BobHandshakeMsg) -> Result<()> {
send_handshake_message(context, &self.invite, self.chat_id, step).await
}

/// Returns whether we are waiting for a SecureJoin message from Alice, i.e. the protocol hasn't
/// yet completed.
pub(crate) fn in_progress(&self) -> bool {
!matches!(
self.next,
SecureJoinStep::Terminated | SecureJoinStep::Completed
)
}
}

/// Sends the requested handshake message to Alice.
Expand Down
30 changes: 16 additions & 14 deletions src/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use rusqlite::{config::DbConfig, types::ValueRef, Connection, OpenFlags, Row};
use tokio::sync::{Mutex, MutexGuard, RwLock};

use crate::blob::BlobObject;
use crate::chat::{add_device_msg, update_device_icon, update_saved_messages_icon};
use crate::chat::{self, add_device_msg, update_device_icon, update_saved_messages_icon};
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::context::Context;
Expand Down Expand Up @@ -289,21 +289,23 @@ impl Sql {
let passphrase_nonempty = !passphrase.is_empty();
if let Err(err) = self.try_open(context, &self.dbfile, passphrase).await {
self.close().await;
Err(err)
} else {
info!(context, "Opened database {:?}.", self.dbfile);
*self.is_encrypted.write().await = Some(passphrase_nonempty);

// setup debug logging if there is an entry containing its id
if let Some(xdc_id) = self
.get_raw_config_u32(Config::DebugLogging.as_ref())
.await?
{
set_debug_logging_xdc(context, Some(MsgId::new(xdc_id))).await?;
}
return Err(err);
}
info!(context, "Opened database {:?}.", self.dbfile);
*self.is_encrypted.write().await = Some(passphrase_nonempty);

Ok(())
// setup debug logging if there is an entry containing its id
if let Some(xdc_id) = self
.get_raw_config_u32(Config::DebugLogging.as_ref())
.await?
{
set_debug_logging_xdc(context, Some(MsgId::new(xdc_id))).await?;
}
chat::resume_securejoin_wait(context)
.await
.log_err(context)
.ok();
Ok(())
}

/// Changes the passphrase of encrypted database.
Expand Down
18 changes: 18 additions & 0 deletions src/stock_str.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,14 @@ pub enum StockMessage {

#[strum(props(fallback = "%1$s reacted %2$s to \"%3$s\""))]
MsgReactedBy = 177,

#[strum(props(fallback = "Establishing guaranteed end-to-end encryption, please wait…"))]
SecurejoinWait = 190,

#[strum(props(
fallback = "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
))]
SecurejoinWaitTimeout = 191,
}

impl StockMessage {
Expand Down Expand Up @@ -842,6 +850,16 @@ pub(crate) async fn secure_join_replies(context: &Context, contact_id: ContactId
.replace1(&contact_id.get_stock_name(context).await)
}

/// Stock string: `Establishing guaranteed end-to-end encryption, please wait…`.
pub(crate) async fn securejoin_wait(context: &Context) -> String {
translated(context, StockMessage::SecurejoinWait).await
}

/// Stock string: `Could not yet establish guaranteed end-to-end encryption, but you may already send a message.`.
pub(crate) async fn securejoin_wait_timeout(context: &Context) -> String {
translated(context, StockMessage::SecurejoinWaitTimeout).await
}

/// Stock string: `Scan to chat with %1$s`.
pub(crate) async fn setup_contact_qr_description(
context: &Context,
Expand Down

0 comments on commit 3d02125

Please sign in to comment.