From 85751668cd2c257741b400ad0bdabbcc03c33bda Mon Sep 17 00:00:00 2001 From: adb Date: Sun, 19 Jul 2026 17:48:06 +0200 Subject: [PATCH] Revert "add group with admin" --- deltachat-jsonrpc/src/api.rs | 13 --- deltachat-jsonrpc/src/api/types/chat.rs | 10 +-- src/chat.rs | 111 ------------------------ src/ephemeral.rs | 13 +-- src/mimeparser.rs | 4 +- src/qr.rs | 20 +---- src/receive_imf.rs | 91 ++++--------------- src/securejoin.rs | 21 ----- src/tools.rs | 17 ---- 9 files changed, 23 insertions(+), 277 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 7c1ce78328..11c5e59d83 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1091,19 +1091,6 @@ 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 a9c8192448..42d69ffa60 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, get_admin_contact_id}; +use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility}; use deltachat::chat::{Chat, ChatId}; use deltachat::constants::Chattype; use deltachat::contact::{Contact, ContactId}; @@ -71,9 +71,6 @@ 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 { @@ -109,10 +106,6 @@ 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(), @@ -135,7 +128,6 @@ impl FullChat { can_send, was_seen_recently, mailing_list_address, - group_admin_id, }) } } diff --git a/src/chat.rs b/src/chat.rs index 431c48d72c..47247cd6aa 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -57,26 +57,6 @@ 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 { @@ -3563,56 +3543,6 @@ 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 @@ -3982,15 +3912,6 @@ 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 @@ -4247,16 +4168,6 @@ 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 { @@ -4371,14 +4282,6 @@ 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(()); @@ -4467,13 +4370,6 @@ 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( @@ -4545,13 +4441,6 @@ 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 9fdc3d219d..d9ea5b16b5 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::{Chat, ChatId, ChatIdBlocked, admin_group_fingerprint, send_msg}; +use crate::chat::{ChatId, ChatIdBlocked, send_msg}; use crate::config::Config; use crate::constants::{DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH}; use crate::contact::ContactId; @@ -210,17 +210,6 @@ 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/mimeparser.rs b/src/mimeparser.rs index be0a47277c..138414d82a 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_group_id}; +use crate::tools::{get_filemeta, parse_receive_headers, time, truncate_msg_text, validate_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_group_id(s)) + .filter(|s| validate_id(s)) } async fn parse_mime_recursive<'a>( diff --git a/src/qr.rs b/src/qr.rs index 0d986c6aab..9a07249555 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -13,7 +13,6 @@ 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; @@ -512,25 +511,14 @@ 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 23ead58a58..c91cdea22b 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -16,7 +16,6 @@ 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}; @@ -1888,7 +1887,6 @@ 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, &chat.grpid, from_id).await?; info!( context, @@ -1899,11 +1897,6 @@ 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? @@ -2851,18 +2844,6 @@ 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) @@ -3066,8 +3047,6 @@ 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()); @@ -3081,27 +3060,17 @@ async fn apply_group_changes( lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?; } if let Some(id) = removed_id { - // 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()); + better_msg = if id == from_id { + silent = true; + Some(stock_str::msg_group_left_local(context, from_id).await) } else { - 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) - }; - } + 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 || !sender_is_admin { + if !is_from_in_chat { better_msg = Some(String::new()); } else if let Some(key) = mime_parser.gossiped_keys.get(added_addr) { if !chat_contacts.contains(&from_id) { @@ -3150,7 +3119,6 @@ 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 = @@ -3187,7 +3155,6 @@ 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, @@ -3330,10 +3297,6 @@ 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()); @@ -3356,7 +3319,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 can_modify_group_state + if is_from_in_chat && (chat_group_name_timestamp, grpname) < (group_name_timestamp, &chat.name) && chat .id @@ -3378,7 +3341,7 @@ async fn apply_chat_name_avatar_and_description_changes( .get_header(HeaderDef::ChatGroupNameChanged) .is_some() { - if can_modify_group_state { + if is_from_in_chat { let old_name = &sanitize_single_line(old_name); better_msg.get_or_insert( if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) { @@ -3388,7 +3351,7 @@ async fn apply_chat_name_avatar_and_description_changes( }, ); } else { - // Attempt to change group name by non-member or non-admin, trash it. + // Attempt to change group name by non-member, trash it. *better_msg = Some(String::new()); } } @@ -3413,7 +3376,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 can_modify_group_state + if is_from_in_chat && (old_timestamp, &old_description) < (new_timestamp, &new_description) && chat .id @@ -3435,11 +3398,11 @@ async fn apply_chat_name_avatar_and_description_changes( .get_header(HeaderDef::ChatGroupDescriptionChanged) .is_some() { - if can_modify_group_state { + if is_from_in_chat { better_msg .get_or_insert(stock_str::msg_chat_description_changed(context, from_id).await); } else { - // Attempt to change group description by non-member or non-admin, trash it. + // Attempt to change group description by non-member, trash it. *better_msg = Some(String::new()); } } @@ -3451,7 +3414,7 @@ async fn apply_chat_name_avatar_and_description_changes( && value == "group-avatar-changed" && let Some(avatar_action) = &mime_parser.group_avatar { - if can_modify_group_state { + if is_from_in_chat { // 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( @@ -3469,13 +3432,13 @@ async fn apply_chat_name_avatar_and_description_changes( }, ); } else { - // Attempt to change group avatar by non-member or non-admin, trash it. + // Attempt to change group avatar by non-member, trash it. *better_msg = Some(String::new()); } } if let Some(avatar_action) = &mime_parser.group_avatar - && can_modify_group_state + && is_from_in_chat && chat .param .update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)? @@ -4325,30 +4288,6 @@ 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 48d2cffdde..3ab31805db 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -6,7 +6,6 @@ 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::{ @@ -175,12 +174,6 @@ 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}", @@ -670,20 +663,6 @@ 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 621ccea5b6..cfa31781aa 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -300,23 +300,6 @@ 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