use crate::protocol::{
base64_decode, base64_encode, internal_prefixes, GroupMemberRemovedPayload,
InternalMessageResult, OfflineProtocol, RichPayloadV1, RichSendExtras, RICH_PAYLOAD_V1,
};
use crate::{Error, Event, Result};
use chrono::{DateTime, Utc};
use offline_protocol_core::{
ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority,
};
use offline_protocol_mls::GroupRole;
use offline_protocol_transport::TransportType;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::{Duration as StdDuration, Instant};
use tracing::{debug, error, info, warn};
pub(super) const GROUP_MESSAGE_DEDUP_TTL_SECS: u64 = 300;
pub(super) const MAX_GROUP_MESSAGE_DEDUP_ENTRIES: usize = 10_000;
pub(crate) const MAX_BASE64_PAYLOAD_SIZE: usize = 1_048_576;
pub(super) const RELAY_REGISTER_ACK_TIMEOUT_SECS: i64 = 30;
pub(super) const RELAY_REGISTER_MAX_ATTEMPTS: u32 = 3;
pub(super) const RELAY_BROADCAST_REPORT_TIMEOUT_SECS: i64 = 60;
pub(super) const RELAY_BROADCAST_MAX_ATTEMPTS: u32 = 3;
pub(super) const MAX_RELAY_BROADCAST_PENDING: usize = 64;
pub(crate) const RELAY_CAP_GROUP_DELIVERY_V3: &str = "group_delivery_v3";
pub(super) const MAX_RELAY_CAPABILITIES: usize = 64;
pub(super) const MAX_RELAY_CAPABILITY_TOKEN_BYTES: usize = 128;
pub(super) const MAX_PENDING_COMMITS_PER_GROUP: usize = 8;
pub(super) const PENDING_COMMIT_TTL_SECS: u64 = GROUP_MESSAGE_DEDUP_TTL_SECS;
pub(super) const MAX_PENDING_COMMITS_TOTAL: usize = 64;
pub(super) const MAX_PENDING_COMMIT_TOTAL_BYTES: usize = 4 * 1024 * 1024;
pub(super) const MAX_PENDING_COMMIT_GROUPS: usize = 16;
pub(super) const MAX_PENDING_GROUP_MESSAGES_PER_GROUP: usize = 16;
pub(super) const PENDING_GROUP_MESSAGE_TTL_SECS: u64 = GROUP_MESSAGE_DEDUP_TTL_SECS;
pub(super) const MAX_PENDING_GROUP_MESSAGES_TOTAL: usize = 256;
pub(super) const MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES: usize = 8 * 1024 * 1024;
pub(super) const MAX_PENDING_GROUP_MESSAGE_GROUPS: usize = 32;
pub(super) const LEAVE_ELECTION_TIMEOUT_SECS: u64 = 30;
pub(super) const EPOCH_FORK_RESOLUTION_DELAY_SECS: u64 = 30;
pub(super) const MAX_EPOCH_FORK_ENTRIES: usize = 32;
pub(super) const MAX_PENDING_LEAVE_ELECTIONS: usize = 64;
pub(super) const LEAVE_ELECTION_MAX_LIFETIME_SECS: u64 = 300;
pub(super) const LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS: u64 = 5;
pub(super) const UNAUTHORIZED_REPORT_SUPPRESS_SECS: u64 = 300;
pub(super) const MAX_UNAUTHORIZED_REPORT_ENTRIES: usize = 256;
#[derive(Debug, Clone)]
pub(crate) struct RelayRegisterPending {
pub(crate) armed_at: DateTime<Utc>,
pub(crate) attempts: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct RelayBroadcastPending {
pub(crate) group_id: String,
pub(crate) ciphertext_b64: String,
pub(crate) epoch: u64,
pub(crate) reply_to: Option<String>,
pub(crate) forward_info: Option<ForwardInfo>,
pub(crate) priority: MessagePriority,
pub(crate) armed_at: DateTime<Utc>,
pub(crate) attempts: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum UnprovenLeafSite {
WelcomeDeclined,
CommitRefused,
RosterEntry,
}
fn claim_report_window<K: Eq + std::hash::Hash + Clone>(
reports: &mut HashMap<K, Instant>,
key: K,
) -> bool {
let now = Instant::now();
let suppressed = reports.get(&key).is_some_and(|last| {
now.duration_since(*last).as_secs() < UNAUTHORIZED_REPORT_SUPPRESS_SECS
});
if suppressed {
return false;
}
if reports.len() >= MAX_UNAUTHORIZED_REPORT_ENTRIES {
reports.retain(|_, last| {
now.duration_since(*last).as_secs() < UNAUTHORIZED_REPORT_SUPPRESS_SECS
});
}
if reports.len() >= MAX_UNAUTHORIZED_REPORT_ENTRIES {
if let Some(oldest) = reports
.iter()
.min_by_key(|(_, t)| **t)
.map(|(k, _)| k.clone())
{
reports.remove(&oldest);
}
}
reports.insert(key, now);
true
}
#[derive(Default)]
pub(crate) struct GroupMeshState {
pub(crate) members: HashMap<String, Vec<String>>,
pub(crate) message_dedup: HashMap<String, Instant>,
pub(crate) pending_commits: HashMap<String, VecDeque<PendingCommit>>,
pub(crate) pending_group_messages: HashMap<String, VecDeque<PendingGroupMessage>>,
pub(crate) relay_synced: HashSet<String>,
pub(crate) relay_register_pending: HashMap<String, RelayRegisterPending>,
pub(crate) relay_capabilities: HashSet<String>,
pub(crate) relay_broadcast_pending: HashMap<String, RelayBroadcastPending>,
pub(crate) internet_was_available: bool,
pub(crate) pending_leave_elections: HashMap<(String, String), PendingLeaveElection>,
pub(crate) epoch_forks: HashMap<String, EpochForkState>,
pub(crate) unauthorized_change_reports: HashMap<(String, String, bool), Instant>,
pub(crate) unproven_leaf_reports: HashMap<(String, String, UnprovenLeafSite), Instant>,
}
enum CommitOutcome {
Success(String),
Retriable(String),
Rejected,
}
pub(crate) struct MembershipJudgment {
pub(crate) sender_is_admin: bool,
pub(crate) authorized: bool,
}
enum GroupDecryptOutcome {
Plaintext(Vec<u8>),
NonApplication,
SecurityRejected,
Retriable,
NotMlsCiphertext,
PolicyRejected {
committer: String,
added: Vec<String>,
removed: Vec<String>,
},
Failed,
}
#[derive(Debug, Clone, Default)]
pub struct GroupSendOptions {
pub priority: Option<MessagePriority>,
pub reply_to_msg: Option<String>,
pub content_type: Option<ContentType>,
pub media_metadata: Option<MediaMetadata>,
}
#[derive(Debug, Clone)]
pub struct GroupRichReadiness {
pub ready: bool,
pub unknown_members: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelaySyncState {
Synced,
Pending,
Unsynced,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GroupMlsMessagePayload {
pub(crate) group_id: String,
pub(crate) ciphertext: String,
pub(crate) epoch: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) message_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GroupMlsWelcomePayload {
pub(crate) group_id: String,
pub(crate) group_name: Option<String>,
pub(crate) welcome_data: String,
pub(crate) member_list: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub(crate) member_roles: HashMap<String, GroupRole>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub(crate) member_rich: HashMap<String, Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) created_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum GroupCommitType {
Add,
Remove,
KeyUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GroupMlsCommitPayload {
pub(crate) group_id: String,
pub(crate) commit_type: GroupCommitType,
pub(crate) ciphertext: String,
pub(crate) epoch: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) affected_member: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) role: Option<GroupRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) affected_member_rich: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GroupMlsLeavePayload {
pub(crate) group_id: String,
pub(crate) leaving_member: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GroupRoleChangePayload {
pub(crate) group_id: String,
pub(crate) target_user_id: String,
pub(crate) new_role: GroupRole,
pub(crate) changed_by: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GroupRenamePayload {
pub(crate) group_id: String,
pub(crate) new_name: String,
pub(crate) renamed_by: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RelayGroupRegistrationPayload {
pub(crate) group_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) group_name: Option<String>,
pub(crate) members: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) is_admin: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RelayGroupBroadcastPayload {
pub(crate) group_id: String,
pub(crate) ciphertext: String,
pub(crate) epoch: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
#[serde(default)]
pub(crate) message_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct RelayGroupDeliveryReport {
pub(crate) group_id: String,
pub(crate) message_id: String,
#[serde(default)]
pub(crate) delivered: Vec<String>,
#[serde(default)]
pub(crate) pushed: Vec<String>,
#[serde(default)]
pub(crate) missed: Vec<RelayGroupDeliveryMiss>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct RelayGroupDeliveryMiss {
pub(crate) username: String,
#[serde(default)]
pub(crate) reason: String,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingCommit {
pub(crate) sender: String,
pub(crate) message_id: String,
pub(crate) data: String,
pub(crate) buffered_at: Instant,
pub(crate) retry_count: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingGroupMessage {
pub(crate) sender: String,
pub(crate) message_id: String,
pub(crate) logical_id: Option<String>,
pub(crate) ciphertext_b64: String,
pub(crate) timestamp: Option<String>,
pub(crate) reply_to: Option<String>,
pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
pub(crate) buffered_at: Instant,
pub(crate) received_via: Option<TransportType>,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingLeaveElection {
pub(crate) group_id: String,
pub(crate) leaving_member: String,
pub(crate) received_at: Instant,
pub(crate) last_attempt_at: Option<Instant>,
}
#[derive(Debug, Clone)]
pub(crate) struct EpochForkState {
pub(crate) group_id: String,
pub(crate) local_epoch: Option<u64>,
pub(crate) detected_at: Instant,
pub(crate) resolution_attempted: bool,
}
struct GlobalBufferCaps {
max_groups: usize,
max_entries: usize,
max_bytes: usize,
}
const PENDING_COMMIT_CAPS: GlobalBufferCaps = GlobalBufferCaps {
max_groups: MAX_PENDING_COMMIT_GROUPS,
max_entries: MAX_PENDING_COMMITS_TOTAL,
max_bytes: MAX_PENDING_COMMIT_TOTAL_BYTES,
};
const PENDING_GROUP_MESSAGE_CAPS: GlobalBufferCaps = GlobalBufferCaps {
max_groups: MAX_PENDING_GROUP_MESSAGE_GROUPS,
max_entries: MAX_PENDING_GROUP_MESSAGES_TOTAL,
max_bytes: MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES,
};
fn enforce_global_buffer_bound<T>(
map: &mut HashMap<String, VecDeque<T>>,
incoming_group: &str,
caps: &GlobalBufferCaps,
incoming_bytes: usize,
entry_bytes: impl Fn(&T) -> usize,
buffered_at: impl Fn(&T) -> Instant,
) -> Option<Vec<T>> {
if incoming_bytes > caps.max_bytes {
return None;
}
let (mut entries, mut bytes) = map
.values()
.flat_map(|buf| buf.iter())
.fold((0usize, 0usize), |(n, b), e| (n + 1, b + entry_bytes(e)));
let pick_victim = |map: &HashMap<String, VecDeque<T>>| {
map.iter()
.filter_map(|(gid, buf)| {
buf.front()
.map(|e| ((buf.len(), std::cmp::Reverse(buffered_at(e))), gid))
})
.max_by_key(|(rank, _)| *rank)
.map(|(_, gid)| gid.clone())
};
let mut all_evicted = Vec::new();
while map.len() >= caps.max_groups && !map.contains_key(incoming_group) {
let Some(victim_group) = pick_victim(map) else {
break;
};
warn!(
group_id = %victim_group,
"Pending buffers at group capacity, evicting largest group buffer wholesale"
);
if let Some(buf) = map.remove(&victim_group) {
entries -= buf.len();
bytes -= buf.iter().map(&entry_bytes).sum::<usize>();
all_evicted.extend(buf);
}
}
while entries >= caps.max_entries || bytes + incoming_bytes > caps.max_bytes {
let Some(victim_group) = pick_victim(map) else {
break;
};
warn!(
group_id = %victim_group,
"Pending buffer at global capacity, evicting oldest entry of largest group buffer"
);
if let Some(buf) = map.get_mut(&victim_group) {
if let Some(evicted) = buf.pop_front() {
entries -= 1;
bytes -= entry_bytes(&evicted);
all_evicted.push(evicted);
}
if buf.is_empty() {
map.remove(&victim_group);
}
}
}
Some(all_evicted)
}
impl OfflineProtocol {
#[cfg(test)]
pub(crate) fn handle_group_mls_msg(
&mut self,
message: &Message,
sender: &str,
data: &str,
) -> InternalMessageResult {
self.handle_group_mls_msg_via(message, sender, data, None)
}
pub(crate) fn handle_group_mls_msg_via(
&mut self,
message: &Message,
sender: &str,
data: &str,
arrival_transport: Option<TransportType>,
) -> InternalMessageResult {
let payload = match serde_json::from_str::<GroupMlsMessagePayload>(data) {
Ok(p) => p,
Err(_) => {
warn!(sender = %sender, "Failed to parse GroupMlsMessage payload");
return InternalMessageResult::Consumed;
}
};
let dedup_key = message.id.as_str().to_string();
let logical_key = payload.message_id.clone();
let seen = self.group_mesh.message_dedup.contains_key(&dedup_key)
|| logical_key
.as_deref()
.is_some_and(|l| self.group_mesh.message_dedup.contains_key(l));
if seen {
let pending = self.is_group_message_pending(&payload.group_id, &dedup_key)
|| logical_key
.as_deref()
.is_some_and(|l| self.is_group_message_pending(&payload.group_id, l));
if pending {
debug!(
group_id = %payload.group_id,
msg_id = %dedup_key,
"Duplicate of a still-pending group message, deferring ACK"
);
return InternalMessageResult::Deferred;
}
debug!(
group_id = %payload.group_id,
msg_id = %dedup_key,
"Duplicate group message, skipping"
);
return InternalMessageResult::Consumed;
}
self.group_mesh
.message_dedup
.insert(dedup_key, Instant::now());
if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
self.cleanup_group_message_dedup();
}
let ciphertext_bytes = match base64_decode(&payload.ciphertext) {
Ok(bytes) => bytes,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "Failed to decode group message ciphertext");
return InternalMessageResult::Consumed;
}
};
match self.decrypt_group_application(&payload.group_id, ciphertext_bytes, sender) {
GroupDecryptOutcome::Plaintext(plaintext) => {
let Ok(text) = String::from_utf8(plaintext) else {
warn!(
group_id = %payload.group_id,
"Decrypted group payload is not valid UTF-8, dropping"
);
return InternalMessageResult::Consumed;
};
let msg_id = logical_key.unwrap_or_else(|| message.id.as_str().to_string());
if msg_id != message.id.as_str() {
self.group_mesh
.message_dedup
.insert(msg_id.clone(), Instant::now());
}
let timestamp = chrono::Utc::now().to_rfc3339();
info!(group_id = %payload.group_id, "Decrypted mesh group message");
let (content, media_metadata, content_type, forward_info_event) =
Self::restore_group_rich(text, payload.forward_info, sender);
self.emit_event(Event::group_message_received(
payload.group_id,
sender.to_string(),
content,
timestamp,
msg_id,
payload.reply_to,
forward_info_event,
media_metadata,
content_type,
));
InternalMessageResult::Consumed
}
GroupDecryptOutcome::SecurityRejected => {
self.group_mesh.message_dedup.remove(&message.id.as_str());
InternalMessageResult::SecurityRejected
}
GroupDecryptOutcome::Retriable => {
let group_id = payload.group_id.clone();
self.buffer_pending_group_message(
&group_id,
PendingGroupMessage {
sender: sender.to_string(),
message_id: message.id.as_str().to_string(),
logical_id: logical_key,
ciphertext_b64: payload.ciphertext,
timestamp: None,
reply_to: payload.reply_to,
forward_info: payload.forward_info,
buffered_at: Instant::now(),
received_via: arrival_transport,
},
);
InternalMessageResult::Deferred
}
GroupDecryptOutcome::NonApplication => {
self.drain_pending_commits(&payload.group_id);
self.drain_pending_group_messages(&payload.group_id);
InternalMessageResult::Consumed
}
GroupDecryptOutcome::PolicyRejected {
committer,
added,
removed,
} => {
self.report_unauthorized_membership_change(
&payload.group_id,
&committer,
&added,
&removed,
"sender_not_admin",
true,
);
InternalMessageResult::Consumed
}
GroupDecryptOutcome::NotMlsCiphertext | GroupDecryptOutcome::Failed => {
InternalMessageResult::Consumed
}
}
}
fn decrypt_group_application(
&self,
group_id: &str,
ciphertext: Vec<u8>,
sender: &str,
) -> GroupDecryptOutcome {
let mls_guard = match self.read_mls_guard() {
Ok(guard) => guard,
Err(e) => {
warn!(group_id = %group_id, error = %e, "MLS unavailable, dropping group message");
return GroupDecryptOutcome::Failed;
}
};
let gid = match offline_protocol_mls::GroupId::new(group_id) {
Ok(gid) => gid,
Err(e) => {
warn!(group_id = %group_id, error = %e, "Dropping group message with invalid group id");
return GroupDecryptOutcome::Failed;
}
};
let encrypted = offline_protocol_mls::EncryptedMessage {
group_id: gid,
message_type: offline_protocol_mls::MlsMessageType::Application,
epoch: 0,
ciphertext,
sender_id: sender.to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
};
match mls_guard.decrypt_from_group(&encrypted, sender) {
Ok(Some(plaintext)) => GroupDecryptOutcome::Plaintext(plaintext),
Ok(None) => {
debug!(
group_id = %group_id,
"MLS returned no plaintext (commit/proposal consumed), not an application message"
);
GroupDecryptOutcome::NonApplication
}
Err(offline_protocol_mls::MlsError::SenderIdentityMismatch {
claimed,
authenticated,
}) => {
error!(
group_id = %group_id,
claimed = %claimed,
authenticated = %authenticated,
"SECURITY: wire sender does not match MLS-authenticated sender, rejecting group message"
);
GroupDecryptOutcome::SecurityRejected
}
Err(e @ offline_protocol_mls::MlsError::LeafAddressMismatch { .. }) => {
error!(
group_id = %group_id,
sender = %sender,
error = %e,
"SECURITY: group leaf does not prove the identity it claims, rejecting group message"
);
GroupDecryptOutcome::SecurityRejected
}
Err(e @ offline_protocol_mls::MlsError::UnsupportedSender { .. }) => {
error!(
group_id = %group_id,
sender = %sender,
error = %e,
"SECURITY: rejecting a group message from an unsupported sender role"
);
GroupDecryptOutcome::SecurityRejected
}
Err(offline_protocol_mls::MlsError::CommitNotAuthorized {
committer,
added,
removed,
}) => GroupDecryptOutcome::PolicyRejected {
committer,
added,
removed,
},
Err(e @ offline_protocol_mls::MlsError::GroupNotFound(_)) => {
if offline_protocol_mls::is_mls_framed(&encrypted.ciphertext) {
warn!(
group_id = %group_id,
error = %e,
"Failed to decrypt group message (may be out-of-order, buffering)"
);
GroupDecryptOutcome::Retriable
} else {
debug!(
group_id = %group_id,
"Group message payload is not MLS framing and no local group state exists"
);
GroupDecryptOutcome::NotMlsCiphertext
}
}
Err(
e @ (offline_protocol_mls::MlsError::Decryption(_)
| offline_protocol_mls::MlsError::SessionDesync(_)),
) => {
warn!(
group_id = %group_id,
error = %e,
"Failed to decrypt group message (may be out-of-order, buffering)"
);
GroupDecryptOutcome::Retriable
}
Err(e) => {
warn!(group_id = %group_id, error = %e, "Failed to decrypt group message");
GroupDecryptOutcome::Failed
}
}
}
pub(crate) fn handle_group_mls_welcome(&mut self, message_id: &str, sender: &str, data: &str) {
let payload = match serde_json::from_str::<GroupMlsWelcomePayload>(data) {
Ok(p) => p,
Err(_) => {
warn!(sender = %sender, "Failed to parse GroupMlsWelcome payload");
return;
}
};
info!(group_id = %payload.group_id, "Received mesh group Welcome");
let dedup_key = message_id.to_string();
if self.group_mesh.message_dedup.contains_key(&dedup_key) {
debug!(group_id = %payload.group_id, msg_id = %dedup_key, "Duplicate Welcome message, skipping");
return;
}
self.group_mesh
.message_dedup
.insert(dedup_key, Instant::now());
if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
self.cleanup_group_message_dedup();
}
if self.group_mesh.members.contains_key(&payload.group_id) {
debug!(group_id = %payload.group_id, "Ignoring duplicate Welcome — already a member of this group");
return;
}
let welcome_bytes = match base64_decode(&payload.welcome_data) {
Ok(bytes) => bytes,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "Failed to decode welcome data");
return;
}
};
let mls_guard = match self.read_mls_guard() {
Ok(guard) => guard,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "MLS unavailable, dropping group welcome");
return;
}
};
let gid = match offline_protocol_mls::GroupId::new(&payload.group_id) {
Ok(gid) => gid,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "Dropping group welcome with invalid group id");
return;
}
};
let welcome = offline_protocol_mls::WelcomeMessage {
group_id: gid.clone(),
welcome_data: welcome_bytes,
inviter_id: sender.to_string(),
group_name: payload.group_name.clone(),
timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
};
let mut unproven_leaf_invite = false;
let join_result = match mls_guard.join_group(&welcome) {
Ok(group_info) => Some(group_info.members.clone()),
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "Failed to join mesh group");
if matches!(
e,
offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
| offline_protocol_mls::MlsError::UnsupportedSender { .. }
) {
unproven_leaf_invite = true;
}
None
}
};
drop(mls_guard);
if unproven_leaf_invite {
let group_id = payload.group_id.clone();
self.report_unproven_leaf(
&group_id,
sender,
UnprovenLeafSite::WelcomeDeclined,
"Group invite declined: it carried an identity claim this device could not \
verify, so messages in this group could not be reliably attributed"
.to_string(),
);
}
if let Some(members) = join_result {
let group_id = payload.group_id.clone();
for (user_id, versions) in &payload.member_rich {
if members.contains(user_id) {
self.record_attested_rich(user_id, versions);
}
}
self.group_mesh.members.insert(group_id.clone(), members);
if !payload.member_roles.is_empty() || payload.created_by.is_some() {
if let Ok(mls_guard) = self.read_mls_guard() {
for (user_id, role) in &payload.member_roles {
if let Err(e) = mls_guard.set_member_role(&gid, user_id, *role) {
warn!(user_id = %user_id, error = %e, "Failed to store member role from welcome");
}
}
if let Some(creator) = &payload.created_by {
if let Err(e) = mls_guard.set_group_creator(&gid, creator) {
warn!(creator = %creator, error = %e, "Failed to store group creator from welcome");
}
}
}
}
self.key_package_sent_to.remove(sender);
if let Err(e) = self.send_key_package_to(sender, false) {
debug!(
inviter = %sender,
error = %e,
"Failed to send fresh key package to inviter after Welcome (will retry on discovery)"
);
}
self.emit_event(Event::group_member_added(
group_id.clone(),
self.local_id.clone(),
sender.to_string(),
payload.group_name.clone(),
None,
));
self.drain_pending_commits(&group_id);
self.drain_pending_group_messages(&group_id);
}
}
pub(crate) fn handle_group_mls_commit(&mut self, message_id: &str, sender: &str, data: &str) {
let dedup_key = message_id.to_string();
if self.group_mesh.message_dedup.contains_key(&dedup_key) {
debug!(msg_id = %dedup_key, "Duplicate commit message, skipping");
return;
}
self.group_mesh
.message_dedup
.insert(dedup_key, Instant::now());
if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
self.cleanup_group_message_dedup();
}
match self.process_commit_core(sender, data) {
CommitOutcome::Success(group_id) => {
self.drain_pending_commits(&group_id);
self.drain_pending_group_messages(&group_id);
}
CommitOutcome::Retriable(group_id) => {
self.buffer_pending_commit(&group_id, message_id, sender, data);
}
CommitOutcome::Rejected => {}
}
}
fn process_commit_core(&mut self, sender: &str, data: &str) -> CommitOutcome {
let payload = match serde_json::from_str::<GroupMlsCommitPayload>(data) {
Ok(p) => p,
Err(_) => {
warn!(sender = %sender, "Failed to parse GroupMlsCommit payload");
return CommitOutcome::Rejected;
}
};
info!(
group_id = %payload.group_id,
commit_type = ?payload.commit_type,
"Received mesh group Commit"
);
if payload.ciphertext.is_empty() {
warn!(
group_id = %payload.group_id,
"Received Commit with empty ciphertext, cannot advance MLS epoch"
);
return CommitOutcome::Rejected;
}
let ciphertext_bytes = match base64_decode(&payload.ciphertext) {
Ok(bytes) => bytes,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "Failed to decode commit ciphertext");
return CommitOutcome::Rejected;
}
};
let mls_guard = match self.read_mls_guard() {
Ok(guard) => guard,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "MLS unavailable, dropping group commit");
return CommitOutcome::Rejected;
}
};
let gid = match offline_protocol_mls::GroupId::new(&payload.group_id) {
Ok(gid) => gid,
Err(e) => {
warn!(group_id = %payload.group_id, error = %e, "Rejecting group commit with invalid group id");
return CommitOutcome::Rejected;
}
};
let members_before: Option<HashSet<String>> = match mls_guard.get_group_info(&gid) {
Ok(info) => Some(
info.map(|i| i.members.into_iter().collect())
.unwrap_or_default(),
),
Err(e) => {
warn!(
group_id = %payload.group_id,
error = %e,
"Failed to read pre-commit roster — this commit's membership delta will not be derived or judged"
);
None
}
};
let encrypted = offline_protocol_mls::EncryptedMessage {
group_id: gid.clone(),
message_type: offline_protocol_mls::MlsMessageType::Commit,
epoch: payload.epoch,
ciphertext: ciphertext_bytes,
sender_id: sender.to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
};
let mls_result = mls_guard.decrypt_from_group(&encrypted, sender);
drop(mls_guard);
match mls_result {
Ok(_) => {}
Err(ref e) => {
let self_id = self.local_id.clone();
if matches!(payload.commit_type, GroupCommitType::Remove)
&& payload.affected_member.as_deref() == Some(&self_id)
&& self
.check_is_admin(&payload.group_id, sender)
.unwrap_or(false)
{
info!(
group_id = %payload.group_id,
"Received remove-commit targeting us — cleaning up local state"
);
if let Ok(mls_guard) = self.read_mls_guard() {
let _ = mls_guard.leave_group(&gid);
}
self.group_mesh.members.remove(&payload.group_id);
let was_synced = self.group_mesh.relay_synced.remove(&payload.group_id);
let was_pending = self
.group_mesh
.relay_register_pending
.remove(&payload.group_id)
.is_some();
if was_synced || was_pending {
self.emit_event(Event::group_relay_sync_changed(
payload.group_id.clone(),
false,
"removed",
));
}
self.group_mesh
.pending_group_messages
.remove(&payload.group_id);
self.group_mesh.pending_commits.remove(&payload.group_id);
self.emit_event(Event::group_member_removed(
payload.group_id.clone(),
self_id,
sender.to_string(),
Some(true),
));
return CommitOutcome::Rejected;
}
if let offline_protocol_mls::MlsError::CommitNotAuthorized {
committer,
added,
removed,
} = e
{
let (committer, added, removed) =
(committer.clone(), added.clone(), removed.clone());
self.report_unauthorized_membership_change(
&payload.group_id,
&committer,
&added,
&removed,
"sender_not_admin",
true,
);
return CommitOutcome::Rejected;
}
let is_permanent = matches!(
e,
offline_protocol_mls::MlsError::Deserialization(_)
| offline_protocol_mls::MlsError::InvalidMessage(_)
| offline_protocol_mls::MlsError::Storage(_)
| offline_protocol_mls::MlsError::SenderIdentityMismatch { .. }
| offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
| offline_protocol_mls::MlsError::UnsupportedSender { .. }
);
if is_permanent {
error!(
group_id = %payload.group_id,
epoch = payload.epoch,
error = %e,
"Permanently failed to process group commit (not retriable)"
);
if matches!(
e,
offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
| offline_protocol_mls::MlsError::UnsupportedSender { .. }
) {
let detail = "Group membership change refused: it carried an identity \
claim this device could not verify, so messages in this \
group could not be reliably attributed"
.to_string();
let group_id = payload.group_id.clone();
self.report_unproven_leaf(
&group_id,
sender,
UnprovenLeafSite::CommitRefused,
detail,
);
}
return CommitOutcome::Rejected;
}
error!(
group_id = %payload.group_id,
epoch = payload.epoch,
error = %e,
"Failed to process group commit (may be out-of-order, buffering)"
);
return CommitOutcome::Retriable(payload.group_id);
}
}
let members_after: Option<HashSet<String>> = match self
.refresh_group_members(&payload.group_id)
{
Ok(members) => Some(members.into_iter().collect()),
Err(e) => {
warn!(
group_id = %payload.group_id,
error = %e,
"Failed to refresh post-commit roster — this commit's membership delta will not be derived or judged"
);
None
}
};
let (Some(members_before), Some(members_after)) = (members_before, members_after) else {
if matches!(payload.commit_type, GroupCommitType::KeyUpdate) {
self.group_mesh.epoch_forks.remove(&payload.group_id);
}
return CommitOutcome::Success(payload.group_id);
};
let mut actual_added: Vec<String> =
members_after.difference(&members_before).cloned().collect();
actual_added.sort();
let mut actual_removed: Vec<String> =
members_before.difference(&members_after).cloned().collect();
actual_removed.sort();
let has_membership_change = !actual_added.is_empty() || !actual_removed.is_empty();
let mut claim_matches_delta = true;
if let Some(claimed) = &payload.affected_member {
let valid = match payload.commit_type {
GroupCommitType::Add => actual_added.contains(claimed),
GroupCommitType::Remove => actual_removed.contains(claimed),
GroupCommitType::KeyUpdate => true, };
if !valid && has_membership_change {
claim_matches_delta = false;
error!(
group_id = %payload.group_id,
sender = %sender,
claimed = %claimed,
actual_added = ?actual_added,
actual_removed = ?actual_removed,
"SECURITY: Commit affected_member does not match actual MLS state delta — possible forgery"
);
}
}
let judgment = if has_membership_change {
Some(self.judge_membership_change(
&payload.group_id,
sender,
&actual_added,
&actual_removed,
claim_matches_delta,
))
} else {
None
};
let metadata_sender_is_admin =
judgment.as_ref().is_some_and(|j| j.sender_is_admin) && !actual_added.is_empty();
let authorized = judgment.map(|j| j.authorized);
for member in &actual_added {
if let (Some(role), Some(affected)) = (&payload.role, &payload.affected_member) {
if member == affected {
if metadata_sender_is_admin {
if let Ok(mls_guard) = self.read_mls_guard() {
if let Err(e) = mls_guard.set_member_role(&gid, member, *role) {
warn!(user_id = %member, error = %e, "Failed to store member role from commit");
}
}
} else {
warn!(
sender = %sender,
group_id = %payload.group_id,
"Ignoring role from commit: sender is not admin"
);
}
}
}
if let (Some(versions), Some(affected)) =
(&payload.affected_member_rich, &payload.affected_member)
{
if member == affected && metadata_sender_is_admin {
self.record_attested_rich(member, versions);
}
}
self.emit_event(Event::group_member_added(
payload.group_id.clone(),
member.clone(),
sender.to_string(),
None,
authorized,
));
}
for member in &actual_removed {
if let Ok(mls_guard) = self.read_mls_guard() {
if let Err(e) = mls_guard.remove_member_role(&gid, member) {
warn!(user_id = %member, error = %e, "Failed to clean up role metadata for removed member");
}
}
self.emit_event(Event::group_member_removed(
payload.group_id.clone(),
member.clone(),
sender.to_string(),
authorized,
));
self.group_mesh
.pending_leave_elections
.remove(&(payload.group_id.clone(), member.to_string()));
}
if !actual_removed.is_empty() {
let remaining = self
.group_mesh
.members
.get(&payload.group_id)
.cloned()
.unwrap_or_default();
self.auto_promote_if_no_admin(&payload.group_id, &remaining);
}
if matches!(payload.commit_type, GroupCommitType::KeyUpdate) {
self.group_mesh.epoch_forks.remove(&payload.group_id);
}
CommitOutcome::Success(payload.group_id)
}
pub(crate) fn judge_membership_change(
&mut self,
group_id: &str,
sender: &str,
added: &[String],
removed: &[String],
claim_matches_delta: bool,
) -> MembershipJudgment {
let sender_is_admin = self.check_is_admin(group_id, sender).unwrap_or(false);
let authorized = sender_is_admin && claim_matches_delta;
if !authorized {
let reason = if sender_is_admin {
"affected_member_mismatch"
} else {
"sender_not_admin"
};
self.report_unauthorized_membership_change(
group_id, sender, added, removed, reason, false,
);
}
MembershipJudgment {
sender_is_admin,
authorized,
}
}
fn report_unproven_leaf(
&mut self,
group_id: &str,
peer: &str,
site: UnprovenLeafSite,
detail: String,
) -> bool {
let key = (group_id.to_string(), peer.to_string(), site);
if !claim_report_window(&mut self.group_mesh.unproven_leaf_reports, key) {
debug!(
group_id = %group_id,
sender = %peer,
?site,
"Suppressing repeated unproven-leaf report within the rate-limit window"
);
return false;
}
self.emit_event(Event::security_warning(
peer.to_string(),
crate::events::SecurityWarningCode::GroupLeafIdentityUnproven,
detail,
));
true
}
fn report_unauthorized_membership_change(
&mut self,
group_id: &str,
committer: &str,
added: &[String],
removed: &[String],
reason: &str,
enforced: bool,
) {
let key = (group_id.to_string(), committer.to_string(), enforced);
if !claim_report_window(&mut self.group_mesh.unauthorized_change_reports, key) {
debug!(
group_id = %group_id,
sender = %committer,
reason,
enforced,
"Suppressing repeated unauthorized-membership-change report within the rate-limit window"
);
return;
}
if enforced {
warn!(
group_id = %group_id,
sender = %committer,
reason,
added = ?added,
removed = ?removed,
"SECURITY: Unauthorized group membership change refused — the commit was not merged, so our epoch now trails members that accepted it"
);
} else {
warn!(
group_id = %group_id,
sender = %committer,
reason,
added = ?added,
removed = ?removed,
"SECURITY: Unauthorized group membership change applied — MLS accepted the commit, but the SDK's admin policy did not authorize it"
);
}
self.emit_event(Event::group_unauthorized_membership_change(
group_id.to_string(),
committer.to_string(),
added.to_vec(),
removed.to_vec(),
reason.to_string(),
enforced,
));
}
pub(crate) fn release_replay_protection(&mut self, message_id: &str) {
self.group_mesh.message_dedup.remove(message_id);
if let Ok(envelope_id) = MessageId::from_str(message_id) {
self.deduplicator.unmark_seen(&envelope_id);
}
}
pub(crate) fn buffer_pending_commit(
&mut self,
group_id: &str,
message_id: &str,
sender: &str,
data: &str,
) {
let pending = PendingCommit {
sender: sender.to_string(),
message_id: message_id.to_string(),
data: data.to_string(),
buffered_at: Instant::now(),
retry_count: 0,
};
let Some(evicted) = enforce_global_buffer_bound(
&mut self.group_mesh.pending_commits,
group_id,
&PENDING_COMMIT_CAPS,
pending.data.len(),
|c| c.data.len(),
|c| c.buffered_at,
) else {
warn!(
group_id = %group_id,
size = pending.data.len(),
"Pending commit exceeds the global byte budget, dropping"
);
return;
};
for entry in &evicted {
self.release_replay_protection(&entry.message_id);
}
let displaced = {
let buf = self
.group_mesh
.pending_commits
.entry(group_id.to_string())
.or_default();
let displaced = if buf.len() >= MAX_PENDING_COMMITS_PER_GROUP {
warn!(
group_id = %group_id,
"Pending commit buffer full, dropping oldest"
);
buf.pop_front()
} else {
None
};
buf.push_back(pending);
debug!(
group_id = %group_id,
buffered_count = buf.len(),
"Buffered out-of-order commit for deferred retry"
);
displaced
};
if let Some(displaced) = displaced {
self.release_replay_protection(&displaced.message_id);
}
}
pub(crate) fn drain_pending_commits(&mut self, group_id: &str) {
let mut retried_expired_count: usize = 0;
for _ in 0..MAX_PENDING_COMMITS_PER_GROUP {
let pending = match self.group_mesh.pending_commits.get_mut(group_id) {
Some(buf) if !buf.is_empty() => std::mem::take(buf),
_ => break,
};
let mut any_succeeded = false;
let mut still_pending = VecDeque::new();
for entry in pending {
if entry.buffered_at.elapsed() > StdDuration::from_secs(PENDING_COMMIT_TTL_SECS) {
debug!(
group_id = %group_id,
retry_count = entry.retry_count,
"Dropping expired pending commit"
);
if entry.retry_count > 0 {
retried_expired_count += 1;
}
self.release_replay_protection(&entry.message_id);
continue;
}
match self.process_commit_core(&entry.sender, &entry.data) {
CommitOutcome::Success(_) => {
any_succeeded = true;
}
CommitOutcome::Retriable(_) => {
let mut entry = entry;
entry.retry_count += 1;
still_pending.push_back(entry);
}
CommitOutcome::Rejected => {
}
}
}
if !still_pending.is_empty() {
self.group_mesh
.pending_commits
.entry(group_id.to_string())
.or_default()
.extend(still_pending);
}
if !any_succeeded {
break;
}
}
if self
.group_mesh
.pending_commits
.get(group_id)
.is_none_or(|v| v.is_empty())
{
self.group_mesh.pending_commits.remove(group_id);
}
if retried_expired_count > 0 && !self.group_mesh.epoch_forks.contains_key(group_id) {
self.flag_potential_epoch_fork(group_id);
}
}
pub(crate) fn buffer_pending_group_message(
&mut self,
group_id: &str,
pending: PendingGroupMessage,
) {
let Some(evicted) = enforce_global_buffer_bound(
&mut self.group_mesh.pending_group_messages,
group_id,
&PENDING_GROUP_MESSAGE_CAPS,
pending.ciphertext_b64.len(),
|m| m.ciphertext_b64.len(),
|m| m.buffered_at,
) else {
warn!(
group_id = %group_id,
size = pending.ciphertext_b64.len(),
"Pending group message exceeds the global byte budget, dropping"
);
return;
};
for entry in &evicted {
self.release_replay_protection(&entry.message_id);
}
let displaced = {
let buf = self
.group_mesh
.pending_group_messages
.entry(group_id.to_string())
.or_default();
let displaced = if buf.len() >= MAX_PENDING_GROUP_MESSAGES_PER_GROUP {
warn!(
group_id = %group_id,
"Pending group message buffer full, dropping oldest"
);
buf.pop_front()
} else {
None
};
buf.push_back(pending);
debug!(
group_id = %group_id,
buffered_count = buf.len(),
"Buffered out-of-order group message for deferred retry"
);
displaced
};
if let Some(displaced) = displaced {
self.release_replay_protection(&displaced.message_id);
}
}
fn is_group_message_pending(&self, group_id: &str, message_id: &str) -> bool {
self.group_mesh
.pending_group_messages
.get(group_id)
.is_some_and(|buf| {
buf.iter().any(|m| {
m.message_id == message_id || m.logical_id.as_deref() == Some(message_id)
})
})
}
fn ack_drained_group_message(
&mut self,
ack_to: &str,
acked_message_id: &str,
received_via: Option<TransportType>,
) {
let Some(transport) = received_via else {
return;
};
if let Err(err) = self.send_group_delivery_ack(ack_to, acked_message_id, transport) {
error!(
msg_id = %acked_message_id,
error = %err,
"Failed to send deferred delivery ACK for drained group message"
);
}
}
pub(crate) fn drain_pending_group_messages(&mut self, group_id: &str) {
let mut delivered_here: HashSet<String> = HashSet::new();
loop {
let pending = match self.group_mesh.pending_group_messages.get_mut(group_id) {
Some(buf) if !buf.is_empty() => std::mem::take(buf),
_ => break,
};
let mut state_advanced = false;
let mut still_pending = VecDeque::new();
for entry in pending {
if delivered_here.contains(&entry.message_id)
|| entry
.logical_id
.as_deref()
.is_some_and(|l| delivered_here.contains(l))
{
debug!(
group_id = %group_id,
msg_id = %entry.message_id,
"Buffered copy of a message this drain already delivered, dropping"
);
self.ack_drained_group_message(
&entry.sender,
&entry.message_id,
entry.received_via,
);
continue;
}
if entry.buffered_at.elapsed()
> StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS)
{
debug!(
group_id = %group_id,
msg_id = %entry.message_id,
"Dropping expired pending group message"
);
self.release_replay_protection(&entry.message_id);
continue;
}
let Ok(ciphertext_bytes) = base64_decode(&entry.ciphertext_b64) else {
continue;
};
match self.decrypt_group_application(group_id, ciphertext_bytes, &entry.sender) {
GroupDecryptOutcome::Plaintext(plaintext) => {
let Ok(text) = String::from_utf8(plaintext) else {
warn!(
group_id = %group_id,
"Decrypted group payload is not valid UTF-8, dropping"
);
continue;
};
info!(
group_id = %group_id,
msg_id = %entry.message_id,
"Delivered buffered group message after group state caught up"
);
let received_via = entry.received_via;
let ack_sender = entry.sender.clone();
let ack_message_id = entry.message_id.clone();
let emit_id = match entry.logical_id {
Some(logical) => {
self.group_mesh
.message_dedup
.insert(logical.clone(), Instant::now());
logical
}
None => entry.message_id,
};
delivered_here.insert(ack_message_id.clone());
delivered_here.insert(emit_id.clone());
let (content, media_metadata, content_type, forward_info_event) =
Self::restore_group_rich(text, entry.forward_info, &entry.sender);
let timestamp = entry
.timestamp
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
self.emit_event(Event::group_message_received(
group_id.to_string(),
entry.sender,
content,
timestamp,
emit_id,
entry.reply_to,
forward_info_event,
media_metadata,
content_type,
));
self.ack_drained_group_message(&ack_sender, &ack_message_id, received_via);
}
GroupDecryptOutcome::Retriable => {
still_pending.push_back(entry);
}
GroupDecryptOutcome::NonApplication => {
state_advanced = true;
}
GroupDecryptOutcome::PolicyRejected {
committer,
added,
removed,
} => {
self.report_unauthorized_membership_change(
group_id,
&committer,
&added,
&removed,
"sender_not_admin",
true,
);
}
GroupDecryptOutcome::SecurityRejected
| GroupDecryptOutcome::NotMlsCiphertext
| GroupDecryptOutcome::Failed => {
self.release_replay_protection(&entry.message_id);
}
}
}
if !still_pending.is_empty() {
self.group_mesh
.pending_group_messages
.entry(group_id.to_string())
.or_default()
.extend(still_pending);
}
if !state_advanced {
break;
}
self.drain_pending_commits(group_id);
}
if self
.group_mesh
.pending_group_messages
.get(group_id)
.is_none_or(|v| v.is_empty())
{
self.group_mesh.pending_group_messages.remove(group_id);
}
}
fn flag_potential_epoch_fork(&mut self, group_id: &str) {
if self.group_mesh.epoch_forks.len() >= MAX_EPOCH_FORK_ENTRIES {
if let Some(oldest_key) = self
.group_mesh
.epoch_forks
.values()
.min_by_key(|f| f.detected_at)
.map(|f| f.group_id.clone())
{
self.group_mesh.epoch_forks.remove(&oldest_key);
}
}
let local_epoch = self.refresh_group_members(group_id).ok().and_then(|_| {
let mls_guard = self.read_mls_guard().ok()?;
let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
mls_guard
.get_group_info(&gid)
.ok()
.flatten()
.map(|i| i.epoch)
});
if local_epoch.is_none() {
warn!(
group_id = %group_id,
"Could not read local MLS epoch during fork detection — MLS unavailable or group not found"
);
}
warn!(
group_id = %group_id,
local_epoch = ?local_epoch,
"Potential epoch fork detected — buffered commits expired without resolution"
);
self.group_mesh.epoch_forks.insert(
group_id.to_string(),
EpochForkState {
group_id: group_id.to_string(),
local_epoch,
detected_at: Instant::now(),
resolution_attempted: false,
},
);
self.emit_event(Event::group_epoch_fork_detected(
group_id.to_string(),
local_epoch,
));
}
pub(crate) fn handle_group_mls_leave(&mut self, message_id: &str, sender: &str, data: &str) {
let payload = match serde_json::from_str::<GroupMlsLeavePayload>(data) {
Ok(p) => p,
Err(_) => {
warn!(sender = %sender, "Failed to parse GroupMlsLeave payload");
return;
}
};
let dedup_key = message_id.to_string();
if self.group_mesh.message_dedup.contains_key(&dedup_key) {
debug!(
group_id = %payload.group_id,
msg_id = %dedup_key,
"Duplicate leave notification, skipping"
);
return;
}
self.group_mesh
.message_dedup
.insert(dedup_key, Instant::now());
if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
self.cleanup_group_message_dedup();
}
if payload.leaving_member != sender {
error!(
sender = %sender,
claimed = %payload.leaving_member,
group_id = %payload.group_id,
"SECURITY: Leave notification sender mismatch — possible spoofing attempt, ignoring"
);
return;
}
let self_id = self.local_id.clone();
let members = self
.refresh_group_members(&payload.group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&payload.group_id).cloned())
.unwrap_or_default();
if !members.iter().any(|m| m == sender) {
error!(
sender = %sender,
group_id = %payload.group_id,
"SECURITY: Leave notification from non-member, ignoring"
);
return;
}
info!(
group_id = %payload.group_id,
leaving_member = %payload.leaving_member,
"Received mesh group leave notification"
);
let mut remaining: Vec<String> = members
.iter()
.filter(|m| m.as_str() != sender)
.cloned()
.collect();
remaining.sort();
let should_remove = remaining
.first()
.map(|first| first == &self_id)
.unwrap_or(false);
if should_remove {
debug!(
group_id = %payload.group_id,
leaving_member = %sender,
"Elected to issue MLS remove-commit for leaving member"
);
if let Err(e) = self.remove_from_group(&payload.group_id, sender) {
warn!(
group_id = %payload.group_id,
leaving_member = %sender,
error = %e,
"Failed to issue MLS remove-commit for leaving member"
);
}
let key = (payload.group_id.clone(), sender.to_string());
self.group_mesh.pending_leave_elections.remove(&key);
} else {
if self.group_mesh.pending_leave_elections.len() >= MAX_PENDING_LEAVE_ELECTIONS {
if let Some(oldest_key) = self
.group_mesh
.pending_leave_elections
.iter()
.min_by_key(|(_, e)| e.received_at)
.map(|(k, _)| k.clone())
{
self.group_mesh.pending_leave_elections.remove(&oldest_key);
}
}
let key = (payload.group_id.clone(), sender.to_string());
self.group_mesh.pending_leave_elections.insert(
key,
PendingLeaveElection {
group_id: payload.group_id.clone(),
leaving_member: sender.to_string(),
received_at: Instant::now(),
last_attempt_at: None,
},
);
}
}
pub(crate) fn refresh_group_members(&mut self, group_id: &str) -> Result<Vec<String>> {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let info = mls_guard
.get_group_info(&gid)?
.ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
let members = info.members.clone();
let unproven_members = info.unproven_members;
drop(mls_guard);
self.group_mesh
.members
.insert(group_id.to_string(), members.clone());
if unproven_members > 0 {
let peer = self.local_id.clone();
let reported = self.report_unproven_leaf(
group_id,
&peer,
UnprovenLeafSite::RosterEntry,
"This group holds membership state that could not be verified against the keys \
it claims, so messages in this group cannot be reliably attributed. The group \
should be treated as compromised and abandoned rather than repaired."
.to_string(),
);
if reported {
error!(
group_id = %group_id,
unproven_members,
"SECURITY: local group state holds leaves that do not prove the identity \
they claim; they are kept out of the roster and cannot speak, but they \
have read everything sent to this group"
);
}
}
Ok(members)
}
pub fn create_group(&mut self, group_name: &str) -> Result<offline_protocol_mls::GroupInfo> {
let trimmed = group_name.trim();
if trimmed.is_empty() {
return Err(Error::InvalidArgument(
"Group name cannot be empty".to_string(),
));
}
let mls_guard = self.read_mls_guard()?;
let group_info = mls_guard.create_group(group_name)?;
let group_id = group_info.group_id.as_str().to_string();
let members = group_info.members.clone();
drop(mls_guard);
self.group_mesh
.members
.insert(group_id.clone(), members.clone());
self.emit_event(Event::group_created(
group_id.clone(),
group_name.to_string(),
));
let _ = self.try_relay_register_group(&group_id, Some(group_name), &members);
Ok(group_info)
}
pub fn invite_to_group(&mut self, group_id: &str, invitee_user_id: &str) -> Result<()> {
Self::validate_outbound_recipient(invitee_user_id)?;
let self_id = self.local_id.clone();
if !self.check_is_admin(group_id, &self_id)? {
return Err(Error::PermissionDenied(
"Only admins can invite members".to_string(),
));
}
let current_count = self
.group_mesh
.members
.get(group_id)
.map(|m| m.len())
.or_else(|| {
self.read_mls_guard().ok().and_then(|g| {
g.get_group_info(&offline_protocol_mls::GroupId::new(group_id).ok()?)
.ok()
.flatten()
.map(|info| info.members.len())
})
})
.unwrap_or(0);
let max_members = self.config.group.max_group_members;
if current_count >= max_members {
return Err(Error::InvalidState(format!(
"Group has {} members, cannot exceed {} limit",
current_count, max_members
)));
}
self.try_load_key_package_from_storage_into_memory(invitee_user_id);
let now_ms = Utc::now().timestamp_millis() as u64;
let received_pkg = self
.pending_key_packages
.get(invitee_user_id)
.ok_or_else(|| Error::NoKeyPackage(invitee_user_id.to_string()))?;
if now_ms >= received_pkg.local_expires_at_ms {
self.pending_key_packages.remove(invitee_user_id);
return Err(Error::InvalidState(format!(
"Key package for {} has expired",
invitee_user_id
)));
}
let key_pkg = received_pkg.key_package_data.clone();
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let (welcome, commit) = mls_guard.add_group_member(&gid, invitee_user_id, &key_pkg)?;
let group_name = welcome.group_name.clone();
drop(mls_guard);
self.pending_key_packages.remove(invitee_user_id);
self.delete_peer_key_package_from_storage(invitee_user_id);
self.key_package_sent_to.remove(invitee_user_id);
if let Err(e) = self.send_key_package_to(invitee_user_id, false) {
debug!(
invitee = %invitee_user_id,
error = %e,
"Key package send to invitee deferred (will retry on next discovery)"
);
}
let members = self.refresh_group_members(group_id)?;
let (member_roles, created_by) = {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
if let Err(e) = mls_guard.set_member_role(&gid, invitee_user_id, GroupRole::Member) {
warn!(invitee = %invitee_user_id, error = %e, "Failed to store invitee role");
}
mls_guard
.get_group_metadata(&gid)
.ok()
.flatten()
.map(|m| (m.get_all_roles(), m.created_by.clone()))
.unwrap_or_default()
};
let invitee_rich = self.attestable_rich_versions(invitee_user_id);
let member_rich: HashMap<String, Vec<u8>> = members
.iter()
.filter(|m| m.as_str() != invitee_user_id)
.filter_map(|m| {
if *m == self_id {
self.config
.encryption
.rich_payload_enabled
.then(|| (m.clone(), vec![RICH_PAYLOAD_V1]))
} else {
self.attestable_rich_versions(m).map(|v| (m.clone(), v))
}
})
.collect();
let welcome_payload = GroupMlsWelcomePayload {
group_id: group_id.to_string(),
group_name,
welcome_data: base64_encode(&welcome.welcome_data),
member_list: members.clone(),
member_roles,
member_rich,
created_by,
};
let welcome_content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&welcome_payload)
.map_err(|e| Error::Serialization(format!("Serialize welcome: {}", e)))?
);
self.send_internal_message(invitee_user_id, welcome_content, MessagePriority::High)?;
let commit_payload = GroupMlsCommitPayload {
group_id: group_id.to_string(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(&commit.ciphertext),
epoch: commit.epoch,
affected_member: Some(invitee_user_id.to_string()),
role: Some(GroupRole::Member),
affected_member_rich: invitee_rich,
};
let commit_content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload)
.map_err(|e| Error::Serialization(format!("Serialize commit: {}", e)))?
);
let mut failed_commit_members: Vec<String> = Vec::new();
for member in &members {
if member == &self_id || member == invitee_user_id {
continue;
}
if let Err(e) =
self.send_internal_message(member, commit_content.clone(), MessagePriority::High)
{
warn!(
group_id = %group_id,
member = %member,
error = %e,
"Failed to send commit to group member during invite"
);
failed_commit_members.push(member.clone());
}
}
for member in &failed_commit_members {
let _ =
self.send_internal_message(member, commit_content.clone(), MessagePriority::High);
}
self.emit_event(Event::group_member_added(
group_id.to_string(),
invitee_user_id.to_string(),
self_id,
None,
Some(true),
));
let _ = self.try_relay_register_group(group_id, None, &members);
info!(group_id = %group_id, invitee = %invitee_user_id, "Invited member to group");
Ok(())
}
pub fn remove_from_group(&mut self, group_id: &str, member_id: &str) -> Result<()> {
let self_id = self.local_id.clone();
{
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
let is_admin = if let Some(ref meta) = metadata {
if meta.has_any_admin() {
meta.get_role(&self_id) == GroupRole::Admin
} else if let Some(ref creator) = meta.created_by {
creator == &self_id
} else {
false
}
} else {
false
};
if !is_admin {
return Err(Error::PermissionDenied(
"Only admins can remove members".to_string(),
));
}
if let Some(ref meta) = metadata {
if meta.get_role(member_id) == GroupRole::Admin {
let admin_count = meta
.get_all_roles()
.values()
.filter(|r| **r == GroupRole::Admin)
.count();
let member_count = self
.group_mesh
.members
.get(group_id)
.map(|m| m.len())
.unwrap_or(0);
if admin_count <= 1 && member_count > 2 {
return Err(Error::InvalidState(
"Cannot remove the last admin while other members remain. Promote another member to admin first.".to_string(),
));
}
}
}
}
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let commit_msg = mls_guard.remove_group_member(&gid, member_id)?;
drop(mls_guard);
let members = self.refresh_group_members(group_id)?;
if let Ok(mls_guard) = self.read_mls_guard() {
if let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) {
if let Err(e) = mls_guard.remove_member_role(&gid, member_id) {
warn!(member = %member_id, error = %e, "Failed to clean up role metadata for removed member");
}
}
}
self.auto_promote_if_no_admin(group_id, &members);
let commit_payload = GroupMlsCommitPayload {
group_id: group_id.to_string(),
commit_type: GroupCommitType::Remove,
ciphertext: base64_encode(&commit_msg.ciphertext),
epoch: commit_msg.epoch,
affected_member: Some(member_id.to_string()),
role: None,
affected_member_rich: None,
};
let commit_content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload)
.map_err(|e| Error::Serialization(format!("Serialize commit: {}", e)))?
);
let mut failed_commit_members: Vec<String> = Vec::new();
for member in &members {
if member == &self_id {
continue;
}
if let Err(e) =
self.send_internal_message(member, commit_content.clone(), MessagePriority::High)
{
warn!(
group_id = %group_id,
member = %member,
error = %e,
"Failed to send commit to group member during remove"
);
failed_commit_members.push(member.clone());
}
}
for member in &failed_commit_members {
let _ =
self.send_internal_message(member, commit_content.clone(), MessagePriority::High);
}
let removed_payload = GroupMemberRemovedPayload {
group_id: group_id.to_string(),
user_id: member_id.to_string(),
removed_by: self_id.clone(),
};
if let Ok(json) = serde_json::to_string(&removed_payload) {
let removed_content = format!("{}{}", internal_prefixes::GROUP_MEMBER_REMOVED, json);
if let Err(e) =
self.send_internal_message(member_id, removed_content, MessagePriority::High)
{
warn!(
group_id = %group_id,
member = %member_id,
error = %e,
"Failed to send removal notification to removed member"
);
}
}
self.emit_event(Event::group_member_removed(
group_id.to_string(),
member_id.to_string(),
self_id,
Some(true),
));
let _ = self.try_relay_register_group(group_id, None, &members);
info!(group_id = %group_id, member = %member_id, "Removed member from group");
Ok(())
}
pub fn leave_group(&mut self, group_id: &str) -> Result<()> {
let members = self
.group_mesh
.members
.get(group_id)
.cloned()
.or_else(|| self.refresh_group_members(group_id).ok())
.unwrap_or_default();
let self_id = self.local_id.clone();
let other_members_exist = members.iter().any(|m| m != &self_id);
if other_members_exist {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
if let Some(metadata) = mls_guard.get_group_metadata(&gid)? {
let is_admin = metadata.get_role(&self_id) == GroupRole::Admin;
if is_admin {
let admin_count = metadata
.get_all_roles()
.values()
.filter(|r| **r == GroupRole::Admin)
.count();
if admin_count <= 1 {
return Err(Error::InvalidState(
"Cannot leave group as the last admin while other members remain. Promote another member to admin first.".to_string(),
));
}
}
}
drop(mls_guard);
}
let leave_payload = GroupMlsLeavePayload {
group_id: group_id.to_string(),
leaving_member: self_id.clone(),
};
let leave_content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_LEAVE,
serde_json::to_string(&leave_payload)
.map_err(|e| Error::Serialization(format!("Serialize leave: {}", e)))?
);
let mut any_sent = false;
let mut had_recipients = false;
for member in &members {
if member == &self_id {
continue;
}
had_recipients = true;
match self.send_internal_message(member, leave_content.clone(), MessagePriority::Medium)
{
Ok(_) => {
any_sent = true;
}
Err(e) => {
warn!(
group_id = %group_id,
member = %member,
error = %e,
"Failed to send leave notification to group member"
);
}
}
}
if had_recipients && !any_sent {
return Err(Error::Transport(
offline_protocol_transport::Error::SendFailed(
"All leave notifications failed — local state preserved for retry".to_string(),
),
));
}
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
mls_guard.leave_group(&gid)?;
drop(mls_guard);
self.group_mesh.members.remove(group_id);
let was_synced = self.group_mesh.relay_synced.remove(group_id);
let was_pending = self
.group_mesh
.relay_register_pending
.remove(group_id)
.is_some();
if was_synced || was_pending {
self.emit_event(Event::group_relay_sync_changed(
group_id.to_string(),
false,
"left",
));
}
self.group_mesh.pending_group_messages.remove(group_id);
self.group_mesh.pending_commits.remove(group_id);
info!(group_id = %group_id, "Left group");
Ok(())
}
pub fn send_group_message(
&mut self,
group_id: &str,
content: &str,
priority: Option<MessagePriority>,
reply_to_msg: Option<&str>,
) -> Result<Vec<MessageId>> {
if Self::is_internal_prefix(content) {
return Err(Error::InvalidArgument(
"Message content must not start with a reserved internal prefix".to_string(),
));
}
self.send_group_message_inner(
group_id,
content,
priority.unwrap_or(MessagePriority::Medium),
reply_to_msg,
None,
None,
ContentType::Text,
)
}
pub fn send_group_message_with(
&mut self,
group_id: &str,
content: &str,
options: GroupSendOptions,
) -> Result<Vec<MessageId>> {
if Self::is_internal_prefix(content) {
return Err(Error::InvalidArgument(
"Message content must not start with a reserved internal prefix".to_string(),
));
}
if options.content_type == Some(ContentType::FileChunk) {
return Err(Error::InvalidArgument(
"FileChunk is an internal content type and cannot be sent directly".to_string(),
));
}
Self::check_group_rich_extras_size(options.media_metadata.as_ref(), None)?;
self.send_group_message_inner(
group_id,
content,
options.priority.unwrap_or(MessagePriority::Medium),
options.reply_to_msg.as_deref(),
None,
options.media_metadata,
options.content_type.unwrap_or_default(),
)
}
pub fn forward_message_to_group(
&mut self,
original_message: &Message,
group_id: &str,
priority: Option<MessagePriority>,
) -> Result<Vec<MessageId>> {
if Self::is_internal_prefix(&original_message.content) {
return Err(Error::InvalidArgument(
"Cannot forward a message with reserved internal prefix content".to_string(),
));
}
if original_message.content_type == ContentType::FileChunk {
return Err(Error::InvalidArgument(
"FileChunk is an internal content type and cannot be forwarded".to_string(),
));
}
let forward_info = ForwardInfo::from_message(original_message);
if forward_info.forward_count > crate::constants::MAX_FORWARD_COUNT {
return Err(Error::InvalidArgument(format!(
"Forward count {} exceeds maximum of {}",
forward_info.forward_count,
crate::constants::MAX_FORWARD_COUNT,
)));
}
Self::check_group_rich_extras_size(
original_message.media_metadata.as_ref(),
Some(&forward_info),
)?;
self.send_group_message_inner(
group_id,
&original_message.content,
priority.unwrap_or(MessagePriority::Medium),
None,
Some(forward_info),
original_message.media_metadata.clone(),
original_message.content_type,
)
}
fn check_group_rich_extras_size(
media_metadata: Option<&MediaMetadata>,
forward_info: Option<&ForwardInfo>,
) -> Result<()> {
RichSendExtras {
reply_context: None,
media_metadata: media_metadata.cloned(),
forward_info: forward_info.cloned(),
}
.check_size()
}
pub fn group_rich_readiness(&self, group_id: &str) -> Result<GroupRichReadiness> {
let members = match self.group_mesh.members.get(group_id) {
Some(m) => m.clone(),
None => {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
mls_guard
.get_group_info(&gid)?
.ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?
.members
}
};
let unknown_members = if self.config.encryption.rich_payload_enabled {
self.group_rich_unknown_members(&members)
} else {
Vec::new()
};
Ok(GroupRichReadiness {
ready: self.group_rich_seal_active(&members),
unknown_members,
})
}
pub fn group_relay_sync_state(&self, group_id: &str) -> RelaySyncState {
if self.group_mesh.relay_synced.contains(group_id) {
RelaySyncState::Synced
} else if self
.group_mesh
.relay_register_pending
.contains_key(group_id)
{
RelaySyncState::Pending
} else {
RelaySyncState::Unsynced
}
}
pub fn request_group_relay_registration(&mut self, group_id: &str) -> Result<bool> {
if self.group_mesh.relay_synced.contains(group_id) {
return Ok(true);
}
let members = self
.refresh_group_members(group_id)
.ok()
.or_else(|| self.group_mesh.members.get(group_id).cloned())
.ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
self.try_relay_register_group(group_id, None, &members)
}
#[allow(clippy::too_many_arguments)]
fn send_group_message_inner(
&mut self,
group_id: &str,
content: &str,
priority: MessagePriority,
reply_to_msg: Option<&str>,
forward_info: Option<ForwardInfo>,
media_metadata: Option<MediaMetadata>,
content_type: ContentType,
) -> Result<Vec<MessageId>> {
let members = match self.group_mesh.members.get(group_id) {
Some(m) => m.clone(),
None => {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let info = mls_guard
.get_group_info(&gid)?
.ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
info.members.clone()
}
};
if !self.group_mesh.members.contains_key(group_id) {
self.group_mesh
.members
.insert(group_id.to_string(), members.clone());
}
let extras = RichSendExtras {
reply_context: None,
media_metadata,
forward_info: forward_info.clone(),
};
let wants_seal = extras.is_any() || content_type != ContentType::Text;
let sealed = wants_seal && self.group_rich_seal_active(&members);
let sealed_body;
let plaintext: &str = if sealed {
sealed_body = Self::seal_rich_payload(content, &extras, content_type)?;
&sealed_body
} else {
let unknown = if wants_seal && self.config.encryption.rich_payload_enabled {
self.group_rich_unknown_members(&members)
} else {
Vec::new()
};
if !unknown.is_empty() {
self.backfill_group_rich_capabilities(&unknown);
}
if extras.media_metadata.is_some() {
warn!(
group_id = %group_id,
"Group not fully rich-capable, dropping rich media metadata (members \
will receive the text without the media attachment)"
);
self.emit_event(Event::group_rich_extras_dropped(
group_id.to_string(),
unknown,
));
}
content
};
let payload_forward_info = if sealed { None } else { forward_info };
let encrypted = {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
mls_guard.encrypt_for_group(&gid, plaintext.as_bytes())?
};
let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
let epoch = encrypted.epoch;
let self_id = self.local_id.clone();
if self.config.group.relay_broadcast_enabled
&& self.group_mesh.relay_synced.contains(group_id)
&& self
.group_mesh
.relay_capabilities
.contains(RELAY_CAP_GROUP_DELIVERY_V3)
&& self.is_internet_available()
{
if let Ok(mid) = self.try_relay_broadcast(
group_id,
&ciphertext_b64,
epoch,
reply_to_msg,
payload_forward_info.clone(),
priority,
) {
let member_count = members.iter().filter(|m| m.as_str() != self_id).count() as u32;
self.emit_event(Event::group_message_sent(
group_id.to_string(),
vec![mid.as_str().to_string()],
member_count,
));
return Ok(vec![mid]);
}
}
let (message_ids, succeeded_members, failed_members) = self
.fanout_group_frames_per_member(
group_id,
&members,
None,
&ciphertext_b64,
epoch,
reply_to_msg,
payload_forward_info,
priority,
)?;
let member_count = succeeded_members.len() as u32;
let had_recipients = members.iter().any(|m| m != &self_id);
if had_recipients && message_ids.is_empty() {
self.emit_event(Event::group_message_partial_failure(
group_id.to_string(),
failed_members,
succeeded_members,
));
return Err(Error::Transport(
offline_protocol_transport::Error::SendFailed(
"All group message sends failed".to_string(),
),
));
}
if failed_members.is_empty() {
self.emit_event(Event::group_message_sent(
group_id.to_string(),
message_ids.iter().map(|m| m.as_str().to_string()).collect(),
member_count,
));
} else {
self.emit_event(Event::group_message_partial_failure(
group_id.to_string(),
failed_members,
succeeded_members,
));
}
Ok(message_ids)
}
pub fn list_groups(&self) -> Result<Vec<String>> {
let mls_guard = self.read_mls_guard()?;
let groups = mls_guard.list_groups()?;
Ok(groups.into_iter().map(|g| g.as_str().to_string()).collect())
}
pub fn get_group_info(
&self,
group_id: &str,
) -> Result<Option<offline_protocol_mls::GroupInfo>> {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
Ok(mls_guard.get_group_info(&gid)?)
}
fn auto_promote_if_no_admin(&self, group_id: &str, members: &[String]) {
if members.is_empty() {
return;
}
if let Ok(mls_guard) = self.read_mls_guard() {
let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) else {
return;
};
if let Some(metadata) = mls_guard.get_group_metadata(&gid).ok().flatten() {
if metadata.has_any_admin() {
return;
}
let mut sorted = members.to_vec();
sorted.sort();
let promote_id = &sorted[0];
if let Err(e) = mls_guard.set_member_role(&gid, promote_id, GroupRole::Admin) {
warn!(user_id = %promote_id, error = %e, "Failed to auto-promote member to admin");
} else {
info!(user_id = %promote_id, group_id = %group_id, "Auto-promoted member to admin after last admin was removed");
}
}
}
}
fn group_metadata_or_not_found(
mls_guard: &offline_protocol_mls::MlsManager,
gid: &offline_protocol_mls::GroupId,
group_id: &str,
) -> Result<Option<offline_protocol_mls::GroupMetadata>> {
let metadata = mls_guard.get_group_metadata(gid)?;
if metadata.is_none() && mls_guard.get_group_info(gid)?.is_none() {
return Err(Error::GroupNotFound(group_id.to_string()));
}
Ok(metadata)
}
pub(crate) fn check_is_admin(&self, group_id: &str, user_id: &str) -> Result<bool> {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
drop(mls_guard);
if let Some(meta) = &metadata {
if meta.has_any_admin() {
return Ok(meta.get_role(user_id) == GroupRole::Admin);
}
if let Some(creator) = &meta.created_by {
return Ok(creator == user_id);
}
}
Ok(false)
}
pub fn set_member_role(
&mut self,
group_id: &str,
target_user_id: &str,
role: GroupRole,
) -> Result<()> {
let self_id = self.local_id.clone();
if !self.check_is_admin(group_id, &self_id)? {
return Err(Error::PermissionDenied(
"Only admins can change roles".to_string(),
));
}
let members = self
.group_mesh
.members
.get(group_id)
.cloned()
.or_else(|| self.refresh_group_members(group_id).ok())
.unwrap_or_default();
if !members.iter().any(|m| m == target_user_id) {
return Err(Error::InvalidState(format!(
"User {} is not a member of group {}",
target_user_id, group_id
)));
}
{
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
if role == GroupRole::Member {
if let Some(metadata) = mls_guard.get_group_metadata(&gid).ok().flatten() {
if metadata.get_role(target_user_id) == GroupRole::Admin {
let admin_count = metadata
.get_all_roles()
.values()
.filter(|r| **r == GroupRole::Admin)
.count();
if admin_count <= 1 {
return Err(Error::InvalidState(
"Cannot demote the last admin".to_string(),
));
}
}
}
}
mls_guard.set_member_role(&gid, target_user_id, role)?;
}
let payload = GroupRoleChangePayload {
group_id: group_id.to_string(),
target_user_id: target_user_id.to_string(),
new_role: role,
changed_by: self_id.clone(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_ROLE_CHANGE,
serde_json::to_string(&payload)
.map_err(|e| Error::Serialization(format!("Serialize role change: {}", e)))?
);
for member in &members {
if member == &self_id {
continue;
}
if let Err(e) =
self.send_internal_message(member, content.clone(), MessagePriority::High)
{
warn!(member = %member, group_id = %group_id, error = %e, "Failed to send role change broadcast");
}
}
self.emit_event(Event::group_role_changed(
group_id.to_string(),
target_user_id.to_string(),
role.to_string(),
self_id,
));
info!(group_id = %group_id, target = %target_user_id, role = %role, "Changed member role");
Ok(())
}
pub fn get_member_role(&self, group_id: &str, user_id: &str) -> Result<GroupRole> {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
Ok(metadata.map(|m| m.get_role(user_id)).unwrap_or_default())
}
pub fn get_group_roles(&self, group_id: &str) -> Result<HashMap<String, GroupRole>> {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
Ok(metadata.map(|m| m.get_all_roles()).unwrap_or_default())
}
pub(crate) fn handle_group_role_change(&mut self, message_id: &str, sender: &str, data: &str) {
let payload = match serde_json::from_str::<GroupRoleChangePayload>(data) {
Ok(p) => p,
Err(_) => {
warn!(sender = %sender, "Failed to parse GroupRoleChange payload");
return;
}
};
let dedup_key = message_id.to_string();
if self.group_mesh.message_dedup.contains_key(&dedup_key) {
return;
}
self.group_mesh
.message_dedup
.insert(dedup_key, Instant::now());
match self.check_is_admin(&payload.group_id, sender) {
Ok(true) => {}
Ok(false) => {
error!(
sender = %sender,
group_id = %payload.group_id,
"SECURITY: Role change from non-admin, ignoring"
);
return;
}
Err(e) => {
warn!(
sender = %sender,
group_id = %payload.group_id,
error = %e,
"Failed to verify admin status for role change"
);
return;
}
}
let Ok(gid) = offline_protocol_mls::GroupId::new(&payload.group_id) else {
warn!(group_id = %payload.group_id, "Dropping role change with invalid group id");
return;
};
if let Ok(mls_guard) = self.read_mls_guard() {
if let Err(e) =
mls_guard.set_member_role(&gid, &payload.target_user_id, payload.new_role)
{
warn!(
target = %payload.target_user_id,
error = %e,
"Failed to store role from incoming role change"
);
}
}
self.emit_event(Event::group_role_changed(
payload.group_id,
payload.target_user_id,
payload.new_role.to_string(),
sender.to_string(),
));
}
pub fn rename_group(&mut self, group_id: &str, new_name: &str) -> Result<()> {
let trimmed = new_name.trim();
if trimmed.is_empty() {
return Err(Error::InvalidArgument(
"Group name cannot be empty".to_string(),
));
}
let self_id = self.local_id.clone();
if !self.check_is_admin(group_id, &self_id)? {
return Err(Error::PermissionDenied(
"Only admins can rename groups".to_string(),
));
}
let old_name = {
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
mls_guard
.get_group_metadata(&gid)?
.and_then(|m| m.name.clone())
};
{
let mls_guard = self.read_mls_guard()?;
let gid = offline_protocol_mls::GroupId::new(group_id)?;
mls_guard.set_group_name(&gid, new_name)?;
}
let members = self
.group_mesh
.members
.get(group_id)
.cloned()
.or_else(|| self.refresh_group_members(group_id).ok())
.unwrap_or_default();
let payload = GroupRenamePayload {
group_id: group_id.to_string(),
new_name: new_name.to_string(),
renamed_by: self_id.clone(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_RENAME,
serde_json::to_string(&payload)
.map_err(|e| Error::Serialization(format!("Serialize group rename: {}", e)))?
);
for member in &members {
if member == &self_id {
continue;
}
if let Err(e) =
self.send_internal_message(member, content.clone(), MessagePriority::High)
{
warn!(member = %member, group_id = %group_id, error = %e, "Failed to send group rename broadcast");
}
}
self.emit_event(Event::group_renamed(
group_id.to_string(),
new_name.to_string(),
old_name,
self_id,
));
let _ = self.try_relay_register_group(group_id, Some(new_name), &members);
info!(group_id = %group_id, new_name = %new_name, "Renamed group");
Ok(())
}
pub(crate) fn handle_group_rename(&mut self, message_id: &str, sender: &str, data: &str) {
let payload = match serde_json::from_str::<GroupRenamePayload>(data) {
Ok(p) => p,
Err(_) => {
warn!(sender = %sender, "Failed to parse GroupRename payload");
return;
}
};
let dedup_key = message_id.to_string();
if self.group_mesh.message_dedup.contains_key(&dedup_key) {
return;
}
self.group_mesh
.message_dedup
.insert(dedup_key, Instant::now());
match self.check_is_admin(&payload.group_id, sender) {
Ok(true) => {}
Ok(false) => {
error!(
sender = %sender,
group_id = %payload.group_id,
"SECURITY: Group rename from non-admin, ignoring"
);
return;
}
Err(e) => {
warn!(
sender = %sender,
group_id = %payload.group_id,
error = %e,
"Failed to verify admin status for group rename"
);
return;
}
}
let Ok(gid) = offline_protocol_mls::GroupId::new(&payload.group_id) else {
warn!(group_id = %payload.group_id, "Dropping group rename with invalid group id");
return;
};
let old_name = if let Ok(mls_guard) = self.read_mls_guard() {
mls_guard
.get_group_metadata(&gid)
.ok()
.flatten()
.and_then(|m| m.name.clone())
} else {
None
};
if let Ok(mls_guard) = self.read_mls_guard() {
if let Err(e) = mls_guard.set_group_name(&gid, &payload.new_name) {
warn!(
group_id = %payload.group_id,
error = %e,
"Failed to store new group name from incoming rename"
);
}
}
self.emit_event(Event::group_renamed(
payload.group_id,
payload.new_name,
old_name,
sender.to_string(),
));
}
pub(crate) fn cleanup_group_message_dedup(&mut self) {
let now = Instant::now();
let dedup_ttl = StdDuration::from_secs(GROUP_MESSAGE_DEDUP_TTL_SECS);
self.group_mesh
.message_dedup
.retain(|_, seen_at| now.saturating_duration_since(*seen_at) < dedup_ttl);
let len = self.group_mesh.message_dedup.len();
if len > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
let mut entries: Vec<_> = self.group_mesh.message_dedup.drain().collect();
entries.select_nth_unstable_by_key(MAX_GROUP_MESSAGE_DEDUP_ENTRIES, |(_, ts)| {
std::cmp::Reverse(*ts)
});
entries.truncate(MAX_GROUP_MESSAGE_DEDUP_ENTRIES);
self.group_mesh.message_dedup = entries.into_iter().collect();
}
let commit_ttl = StdDuration::from_secs(PENDING_COMMIT_TTL_SECS);
let mut fork_candidates: Vec<String> = Vec::new();
let mut expired_ids: Vec<String> = Vec::new();
self.group_mesh.pending_commits.retain(|group_id, commits| {
let mut retried_expired = false;
commits.retain(|c| {
let alive = now.saturating_duration_since(c.buffered_at) < commit_ttl;
if !alive {
if c.retry_count > 0 {
retried_expired = true;
}
expired_ids.push(c.message_id.clone());
}
alive
});
if retried_expired {
fork_candidates.push(group_id.clone());
}
!commits.is_empty()
});
for group_id in fork_candidates {
if !self.group_mesh.epoch_forks.contains_key(&group_id) {
self.flag_potential_epoch_fork(&group_id);
}
}
let msg_ttl = StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS);
self.group_mesh.pending_group_messages.retain(|_, msgs| {
msgs.retain(|m| {
let alive = now.saturating_duration_since(m.buffered_at) < msg_ttl;
if !alive {
expired_ids.push(m.message_id.clone());
}
alive
});
!msgs.is_empty()
});
for message_id in expired_ids {
self.release_replay_protection(&message_id);
}
}
fn relay_registration_admin_hint(&self, group_id: &str) -> Option<bool> {
let mls_guard = self.read_mls_guard().ok()?;
let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id).ok()??;
if metadata.has_any_admin() {
return Some(metadata.get_role(&self.local_id) == GroupRole::Admin);
}
metadata
.created_by
.as_ref()
.map(|creator| creator == &self.local_id)
}
fn try_relay_register_group(
&mut self,
group_id: &str,
group_name: Option<&str>,
members: &[String],
) -> Result<bool> {
if !self.config.group.relay_enabled || !self.is_internet_available() {
return Ok(false);
}
let payload = RelayGroupRegistrationPayload {
group_id: group_id.to_string(),
group_name: group_name.map(|s| s.to_string()),
members: members.to_vec(),
is_admin: self.relay_registration_admin_hint(group_id),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_RELAY_REGISTER,
serde_json::to_string(&payload).map_err(|e| Error::Serialization(format!(
"Serialize relay registration: {}",
e
)))?
);
match self.send_relay_hint_message(content, MessagePriority::Medium) {
Ok(_) => {
let entry = self
.group_mesh
.relay_register_pending
.entry(group_id.to_string())
.or_insert(RelayRegisterPending {
armed_at: Utc::now(),
attempts: 0,
});
entry.armed_at = Utc::now();
entry.attempts = entry.attempts.saturating_add(1);
debug!(group_id = %group_id, attempts = entry.attempts, "Sent group registration to relay server");
Ok(true)
}
Err(e) => {
debug!(group_id = %group_id, error = %e, "Failed to register group with relay");
Ok(false)
}
}
}
fn restore_group_rich(
text: String,
payload_forward_info: Option<ForwardInfo>,
sender: &str,
) -> (
String,
Option<MediaMetadata>,
Option<String>,
Option<crate::events::ForwardInfoEvent>,
) {
if let Some(rich) = RichPayloadV1::parse_sealed(&text, sender) {
return (
rich.text,
rich.media_metadata,
rich.content_type.map(|ct| ct.to_string()),
rich.forward_info
.as_ref()
.map(crate::events::ForwardInfoEvent::from),
);
}
(
text,
None,
None,
payload_forward_info
.as_ref()
.map(crate::events::ForwardInfoEvent::from),
)
}
fn try_relay_broadcast(
&mut self,
group_id: &str,
ciphertext_b64: &str,
epoch: u64,
reply_to: Option<&str>,
forward_info: Option<ForwardInfo>,
priority: MessagePriority,
) -> Result<MessageId> {
let logical_id = MessageId::new();
let payload = RelayGroupBroadcastPayload {
group_id: group_id.to_string(),
ciphertext: ciphertext_b64.to_string(),
epoch,
reply_to: reply_to.map(|s| s.to_string()),
forward_info: forward_info.clone(),
message_id: logical_id.as_str(),
};
self.send_relay_broadcast_frame(&payload)?;
while self.group_mesh.relay_broadcast_pending.len() >= MAX_RELAY_BROADCAST_PENDING {
let Some(oldest) = self
.group_mesh
.relay_broadcast_pending
.iter()
.min_by_key(|(_, pending)| pending.armed_at)
.map(|(id, _)| id.clone())
else {
break;
};
let Some(stale) = self.group_mesh.relay_broadcast_pending.remove(&oldest) else {
break;
};
warn!(
msg_id = %oldest,
"Relay broadcast tracker full, downgrading oldest to per-member fan-out"
);
self.downgrade_broadcast_to_per_member(&oldest, stale);
}
self.group_mesh.relay_broadcast_pending.insert(
logical_id.as_str(),
RelayBroadcastPending {
group_id: group_id.to_string(),
ciphertext_b64: ciphertext_b64.to_string(),
epoch,
reply_to: reply_to.map(|s| s.to_string()),
forward_info,
priority,
armed_at: Utc::now(),
attempts: 1,
},
);
debug!(
group_id = %group_id,
msg_id = %logical_id,
"Sent group message via relay broadcast, awaiting delivery report"
);
Ok(logical_id)
}
fn send_relay_broadcast_frame(&mut self, payload: &RelayGroupBroadcastPayload) -> Result<()> {
let content = format!(
"{}{}",
internal_prefixes::GROUP_RELAY_BROADCAST,
serde_json::to_string(payload)
.map_err(|e| Error::Serialization(format!("Serialize relay broadcast: {}", e)))?
);
self.send_relay_hint_message(content, MessagePriority::Medium)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn fanout_group_frames_per_member(
&mut self,
group_id: &str,
members: &[String],
logical_id: Option<&str>,
ciphertext_b64: &str,
epoch: u64,
reply_to: Option<&str>,
forward_info: Option<ForwardInfo>,
priority: MessagePriority,
) -> Result<(Vec<MessageId>, Vec<String>, Vec<String>)> {
let msg_payload = GroupMlsMessagePayload {
group_id: group_id.to_string(),
ciphertext: ciphertext_b64.to_string(),
epoch,
reply_to: reply_to.map(|s| s.to_string()),
forward_info,
message_id: logical_id.map(|s| s.to_string()),
};
let base_content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload)
.map_err(|e| Error::Serialization(format!("Serialize group message: {}", e)))?
);
let self_id = self.local_id.clone();
let mut message_ids = Vec::new();
let mut succeeded_members = Vec::new();
let mut failed_members = Vec::new();
for member in members {
if member == &self_id {
continue;
}
match self.send_internal_message(member, base_content.clone(), priority) {
Ok(mid) => {
message_ids.push(mid);
succeeded_members.push(member.clone());
}
Err(e) => {
warn!(
group_id = %group_id,
member = %member,
error = %e,
"Failed to send group message to member"
);
failed_members.push(member.clone());
}
}
}
Ok((message_ids, succeeded_members, failed_members))
}
pub fn set_relay_capabilities(&mut self, capabilities: Vec<String>) {
let caps: HashSet<String> = capabilities
.into_iter()
.filter(|c| !c.is_empty() && c.len() <= MAX_RELAY_CAPABILITY_TOKEN_BYTES)
.take(MAX_RELAY_CAPABILITIES)
.collect();
debug!(count = caps.len(), "Relay capabilities updated");
self.group_mesh.relay_capabilities = caps;
}
pub fn handle_relay_group_delivery_report(&mut self, report_json: &str) -> Result<()> {
let report: RelayGroupDeliveryReport = serde_json::from_str(report_json)
.map_err(|e| Error::Serialization(format!("Parse group delivery report: {}", e)))?;
let Some(entry) = self
.group_mesh
.relay_broadcast_pending
.get(&report.message_id)
else {
debug!(
group_id = %report.group_id,
msg_id = %report.message_id,
"Group delivery report correlates with no in-flight broadcast, ignoring"
);
return Ok(());
};
if entry.group_id != report.group_id {
warn!(
report_group = %report.group_id,
armed_group = %entry.group_id,
msg_id = %report.message_id,
"Group delivery report names a different group than the armed broadcast, ignoring"
);
return Ok(());
}
let entry = self
.group_mesh
.relay_broadcast_pending
.remove(&report.message_id)
.expect("checked above");
for miss in &report.missed {
debug!(
group_id = %report.group_id,
member = %miss.username,
reason = %miss.reason,
"Relay reports group member missed"
);
}
let members = self
.refresh_group_members(&entry.group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&entry.group_id).cloned())
.unwrap_or_default();
let reached: HashSet<&str> = report
.delivered
.iter()
.chain(report.pushed.iter())
.map(String::as_str)
.collect();
let self_id = self.local_id.clone();
let unreached: Vec<String> = members
.iter()
.filter(|m| m.as_str() != self_id && !reached.contains(m.as_str()))
.cloned()
.collect();
let mut reissued = Vec::new();
if !unreached.is_empty() {
info!(
group_id = %entry.group_id,
msg_id = %report.message_id,
count = unreached.len(),
"Re-sending group message per-member to members the relay did not reach"
);
match self.fanout_group_frames_per_member(
&entry.group_id,
&unreached,
Some(&report.message_id),
&entry.ciphertext_b64,
entry.epoch,
entry.reply_to.as_deref(),
entry.forward_info.clone(),
entry.priority,
) {
Ok((_ids, succeeded, _failed)) => reissued = succeeded,
Err(e) => {
error!(
group_id = %entry.group_id,
msg_id = %report.message_id,
error = %e,
"Failed to re-issue per-member copies for unreached members"
);
}
}
}
self.emit_event(Event::group_message_delivery_report(
entry.group_id,
report.message_id,
report.delivered,
report.pushed,
reissued,
));
Ok(())
}
pub(crate) fn is_internet_available(&self) -> bool {
self.transport_manager
.get_available_transports()
.contains_key(&TransportType::Internet)
}
fn read_current_epoch(&self, group_id: &str) -> Option<u64> {
let guard = self.read_mls_guard().ok()?;
let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
let info = guard.get_group_info(&gid).ok()??;
Some(info.epoch)
}
pub(crate) fn check_epoch_forks(&mut self) {
let delay = StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS);
let self_id = self.local_id.clone();
let ready: Vec<EpochForkState> = self
.group_mesh
.epoch_forks
.values()
.filter(|f| !f.resolution_attempted && f.detected_at.elapsed() > delay)
.cloned()
.collect();
for fork in ready {
if let Some(detected_epoch) = fork.local_epoch {
let current_epoch = self.read_current_epoch(&fork.group_id);
if let Some(current) = current_epoch {
if current > detected_epoch {
debug!(
group_id = %fork.group_id,
detected_epoch,
current_epoch = current,
"Epoch fork auto-cancelled: epoch advanced since detection"
);
self.group_mesh.epoch_forks.remove(&fork.group_id);
continue;
}
}
}
let members = self
.refresh_group_members(&fork.group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&fork.group_id).cloned())
.unwrap_or_default();
let mut sorted = members.clone();
sorted.sort();
let am_leader = sorted.first().map(|f| f == &self_id).unwrap_or(false);
if let Some(state) = self.group_mesh.epoch_forks.get_mut(&fork.group_id) {
state.resolution_attempted = true;
}
if !am_leader {
continue;
}
info!(
group_id = %fork.group_id,
local_epoch = ?fork.local_epoch,
"Attempting epoch fork resolution as elected leader"
);
let update_result = {
if let Ok(guard) = self.read_mls_guard() {
let r = offline_protocol_mls::GroupId::new(&fork.group_id)
.and_then(|gid| guard.update_keys(&gid));
drop(guard);
Some(r)
} else {
None
}
};
let update_result = match update_result {
Some(r) => r,
None => {
warn!(group_id = %fork.group_id, "MLS unavailable during epoch fork resolution");
if let Some(state) = self.group_mesh.epoch_forks.get_mut(&fork.group_id) {
state.resolution_attempted = false;
}
continue;
}
};
match update_result {
Ok(commit_msg) => {
let commit_payload = GroupMlsCommitPayload {
group_id: fork.group_id.clone(),
commit_type: GroupCommitType::KeyUpdate,
ciphertext: base64_encode(&commit_msg.ciphertext),
epoch: commit_msg.epoch,
affected_member: None,
role: None,
affected_member_rich: None,
};
let commit_content = match serde_json::to_string(&commit_payload) {
Ok(json) => format!("{}{}", internal_prefixes::GROUP_MLS_COMMIT, json),
Err(e) => {
warn!(
group_id = %fork.group_id,
error = %e,
"Failed to serialize fork resolution commit"
);
continue;
}
};
let mut failed_members = Vec::new();
for member in &members {
if member == &self_id {
continue;
}
if let Err(e) = self.send_internal_message(
member,
commit_content.clone(),
MessagePriority::High,
) {
warn!(
group_id = %fork.group_id,
member = %member,
error = %e,
"Failed to send fork resolution commit to member"
);
failed_members.push(member.clone());
}
}
info!(
group_id = %fork.group_id,
new_epoch = commit_msg.epoch,
failed_count = failed_members.len(),
"Epoch fork resolution commit distributed"
);
self.emit_event(Event::group_epoch_fork_resolved(
fork.group_id.clone(),
commit_msg.epoch,
failed_members,
));
self.group_mesh.epoch_forks.remove(&fork.group_id);
}
Err(e) => {
warn!(
group_id = %fork.group_id,
error = %e,
"Failed to issue key update for epoch fork resolution"
);
}
}
}
let stale_threshold = StdDuration::from_secs(300);
self.group_mesh
.epoch_forks
.retain(|_, f| f.detected_at.elapsed() < stale_threshold);
}
pub(crate) fn check_leave_election_timeouts(&mut self) {
let timeout = StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS);
let max_lifetime = StdDuration::from_secs(LEAVE_ELECTION_MAX_LIFETIME_SECS);
let attempt_cooldown = StdDuration::from_secs(LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS);
let self_id = self.local_id.clone();
let timed_out: Vec<((String, String), PendingLeaveElection)> = self
.group_mesh
.pending_leave_elections
.iter()
.filter(|(_, e)| e.received_at.elapsed() > timeout)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (election_key, election) in timed_out {
if election.received_at.elapsed() > max_lifetime {
warn!(
group_id = %election.group_id,
leaving_member = %election.leaving_member,
"Leave election exceeded max lifetime, abandoning — member may need manual removal"
);
self.group_mesh
.pending_leave_elections
.remove(&election_key);
continue;
}
let current_members = self
.refresh_group_members(&election.group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&election.group_id).cloned())
.unwrap_or_default();
let still_member = current_members
.iter()
.any(|id| id == &election.leaving_member);
if !still_member {
self.group_mesh
.pending_leave_elections
.remove(&election_key);
continue;
}
let mut remaining: Vec<String> = current_members
.iter()
.filter(|m| m.as_str() != election.leaving_member)
.cloned()
.collect();
remaining.sort();
let elapsed_intervals =
(election.received_at.elapsed().as_secs() / LEAVE_ELECTION_TIMEOUT_SECS) as usize;
let candidate_idx = elapsed_intervals.min(remaining.len().saturating_sub(1));
if remaining
.get(candidate_idx)
.map(|c| c == &self_id)
.unwrap_or(false)
{
if let Some(last) = election.last_attempt_at {
if last.elapsed() < attempt_cooldown {
continue;
}
}
info!(
group_id = %election.group_id,
leaving_member = %election.leaving_member,
attempt = candidate_idx + 1,
"Re-elected to issue MLS remove-commit (prior elected member timed out)"
);
if let Some(state) = self
.group_mesh
.pending_leave_elections
.get_mut(&election_key)
{
state.last_attempt_at = Some(Instant::now());
}
if let Err(e) = self.remove_from_group(&election.group_id, &election.leaving_member)
{
warn!(
group_id = %election.group_id,
leaving_member = %election.leaving_member,
error = %e,
"Failed to issue MLS remove-commit during re-election"
);
} else {
self.group_mesh
.pending_leave_elections
.remove(&election_key);
}
}
}
}
pub(crate) fn check_relay_group_sync(&mut self) {
if !self.config.group.relay_enabled {
return;
}
let internet_available = self.is_internet_available();
let was_available = self.group_mesh.internet_was_available;
self.group_mesh.internet_was_available = internet_available;
if internet_available && !was_available {
self.sync_groups_to_relay();
} else if !internet_available && was_available {
let mut affected: Vec<String> = self.group_mesh.relay_synced.drain().collect();
for group_id in self.group_mesh.relay_register_pending.drain() {
if !affected.contains(&group_id.0) {
affected.push(group_id.0);
}
}
for group_id in affected {
self.emit_event(Event::group_relay_sync_changed(
group_id,
false,
"internet_dropped",
));
}
self.group_mesh.relay_capabilities.clear();
let stranded: Vec<(String, RelayBroadcastPending)> =
self.group_mesh.relay_broadcast_pending.drain().collect();
for (logical_id, entry) in stranded {
self.downgrade_broadcast_to_per_member(&logical_id, entry);
}
}
}
pub(crate) fn process_relay_register_retries(&mut self) {
if !self.config.group.relay_enabled
|| self.group_mesh.relay_register_pending.is_empty()
|| !self.is_internet_available()
{
return;
}
let cutoff = Utc::now() - chrono::Duration::seconds(RELAY_REGISTER_ACK_TIMEOUT_SECS);
let expired: Vec<(String, u32)> = self
.group_mesh
.relay_register_pending
.iter()
.filter(|(_, pending)| pending.armed_at <= cutoff)
.map(|(group_id, pending)| (group_id.clone(), pending.attempts))
.collect();
for (group_id, attempts) in expired {
if attempts >= RELAY_REGISTER_MAX_ATTEMPTS
|| !self.group_mesh.members.contains_key(&group_id)
|| self.group_mesh.relay_synced.contains(&group_id)
{
self.group_mesh.relay_register_pending.remove(&group_id);
debug!(
group_id = %group_id,
attempts,
"Expired unanswered relay group registration"
);
if attempts >= RELAY_REGISTER_MAX_ATTEMPTS
&& self.group_mesh.members.contains_key(&group_id)
&& !self.group_mesh.relay_synced.contains(&group_id)
{
self.emit_event(Event::group_relay_sync_changed(
group_id.clone(),
false,
"ack_timeout",
));
}
continue;
}
let members = self
.refresh_group_members(&group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&group_id).cloned());
if let Some(members) = members {
let _ = self.try_relay_register_group(&group_id, None, &members);
} else {
self.group_mesh.relay_register_pending.remove(&group_id);
}
}
}
pub(crate) fn process_relay_broadcast_report_timeouts(&mut self) {
if self.group_mesh.relay_broadcast_pending.is_empty() {
return;
}
let cutoff = Utc::now() - chrono::Duration::seconds(RELAY_BROADCAST_REPORT_TIMEOUT_SECS);
let expired: Vec<String> = self
.group_mesh
.relay_broadcast_pending
.iter()
.filter(|(_, pending)| pending.armed_at <= cutoff)
.map(|(logical_id, _)| logical_id.clone())
.collect();
for logical_id in expired {
let Some(mut entry) = self.group_mesh.relay_broadcast_pending.remove(&logical_id)
else {
continue;
};
let gate_holds = self.config.group.relay_broadcast_enabled
&& self.group_mesh.relay_synced.contains(&entry.group_id)
&& self
.group_mesh
.relay_capabilities
.contains(RELAY_CAP_GROUP_DELIVERY_V3)
&& self.is_internet_available();
if entry.attempts < RELAY_BROADCAST_MAX_ATTEMPTS && gate_holds {
let payload = RelayGroupBroadcastPayload {
group_id: entry.group_id.clone(),
ciphertext: entry.ciphertext_b64.clone(),
epoch: entry.epoch,
reply_to: entry.reply_to.clone(),
forward_info: entry.forward_info.clone(),
message_id: logical_id.clone(),
};
if self.send_relay_broadcast_frame(&payload).is_ok() {
warn!(
group_id = %entry.group_id,
msg_id = %logical_id,
attempts = entry.attempts + 1,
"Group broadcast delivery report lost, re-sent broadcast"
);
entry.attempts += 1;
entry.armed_at = Utc::now();
self.group_mesh
.relay_broadcast_pending
.insert(logical_id, entry);
continue;
}
}
self.downgrade_broadcast_to_per_member(&logical_id, entry);
}
}
fn downgrade_broadcast_to_per_member(
&mut self,
logical_id: &str,
entry: RelayBroadcastPending,
) {
let members = self
.refresh_group_members(&entry.group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&entry.group_id).cloned())
.unwrap_or_default();
info!(
group_id = %entry.group_id,
msg_id = %logical_id,
members = members.len(),
"Downgrading relay group broadcast to per-member fan-out"
);
if let Err(e) = self.fanout_group_frames_per_member(
&entry.group_id,
&members,
Some(logical_id),
&entry.ciphertext_b64,
entry.epoch,
entry.reply_to.as_deref(),
entry.forward_info.clone(),
entry.priority,
) {
error!(
group_id = %entry.group_id,
msg_id = %logical_id,
error = %e,
"Failed to downgrade group broadcast to per-member fan-out"
);
}
}
fn sync_groups_to_relay(&mut self) {
let group_ids: Vec<String> = self.group_mesh.members.keys().cloned().collect();
for group_id in group_ids {
if self.group_mesh.relay_synced.contains(&group_id) {
continue;
}
let members = self
.refresh_group_members(&group_id)
.ok()
.or_else(|| self.group_mesh.members.get(&group_id).cloned());
if let Some(members) = members {
let _ = self.try_relay_register_group(&group_id, None, &members);
}
}
}
fn has_mls_group_state(&self, group_id: &str) -> bool {
if !self.is_mls_initialized() {
return false;
}
let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) else {
return false;
};
match self.read_mls_guard() {
Ok(guard) => guard.has_group(&gid).unwrap_or(true),
Err(_) => true,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_relay_group_message_with_mls(
&mut self,
group_id: &str,
sender: &str,
content: &str,
timestamp: &str,
message_id: &str,
reply_to_msg: Option<String>,
forward_info: Option<offline_protocol_core::ForwardInfo>,
) {
let dedup_key = message_id.to_string();
if self.group_mesh.message_dedup.contains_key(&dedup_key) {
debug!(group_id = %group_id, msg_id = %dedup_key, "Duplicate relay group message, skipping");
return;
}
self.group_mesh
.message_dedup
.insert(dedup_key.clone(), Instant::now());
if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
self.cleanup_group_message_dedup();
}
let forward_info_event = forward_info
.as_ref()
.map(crate::events::ForwardInfoEvent::from);
let ciphertext_bytes = match base64_decode(content) {
Ok(bytes) => bytes,
Err(_) => {
if self.has_mls_group_state(group_id) {
warn!(
group_id = %group_id,
sender = %sender,
"SECURITY: dropping non-MLS plaintext naming an MLS-secured group (sender spoofing)"
);
self.emit_event(Event::security_warning(
sender.to_string(),
crate::events::SecurityWarningCode::PlaintextReceiveRejected,
"Plaintext group message rejected: the named group is secured with MLS, \
so unauthenticated plaintext cannot be attributed to the claimed sender"
.to_string(),
));
self.group_mesh.message_dedup.remove(&dedup_key);
return;
}
self.emit_event(Event::group_message_received(
group_id.to_string(),
sender.to_string(),
content.to_string(),
timestamp.to_string(),
message_id.to_string(),
reply_to_msg,
forward_info_event,
None,
None,
));
return;
}
};
match self.decrypt_group_application(group_id, ciphertext_bytes, sender) {
GroupDecryptOutcome::Plaintext(pt) => match String::from_utf8(pt) {
Ok(text) => {
let (content, media_metadata, content_type, forward_info_event) =
Self::restore_group_rich(text, forward_info, sender);
self.emit_event(Event::group_message_received(
group_id.to_string(),
sender.to_string(),
content,
timestamp.to_string(),
message_id.to_string(),
reply_to_msg,
forward_info_event,
media_metadata,
content_type,
));
}
Err(_) => {
warn!(
group_id = %group_id,
"Decrypted relay group payload is not valid UTF-8, dropping"
);
}
},
GroupDecryptOutcome::Retriable => {
self.buffer_pending_group_message(
group_id,
PendingGroupMessage {
sender: sender.to_string(),
message_id: message_id.to_string(),
logical_id: None,
ciphertext_b64: content.to_string(),
timestamp: Some(timestamp.to_string()),
reply_to: reply_to_msg,
forward_info,
buffered_at: Instant::now(),
received_via: None,
},
);
}
GroupDecryptOutcome::NonApplication => {
self.drain_pending_commits(group_id);
self.drain_pending_group_messages(group_id);
}
GroupDecryptOutcome::PolicyRejected {
committer,
added,
removed,
} => {
self.report_unauthorized_membership_change(
group_id,
&committer,
&added,
&removed,
"sender_not_admin",
true,
);
}
GroupDecryptOutcome::NotMlsCiphertext => {
self.emit_event(Event::group_message_received(
group_id.to_string(),
sender.to_string(),
content.to_string(),
timestamp.to_string(),
message_id.to_string(),
reply_to_msg,
forward_info_event,
None,
None,
));
}
GroupDecryptOutcome::SecurityRejected | GroupDecryptOutcome::Failed => {
warn!(
group_id = %group_id,
"Failed to decrypt relay group message via MLS, dropping"
);
self.group_mesh.message_dedup.remove(&dedup_key);
}
}
}
}