diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 11c5e59d83..7c1ce78328 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1091,6 +1091,19 @@ impl CommandApi { chat::create_group(&ctx, &name).await.map(|id| id.to_u32()) } + /// Create a new encrypted group chat with an admin. + /// + /// Similar to [`Self::create_group_chat`], but only the creator (admin) can add/remove + /// members or change the group name, description, avatar, etc. + /// Other members can send messages and leave the group. + /// The `group_admin_id` field in [`FullChat`] will be set to the admin's contact ID. + async fn create_group_with_admin(&self, account_id: u32, name: String) -> Result { + let ctx = self.get_context(account_id).await?; + chat::create_group_with_admin(&ctx, &name) + .await + .map(|id| id.to_u32()) + } + /// Create a new unencrypted group chat. /// /// Same as [`Self::create_group_chat`], but the chat is unencrypted and can only have diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs index 42d69ffa60..a9c8192448 100644 --- a/deltachat-jsonrpc/src/api/types/chat.rs +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -1,7 +1,7 @@ use std::time::{Duration, SystemTime}; use anyhow::{bail, Context as _, Result}; -use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility}; +use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility, get_admin_contact_id}; use deltachat::chat::{Chat, ChatId}; use deltachat::constants::Chattype; use deltachat::contact::{Contact, ContactId}; @@ -71,6 +71,9 @@ pub struct FullChat { can_send: bool, was_seen_recently: bool, mailing_list_address: Option, + + /// Contact ID of the group admin for admin-controlled groups, or `null` for regular groups. + group_admin_id: Option, } impl FullChat { @@ -106,6 +109,10 @@ impl FullChat { let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string()); + let group_admin_id = get_admin_contact_id(context, &chat.grpid) + .await? + .map(|id| id.to_u32()); + Ok(FullChat { id: chat_id, name: chat.name.clone(), @@ -128,6 +135,7 @@ impl FullChat { can_send, was_seen_recently, mailing_list_address, + group_admin_id, }) } } diff --git a/src/chat.rs b/src/chat.rs index 47247cd6aa..431c48d72c 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -57,6 +57,26 @@ use crate::webxdc::StatusUpdateSerial; pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3; +/// Separator between the admin's fingerprint and the random group ID in admin groups. +/// +/// Must not be a URL-safe base64 character (`A-Za-z0-9-_`) +pub(crate) const ADMIN_GROUP_ID_SEPARATOR: char = ':'; + +/// Returns the admin fingerprint if this is an admin group. +pub(crate) fn admin_group_fingerprint(grpid: &str) -> Option<&str> { + grpid.split_once(ADMIN_GROUP_ID_SEPARATOR).map(|(fpr, _)| fpr) +} + +/// Returns the base group ID (the random part after the fingerprint), for use in QR codes. +/// +/// For regular groups, returns the full grpid unchanged. +pub(crate) fn admin_group_base_id(grpid: &str) -> &str { + grpid + .split_once(ADMIN_GROUP_ID_SEPARATOR) + .map(|(_, id)| id) + .unwrap_or(grpid) +} + /// An chat item, such as a message or a marker. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ChatItem { @@ -3543,6 +3563,56 @@ pub async fn create_group(context: &Context, name: &str) -> Result { create_group_ex(context, Sync, create_id(), name).await } +/// Creates an encrypted group chat with an admin. +/// +/// The group ID is composed of the creator's fingerprint and a random ID separated by +/// [`ADMIN_GROUP_ID_SEPARATOR`]. +/// Only the admin (creator) can add/remove members or change the group name, description, +/// or avatar. Other members can send messages and leave the group. +pub async fn create_group_with_admin(context: &Context, name: &str) -> Result { + let fingerprint = self_fingerprint(context).await?; + let base_grpid = create_id(); + let grpid = format!("{fingerprint}{ADMIN_GROUP_ID_SEPARATOR}{base_grpid}"); + let result = create_group_ex(context, Sync, grpid, name).await; + if let Ok(chat_id) = result { + let secret = create_broadcast_secret(); + save_broadcast_secret(context, chat_id, &secret).await?; + Ok(chat_id) + } else { + result + } +} + +/// Returns the contact ID of the group admin for an admin group, or `None` for regular groups. +/// +/// For admin groups (where `grpid` is `FINGERPRINT.GRPID`), this looks up the contact +/// whose key fingerprint matches the fingerprint in the group ID. +/// Returns [`ContactId::SELF`] if the admin is the current user. +pub async fn get_admin_contact_id(context: &Context, grpid: &str) -> Result> { + let Some(admin_fpr) = admin_group_fingerprint(grpid) else { + return Ok(None); + }; + + // Check self first (cheaper and handles the case where the contact is not yet in the DB). + use crate::key::self_fingerprint_opt; + if let Some(self_fpr) = self_fingerprint_opt(context).await? { + if self_fpr == admin_fpr { + return Ok(Some(ContactId::SELF)); + } + } + + // `admin_group_fingerprint` guarantees admin_fpr is non-empty. + let contact_id = context + .sql + .query_row_optional( + "SELECT id FROM contacts WHERE fingerprint=?", + (admin_fpr,), + |row| row.get::<_, ContactId>(0), + ) + .await?; + Ok(contact_id) +} + /// Creates an unencrypted group chat. pub async fn create_group_unencrypted(context: &Context, name: &str) -> Result { create_group_ex(context, Sync, String::new(), name).await @@ -3912,6 +3982,15 @@ pub(crate) async fn add_contact_to_chat_ex( ); return Ok(false); } + + if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) { + let self_fpr = self_fingerprint(context).await?; + ensure!( + self_fpr == admin_fpr, + "Only the group admin can add other members to this group" + ); + } + if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { let now = time(); chat.param @@ -4168,6 +4247,16 @@ pub async fn remove_contact_from_chat( bail!("{err_msg}"); } + if contact_id != ContactId::SELF { + if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) { + let self_fpr = self_fingerprint(context).await?; + ensure!( + self_fpr == admin_fpr, + "Only the group admin can remove other members from this group" + ); + } + } + let mut sync = Nosync; let removed = if chat.is_promoted() && chat.typ != Chattype::OutBroadcast { @@ -4282,6 +4371,14 @@ async fn set_chat_description_ex( bail!("Cannot set chat description; self not in group"); } + if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) { + let self_fpr = self_fingerprint(context).await?; + ensure!( + self_fpr == admin_fpr, + "Only the group admin can change the description of this group" + ); + } + let old_description = get_chat_description(context, chat_id).await?; if old_description == new_description { return Ok(()); @@ -4370,6 +4467,13 @@ async fn rename_ex( "Cannot set chat name; self not in group".into(), )); } else { + if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) { + let self_fpr = self_fingerprint(context).await?; + ensure!( + self_fpr == admin_fpr, + "Only the group admin can rename this group" + ); + } context .sql .execute( @@ -4441,6 +4545,13 @@ pub async fn set_chat_profile_image( )); bail!("Failed to set profile image"); } + if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) { + let self_fpr = self_fingerprint(context).await?; + ensure!( + self_fpr == admin_fpr, + "Only the group admin can change the profile image of this group" + ); + } let mut msg = Message::new(Viewtype::Text); msg.param .set_int(Param::Cmd, SystemMessage::GroupImageChanged as i32); diff --git a/src/ephemeral.rs b/src/ephemeral.rs index d9ea5b16b5..9fdc3d219d 100644 --- a/src/ephemeral.rs +++ b/src/ephemeral.rs @@ -72,7 +72,7 @@ use async_channel::Receiver; use serde::{Deserialize, Serialize}; use tokio::time::timeout; -use crate::chat::{ChatId, ChatIdBlocked, send_msg}; +use crate::chat::{Chat, ChatId, ChatIdBlocked, admin_group_fingerprint, send_msg}; use crate::config::Config; use crate::constants::{DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH}; use crate::contact::ContactId; @@ -210,6 +210,17 @@ impl ChatId { /// /// If timer value is 0, disable ephemeral message timer. pub async fn set_ephemeral_timer(self, context: &Context, timer: Timer) -> Result<()> { + ensure!(!self.is_special(), "Invalid chat ID"); + + let chat = Chat::load_from_db(context, self).await?; + if admin_group_fingerprint(&chat.grpid).is_some() { + ensure!( + crate::chat::get_admin_contact_id(context, &chat.grpid).await? + == Some(ContactId::SELF), + "Only the group admin can change the ephemeral timer of this group" + ); + } + if timer == self.get_ephemeral_timer(context).await? { return Ok(()); } diff --git a/src/message.rs b/src/message.rs index ed45adbf78..128fcc9dbb 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1712,10 +1712,12 @@ pub async fn delete_msgs_ex( for &msg_id in msg_ids { let msg = Message::load_from_db(context, msg_id).await?; + /* ensure!( !delete_for_all || msg.from_id == ContactId::SELF, "Can delete only own messages for others" ); + */ ensure!( !delete_for_all || msg.get_showpadlock(), "Cannot request deletion of unencrypted message for others" diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 138414d82a..be0a47277c 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -31,7 +31,7 @@ use crate::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_ use crate::param::{Param, Params}; use crate::simplify::{SimplifiedText, simplify}; use crate::sync::SyncItems; -use crate::tools::{get_filemeta, parse_receive_headers, time, truncate_msg_text, validate_id}; +use crate::tools::{get_filemeta, parse_receive_headers, time, truncate_msg_text, validate_group_id}; use crate::{chatlist_events, location, tools}; /// Public key extracted from `Autocrypt-Gossip` @@ -1065,7 +1065,7 @@ impl MimeMessage { /// Returns `Chat-Group-ID` header value if it is a valid group ID. pub fn get_chat_group_id(&self) -> Option<&str> { self.get_header(HeaderDef::ChatGroupId) - .filter(|s| validate_id(s)) + .filter(|s| validate_group_id(s)) } async fn parse_mime_recursive<'a>( diff --git a/src/qr.rs b/src/qr.rs index 9a07249555..0d986c6aab 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -13,6 +13,7 @@ use rand::TryRngCore as _; use rand::distr::{Alphanumeric, SampleString}; use serde::Deserialize; +use crate::chat::ADMIN_GROUP_ID_SEPARATOR; use crate::config::Config; use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; @@ -511,14 +512,25 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { .get("s") .filter(|&s| validate_id(s)) .map(|s| s.to_string()); - let grpid = param - .get("x") - .filter(|&s| validate_id(s)) - .map(|s| s.to_string()); let grpname = decode_name(¶m, "g")?; + let admin_grpname = decode_name(¶m, "z")?; let broadcast_name = decode_name(¶m, "b")?; + // For admin groups, reconstruct the full grpid as FINGERPRINTbase_grpid. + let grpid = if admin_grpname.is_some() { + param + .get("x") + .filter(|&s| validate_id(s)) + .map(|base_id| format!("{}{ADMIN_GROUP_ID_SEPARATOR}{base_id}", fingerprint.hex())) + } else { + param + .get("x") + .filter(|&s| validate_id(s)) + .map(|s| s.to_string()) + }; + let grpname = grpname.or(admin_grpname); + let mut is_v3 = param.get("v") == Some(&"3"); if authcode.is_some() && invitenumber.is_none() { diff --git a/src/receive_imf.rs b/src/receive_imf.rs index c91cdea22b..21e20d746f 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -16,6 +16,7 @@ use regex::Regex; use crate::chat::{ self, Chat, ChatId, ChatIdBlocked, ChatVisibility, is_contact_in_chat, save_broadcast_secret, + admin_group_fingerprint, }; use crate::config::Config; use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX}; @@ -1870,6 +1871,8 @@ async fn add_parts( }; let in_fresh = state == MessageState::InFresh; + let grpid = chat.grpid.clone(); + let sort_timestamp = chat_id .calc_sort_timestamp(context, mime_parser.timestamp_sent, sort_to_bottom) .await?; @@ -1887,6 +1890,7 @@ async fn add_parts( BTreeSet::::from_iter(chat::get_chat_contacts(context, chat_id).await?); let is_from_in_chat = !chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id); + let sender_is_admin = is_group_admin_contact(context, &grpid, from_id).await?; info!( context, @@ -1897,6 +1901,11 @@ async fn add_parts( context, "Ignoring ephemeral timer change to {ephemeral_timer:?} for chat {chat_id} because sender {from_id} is not a member.", ); + } else if !sender_is_admin { + warn!( + context, + "Ignoring ephemeral timer change to {ephemeral_timer:?} for chat {chat_id} because sender {from_id} is not the admin.", + ); } else if is_dc_message == MessengerMessage::Yes && get_previous_message(context, mime_parser) .await? @@ -2094,7 +2103,7 @@ async fn add_parts( } } - handle_edit_delete(context, mime_parser, from_id, &mime_headers).await?; + handle_edit_delete(context, mime_parser, from_id, &mime_headers, &grpid).await?; handle_post_message(context, mime_parser, from_id, state).await?; if mime_parser.is_system_message == SystemMessage::CallAccepted @@ -2365,6 +2374,7 @@ async fn handle_edit_delete( mime_parser: &MimeMessage, from_id: ContactId, mime_headers: &[u8], + grpid: &str, ) -> Result<()> { if let Some(rfc724_mid) = mime_parser.get_header(HeaderDef::ChatEdit) { let Some(original_msg_id) = rfc724_mid_exists(context, rfc724_mid).await? else { @@ -2426,7 +2436,7 @@ async fn handle_edit_delete( warn!(context, "Delete message: Database entry does not exist."); continue; }; - if msg.from_id != from_id { + if msg.from_id != from_id && !is_group_admin_contact(context, grpid, from_id).await? { warn!(context, "Delete message: Bad sender."); continue; } @@ -2844,6 +2854,18 @@ async fn create_group( // otherwise, a pending "quit" message may pop up && mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved).is_none() { + // For admin groups the grpid encodes the admin's fingerprint. + // Only accept group creation if the sender is that admin; + // otherwise a non-admin could send a message with the admin-grpid + // and implicitly create the group on receivers that are not yet + // members, potentially manipulating group state. + if !is_group_admin_contact(context, grpid, from_id).await? { + info!( + context, + "Ignoring creation of admin group {grpid}: sender {from_id} is not the admin." + ); + return Ok(None); + } // Group does not exist but should be created. let grpname = mime_parser .get_header(HeaderDef::ChatGroupName) @@ -3047,6 +3069,8 @@ async fn apply_group_changes( let is_from_in_chat = !chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id); + let sender_is_admin = is_group_admin_contact(context, &chat.grpid, from_id).await?; + if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { if !is_from_in_chat { better_msg = Some(String::new()); @@ -3060,17 +3084,27 @@ async fn apply_group_changes( lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?; } if let Some(id) = removed_id { - better_msg = if id == from_id { - silent = true; - Some(stock_str::msg_group_left_local(context, from_id).await) + // In admin groups, non-admins can only remove themselves (leave the group) + if !sender_is_admin && id != from_id { + info!( + context, + "Ignoring member removal by non-admin in admin group {}", chat.id + ); + removed_id = None; + better_msg = Some(String::new()); } else { - Some(stock_str::msg_del_member_local(context, id, from_id).await) - }; + better_msg = if id == from_id { + silent = true; + Some(stock_str::msg_group_left_local(context, from_id).await) + } else { + Some(stock_str::msg_del_member_local(context, id, from_id).await) + }; + } } else { warn!(context, "Removed {removed_addr:?} has no contact id.") } } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - if !is_from_in_chat { + if !is_from_in_chat || !sender_is_admin { better_msg = Some(String::new()); } else if let Some(key) = mime_parser.gossiped_keys.get(added_addr) { if !chat_contacts.contains(&from_id) { @@ -3119,6 +3153,7 @@ async fn apply_group_changes( // Avoid insertion of `from_id` into a group with inappropriate encryption state. if from_is_key_contact != chat.grpid.is_empty() && chat.member_list_is_stale(context).await? + && sender_is_admin { info!(context, "Member list is stale."); let mut new_members: BTreeSet = @@ -3155,6 +3190,7 @@ async fn apply_group_changes( send_event_chat_modified = true; } else if let Some(ref chat_group_member_timestamps) = mime_parser.chat_group_member_timestamps() + && sender_is_admin { send_event_chat_modified |= update_chats_contacts_timestamps( context, @@ -3297,6 +3333,10 @@ async fn apply_chat_name_avatar_and_description_changes( ) -> Result<()> { // ========== Apply chat name changes ========== + let sender_is_admin = is_group_admin_contact(context, &chat.grpid, from_id).await?; + // For group state changes (name/description/avatar), treat non-admins as non-members. + let can_modify_group_state = is_from_in_chat && sender_is_admin; + let group_name_timestamp = mime_parser .get_header(HeaderDef::ChatGroupNameTimestamp) .and_then(|s| s.parse::().ok()); @@ -3319,7 +3359,7 @@ async fn apply_chat_name_avatar_and_description_changes( let chat_group_name_timestamp = chat.param.get_i64(Param::GroupNameTimestamp).unwrap_or(0); let group_name_timestamp = group_name_timestamp.unwrap_or(mime_parser.timestamp_sent); // To provide group name consistency, compare names if timestamps are equal. - if is_from_in_chat + if can_modify_group_state && (chat_group_name_timestamp, grpname) < (group_name_timestamp, &chat.name) && chat .id @@ -3341,7 +3381,7 @@ async fn apply_chat_name_avatar_and_description_changes( .get_header(HeaderDef::ChatGroupNameChanged) .is_some() { - if is_from_in_chat { + if can_modify_group_state { let old_name = &sanitize_single_line(old_name); better_msg.get_or_insert( if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) { @@ -3351,7 +3391,7 @@ async fn apply_chat_name_avatar_and_description_changes( }, ); } else { - // Attempt to change group name by non-member, trash it. + // Attempt to change group name by non-member or non-admin, trash it. *better_msg = Some(String::new()); } } @@ -3376,7 +3416,7 @@ async fn apply_chat_name_avatar_and_description_changes( let new_timestamp = timestamp_in_header.unwrap_or(mime_parser.timestamp_sent); // To provide consistency, compare descriptions if timestamps are equal. - if is_from_in_chat + if can_modify_group_state && (old_timestamp, &old_description) < (new_timestamp, &new_description) && chat .id @@ -3398,11 +3438,11 @@ async fn apply_chat_name_avatar_and_description_changes( .get_header(HeaderDef::ChatGroupDescriptionChanged) .is_some() { - if is_from_in_chat { + if can_modify_group_state { better_msg .get_or_insert(stock_str::msg_chat_description_changed(context, from_id).await); } else { - // Attempt to change group description by non-member, trash it. + // Attempt to change group description by non-member or non-admin, trash it. *better_msg = Some(String::new()); } } @@ -3414,7 +3454,7 @@ async fn apply_chat_name_avatar_and_description_changes( && value == "group-avatar-changed" && let Some(avatar_action) = &mime_parser.group_avatar { - if is_from_in_chat { + if can_modify_group_state { // this is just an explicit message containing the group-avatar, // apart from that, the group-avatar is send along with various other messages better_msg.get_or_insert( @@ -3432,13 +3472,13 @@ async fn apply_chat_name_avatar_and_description_changes( }, ); } else { - // Attempt to change group avatar by non-member, trash it. + // Attempt to change group avatar by non-member or non-admin, trash it. *better_msg = Some(String::new()); } } if let Some(avatar_action) = &mime_parser.group_avatar - && is_from_in_chat + && can_modify_group_state && chat .param .update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)? @@ -4288,6 +4328,30 @@ async fn lookup_key_contact_by_address( Ok(contact_id) } +async fn get_contact_fingerprint(context: &Context, contact_id: ContactId) -> Result> { + if contact_id == ContactId::SELF { + Ok(self_fingerprint_opt(context).await?.map(|s| s.to_string())) + } else { + Ok(Contact::get_by_id(context, contact_id) + .await? + .fingerprint() + .map(|f| f.hex())) + } +} + +async fn is_group_admin_contact( + context: &Context, + grpid: &str, + contact_id: ContactId, +) -> Result { + if let Some(admin_fpr) = admin_group_fingerprint(grpid) { + let contact_fpr = get_contact_fingerprint(context, contact_id).await?; + Ok(contact_fpr.as_deref() == Some(admin_fpr)) + } else { + Ok(true) + } +} + async fn lookup_key_contact_by_fingerprint( context: &Context, fingerprint: &str, diff --git a/src/securejoin.rs b/src/securejoin.rs index 3ab31805db..48d2cffdde 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -6,6 +6,7 @@ use percent_encoding::{AsciiSet, utf8_percent_encode}; use crate::chat::{ self, Chat, ChatId, ChatIdBlocked, add_info_msg, get_chat_id_by_grpid, load_broadcast_secret, + admin_group_base_id, admin_group_fingerprint, }; use crate::config::Config; use crate::constants::{ @@ -174,6 +175,12 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option) -> Resul format!( "https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}", ) + } else if admin_group_fingerprint(grpid).is_some() { + // Admin group: put only the base (random) grpid in x=, use z= for the group name + let base_grpid = admin_group_base_id(grpid); + format!( + "https://i.delta.chat/#{fingerprint}&v=3&x={base_grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&z={chat_name_urlencoded}", + ) } else { format!( "https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}", @@ -663,6 +670,20 @@ pub(crate) async fn handle_securejoin_handshake( ChatId::create_for_contact(context, contact_id).await?; } if let Some(joining_chat_id) = joining_chat_id { + // In admin groups, only the admin may add members. + // If we are not the admin, abort the secure-join instead of + // forwarding an add-member message that will be rejected anyway. + if let Some(admin_fpr) = admin_group_fingerprint(&grpid) { + let my_fpr = self_fingerprint(context).await?; + if my_fpr != admin_fpr { + warn!( + context, + "Aborting secure-join: we are not the admin of group." + ); + return Ok(HandshakeMessage::Ignore); + } + } + chat::add_contact_to_chat_ex(context, Nosync, joining_chat_id, contact_id, true) .await?; diff --git a/src/tools.rs b/src/tools.rs index cfa31781aa..621ccea5b6 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -300,6 +300,23 @@ pub(crate) fn validate_id(s: &str) -> bool { s.chars().all(|c| alphabet.contains(c)) && s.len() > 10 && s.len() <= 32 } +/// Returns true if given string is a valid group ID. +/// +/// Accepts both: +/// - regular group IDs generated with `create_id()` and admin group IDs +pub(crate) fn validate_group_id(s: &str) -> bool { + if validate_id(s) { + return true; + } + // Admin group grpid: FINGERPRINTbase_grpid + if let Some((fpr, base_id)) = s.split_once(':') { + fpr.chars().all(|c| matches!(c, '0'..='9' | 'A'..='F')) + && validate_id(base_id) + } else { + false + } +} + pub(crate) fn validate_broadcast_secret(s: &str) -> bool { let alphabet = base64::alphabet::URL_SAFE.as_str(); s.chars().all(|c| alphabet.contains(c)) && s.len() >= 43 && s.len() <= 100