offline_protocol/group_mesh.rs
1//! Group messaging — MLS-encrypted, transport-agnostic group operations.
2//!
3//! This module implements group creation, member invite/remove/leave,
4//! encrypted message fan-out, commit distribution, and pending commit
5//! buffering for out-of-order delivery.
6//!
7//! Groups are transport-agnostic: messages route through whichever transport
8//! DORS selects (BLE, WiFi Direct, or Internet). When Internet is available
9//! and a relay server supports it, the protocol can send a single relay
10//! broadcast instead of O(N) per-member fan-out.
11
12use crate::protocol::{
13 base64_decode, base64_encode, internal_prefixes, GroupMemberRemovedPayload,
14 InternalMessageResult, OfflineProtocol, RichPayloadV1, RichSendExtras, RICH_PAYLOAD_V1,
15};
16use crate::{Error, Event, Result};
17use chrono::{DateTime, Utc};
18use offline_protocol_core::{
19 ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority,
20};
21use offline_protocol_mls::GroupRole;
22use offline_protocol_transport::TransportType;
23use serde::{Deserialize, Serialize};
24use std::collections::{HashMap, HashSet, VecDeque};
25use std::time::{Duration as StdDuration, Instant};
26use tracing::{debug, error, info, warn};
27
28/// TTL for group message dedup entries (5 minutes).
29pub(super) const GROUP_MESSAGE_DEDUP_TTL_SECS: u64 = 300;
30/// Maximum number of group message dedup entries before forced cleanup.
31pub(super) const MAX_GROUP_MESSAGE_DEDUP_ENTRIES: usize = 10_000;
32/// Maximum allowed base64-encoded payload size for incoming group messages (1 MB).
33pub(crate) const MAX_BASE64_PAYLOAD_SIZE: usize = 1_048_576;
34/// How long an outstanding relay group registration waits for its
35/// `__GROUP_CREATED__` answer before the correlation entry is expired. The
36/// relay answers within a round trip, so this is generous slack; what it
37/// bounds is the *forged-ack acceptance window* against a relay that never
38/// answers (prefix-unaware echo relay, legacy server).
39pub(super) const RELAY_REGISTER_ACK_TIMEOUT_SECS: i64 = 30;
40/// Registration sends per connection before giving up on relay sync for a
41/// group. An unanswered registration is re-sent on expiry (the frame may
42/// have been lost); past this budget the group simply stays unsynced and
43/// sends take the always-correct per-member fan-out path. Reset by the
44/// internet 0→1 re-sync, which re-registers from scratch.
45pub(super) const RELAY_REGISTER_MAX_ATTEMPTS: u32 = 3;
46/// How long a relay group broadcast waits for its settled `GroupMessageSent`
47/// delivery report before being re-sent. The relay emits the report only
48/// after its whole fan-out settles, which is bounded by its 45 s wall-clock
49/// budget (`GROUP_FANOUT_DEADLINE_SECS` relay-side) plus a possible wait for
50/// a relay-wide fan-out slot — so this sits above that budget, not above a
51/// round trip. An entry older than this is presumed lost with its frame or
52/// connection, not merely slow.
53pub(super) const RELAY_BROADCAST_REPORT_TIMEOUT_SECS: i64 = 60;
54/// Broadcast sends per logical group message (initial send plus re-sends on
55/// report timeout) before the sender stops trusting the broadcast path and
56/// falls back to per-member fan-out for the whole roster. Re-sent broadcasts
57/// are safe: the relay echoes the client-supplied `message_id`, so receiver
58/// dedup and the relay's push dedup both hold across attempts.
59pub(super) const RELAY_BROADCAST_MAX_ATTEMPTS: u32 = 3;
60/// Upper bound on broadcasts awaiting a delivery report at once. Each entry
61/// holds a full ciphertext copy and lives for up to
62/// `RELAY_BROADCAST_REPORT_TIMEOUT_SECS * RELAY_BROADCAST_MAX_ATTEMPTS`, so
63/// against a relay that accepts broadcasts but never reports, an app sending
64/// steadily would otherwise grow the tracker without limit. At the cap the
65/// oldest entry is downgraded to per-member fan-out, which needs no report
66/// to be correct — the bound costs uplink, never delivery.
67pub(super) const MAX_RELAY_BROADCAST_PENDING: usize = 64;
68/// Relay capability token gating the group broadcast path: the v2 settled
69/// delivery contract (client `message_id` accept/echo, group push fallback,
70/// the settled `GroupMessageSent` delivery report, `forward_info`
71/// passthrough) plus — what v3 adds — an address-aware relay group path:
72/// members are named in the identifier space the roster was registered in
73/// (this SDK registers the MLS roster, i.e. `off1…` addresses), fan-out and
74/// push resolve those names to accounts relay-side, and group sender
75/// attribution carries the sender's declared address. The relay advertises
76/// it (with the granular `group_*` tokens it aggregates) in its
77/// `Authenticated` answer; the bridge injects the list via
78/// `set_relay_capabilities` before reporting the transport up.
79///
80/// A `group_delivery_v2` relay deliberately fails this gate. Against its
81/// username-keyed group path, a post-addressing broadcast is worse than
82/// useless: the relay cannot resolve address-registered members to
83/// connections (the broadcast reaches nobody it was registered for), its
84/// report names members in username space (the roster set-difference
85/// re-issues to the whole roster), and any copy it does deliver arrives
86/// attributed by username — failing the SEC-M1 wire-sender/credential match
87/// and burning the ciphertext on a copy that is then rejected. Failing the
88/// gate keeps every group send on the per-member fan-out path, which post-#27
89/// relays route and attribute correctly by address.
90pub(crate) const RELAY_CAP_GROUP_DELIVERY_V3: &str = "group_delivery_v3";
91/// Upper bound on stored relay capability tokens, and on the byte length of
92/// each token. The list is wire-supplied by the relay — bounded so a hostile
93/// or broken relay cannot grow resident memory with it.
94pub(super) const MAX_RELAY_CAPABILITIES: usize = 64;
95/// See [`MAX_RELAY_CAPABILITIES`].
96pub(super) const MAX_RELAY_CAPABILITY_TOKEN_BYTES: usize = 128;
97/// Maximum number of buffered out-of-order commits per group.
98pub(super) const MAX_PENDING_COMMITS_PER_GROUP: usize = 8;
99/// TTL for buffered pending commits. Must be at least
100/// `GROUP_MESSAGE_DEDUP_TTL_SECS`: the commit's message ID is already in the
101/// dedup table, so the buffered copy is its only path to processing — a
102/// shorter TTL would open a window where the buffer has given up while the
103/// dedup entry still rejects every redelivery, losing the commit for good.
104/// Matching the dedup TTL means that by the time the buffer expires, a
105/// sender-side redelivery can be accepted fresh.
106pub(super) const PENDING_COMMIT_TTL_SECS: u64 = GROUP_MESSAGE_DEDUP_TTL_SECS;
107/// Global cap on buffered pending commits across all groups.
108pub(super) const MAX_PENDING_COMMITS_TOTAL: usize = 64;
109/// Global cap on buffered commit payload bytes across all groups (4 MiB).
110pub(super) const MAX_PENDING_COMMIT_TOTAL_BYTES: usize = 4 * 1024 * 1024;
111/// Global cap on distinct group IDs holding buffered pending commits.
112pub(super) const MAX_PENDING_COMMIT_GROUPS: usize = 16;
113/// Maximum number of buffered out-of-order group application messages per group.
114pub(super) const MAX_PENDING_GROUP_MESSAGES_PER_GROUP: usize = 16;
115/// TTL for buffered out-of-order group application messages. Must be at
116/// least `GROUP_MESSAGE_DEDUP_TTL_SECS` — same reasoning as
117/// `PENDING_COMMIT_TTL_SECS`: the buffered copy is the message's only path
118/// to delivery while its dedup entry blocks redelivery.
119pub(super) const PENDING_GROUP_MESSAGE_TTL_SECS: u64 = GROUP_MESSAGE_DEDUP_TTL_SECS;
120/// Global cap on buffered group application messages across all groups.
121pub(super) const MAX_PENDING_GROUP_MESSAGES_TOTAL: usize = 256;
122/// Global cap on buffered group application ciphertext bytes (base64 length)
123/// across all groups (8 MiB).
124pub(super) const MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES: usize = 8 * 1024 * 1024;
125/// Global cap on distinct group IDs holding buffered group application
126/// messages. Bounds the width of a one-entry-per-fabricated-group spread
127/// flood and the map-key memory it retains (group IDs are wire-supplied,
128/// up to `GroupId::MAX_LEN` bytes each).
129pub(super) const MAX_PENDING_GROUP_MESSAGE_GROUPS: usize = 32;
130/// Timeout before the next eligible member re-elects itself for a leave
131/// remove-commit when the original elected remover fails (30 seconds).
132pub(super) const LEAVE_ELECTION_TIMEOUT_SECS: u64 = 30;
133/// Delay after detecting a potential epoch fork before the leader issues a
134/// resync commit (30 seconds — gives time for buffered commits to drain and
135/// delayed commits to arrive, reducing false positive fork detections).
136pub(super) const EPOCH_FORK_RESOLUTION_DELAY_SECS: u64 = 30;
137/// Maximum number of tracked epoch fork states to prevent unbounded growth.
138pub(super) const MAX_EPOCH_FORK_ENTRIES: usize = 32;
139/// Maximum number of tracked pending leave elections to prevent unbounded growth.
140pub(super) const MAX_PENDING_LEAVE_ELECTIONS: usize = 64;
141/// Maximum lifetime for a leave election before it is abandoned (5 minutes).
142/// After this, the election is cleaned up regardless of whether the member
143/// was removed — prevents infinite retry loops when all candidates fail.
144pub(super) const LEAVE_ELECTION_MAX_LIFETIME_SECS: u64 = 300;
145/// Minimum cooldown between re-election attempts for the same leave election
146/// to prevent spamming MLS operations on every process tick (5 seconds).
147pub(super) const LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS: u64 = 5;
148/// Minimum interval between `GroupUnauthorizedMembershipChange` reports for
149/// the same (group, committer) pair (5 minutes). Divergent role metadata —
150/// e.g. two members auto-promoting different admins — re-fires the judgment
151/// on every commit from the "wrong" admin until Stage 2 adds reconciliation;
152/// without a window that is an unbounded stream of security events and log
153/// lines for one underlying condition. Roster events still carry
154/// `authorized: Some(false)` on every occurrence.
155pub(super) const UNAUTHORIZED_REPORT_SUPPRESS_SECS: u64 = 300;
156/// Maximum number of tracked (group, committer) report timestamps to prevent
157/// unbounded growth (both key halves are wire-influenced strings).
158pub(super) const MAX_UNAUTHORIZED_REPORT_ENTRIES: usize = 256;
159
160/// Correlation state for one in-flight relay group registration.
161#[derive(Debug, Clone)]
162pub(crate) struct RelayRegisterPending {
163 /// When the outstanding `__GRP_RELAY_REG__` frame was (last) sent;
164 /// entries older than [`RELAY_REGISTER_ACK_TIMEOUT_SECS`] are expired
165 /// on the process tick.
166 pub(crate) armed_at: DateTime<Utc>,
167 /// Registration sends on this connection, counted against
168 /// [`RELAY_REGISTER_MAX_ATTEMPTS`].
169 pub(crate) attempts: u32,
170}
171
172/// Correlation state for one in-flight relay group broadcast awaiting its
173/// settled `GroupMessageSent` delivery report.
174///
175/// Keyed (in `GroupMeshState::relay_broadcast_pending`) by the logical group
176/// message id carried in the broadcast payload and echoed back by a v2
177/// relay. Holds everything needed to (a) re-send the broadcast under the
178/// same logical id when the report is lost, and (b) rebuild per-member
179/// `__GRP_MLS_MSG__` copies for members the report names as missed or does
180/// not name at all (the relay's roster can be a strict subset of the MLS
181/// roster).
182#[derive(Debug, Clone)]
183pub(crate) struct RelayBroadcastPending {
184 /// Group the broadcast belongs to.
185 pub(crate) group_id: String,
186 /// Base64-encoded MLS application ciphertext, reused verbatim for
187 /// broadcast re-sends and per-member backstop copies (MLS application
188 /// ciphertext is recipient-independent within a group).
189 pub(crate) ciphertext_b64: String,
190 /// MLS epoch at which the message was encrypted (informational).
191 pub(crate) epoch: u64,
192 /// Optional reply-to message id.
193 pub(crate) reply_to: Option<String>,
194 /// Hop-visible forward attribution; `None` when sealed into the
195 /// ciphertext as rich extras.
196 pub(crate) forward_info: Option<ForwardInfo>,
197 /// Priority of the original send, inherited by backstop copies.
198 pub(crate) priority: MessagePriority,
199 /// When the outstanding broadcast frame was (last) sent; entries older
200 /// than [`RELAY_BROADCAST_REPORT_TIMEOUT_SECS`] are re-sent or downgraded
201 /// on the process tick.
202 pub(crate) armed_at: DateTime<Utc>,
203 /// Broadcast sends for this logical message, counted against
204 /// [`RELAY_BROADCAST_MAX_ATTEMPTS`].
205 pub(crate) attempts: u32,
206}
207
208/// Which gate refused a leaf, for the `GroupLeafIdentityUnproven` rate-limit
209/// key.
210///
211/// Part of the key for the same reason `unauthorized_change_reports` carries
212/// its `enforced` flag: the two are different signals with different urgency,
213/// and neither may suppress the other's first report inside one window. A
214/// declined invite is a stranger's group you never joined; a refused commit
215/// accuses a member of a group you are already in.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
217pub(crate) enum UnprovenLeafSite {
218 /// A group invite declined because its ratchet tree carried the leaf.
219 WelcomeDeclined,
220 /// A membership commit refused because it would have installed the leaf.
221 CommitRefused,
222 /// A roster read skipped the leaf because it was already seated in local
223 /// group state.
224 ///
225 /// The odd one out, and the reason it needs its own site: the other two
226 /// refuse a frame *arriving*, so nothing is installed and there is a
227 /// delivering peer to name. This one reports a leaf that is already in the
228 /// tree, holding live group secrets — the wire gates cannot have admitted
229 /// it, so it arrived by a direct write to the install-scoped provider store
230 /// or by a build predating those gates. There is no delivering peer, so the
231 /// report is attributed to this device (see
232 /// [`OfflineProtocol::report_unproven_leaf`]), and the remedy it implies is
233 /// different too: abandon the group rather than evict a member, because the
234 /// leaf can already read everything sent to it.
235 RosterEntry,
236}
237
238/// Claims a rate-limit window for `key`, returning whether the caller should
239/// report.
240///
241/// Shared by the two security reporters so their suppression, map bounding and
242/// eviction order cannot drift apart. Bounds the map by dropping lapsed windows
243/// first and only then evicting the oldest live entry — the longest-suppressed
244/// one, which is the safe direction to lose.
245fn claim_report_window<K: Eq + std::hash::Hash + Clone>(
246 reports: &mut HashMap<K, Instant>,
247 key: K,
248) -> bool {
249 let now = Instant::now();
250 let suppressed = reports.get(&key).is_some_and(|last| {
251 now.duration_since(*last).as_secs() < UNAUTHORIZED_REPORT_SUPPRESS_SECS
252 });
253 if suppressed {
254 return false;
255 }
256
257 if reports.len() >= MAX_UNAUTHORIZED_REPORT_ENTRIES {
258 reports.retain(|_, last| {
259 now.duration_since(*last).as_secs() < UNAUTHORIZED_REPORT_SUPPRESS_SECS
260 });
261 }
262 if reports.len() >= MAX_UNAUTHORIZED_REPORT_ENTRIES {
263 if let Some(oldest) = reports
264 .iter()
265 .min_by_key(|(_, t)| **t)
266 .map(|(k, _)| k.clone())
267 {
268 reports.remove(&oldest);
269 }
270 }
271 reports.insert(key, now);
272 true
273}
274
275/// Bundled state for group messaging.
276///
277/// Groups together the cached member lists, dedup table, pending commit
278/// buffer, and relay sync tracking so that `OfflineProtocol` doesn't carry
279/// these as individual fields.
280#[derive(Default)]
281pub(crate) struct GroupMeshState {
282 /// Cached group membership lists for fan-out without holding MLS lock.
283 /// Maps group_id -> list of member user IDs.
284 pub(crate) members: HashMap<String, Vec<String>>,
285
286 /// Deduplication cache for group messages received via multiple paths.
287 /// Key: message ID, Value: when first seen.
288 pub(crate) message_dedup: HashMap<String, Instant>,
289
290 /// Buffer for out-of-order MLS commits that failed to decrypt.
291 /// Maps group_id -> deque of pending commits awaiting retry.
292 /// When a commit succeeds for a group, buffered commits are drained and retried.
293 pub(crate) pending_commits: HashMap<String, VecDeque<PendingCommit>>,
294
295 /// Buffer for group application messages that failed MLS decryption
296 /// because local group state lagged the sender's (Welcome not yet
297 /// processed, or epoch behind). Drained after a successful Welcome join
298 /// or commit for the group. Maps group_id -> deque of pending messages.
299 pub(crate) pending_group_messages: HashMap<String, VecDeque<PendingGroupMessage>>,
300
301 /// Group IDs that have been successfully registered with the relay server.
302 /// Cleared when Internet transport disconnects so that groups are re-synced
303 /// when connectivity returns.
304 pub(crate) relay_synced: HashSet<String>,
305
306 /// Group IDs with a relay registration in flight (a `__GRP_RELAY_REG__`
307 /// frame enqueued, no relay answer yet). The `__GROUP_CREATED__` ack only
308 /// sets `relay_synced` for groups in this map: the relay forwards peer
309 /// message content verbatim, so without this correlation any peer that
310 /// knows a group id could forge the ack over the internet path and route
311 /// our broadcasts into a relay that never registered the group. Cleared
312 /// per group on ack, group-scoped `__GROUP_ERROR__`, leave/removal, and
313 /// wholesale when the Internet transport drops (the answer can never
314 /// arrive). Entries a relay never answers are expired on the process
315 /// tick ([`RELAY_REGISTER_ACK_TIMEOUT_SECS`]) and re-registered up to
316 /// [`RELAY_REGISTER_MAX_ATTEMPTS`] times — a relay that never acks
317 /// (prefix-unaware echo relay, legacy server) must not leave the
318 /// acceptance window armed indefinitely for a forged ack to claim.
319 pub(crate) relay_register_pending: HashMap<String, RelayRegisterPending>,
320
321 /// Capability tokens the connected relay advertised in its
322 /// `Authenticated` answer, injected by the platform bridge via
323 /// `set_relay_capabilities` *before* it reports the Internet transport
324 /// up — the false→true flush must already see them. Empty for relays
325 /// that predate the advertisement. Cleared when Internet drops: a
326 /// reconnect may land on a different relay.
327 pub(crate) relay_capabilities: HashSet<String>,
328
329 /// In-flight relay group broadcasts awaiting their settled
330 /// `GroupMessageSent` delivery report, keyed by logical group message
331 /// id. Settled by `handle_relay_group_delivery_report` (which re-sends
332 /// per-member copies to members the relay did not reach), re-sent on
333 /// report timeout, and downgraded to full per-member fan-out when the
334 /// attempt budget is exhausted or the Internet transport drops (the
335 /// report can never arrive on a dead connection).
336 pub(crate) relay_broadcast_pending: HashMap<String, RelayBroadcastPending>,
337
338 /// Whether Internet transport was available on the last `process()` tick.
339 /// Used for edge-detection: sync groups on 0→1 transition, clear on 1→0.
340 pub(crate) internet_was_available: bool,
341
342 /// Pending leave elections awaiting a remove-commit from the elected member.
343 /// Key: (group_id, leaving_member_id) — uses a tuple to avoid ambiguity
344 /// from string concatenation when IDs contain separator characters.
345 pub(crate) pending_leave_elections: HashMap<(String, String), PendingLeaveElection>,
346
347 /// Suspected epoch forks awaiting resolution. Key: group_id.
348 pub(crate) epoch_forks: HashMap<String, EpochForkState>,
349
350 /// When an unauthorized membership change by (group_id, committer,
351 /// enforced) was last reported, for rate-limiting the security event —
352 /// see [`UNAUTHORIZED_REPORT_SUPPRESS_SECS`]. Bounded by
353 /// [`MAX_UNAUTHORIZED_REPORT_ENTRIES`] with oldest-entry eviction.
354 ///
355 /// The `enforced` flag is part of the key on purpose: an applied change
356 /// and a refused one are different signals with different urgency (the
357 /// refusal doubles as a partition alarm), so neither may suppress the
358 /// other's first report inside one window.
359 pub(crate) unauthorized_change_reports: HashMap<(String, String, bool), Instant>,
360
361 /// When an unproven-leaf refusal by (group_id, delivering peer, site) was
362 /// last reported, for rate-limiting `GroupLeafIdentityUnproven` — same
363 /// window and same bound as [`Self::unauthorized_change_reports`], kept
364 /// separate so neither signal can suppress the other's first report.
365 ///
366 /// Needed because the commit half of this report is driven by an
367 /// authenticated group member: refusals are permanent and cheap, so an
368 /// insider can produce one per frame, and an unthrottled event channel
369 /// buries the signal in its own repetition.
370 ///
371 /// [`UnprovenLeafSite`] is part of the key for the same reason
372 /// `unauthorized_change_reports` keys on `enforced`: a declined invite must
373 /// not swallow the first refused *commit*, which is the more urgent signal
374 /// of the two. Relying on the two never colliding — today they cannot,
375 /// because `handle_group_mls_welcome` returns early for a group already
376 /// joined — would hide that guarantee in a different function.
377 pub(crate) unproven_leaf_reports: HashMap<(String, String, UnprovenLeafSite), Instant>,
378}
379
380/// Outcome of attempting to process an MLS Commit.
381enum CommitOutcome {
382 /// Successfully processed; contains the group ID.
383 Success(String),
384 /// MLS decryption failed transiently (e.g., out-of-order delivery);
385 /// the commit may succeed after a prior commit advances the epoch.
386 /// Contains the group ID for buffering.
387 Retriable(String),
388 /// Permanently failed (parse error, bad ciphertext, MLS unavailable);
389 /// should not be retried.
390 Rejected,
391}
392
393/// Verdict from [`OfflineProtocol::judge_membership_change`] for one commit's
394/// applied membership delta.
395pub(crate) struct MembershipJudgment {
396 /// Whether the authenticated committer is a known admin of the group.
397 /// Gates trust in the commit-payload metadata (role, rich attestation).
398 pub(crate) sender_is_admin: bool,
399 /// Whether the change as a whole was authorized: an admin committed it
400 /// *and* the commit's own framing agrees with the delta it produced.
401 pub(crate) authorized: bool,
402}
403
404/// Outcome of attempting to decrypt a group application message.
405enum GroupDecryptOutcome {
406 /// Decrypted application data.
407 Plaintext(Vec<u8>),
408 /// MLS consumed a Commit or Proposal that arrived via the message
409 /// channel (e.g., due to reordering) — not application data.
410 NonApplication,
411 /// Wire sender does not match the MLS-authenticated sender (SEC-M1).
412 SecurityRejected,
413 /// Local group state lags the sender's (no group yet, or epoch behind);
414 /// the message may decrypt after a Welcome or commit lands, so it is
415 /// worth buffering for deferred retry.
416 Retriable,
417 /// No local group state AND the payload is not MLS wire framing at
418 /// all — this is not a welcome-racing ciphertext. On the mesh path
419 /// that means garbage (dropped); on the relay path it means a legacy
420 /// relay-only group message whose plaintext happens to be valid
421 /// base64, which must be emitted raw rather than buffered and lost.
422 NotMlsCiphertext,
423 /// A commit riding the application channel was refused before merging by
424 /// `GroupConfig::enforce_admin_commits`.
425 ///
426 /// This variant is why enforcement lives in the MLS layer rather than in
427 /// the commit handler: a commit ciphertext wrapped in an ordinary
428 /// `__GRP_MLS_MSG__` frame reaches `merge_staged_commit` through *this*
429 /// path, which otherwise treats it as benign reordering
430 /// ([`Self::NonApplication`]). Enforcing only on the commit-framed path
431 /// would leave that reframing as a bypass.
432 ///
433 /// Permanent, like the commit-path rejection: the same ciphertext can
434 /// never become authorized, so it must not be buffered and retried.
435 PolicyRejected {
436 /// The MLS-authenticated committer whose commit was refused.
437 committer: String,
438 /// User ids the refused commit would have added, sorted.
439 added: Vec<String>,
440 /// User ids the refused commit would have removed, sorted.
441 removed: Vec<String>,
442 },
443 /// Permanently failed; not worth retrying.
444 Failed,
445}
446
447// --- Group (mesh/MLS) payloads ---
448
449/// Options for `OfflineProtocol::send_group_message_with`: priority and
450/// reply threading (as on `send_group_message`), plus the rich media fields.
451///
452/// The rich fields (`content_type`, `media_metadata` — including any
453/// cloud-media `encryption_key`/`iv` secrets) only ever travel inside the
454/// MLS-sealed `__RICH_V1__` body of the group ciphertext, and only when
455/// *every* other group member advertised `rich_versions` support (one
456/// ciphertext serves the whole group, so one legacy or unknown-capability
457/// member forces the drop). Otherwise they are silently dropped — never
458/// sent cleartext — and the message degrades to what `send_group_message`
459/// sends.
460#[derive(Debug, Clone, Default)]
461pub struct GroupSendOptions {
462 /// Message priority (defaults to Medium).
463 pub priority: Option<MessagePriority>,
464 /// ID of the message this is replying to.
465 pub reply_to_msg: Option<String>,
466 /// Content-type rendering hint (text, image, video, ...), delivered
467 /// sealed-only. Must not be [`ContentType::FileChunk`].
468 pub content_type: Option<ContentType>,
469 /// Rich media metadata (cloud attachments — including the
470 /// `encryption_key`/`iv` secrets), delivered sealed-only.
471 pub media_metadata: Option<MediaMetadata>,
472}
473
474/// Snapshot of whether a rich group send right now would seal its extras,
475/// from `OfflineProtocol::group_rich_readiness`. Point-in-time and
476/// advisory: capability knowledge changes with key-package exchanges,
477/// attested adds, and restarts, and the send path re-evaluates the gate
478/// itself — this exists so apps can warn before sending (e.g. gray out the
479/// attachment button) instead of learning from `GroupRichExtrasDropped`
480/// after the drop.
481#[derive(Debug, Clone)]
482pub struct GroupRichReadiness {
483 /// True when every other member is known rich-capable and the local
484 /// kill switch is on — rich extras would seal.
485 pub ready: bool,
486 /// Members not known to parse the sealed rich payload (unknown and
487 /// known-non-support are indistinguishable). Empty when `ready`, and
488 /// also empty when only the local kill switch blocks sealing.
489 pub unknown_members: Vec<String>,
490}
491
492/// Relay-side registration state of a group, from
493/// `OfflineProtocol::group_relay_sync_state`. Point-in-time: transitions
494/// are surfaced as `GroupRelaySyncChanged` events.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum RelaySyncState {
497 /// The relay positively acknowledged the group's registration on the
498 /// current connection — relay-dependent server commands for the group
499 /// (invite links, server-side fan-out) can be issued.
500 Synced,
501 /// A registration was sent and its acknowledgment is outstanding.
502 /// The SDK re-sends on a timer and gives up after a bounded number of
503 /// attempts (`GroupRelaySyncChanged { reason: "ack_timeout" }`).
504 Pending,
505 /// No registration is in flight — the group is unknown locally, the
506 /// Internet transport is down, relay grouping is disabled, or a prior
507 /// attempt was answered with an error / timed out.
508 Unsynced,
509}
510
511/// Payload for MLS-encrypted group messages sent via mesh.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub(crate) struct GroupMlsMessagePayload {
514 /// MLS group identifier.
515 pub(crate) group_id: String,
516 /// Base64-encoded MLS ciphertext.
517 pub(crate) ciphertext: String,
518 /// MLS epoch at which the message was encrypted.
519 pub(crate) epoch: u64,
520 /// Optional reply-to message ID.
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub(crate) reply_to: Option<String>,
523 /// Optional forwarding attribution (present when message was forwarded).
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
526 /// Logical id of the group message this frame carries, present when the
527 /// frame re-issues a relay broadcast (the delivery-report backstop or
528 /// the lost-report fallback). Lets the receiver emit the same app-facing
529 /// message id every member sees for this logical message, and absorb a
530 /// cross-path duplicate when the relay's own copy also arrived. Absent
531 /// on the ordinary per-member fan-out path, where the envelope id is
532 /// the only id. Unauthenticated at parse time — the receiver never
533 /// *marks* it as seen before the ciphertext MLS-decrypts (see
534 /// `handle_group_mls_msg_via`), so a non-member cannot poison the id to
535 /// suppress a message it could not itself have sent.
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub(crate) message_id: Option<String>,
538}
539
540/// Payload for MLS Welcome messages (group invites) sent via mesh.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub(crate) struct GroupMlsWelcomePayload {
543 /// MLS group identifier.
544 pub(crate) group_id: String,
545 /// Human-readable group name.
546 pub(crate) group_name: Option<String>,
547 /// Base64-encoded MLS Welcome data.
548 pub(crate) welcome_data: String,
549 /// Current member list (user IDs) at the time of invite.
550 pub(crate) member_list: Vec<String>,
551 /// Role assignments: user_id -> role.
552 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
553 pub(crate) member_roles: HashMap<String, GroupRole>,
554 /// Rich-payload versions the inviter attests per existing member
555 /// (additive; absent from old SDKs), so the joiner can seal rich extras
556 /// toward members it never directly exchanged key packages with.
557 /// Entries only appear for members the inviter knows capable — absence
558 /// means "no information", never a downgrade. Recipient-side trust
559 /// bounds: entries are ignored for user ids outside the joined MLS
560 /// roster, and a later direct key-package exchange with a member
561 /// overrides its attested entry.
562 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
563 pub(crate) member_rich: HashMap<String, Vec<u8>>,
564 /// The group creator the inviter has on record (additive; absent from
565 /// old SDKs and from inviters whose own metadata never learned it).
566 ///
567 /// Without this, a joiner's metadata is materialized by `set_member_role`
568 /// via `GroupMetadata::new(None)`, leaving `created_by` permanently
569 /// `None` — so `check_is_admin`'s creator fallback is dead code for
570 /// every joiner, and a group whose role snapshot arrived incomplete
571 /// resolves to deny-by-default. Propagating it makes the fallback
572 /// reachable.
573 ///
574 /// Trust bounds mirror [`Self::member_roles`]: this is the inviter's
575 /// unauthenticated claim, adopted only when the joiner has no creator on
576 /// record (first write wins, see `MlsManager::set_group_creator`), and
577 /// deliberately **not** bounded to the joined MLS roster — the creator of
578 /// record may have already left the group. It feeds only the admin
579 /// *fallback* used for send-side gating and the authorization report; it
580 /// is never consulted by receive-side commit enforcement, which fails
581 /// open on an unknown admin set rather than trusting a single claim.
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub(crate) created_by: Option<String>,
584}
585
586/// Type of group membership commit operation.
587#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
588#[serde(rename_all = "lowercase")]
589pub(crate) enum GroupCommitType {
590 /// A member was added to the group.
591 Add,
592 /// A member was removed from the group.
593 Remove,
594 /// A key update commit (epoch advancement without membership change).
595 /// Used for epoch fork resolution.
596 KeyUpdate,
597}
598
599/// Payload for MLS Commit messages (membership changes) sent via mesh.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub(crate) struct GroupMlsCommitPayload {
602 /// MLS group identifier.
603 pub(crate) group_id: String,
604 /// Type of commit operation.
605 pub(crate) commit_type: GroupCommitType,
606 /// Base64-encoded MLS commit ciphertext.
607 pub(crate) ciphertext: String,
608 /// MLS epoch at which the commit was created.
609 pub(crate) epoch: u64,
610 /// User ID of the affected member (added or removed).
611 #[serde(skip_serializing_if = "Option::is_none")]
612 pub(crate) affected_member: Option<String>,
613 /// Role assigned to the affected member (for Add commits).
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub(crate) role: Option<GroupRole>,
616 /// Rich-payload versions the inviter attests for the added member
617 /// (additive; absent from old SDKs or when the inviter has no
618 /// knowledge), so existing members — who never exchange key packages
619 /// with the newcomer — can keep sealing rich extras. Recipient-side
620 /// trust bounds mirror `role`: honored only for the member the MLS
621 /// state delta actually added and only from an admin sender, and a
622 /// later direct key-package exchange overrides it.
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub(crate) affected_member_rich: Option<Vec<u8>>,
625}
626
627/// Payload for group leave notifications sent via mesh.
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub(crate) struct GroupMlsLeavePayload {
630 /// MLS group identifier.
631 pub(crate) group_id: String,
632 /// User ID of the leaving member.
633 pub(crate) leaving_member: String,
634}
635
636/// Payload for group role change notifications sent via mesh.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub(crate) struct GroupRoleChangePayload {
639 /// MLS group identifier.
640 pub(crate) group_id: String,
641 /// User ID of the member whose role changed.
642 pub(crate) target_user_id: String,
643 /// New role.
644 pub(crate) new_role: GroupRole,
645 /// User ID of who changed the role.
646 pub(crate) changed_by: String,
647}
648
649/// Payload for group rename notifications sent via mesh.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub(crate) struct GroupRenamePayload {
652 /// MLS group identifier.
653 pub(crate) group_id: String,
654 /// New human-readable group name.
655 pub(crate) new_name: String,
656 /// User ID of who renamed the group.
657 pub(crate) renamed_by: String,
658}
659
660// --- Relay optimization payloads ---
661
662/// Payload for registering/updating a group with the relay server.
663///
664/// Sent as a `__GRP_RELAY_REG__`-prefixed internal message to the user's
665/// own ID via Internet transport. The relay server does NOT intercept the
666/// prefix — a prefix-unaware relay just echoes the self-addressed frame
667/// back. It is the platform bridge translator (the relay adapter) that
668/// recognizes the frame via `internet_control_op` and replaces it with a
669/// relay-native `CreateGroup` plus admin-gated member deltas, so the relay
670/// learns the group → member mapping for server-side fan-out of future
671/// `__GRP_RELAY_BCAST__` messages. A transport without such an adapter
672/// leaves the group unsynced (per-member fan-out) — see
673/// `try_relay_register_group`.
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub(crate) struct RelayGroupRegistrationPayload {
676 /// MLS group identifier.
677 pub(crate) group_id: String,
678 /// Human-readable group name (for display/search on relay).
679 #[serde(skip_serializing_if = "Option::is_none")]
680 pub(crate) group_name: Option<String>,
681 /// Current member user IDs.
682 pub(crate) members: Vec<String>,
683 /// Whether the registering user is a group admin, when locally
684 /// determinable. The bridge translator uses this to skip the
685 /// `AddGroupMember`/`RemoveGroupMember` deltas a non-admin would be
686 /// denied (the relay restricts membership mutation to admins) — the
687 /// denial would otherwise arrive as a group-scoped `GroupError` that
688 /// revokes `relay_synced` and surfaces as an app-visible error on every
689 /// reconnect. Absent means unknown: the bridge falls back to
690 /// send-and-learn.
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub(crate) is_admin: Option<bool>,
693}
694
695/// Payload for a relay-broadcast of an MLS-encrypted group message.
696///
697/// Sent as a `__GRP_RELAY_BCAST__`-prefixed internal message to the user's
698/// own ID via Internet transport. The relay server does NOT intercept the
699/// prefix; the platform bridge translator replaces the frame with a
700/// relay-native `SendGroupMessage`, and the relay's fan-out arrives at each
701/// member as a `__GROUP_MSG__` frame injected by *their* bridge. This path
702/// is only taken once the relay has positively acknowledged the group's
703/// registration (`relay_synced`) — on any other relay the frame would be
704/// echoed back and the content silently lost.
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub(crate) struct RelayGroupBroadcastPayload {
707 /// MLS group identifier.
708 pub(crate) group_id: String,
709 /// Base64-encoded MLS ciphertext.
710 pub(crate) ciphertext: String,
711 /// MLS epoch at which the message was encrypted.
712 pub(crate) epoch: u64,
713 /// Optional reply-to message ID.
714 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub(crate) reply_to: Option<String>,
716 /// Optional forwarding attribution (present when message was forwarded).
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
719 /// Logical message id for this group message, stable across broadcast
720 /// re-sends of the same logical message. The bridge translator stamps it
721 /// onto the relay `SendGroupMessage` frame; a v2 relay echoes it to
722 /// every member (receiver dedup), keys its push dedup by it, and names
723 /// it in the settled `GroupMessageSent` delivery report (sender
724 /// correlation). `default` only so old captured frames in tests still
725 /// deserialize — production frames always carry it.
726 #[serde(default)]
727 pub(crate) message_id: String,
728}
729
730/// A relay `GroupMessageSent` settled delivery report, forwarded verbatim
731/// (as the raw server frame JSON) by the platform bridge into
732/// `handle_relay_group_delivery_report`. Field names follow the relay wire
733/// contract; the enclosing `type` tag and `timestamp` are ignored. All
734/// three member lists default to empty so a v1 relay's bare receipt (no
735/// lists) still parses — it settles the entry with everyone unreached,
736/// which per-member re-issue then covers.
737///
738/// Member-name namespace: a `group_delivery_v3` relay names members by the
739/// identifiers the roster was registered under — for this SDK, the MLS
740/// roster's `off1…` addresses — which is what makes the set difference in
741/// `handle_relay_group_delivery_report` meaningful. A v2 relay names
742/// members by relay-account *username*, a namespace that never intersects
743/// the address roster; the subtraction then removes nothing and the whole
744/// roster is re-issued per-member. That degradation is the fail-safe, not
745/// the contract — the broadcast gate requires v3 precisely so reports in
746/// the wrong namespace only ever arrive from relays this SDK no longer
747/// broadcasts through (e.g. a capability set that changed mid-flight).
748#[derive(Debug, Clone, Deserialize)]
749pub(crate) struct RelayGroupDeliveryReport {
750 /// Group the report is for.
751 pub(crate) group_id: String,
752 /// The logical message id, echoed from the broadcast frame.
753 pub(crate) message_id: String,
754 /// Members whose relay socket write was confirmed (a server-side
755 /// write-ack, not an application-level delivery ACK).
756 #[serde(default)]
757 pub(crate) delivered: Vec<String>,
758 /// Members unreachable over a socket who took a device push.
759 #[serde(default)]
760 pub(crate) pushed: Vec<String>,
761 /// Members the relay reached neither way.
762 #[serde(default)]
763 pub(crate) missed: Vec<RelayGroupDeliveryMiss>,
764}
765
766/// One recipient a relay group fan-out could not reach.
767#[derive(Debug, Clone, Deserialize)]
768pub(crate) struct RelayGroupDeliveryMiss {
769 /// The member's identifier as the relay knows it. Wire field name is
770 /// `username` for relay-contract stability, but under `group_delivery_v3`
771 /// the value is the registered member id (an `off1…` address for this
772 /// SDK); only v2 relays put an account username here (see the namespace
773 /// note on [`RelayGroupDeliveryReport`]).
774 pub(crate) username: String,
775 /// Stable machine-readable code (`offline_no_push`,
776 /// `connection_lost_no_push`, `undeliverable`, `already_pushed`,
777 /// `too_large_for_push`, `fanout_deadline`, …). Treated as opaque —
778 /// logged, never matched on — so new relay codes cannot break parsing.
779 #[serde(default)]
780 pub(crate) reason: String,
781}
782
783/// A commit that arrived out-of-order and is waiting to be processed.
784///
785/// In mesh networks, messages can arrive out of order. If a Commit arrives
786/// before a prior Commit, MLS decryption will fail. We buffer it here and
787/// retry after successfully processing a later commit for the same group.
788#[derive(Debug, Clone)]
789pub(crate) struct PendingCommit {
790 /// The original sender of this commit.
791 pub(crate) sender: String,
792 /// The original message ID (already recorded in the dedup table).
793 /// Released when the buffered copy is dropped undelivered by cap
794 /// eviction, so a redelivery is accepted fresh instead of being
795 /// rejected as a replay of a copy that no longer exists anywhere.
796 pub(crate) message_id: String,
797 /// The raw JSON data (after prefix strip) for replay.
798 pub(crate) data: String,
799 /// When this pending commit was first buffered.
800 pub(crate) buffered_at: Instant,
801 /// Number of times this commit has been retried and still failed.
802 /// A commit that expires with `retry_count > 0` is a strong signal
803 /// of epoch mismatch (true fork), not just slow delivery.
804 pub(crate) retry_count: u32,
805}
806
807/// A group application message that arrived before local MLS state could
808/// decrypt it (Welcome not yet processed, or epoch behind the sender's).
809///
810/// The Welcome and the first group message are sent back-to-back and can
811/// arrive out of order across transports. The ciphertext is buffered at
812/// first arrival — the message-ID dedup table keeps rejecting redeliveries,
813/// so this buffered copy is the message's only path to delivery.
814#[derive(Debug, Clone)]
815pub(crate) struct PendingGroupMessage {
816 /// The original wire sender of this message.
817 pub(crate) sender: String,
818 /// The original message ID (already recorded in the dedup table).
819 pub(crate) message_id: String,
820 /// Logical group message id carried in the frame's payload, when the
821 /// frame re-issued a relay broadcast (`GroupMlsMessagePayload::message_id`).
822 /// On delivery the drain emits this id, so every member sees the same
823 /// app-facing id whichever path reached them; the deferred delivery ACK
824 /// is keyed on `message_id` regardless, since that is the envelope the
825 /// sender's ack_manager awaits. A rival copy of the same logical message
826 /// cannot double-deliver: whichever copy decrypts first spends the
827 /// ratchet generation, and the drain drops any later copy of an id it
828 /// already delivered. `None` on the ordinary fan-out and relay paths.
829 pub(crate) logical_id: Option<String>,
830 /// Base64-encoded MLS application ciphertext.
831 pub(crate) ciphertext_b64: String,
832 /// Relay-provided timestamp; `None` for mesh messages (stamped at emit).
833 pub(crate) timestamp: Option<String>,
834 /// Optional ID of the message this one replies to.
835 pub(crate) reply_to: Option<String>,
836 /// Optional forwarding attribution.
837 pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
838 /// When this message was first buffered.
839 pub(crate) buffered_at: Instant,
840 /// Transport the frame arrived on, recorded so the drain can send the
841 /// deferred delivery ACK directly on it once the message finally decrypts
842 /// (see the deferred-acknowledgement atom in
843 /// `docs/state-machines/delivery-and-acks.md`). `None` for the relay path
844 /// (the relay sender is not ACK-gated) and for transport-less test enqueue
845 /// — in both cases the drain ACK is a correct no-op.
846 pub(crate) received_via: Option<TransportType>,
847}
848
849/// Tracks a leave election where we're waiting for the elected remover to
850/// issue a remove-commit. If the timeout expires and the member is still
851/// in the group, the next eligible member re-elects itself.
852#[derive(Debug, Clone)]
853pub(crate) struct PendingLeaveElection {
854 /// Group the leave is for.
855 pub(crate) group_id: String,
856 /// User ID of the member who is leaving.
857 pub(crate) leaving_member: String,
858 /// When we received the leave notification.
859 pub(crate) received_at: Instant,
860 /// When we last attempted to issue a remove-commit for this election.
861 /// Used as a cooldown to prevent spamming MLS operations on every
862 /// process tick when the current candidate fails repeatedly.
863 pub(crate) last_attempt_at: Option<Instant>,
864}
865
866/// Tracks a suspected epoch fork for a group.
867///
868/// An epoch fork occurs when two members issue concurrent commits at the
869/// same epoch (e.g., two admins adding different members simultaneously).
870/// Members who accepted commit A are on one branch, those who accepted
871/// commit B are on another. Detected when buffered commits expire without
872/// resolution.
873#[derive(Debug, Clone)]
874pub(crate) struct EpochForkState {
875 /// Group with the suspected fork.
876 pub(crate) group_id: String,
877 /// Our local epoch when the fork was detected, or `None` if MLS was unavailable.
878 pub(crate) local_epoch: Option<u64>,
879 /// When the fork was first suspected.
880 pub(crate) detected_at: Instant,
881 /// Whether the leader has already attempted resolution.
882 pub(crate) resolution_attempted: bool,
883}
884
885/// Global limits enforced by [`enforce_global_buffer_bound`], grouped so the
886/// three same-typed caps cannot be transposed at a call site.
887struct GlobalBufferCaps {
888 /// Maximum distinct group IDs in the buffer map.
889 max_groups: usize,
890 /// Maximum buffered entries across all groups.
891 max_entries: usize,
892 /// Maximum buffered payload bytes across all groups.
893 max_bytes: usize,
894}
895
896const PENDING_COMMIT_CAPS: GlobalBufferCaps = GlobalBufferCaps {
897 max_groups: MAX_PENDING_COMMIT_GROUPS,
898 max_entries: MAX_PENDING_COMMITS_TOTAL,
899 max_bytes: MAX_PENDING_COMMIT_TOTAL_BYTES,
900};
901
902const PENDING_GROUP_MESSAGE_CAPS: GlobalBufferCaps = GlobalBufferCaps {
903 max_groups: MAX_PENDING_GROUP_MESSAGE_GROUPS,
904 max_entries: MAX_PENDING_GROUP_MESSAGES_TOTAL,
905 max_bytes: MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES,
906};
907
908/// Enforces a global bound — entry count, payload bytes, and distinct group
909/// count — across all per-group pending buffers, evicting until an incoming
910/// entry of `incoming_bytes` for `incoming_group` fits. Returns the entries
911/// evicted to make room so callers can release per-entry state (e.g. the
912/// replay-dedup ID that would otherwise keep rejecting a redelivery of the
913/// evicted message), or `None` (evicting nothing) when the incoming entry
914/// alone exceeds the byte budget — the caller must drop it. Without that
915/// guard a single oversized entry would purge every buffered entry across
916/// all groups and then land anyway, blowing the cap it was meant to respect.
917///
918/// Group IDs on these buffers come straight off the wire, and the buffered
919/// case (`GroupNotFound`) is precisely the pre-join, unauthenticated one —
920/// per-group caps alone leave total retention open-ended across
921/// attacker-chosen group IDs.
922///
923/// Freeing a *group slot* (admitting a new group ID at the group cap)
924/// evicts the single largest per-group buffer wholesale: emptying some
925/// group is the only way to free a slot, and doing it one entry at a time
926/// would round-robin across level-sized buffers — purging nearly every
927/// buffered entry map-wide before any single group emptied. Evicting one
928/// group as a unit bounds the damage at one per-group buffer and
929/// preferentially removes a concentrated flood over honest few-entry
930/// groups.
931///
932/// Entry/byte pressure then evicts the oldest entry of the *largest*
933/// remaining buffer (tie-broken by age), so a flood that concentrates in a
934/// few group IDs is evicted before honest welcome-race entries, which are
935/// few per group. A flood that instead spreads one entry per fabricated
936/// group ID levels the buffer sizes and degrades eviction to
937/// globally-oldest; the group cap bounds how wide such a spread can reach
938/// (and the map-key memory it retains), but within it a sustained spread
939/// flood can still push out an honest entry. Callers mitigate that by
940/// releasing the evicted entry's dedup ID, turning the loss into a
941/// redelivery retry instead of a permanent one — full protection would
942/// need sender-level accounting, which none of these pre-join paths have.
943fn enforce_global_buffer_bound<T>(
944 map: &mut HashMap<String, VecDeque<T>>,
945 incoming_group: &str,
946 caps: &GlobalBufferCaps,
947 incoming_bytes: usize,
948 entry_bytes: impl Fn(&T) -> usize,
949 buffered_at: impl Fn(&T) -> Instant,
950) -> Option<Vec<T>> {
951 if incoming_bytes > caps.max_bytes {
952 return None;
953 }
954 let (mut entries, mut bytes) = map
955 .values()
956 .flat_map(|buf| buf.iter())
957 .fold((0usize, 0usize), |(n, b), e| (n + 1, b + entry_bytes(e)));
958 // Each deque is in arrival order, so its front is its oldest entry.
959 // Rank groups by (buffer length, entry age): largest buffer first,
960 // oldest front entry breaking ties.
961 let pick_victim = |map: &HashMap<String, VecDeque<T>>| {
962 map.iter()
963 .filter_map(|(gid, buf)| {
964 buf.front()
965 .map(|e| ((buf.len(), std::cmp::Reverse(buffered_at(e))), gid))
966 })
967 .max_by_key(|(rank, _)| *rank)
968 .map(|(_, gid)| gid.clone())
969 };
970 let mut all_evicted = Vec::new();
971 while map.len() >= caps.max_groups && !map.contains_key(incoming_group) {
972 let Some(victim_group) = pick_victim(map) else {
973 break;
974 };
975 warn!(
976 group_id = %victim_group,
977 "Pending buffers at group capacity, evicting largest group buffer wholesale"
978 );
979 if let Some(buf) = map.remove(&victim_group) {
980 entries -= buf.len();
981 bytes -= buf.iter().map(&entry_bytes).sum::<usize>();
982 all_evicted.extend(buf);
983 }
984 }
985 while entries >= caps.max_entries || bytes + incoming_bytes > caps.max_bytes {
986 let Some(victim_group) = pick_victim(map) else {
987 break;
988 };
989 warn!(
990 group_id = %victim_group,
991 "Pending buffer at global capacity, evicting oldest entry of largest group buffer"
992 );
993 if let Some(buf) = map.get_mut(&victim_group) {
994 if let Some(evicted) = buf.pop_front() {
995 entries -= 1;
996 bytes -= entry_bytes(&evicted);
997 all_evicted.push(evicted);
998 }
999 if buf.is_empty() {
1000 map.remove(&victim_group);
1001 }
1002 }
1003 }
1004 Some(all_evicted)
1005}
1006
1007impl OfflineProtocol {
1008 /// Transport-less convenience wrapper over [`Self::handle_group_mls_msg_via`],
1009 /// used only by tests; production always knows the arrival transport.
1010 #[cfg(test)]
1011 pub(crate) fn handle_group_mls_msg(
1012 &mut self,
1013 message: &Message,
1014 sender: &str,
1015 data: &str,
1016 ) -> InternalMessageResult {
1017 self.handle_group_mls_msg_via(message, sender, data, None)
1018 }
1019
1020 /// Handles an incoming MLS-encrypted group message, with the transport the
1021 /// frame arrived on.
1022 ///
1023 /// Returns [`InternalMessageResult::SecurityRejected`] when the wire
1024 /// sender does not match the MLS-authenticated sender (SEC-M1), so the
1025 /// caller suppresses the delivery ACK exactly like the `__MLS_ENC__`
1026 /// path; a genuine crypto/parse failure consumes the message. That
1027 /// refusal also releases the group-level dedup entry marked below, so a
1028 /// replayed copy is judged again on its own merits instead of reading as
1029 /// already delivered in the duplicate branch and being re-ACKed.
1030 ///
1031 /// Deferred-ACK atom (mesh-group analog of the DM/media fix, PR #223): a
1032 /// message buffered because local group state is not ready yet returns
1033 /// [`InternalMessageResult::Deferred`], so the receive loop skips the
1034 /// delivery ACK and unmarks the transport-level dedup — keeping the
1035 /// sender's per-member `ack_manager` retransmitting until the message is
1036 /// actually delivered on drain (`drain_pending_group_messages` sends the
1037 /// deferred ACK on `received_via`). Without this the buffered-but-ACKed
1038 /// copy could be evicted/expired before a commit drained it and be lost,
1039 /// even though the group-level `release_replay_protection` already clears
1040 /// dedup on drop. The group-level `message_dedup` stays marked across the
1041 /// pending lifetime (replay-amplification defense + authoritative
1042 /// double-delivery guard), so — unlike the DM path — the drain does not
1043 /// re-mark the transport dedup.
1044 pub(crate) fn handle_group_mls_msg_via(
1045 &mut self,
1046 message: &Message,
1047 sender: &str,
1048 data: &str,
1049 arrival_transport: Option<TransportType>,
1050 ) -> InternalMessageResult {
1051 let payload = match serde_json::from_str::<GroupMlsMessagePayload>(data) {
1052 Ok(p) => p,
1053 Err(_) => {
1054 warn!(sender = %sender, "Failed to parse GroupMlsMessage payload");
1055 return InternalMessageResult::Consumed;
1056 }
1057 };
1058
1059 // Dedup check using the unique message ID. When the payload carries a
1060 // logical id (a re-issued relay broadcast), the same logical message
1061 // may already have arrived via the relay path under that id — check
1062 // both keys so a cross-path duplicate is absorbed here instead of
1063 // burning an MLS decrypt on a ciphertext whose ratchet generation was
1064 // already consumed (which would classify Retriable and buffer noise).
1065 let dedup_key = message.id.as_str().to_string();
1066 let logical_key = payload.message_id.clone();
1067 let seen = self.group_mesh.message_dedup.contains_key(&dedup_key)
1068 || logical_key
1069 .as_deref()
1070 .is_some_and(|l| self.group_mesh.message_dedup.contains_key(l));
1071 if seen {
1072 // Already seen. If the buffered copy is still awaiting decryption,
1073 // this is a sender retransmit of an undelivered message: defer (no
1074 // ACK) so the sender keeps retrying until we actually deliver on
1075 // drain. Returning before decrypt also preserves the
1076 // replay-amplification defense (one MLS crypto op per id). If the
1077 // id is not buffered, it was already delivered (a dropped-and-
1078 // released id would not be in the dedup table at all), so treat it
1079 // as a normal duplicate and re-ACK to let the sender stop.
1080 let pending = self.is_group_message_pending(&payload.group_id, &dedup_key)
1081 || logical_key
1082 .as_deref()
1083 .is_some_and(|l| self.is_group_message_pending(&payload.group_id, l));
1084 if pending {
1085 debug!(
1086 group_id = %payload.group_id,
1087 msg_id = %dedup_key,
1088 "Duplicate of a still-pending group message, deferring ACK"
1089 );
1090 return InternalMessageResult::Deferred;
1091 }
1092 debug!(
1093 group_id = %payload.group_id,
1094 msg_id = %dedup_key,
1095 "Duplicate group message, skipping"
1096 );
1097 return InternalMessageResult::Consumed;
1098 }
1099
1100 // Mark as seen BEFORE attempting decode/decrypt to prevent replay
1101 // amplification: an adversary replaying the same message (even with a bad
1102 // epoch) should only trigger one MLS crypto operation. Only the envelope
1103 // id is marked here — the payload's logical id is unauthenticated wire
1104 // input until the ciphertext MLS-decrypts, and marking it pre-decrypt
1105 // would let a non-member poison a logical id to suppress the genuine
1106 // copy. It is marked below, on successful decrypt only.
1107 self.group_mesh
1108 .message_dedup
1109 .insert(dedup_key, Instant::now());
1110 if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
1111 self.cleanup_group_message_dedup();
1112 }
1113
1114 // Size guard before base64 decode
1115 let ciphertext_bytes = match base64_decode(&payload.ciphertext) {
1116 Ok(bytes) => bytes,
1117 Err(e) => {
1118 warn!(group_id = %payload.group_id, error = %e, "Failed to decode group message ciphertext");
1119 return InternalMessageResult::Consumed;
1120 }
1121 };
1122
1123 match self.decrypt_group_application(&payload.group_id, ciphertext_bytes, sender) {
1124 GroupDecryptOutcome::Plaintext(plaintext) => {
1125 let Ok(text) = String::from_utf8(plaintext) else {
1126 warn!(
1127 group_id = %payload.group_id,
1128 "Decrypted group payload is not valid UTF-8, dropping"
1129 );
1130 return InternalMessageResult::Consumed;
1131 };
1132 // Decryption authenticated the sender as a group member, so
1133 // the payload's logical id (if any) can now be trusted: mark
1134 // it delivered so the relay path's copy of the same logical
1135 // message dedups against this one, and emit it as the
1136 // app-facing id so every member sees the same id for this
1137 // logical message regardless of arrival path.
1138 let msg_id = logical_key.unwrap_or_else(|| message.id.as_str().to_string());
1139 if msg_id != message.id.as_str() {
1140 self.group_mesh
1141 .message_dedup
1142 .insert(msg_id.clone(), Instant::now());
1143 }
1144 let timestamp = chrono::Utc::now().to_rfc3339();
1145 info!(group_id = %payload.group_id, "Decrypted mesh group message");
1146 let (content, media_metadata, content_type, forward_info_event) =
1147 Self::restore_group_rich(text, payload.forward_info, sender);
1148 self.emit_event(Event::group_message_received(
1149 payload.group_id,
1150 sender.to_string(),
1151 content,
1152 timestamp,
1153 msg_id,
1154 payload.reply_to,
1155 forward_info_event,
1156 media_metadata,
1157 content_type,
1158 ));
1159 InternalMessageResult::Consumed
1160 }
1161 GroupDecryptOutcome::SecurityRejected => {
1162 // Refused on attribution grounds: no ACK, and no dedup entry
1163 // left behind either. Left marked, a replay of this frame
1164 // reaches the duplicate branch above, reads as marked but not
1165 // pending (that is, already delivered), and is Consumed, which
1166 // the receive loop ACKs. That hands back exactly the liveness
1167 // confirmation the silent refusal exists to withhold
1168 // (invariant I4, ADR 0005). Only the envelope id can be marked
1169 // at this point; the logical id is marked on a successful
1170 // decrypt only. The transport-level unmark stays with the
1171 // receive loop's `SecurityRejected` arm, which owns that layer
1172 // for every rejected frame. Cost is one MLS crypto operation
1173 // per replayed copy, the same trade the relay path accepts.
1174 self.group_mesh.message_dedup.remove(&message.id.as_str());
1175 InternalMessageResult::SecurityRejected
1176 }
1177 GroupDecryptOutcome::Retriable => {
1178 let group_id = payload.group_id.clone();
1179 self.buffer_pending_group_message(
1180 &group_id,
1181 PendingGroupMessage {
1182 sender: sender.to_string(),
1183 message_id: message.id.as_str().to_string(),
1184 logical_id: logical_key,
1185 ciphertext_b64: payload.ciphertext,
1186 timestamp: None,
1187 reply_to: payload.reply_to,
1188 forward_info: payload.forward_info,
1189 buffered_at: Instant::now(),
1190 received_via: arrival_transport,
1191 },
1192 );
1193 // Buffered, not delivered: defer the ACK so the sender keeps
1194 // retransmitting until the drain surfaces it. The drain then
1195 // ACKs directly on `arrival_transport`.
1196 InternalMessageResult::Deferred
1197 }
1198 GroupDecryptOutcome::NonApplication => {
1199 // MLS consumed a commit or proposal riding the application
1200 // channel — group state may have advanced, which can unblock
1201 // buffered commits and messages exactly like a commit-channel
1202 // success.
1203 self.drain_pending_commits(&payload.group_id);
1204 self.drain_pending_group_messages(&payload.group_id);
1205 InternalMessageResult::Consumed
1206 }
1207 GroupDecryptOutcome::PolicyRejected {
1208 committer,
1209 added,
1210 removed,
1211 } => {
1212 // A commit reframed onto the application channel, refused by
1213 // enforcement before merging. Consume it: the rejection is
1214 // permanent, so buffering would retry a ciphertext that can
1215 // never be authorized.
1216 self.report_unauthorized_membership_change(
1217 &payload.group_id,
1218 &committer,
1219 &added,
1220 &removed,
1221 "sender_not_admin",
1222 true,
1223 );
1224 InternalMessageResult::Consumed
1225 }
1226 // The mesh channel carries MLS ciphertext by protocol, so a
1227 // payload that is not MLS framing is garbage, not legacy content.
1228 GroupDecryptOutcome::NotMlsCiphertext | GroupDecryptOutcome::Failed => {
1229 InternalMessageResult::Consumed
1230 }
1231 }
1232 }
1233
1234 /// Decrypts a group application ciphertext, classifying the failure
1235 /// modes shared by the mesh (`handle_group_mls_msg`), relay
1236 /// (`handle_relay_group_message_with_mls`), and deferred-retry
1237 /// (`drain_pending_group_messages`) inbound paths.
1238 fn decrypt_group_application(
1239 &self,
1240 group_id: &str,
1241 ciphertext: Vec<u8>,
1242 sender: &str,
1243 ) -> GroupDecryptOutcome {
1244 let mls_guard = match self.read_mls_guard() {
1245 Ok(guard) => guard,
1246 Err(e) => {
1247 warn!(group_id = %group_id, error = %e, "MLS unavailable, dropping group message");
1248 return GroupDecryptOutcome::Failed;
1249 }
1250 };
1251 let gid = match offline_protocol_mls::GroupId::new(group_id) {
1252 Ok(gid) => gid,
1253 Err(e) => {
1254 warn!(group_id = %group_id, error = %e, "Dropping group message with invalid group id");
1255 return GroupDecryptOutcome::Failed;
1256 }
1257 };
1258 // The epoch field is not used for decryption — OpenMLS determines
1259 // the epoch from the ciphertext header itself.
1260 let encrypted = offline_protocol_mls::EncryptedMessage {
1261 group_id: gid,
1262 message_type: offline_protocol_mls::MlsMessageType::Application,
1263 epoch: 0,
1264 ciphertext,
1265 sender_id: sender.to_string(),
1266 timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1267 };
1268 match mls_guard.decrypt_from_group(&encrypted, sender) {
1269 Ok(Some(plaintext)) => GroupDecryptOutcome::Plaintext(plaintext),
1270 Ok(None) => {
1271 // Ok(None) means MLS consumed a Commit or Proposal — not application
1272 // data. This is normal for non-application messages that arrive via the
1273 // group message channel (e.g., due to message reordering).
1274 debug!(
1275 group_id = %group_id,
1276 "MLS returned no plaintext (commit/proposal consumed), not an application message"
1277 );
1278 GroupDecryptOutcome::NonApplication
1279 }
1280 Err(offline_protocol_mls::MlsError::SenderIdentityMismatch {
1281 claimed,
1282 authenticated,
1283 }) => {
1284 error!(
1285 group_id = %group_id,
1286 claimed = %claimed,
1287 authenticated = %authenticated,
1288 "SECURITY: wire sender does not match MLS-authenticated sender, rejecting group message"
1289 );
1290 GroupDecryptOutcome::SecurityRejected
1291 }
1292 // The same class as SEC-M1 and the same disposition: a permanent
1293 // refusal to attribute this frame to anyone. A leaf whose key does
1294 // not hash to its own credential can never become legitimate, so
1295 // this must not be buffered — and it must withhold the ACK and
1296 // unmark the id, which is what `SecurityRejected` already carries.
1297 Err(e @ offline_protocol_mls::MlsError::LeafAddressMismatch { .. }) => {
1298 error!(
1299 group_id = %group_id,
1300 sender = %sender,
1301 error = %e,
1302 "SECURITY: group leaf does not prove the identity it claims, rejecting group message"
1303 );
1304 GroupDecryptOutcome::SecurityRejected
1305 }
1306 Err(e @ offline_protocol_mls::MlsError::UnsupportedSender { .. }) => {
1307 error!(
1308 group_id = %group_id,
1309 sender = %sender,
1310 error = %e,
1311 "SECURITY: rejecting a group message from an unsupported sender role"
1312 );
1313 GroupDecryptOutcome::SecurityRejected
1314 }
1315 Err(offline_protocol_mls::MlsError::CommitNotAuthorized {
1316 committer,
1317 added,
1318 removed,
1319 }) => GroupDecryptOutcome::PolicyRejected {
1320 committer,
1321 added,
1322 removed,
1323 },
1324 Err(e @ offline_protocol_mls::MlsError::GroupNotFound(_)) => {
1325 // No local group state yet. Only buffer what is actually MLS
1326 // wire framing — a racing ciphertext parses, legacy relay
1327 // plaintext that happens to be valid base64 does not.
1328 // (`Decryption` failures below skip this check: decryption
1329 // only runs after framing already parsed.)
1330 if offline_protocol_mls::is_mls_framed(&encrypted.ciphertext) {
1331 warn!(
1332 group_id = %group_id,
1333 error = %e,
1334 "Failed to decrypt group message (may be out-of-order, buffering)"
1335 );
1336 GroupDecryptOutcome::Retriable
1337 } else {
1338 debug!(
1339 group_id = %group_id,
1340 "Group message payload is not MLS framing and no local group state exists"
1341 );
1342 GroupDecryptOutcome::NotMlsCiphertext
1343 }
1344 }
1345 Err(
1346 e @ (offline_protocol_mls::MlsError::Decryption(_)
1347 | offline_protocol_mls::MlsError::SessionDesync(_)),
1348 ) => {
1349 // Epoch-lagged ciphertext — may decrypt once a commit
1350 // advances local group state. `SessionDesync` is the same
1351 // epoch-mismatch condition surfaced with a distinct variant for
1352 // the 1:1 re-key path; on the group path it buffers exactly as
1353 // `Decryption` always has (the group's own recovery is the
1354 // commit-driven drain, not a re-key).
1355 warn!(
1356 group_id = %group_id,
1357 error = %e,
1358 "Failed to decrypt group message (may be out-of-order, buffering)"
1359 );
1360 GroupDecryptOutcome::Retriable
1361 }
1362 Err(e) => {
1363 warn!(group_id = %group_id, error = %e, "Failed to decrypt group message");
1364 GroupDecryptOutcome::Failed
1365 }
1366 }
1367 }
1368
1369 /// Handles an incoming MLS Welcome message (group invite).
1370 pub(crate) fn handle_group_mls_welcome(&mut self, message_id: &str, sender: &str, data: &str) {
1371 let payload = match serde_json::from_str::<GroupMlsWelcomePayload>(data) {
1372 Ok(p) => p,
1373 Err(_) => {
1374 warn!(sender = %sender, "Failed to parse GroupMlsWelcome payload");
1375 return;
1376 }
1377 };
1378
1379 info!(group_id = %payload.group_id, "Received mesh group Welcome");
1380
1381 // Dedup check — prevents double-join when the same Welcome arrives via
1382 // multiple transports nearly simultaneously (TOCTOU race on members cache).
1383 let dedup_key = message_id.to_string();
1384 if self.group_mesh.message_dedup.contains_key(&dedup_key) {
1385 debug!(group_id = %payload.group_id, msg_id = %dedup_key, "Duplicate Welcome message, skipping");
1386 return;
1387 }
1388 self.group_mesh
1389 .message_dedup
1390 .insert(dedup_key, Instant::now());
1391 if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
1392 self.cleanup_group_message_dedup();
1393 }
1394
1395 // Skip duplicate Welcome — we're already a member
1396 if self.group_mesh.members.contains_key(&payload.group_id) {
1397 debug!(group_id = %payload.group_id, "Ignoring duplicate Welcome — already a member of this group");
1398 return;
1399 }
1400
1401 let welcome_bytes = match base64_decode(&payload.welcome_data) {
1402 Ok(bytes) => bytes,
1403 Err(e) => {
1404 warn!(group_id = %payload.group_id, error = %e, "Failed to decode welcome data");
1405 return;
1406 }
1407 };
1408
1409 // Join group via MLS, then update cache
1410 let mls_guard = match self.read_mls_guard() {
1411 Ok(guard) => guard,
1412 Err(e) => {
1413 warn!(group_id = %payload.group_id, error = %e, "MLS unavailable, dropping group welcome");
1414 return;
1415 }
1416 };
1417 let gid = match offline_protocol_mls::GroupId::new(&payload.group_id) {
1418 Ok(gid) => gid,
1419 Err(e) => {
1420 warn!(group_id = %payload.group_id, error = %e, "Dropping group welcome with invalid group id");
1421 return;
1422 }
1423 };
1424 let welcome = offline_protocol_mls::WelcomeMessage {
1425 group_id: gid.clone(),
1426 welcome_data: welcome_bytes,
1427 inviter_id: sender.to_string(),
1428 group_name: payload.group_name.clone(),
1429 timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1430 };
1431 // Collected under the MLS guard, emitted after it drops: `emit_event`
1432 // takes the shared state, and holding both is the lock order this
1433 // module avoids everywhere else.
1434 let mut unproven_leaf_invite = false;
1435 let join_result = match mls_guard.join_group(&welcome) {
1436 Ok(group_info) => Some(group_info.members.clone()),
1437 Err(e) => {
1438 warn!(group_id = %payload.group_id, error = %e, "Failed to join mesh group");
1439 // A forged ratchet tree is the one join failure that is an
1440 // accusation rather than a fault: the inviter built a leaf
1441 // around an identity they cannot prove. Everything else here
1442 // (a spent key package, a storage error) is noise, so only
1443 // this class is surfaced.
1444 if matches!(
1445 e,
1446 offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1447 | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1448 ) {
1449 unproven_leaf_invite = true;
1450 }
1451 None
1452 }
1453 };
1454 drop(mls_guard);
1455
1456 if unproven_leaf_invite {
1457 let group_id = payload.group_id.clone();
1458 self.report_unproven_leaf(
1459 &group_id,
1460 sender,
1461 UnprovenLeafSite::WelcomeDeclined,
1462 // Identifier-free for the same reason as the commit site: a
1463 // `SecurityWarning`'s `reason` is not scrubbed, and the error
1464 // names the impersonated address. The detail stays in the log.
1465 "Group invite declined: it carried an identity claim this device could not \
1466 verify, so messages in this group could not be reliably attributed"
1467 .to_string(),
1468 );
1469 }
1470
1471 if let Some(members) = join_result {
1472 let group_id = payload.group_id.clone();
1473
1474 // Record the inviter's rich-capability attestations for members
1475 // we have never (and may never) directly exchange key packages
1476 // with — without them every rich send into this group drops its
1477 // extras. Bounded to the authoritative MLS roster we just
1478 // joined: entries for non-members are attacker-suppliable dead
1479 // weight and are ignored. `record_attested_rich` skips self,
1480 // non-V1 lists, and peers we already know directly.
1481 for (user_id, versions) in &payload.member_rich {
1482 if members.contains(user_id) {
1483 self.record_attested_rich(user_id, versions);
1484 }
1485 }
1486
1487 self.group_mesh.members.insert(group_id.clone(), members);
1488
1489 // Store member roles and the creator of record from the welcome
1490 // payload. The creator is what makes `check_is_admin`'s fallback
1491 // reachable for a joiner: without it our metadata is materialized
1492 // by `set_member_role` via `GroupMetadata::new(None)`, so a group
1493 // whose role snapshot arrived incomplete would resolve to
1494 // deny-by-default with no way back. Deliberately not bounded to
1495 // the roster we just joined — the creator of record may already
1496 // have left. `set_group_creator` is first-write-wins, so a
1497 // duplicate or later Welcome cannot rewrite it.
1498 if !payload.member_roles.is_empty() || payload.created_by.is_some() {
1499 if let Ok(mls_guard) = self.read_mls_guard() {
1500 for (user_id, role) in &payload.member_roles {
1501 if let Err(e) = mls_guard.set_member_role(&gid, user_id, *role) {
1502 warn!(user_id = %user_id, error = %e, "Failed to store member role from welcome");
1503 }
1504 }
1505 if let Some(creator) = &payload.created_by {
1506 if let Err(e) = mls_guard.set_group_creator(&gid, creator) {
1507 warn!(creator = %creator, error = %e, "Failed to store group creator from welcome");
1508 }
1509 }
1510 }
1511 }
1512
1513 // Send a fresh key package to the inviter. The inviter consumed our
1514 // previous key package to create this Welcome, so they need a new one
1515 // for future group invites. Clear the tracking flag first so the send
1516 // isn't suppressed as a duplicate.
1517 self.key_package_sent_to.remove(sender);
1518 if let Err(e) = self.send_key_package_to(sender, false) {
1519 debug!(
1520 inviter = %sender,
1521 error = %e,
1522 "Failed to send fresh key package to inviter after Welcome (will retry on discovery)"
1523 );
1524 }
1525
1526 self.emit_event(Event::group_member_added(
1527 group_id.clone(),
1528 self.local_id.clone(),
1529 sender.to_string(),
1530 payload.group_name.clone(),
1531 // Not evaluated: this Welcome is what creates our view of the
1532 // group, so there is no prior role state to judge the inviter
1533 // against. `Some(true)` here would claim a check that cannot
1534 // exist on this path.
1535 None,
1536 ));
1537
1538 // A commit or group message can race ahead of its Welcome (they
1539 // are sent back-to-back and may take different paths); now that
1540 // the group exists locally, retry anything that was buffered.
1541 // Commits first — an epoch-advancing commit may be exactly what
1542 // a buffered message is waiting for.
1543 self.drain_pending_commits(&group_id);
1544 self.drain_pending_group_messages(&group_id);
1545 }
1546 }
1547
1548 /// Handles an incoming MLS Commit message (membership change).
1549 ///
1550 /// Validates the `affected_member` claim against actual MLS state delta
1551 /// to prevent forged membership events.
1552 ///
1553 /// If decryption fails (e.g., due to out-of-order delivery in a mesh network),
1554 /// the commit is buffered for deferred retry. When a subsequent commit for the
1555 /// same group succeeds, buffered commits are drained and retried.
1556 pub(crate) fn handle_group_mls_commit(&mut self, message_id: &str, sender: &str, data: &str) {
1557 // Dedup check — prevents processing the same commit twice when it
1558 // arrives via multiple transports. Without this, the second copy fails
1559 // at MLS (wrong epoch) and gets buffered as a "retriable" pending
1560 // commit, wasting space and potentially triggering false fork detection.
1561 let dedup_key = message_id.to_string();
1562 if self.group_mesh.message_dedup.contains_key(&dedup_key) {
1563 debug!(msg_id = %dedup_key, "Duplicate commit message, skipping");
1564 return;
1565 }
1566 self.group_mesh
1567 .message_dedup
1568 .insert(dedup_key, Instant::now());
1569 if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
1570 self.cleanup_group_message_dedup();
1571 }
1572
1573 match self.process_commit_core(sender, data) {
1574 CommitOutcome::Success(group_id) => {
1575 self.drain_pending_commits(&group_id);
1576 // A commit that advanced the epoch may unblock buffered
1577 // application messages encrypted at the newer epoch.
1578 self.drain_pending_group_messages(&group_id);
1579 }
1580 CommitOutcome::Retriable(group_id) => {
1581 self.buffer_pending_commit(&group_id, message_id, sender, data);
1582 }
1583 CommitOutcome::Rejected => {}
1584 }
1585 }
1586
1587 /// Core commit processing logic. Returns an outcome indicating success,
1588 /// retriable failure, or permanent rejection.
1589 ///
1590 /// Does **not** buffer on failure — callers decide whether to buffer based
1591 /// on the returned outcome.
1592 fn process_commit_core(&mut self, sender: &str, data: &str) -> CommitOutcome {
1593 let payload = match serde_json::from_str::<GroupMlsCommitPayload>(data) {
1594 Ok(p) => p,
1595 Err(_) => {
1596 warn!(sender = %sender, "Failed to parse GroupMlsCommit payload");
1597 return CommitOutcome::Rejected;
1598 }
1599 };
1600
1601 info!(
1602 group_id = %payload.group_id,
1603 commit_type = ?payload.commit_type,
1604 "Received mesh group Commit"
1605 );
1606
1607 if payload.ciphertext.is_empty() {
1608 warn!(
1609 group_id = %payload.group_id,
1610 "Received Commit with empty ciphertext, cannot advance MLS epoch"
1611 );
1612 return CommitOutcome::Rejected;
1613 }
1614
1615 let ciphertext_bytes = match base64_decode(&payload.ciphertext) {
1616 Ok(bytes) => bytes,
1617 Err(e) => {
1618 warn!(group_id = %payload.group_id, error = %e, "Failed to decode commit ciphertext");
1619 return CommitOutcome::Rejected;
1620 }
1621 };
1622
1623 let mls_guard = match self.read_mls_guard() {
1624 Ok(guard) => guard,
1625 Err(e) => {
1626 warn!(group_id = %payload.group_id, error = %e, "MLS unavailable, dropping group commit");
1627 return CommitOutcome::Rejected;
1628 }
1629 };
1630
1631 let gid = match offline_protocol_mls::GroupId::new(&payload.group_id) {
1632 Ok(gid) => gid,
1633 Err(e) => {
1634 warn!(group_id = %payload.group_id, error = %e, "Rejecting group commit with invalid group id");
1635 return CommitOutcome::Rejected;
1636 }
1637 };
1638
1639 // Capture members before commit for delta validation. `None` marks a
1640 // failed read: this and the post-commit refresh below are the two
1641 // inputs to the membership delta, and a transient storage failure
1642 // (`MlsStorage` is a platform keychain/keystore) silently defaulting
1643 // to an empty roster would fabricate a full-roster delta — and, with
1644 // it, a security report naming an innocent committer as having added
1645 // every member. `Ok(None)` (no local group yet) stays an empty
1646 // roster: past a successful decrypt it is unreachable anyway.
1647 //
1648 // Deliberately MLS-derived, not the `group_mesh.members` cache: relay
1649 // reconciliation can splice ids into the cache that MLS never
1650 // admitted, and a cache-derived "before" would report such a phantom
1651 // as an unauthorized removal on the next real commit.
1652 let members_before: Option<HashSet<String>> = match mls_guard.get_group_info(&gid) {
1653 Ok(info) => Some(
1654 info.map(|i| i.members.into_iter().collect())
1655 .unwrap_or_default(),
1656 ),
1657 Err(e) => {
1658 warn!(
1659 group_id = %payload.group_id,
1660 error = %e,
1661 "Failed to read pre-commit roster — this commit's membership delta will not be derived or judged"
1662 );
1663 None
1664 }
1665 };
1666
1667 // Process Commit via MLS to advance epoch (single lock acquisition)
1668 let encrypted = offline_protocol_mls::EncryptedMessage {
1669 group_id: gid.clone(),
1670 message_type: offline_protocol_mls::MlsMessageType::Commit,
1671 epoch: payload.epoch,
1672 ciphertext: ciphertext_bytes,
1673 sender_id: sender.to_string(),
1674 timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1675 };
1676 let mls_result = mls_guard.decrypt_from_group(&encrypted, sender);
1677 drop(mls_guard);
1678
1679 match mls_result {
1680 Ok(_) => {}
1681 Err(ref e) => {
1682 // If this is a Remove commit targeting us, we can't decrypt it
1683 // because the admin already removed us from the MLS group. But the
1684 // commit payload metadata tells us we were removed, so handle it
1685 // gracefully: emit the event, clean up local state, and reject the
1686 // commit (don't buffer it — it will never succeed).
1687 //
1688 // SECURITY: Verify the sender is an admin before trusting the
1689 // unencrypted commit metadata. Without this check, a non-admin
1690 // group member could forge a commit with garbage ciphertext and
1691 // affected_member = victim to force group eviction.
1692 let self_id = self.local_id.clone();
1693 if matches!(payload.commit_type, GroupCommitType::Remove)
1694 && payload.affected_member.as_deref() == Some(&self_id)
1695 && self
1696 .check_is_admin(&payload.group_id, sender)
1697 .unwrap_or(false)
1698 {
1699 info!(
1700 group_id = %payload.group_id,
1701 "Received remove-commit targeting us — cleaning up local state"
1702 );
1703 // Clean up local MLS state
1704 if let Ok(mls_guard) = self.read_mls_guard() {
1705 let _ = mls_guard.leave_group(&gid);
1706 }
1707 self.group_mesh.members.remove(&payload.group_id);
1708 let was_synced = self.group_mesh.relay_synced.remove(&payload.group_id);
1709 let was_pending = self
1710 .group_mesh
1711 .relay_register_pending
1712 .remove(&payload.group_id)
1713 .is_some();
1714 if was_synced || was_pending {
1715 self.emit_event(Event::group_relay_sync_changed(
1716 payload.group_id.clone(),
1717 false,
1718 "removed",
1719 ));
1720 }
1721 self.group_mesh
1722 .pending_group_messages
1723 .remove(&payload.group_id);
1724 // Also drop buffered commits: they can never apply now,
1725 // and a stale one retried after a rapid re-invite would
1726 // expire with retry_count > 0 and falsely flag an epoch
1727 // fork.
1728 self.group_mesh.pending_commits.remove(&payload.group_id);
1729 self.emit_event(Event::group_member_removed(
1730 payload.group_id.clone(),
1731 self_id,
1732 sender.to_string(),
1733 // Reached only through the `check_is_admin` gate above.
1734 Some(true),
1735 ));
1736 return CommitOutcome::Rejected;
1737 }
1738
1739 // Enforcement refused this commit before merging (only
1740 // possible with `GroupConfig::enforce_admin_commits` on).
1741 // Report it on the same signal the applied case uses, marked
1742 // `enforced` so the app can tell "this happened, undo it"
1743 // from "this was blocked and we are now behind the group's
1744 // epoch". Nothing merged, so no roster event accompanies it.
1745 if let offline_protocol_mls::MlsError::CommitNotAuthorized {
1746 committer,
1747 added,
1748 removed,
1749 } = e
1750 {
1751 let (committer, added, removed) =
1752 (committer.clone(), added.clone(), removed.clone());
1753 self.report_unauthorized_membership_change(
1754 &payload.group_id,
1755 &committer,
1756 &added,
1757 &removed,
1758 "sender_not_admin",
1759 true,
1760 );
1761 return CommitOutcome::Rejected;
1762 }
1763
1764 // Classify MLS errors: permanent failures should not be retried,
1765 // only failures caused by lagging local group state are worth
1766 // buffering. GroupNotFound is NOT permanent — a commit can
1767 // outrun our Welcome (they may take different transports), so
1768 // it is buffered and retried after the join lands, exactly
1769 // like application messages. A spoofed sender (SEC-M1) is
1770 // permanent — retrying the same forged commit can never
1771 // succeed.
1772 //
1773 // The leaf-binding refusals are permanent for the same reason,
1774 // and getting this wrong is worse here than merely wasteful. A
1775 // leaf whose key does not hash to its own credential can never
1776 // start doing so, and an unsupported sender role never becomes
1777 // supported, so every retry re-runs a full MLS `process_message`
1778 // on a frame with no reachable success. Worse, a buffered commit
1779 // that expires with `retry_count > 0` is read as an epoch fork
1780 // (`drain_pending_commits`), which emits a false
1781 // `group_epoch_fork_detected` and has the elected leader fan an
1782 // `update_keys` commit out to the whole group — so one forged
1783 // commit, from any member, would buy an attacker a group-wide
1784 // re-key round and a bogus security alarm. Buffering also spends
1785 // one of `MAX_PENDING_COMMITS_PER_GROUP` slots, evicting
1786 // legitimate out-of-order commits.
1787 let is_permanent = matches!(
1788 e,
1789 offline_protocol_mls::MlsError::Deserialization(_)
1790 | offline_protocol_mls::MlsError::InvalidMessage(_)
1791 | offline_protocol_mls::MlsError::Storage(_)
1792 | offline_protocol_mls::MlsError::SenderIdentityMismatch { .. }
1793 | offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1794 | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1795 );
1796 if is_permanent {
1797 error!(
1798 group_id = %payload.group_id,
1799 epoch = payload.epoch,
1800 error = %e,
1801 "Permanently failed to process group commit (not retriable)"
1802 );
1803 // The commit half of `GroupLeafIdentityUnproven`. This is
1804 // the more serious of the two sites the code emits it from:
1805 // a Welcome refusal declines an invite from a stranger, but
1806 // this accuses a member of a group the user is already in.
1807 // `sender` is the peer that delivered the commit and it is
1808 // proved — `__GRP_MLS_COMMIT__` is security-gated, so the
1809 // sender signed with the key its own address derives from
1810 // — while the identity the forged leaf claimed stays in the
1811 // diagnostic text.
1812 if matches!(
1813 e,
1814 offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1815 | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1816 ) {
1817 // Variant-neutral summary: the `UnsupportedSender`
1818 // half seats no leaf at all (an `ExternalSenders`
1819 // extension, or a non-member sender role), so the
1820 // "identity nobody proved" wording would be wrong for
1821 // it.
1822 //
1823 // The error is deliberately NOT interpolated: it names
1824 // the impersonated address, and the telemetry scrubber
1825 // hashes only `peer_id` on a `SecurityWarning` — it
1826 // ships `reason` verbatim. Leaking a third party's
1827 // address to a sink running `scrub_ids: true` is the
1828 // exact trap the `convergence_diag` detail rule names.
1829 // The full error is in the `error!` above.
1830 let detail = "Group membership change refused: it carried an identity \
1831 claim this device could not verify, so messages in this \
1832 group could not be reliably attributed"
1833 .to_string();
1834 let group_id = payload.group_id.clone();
1835 self.report_unproven_leaf(
1836 &group_id,
1837 sender,
1838 UnprovenLeafSite::CommitRefused,
1839 detail,
1840 );
1841 }
1842 return CommitOutcome::Rejected;
1843 }
1844 error!(
1845 group_id = %payload.group_id,
1846 epoch = payload.epoch,
1847 error = %e,
1848 "Failed to process group commit (may be out-of-order, buffering)"
1849 );
1850 return CommitOutcome::Retriable(payload.group_id);
1851 }
1852 }
1853
1854 // Refresh cache and compute the actual membership delta. Like the
1855 // pre-commit read above, a failed refresh must not default to an
1856 // empty roster — so when either read failed, the delta is unknowable
1857 // and everything derived from it (roster events, the authorization
1858 // judgment, leave-election cleanup, auto-promotion) is skipped for
1859 // this commit rather than fabricated. The commit itself has already
1860 // merged; the member cache and events self-heal on the next
1861 // successful commit or refresh.
1862 let members_after: Option<HashSet<String>> = match self
1863 .refresh_group_members(&payload.group_id)
1864 {
1865 Ok(members) => Some(members.into_iter().collect()),
1866 Err(e) => {
1867 warn!(
1868 group_id = %payload.group_id,
1869 error = %e,
1870 "Failed to refresh post-commit roster — this commit's membership delta will not be derived or judged"
1871 );
1872 None
1873 }
1874 };
1875 let (Some(members_before), Some(members_after)) = (members_before, members_after) else {
1876 if matches!(payload.commit_type, GroupCommitType::KeyUpdate) {
1877 self.group_mesh.epoch_forks.remove(&payload.group_id);
1878 }
1879 return CommitOutcome::Success(payload.group_id);
1880 };
1881
1882 // Sorted so event payloads and log lines are deterministic.
1883 let mut actual_added: Vec<String> =
1884 members_after.difference(&members_before).cloned().collect();
1885 actual_added.sort();
1886 let mut actual_removed: Vec<String> =
1887 members_before.difference(&members_after).cloned().collect();
1888 actual_removed.sort();
1889
1890 let has_membership_change = !actual_added.is_empty() || !actual_removed.is_empty();
1891
1892 // Validate claimed affected_member against actual MLS delta. A
1893 // mismatch means the commit's unencrypted framing disagrees with the
1894 // authenticated MLS state change it actually produced — forged or
1895 // replayed metadata.
1896 let mut claim_matches_delta = true;
1897 if let Some(claimed) = &payload.affected_member {
1898 let valid = match payload.commit_type {
1899 GroupCommitType::Add => actual_added.contains(claimed),
1900 GroupCommitType::Remove => actual_removed.contains(claimed),
1901 GroupCommitType::KeyUpdate => true, // No membership change expected
1902 };
1903 if !valid && has_membership_change {
1904 claim_matches_delta = false;
1905 error!(
1906 group_id = %payload.group_id,
1907 sender = %sender,
1908 claimed = %claimed,
1909 actual_added = ?actual_added,
1910 actual_removed = ?actual_removed,
1911 "SECURITY: Commit affected_member does not match actual MLS state delta — possible forgery"
1912 );
1913 }
1914 }
1915
1916 // No delta, no judgment: a pure KeyUpdate changes no membership and
1917 // needs no admin (fork-resolution KeyUpdates are issued by the
1918 // deterministic leader, who is often not one).
1919 let judgment = if has_membership_change {
1920 Some(self.judge_membership_change(
1921 &payload.group_id,
1922 sender,
1923 &actual_added,
1924 &actual_removed,
1925 claim_matches_delta,
1926 ))
1927 } else {
1928 None
1929 };
1930
1931 // Commit-payload metadata (role, rich attestation) is honored only
1932 // for the member the MLS delta actually added and only from an
1933 // admin sender — adds are admin-only, so a non-admin sender means
1934 // forged metadata on a replayed/crafted frame.
1935 let metadata_sender_is_admin =
1936 judgment.as_ref().is_some_and(|j| j.sender_is_admin) && !actual_added.is_empty();
1937 // What the roster events below carry. `None` never reaches them: an
1938 // empty delta emits no events.
1939 let authorized = judgment.map(|j| j.authorized);
1940
1941 // Emit events based on actual MLS membership changes, not claimed affected_member
1942 for member in &actual_added {
1943 // Store the role from the commit payload only for the specific affected member,
1944 // and only if the sender is an admin (prevents non-admins from injecting elevated roles).
1945 if let (Some(role), Some(affected)) = (&payload.role, &payload.affected_member) {
1946 if member == affected {
1947 if metadata_sender_is_admin {
1948 if let Ok(mls_guard) = self.read_mls_guard() {
1949 if let Err(e) = mls_guard.set_member_role(&gid, member, *role) {
1950 warn!(user_id = %member, error = %e, "Failed to store member role from commit");
1951 }
1952 }
1953 } else {
1954 warn!(
1955 sender = %sender,
1956 group_id = %payload.group_id,
1957 "Ignoring role from commit: sender is not admin"
1958 );
1959 }
1960 }
1961 }
1962 // Record the inviter's rich-capability attestation for the
1963 // newcomer — we existing members never exchange key packages
1964 // with them, and without this one unknown member drops rich
1965 // extras for the whole group. Same trust bounds as the role
1966 // above; a later direct exchange overrides it.
1967 if let (Some(versions), Some(affected)) =
1968 (&payload.affected_member_rich, &payload.affected_member)
1969 {
1970 if member == affected && metadata_sender_is_admin {
1971 self.record_attested_rich(member, versions);
1972 }
1973 }
1974 self.emit_event(Event::group_member_added(
1975 payload.group_id.clone(),
1976 member.clone(),
1977 sender.to_string(),
1978 None,
1979 authorized,
1980 ));
1981 }
1982 for member in &actual_removed {
1983 // Clean up role metadata for removed members
1984 if let Ok(mls_guard) = self.read_mls_guard() {
1985 if let Err(e) = mls_guard.remove_member_role(&gid, member) {
1986 warn!(user_id = %member, error = %e, "Failed to clean up role metadata for removed member");
1987 }
1988 }
1989 self.emit_event(Event::group_member_removed(
1990 payload.group_id.clone(),
1991 member.clone(),
1992 sender.to_string(),
1993 authorized,
1994 ));
1995 // Clear any pending leave election for this member — the remove
1996 // has been committed successfully.
1997 self.group_mesh
1998 .pending_leave_elections
1999 .remove(&(payload.group_id.clone(), member.to_string()));
2000 }
2001
2002 // Auto-promote if removal left zero admins and other members remain
2003 if !actual_removed.is_empty() {
2004 let remaining = self
2005 .group_mesh
2006 .members
2007 .get(&payload.group_id)
2008 .cloned()
2009 .unwrap_or_default();
2010 self.auto_promote_if_no_admin(&payload.group_id, &remaining);
2011 }
2012
2013 // Only clear fork tracking when a KeyUpdate commit succeeds — these
2014 // are the resolution commits issued by the leader. Regular Add/Remove
2015 // commits from the same branch succeeding doesn't prove the fork is
2016 // resolved; members on the other branch are still diverged.
2017 if matches!(payload.commit_type, GroupCommitType::KeyUpdate) {
2018 self.group_mesh.epoch_forks.remove(&payload.group_id);
2019 }
2020
2021 CommitOutcome::Success(payload.group_id)
2022 }
2023
2024 /// Judges one **applied** membership delta against the local admin
2025 /// overlay, reporting an unauthorized change (rate-limited) via
2026 /// `GroupUnauthorizedMembershipChange`.
2027 ///
2028 /// This deliberately does **not** gate the membership change. MLS
2029 /// authorizes membership by MLS membership — RFC 9420 leaves access
2030 /// control to the application — and by the time the delta exists,
2031 /// `decrypt_from_group` has already merged the commit, advancing our
2032 /// epoch irreversibly. The only way to reject would be to refuse the
2033 /// merge, which forks us permanently from every peer that accepted it
2034 /// (see `check_epoch_forks`: members left on a forked branch have to be
2035 /// re-invited by the app, and a fork the local role metadata caused —
2036 /// it is best-effort and can legitimately lag — would be a partition
2037 /// with no attacker involved). An unrecoverable partition is a worse
2038 /// failure than an insider membership change, so the change is applied
2039 /// and reported truthfully.
2040 ///
2041 /// Unlike the commit-metadata gate in `process_commit_core`, this covers
2042 /// removals too. When receive-side enforcement is enabled
2043 /// (`GroupConfig::enforce_admin_commits`) the *rejection* happens earlier,
2044 /// pre-merge in the MLS layer, and never reaches this function — but it
2045 /// reports through the same [`Self::report_unauthorized_membership_change`]
2046 /// so both outcomes surface on one signal.
2047 pub(crate) fn judge_membership_change(
2048 &mut self,
2049 group_id: &str,
2050 sender: &str,
2051 added: &[String],
2052 removed: &[String],
2053 claim_matches_delta: bool,
2054 ) -> MembershipJudgment {
2055 let sender_is_admin = self.check_is_admin(group_id, sender).unwrap_or(false);
2056
2057 // A membership change is authorized only when an admin committed it
2058 // *and* the commit's own framing agrees with the delta it produced.
2059 let authorized = sender_is_admin && claim_matches_delta;
2060
2061 if !authorized {
2062 let reason = if sender_is_admin {
2063 "affected_member_mismatch"
2064 } else {
2065 "sender_not_admin"
2066 };
2067 self.report_unauthorized_membership_change(
2068 group_id, sender, added, removed, reason, false,
2069 );
2070 }
2071
2072 MembershipJudgment {
2073 sender_is_admin,
2074 authorized,
2075 }
2076 }
2077
2078 /// Emits the rate-limited `GroupLeafIdentityUnproven` report.
2079 ///
2080 /// Shared by the three group-side sites that find a leaf which does not
2081 /// derive its own credential: declining a Welcome whose ratchet tree
2082 /// contains one, refusing a commit that would install one, and a roster read
2083 /// skipping one already seated in local state.
2084 ///
2085 /// `peer` is who the finding is attributed to, and it is not the same kind
2086 /// of thing in all three. For the two wire sites it is the peer that
2087 /// delivered the forgery — the inviter or the committing sender — which is
2088 /// proved, both frames being security-gated against the sender's own
2089 /// address. For [`UnprovenLeafSite::RosterEntry`] there is no delivering
2090 /// peer at all: the leaf is already in the tree and no wire gate admitted
2091 /// it, so the caller passes this device's own id, the same attribution
2092 /// `RelayAddressDeclarationRefused` uses when the finding is about this
2093 /// device rather than a peer's behaviour. Apps must therefore read `peer_id`
2094 /// on this code as "who this concerns", not "who to blame".
2095 ///
2096 /// The identity the forged leaf claimed stays in `detail`, which is
2097 /// diagnostic text.
2098 ///
2099 /// Rate-limited per `(group, peer, site)` through the shared
2100 /// [`claim_report_window`], on the same window and bound as
2101 /// [`Self::report_unauthorized_membership_change`]. The commit half makes
2102 /// this necessary rather than tidy: refusals are permanent and cost the
2103 /// sender nothing, so an authenticated member can emit one per frame and
2104 /// bury the signal in its own repetition. `site` is in the key so a
2105 /// declined invite cannot swallow the first refused *commit* — see
2106 /// [`UnprovenLeafSite`].
2107 ///
2108 /// `detail` must stay **identifier-free**: it becomes the event `reason`,
2109 /// which the telemetry scrubber passes through verbatim while hashing only
2110 /// `peer_id`. The impersonated address belongs in the log, not here.
2111 fn report_unproven_leaf(
2112 &mut self,
2113 group_id: &str,
2114 peer: &str,
2115 site: UnprovenLeafSite,
2116 detail: String,
2117 ) -> bool {
2118 let key = (group_id.to_string(), peer.to_string(), site);
2119 if !claim_report_window(&mut self.group_mesh.unproven_leaf_reports, key) {
2120 debug!(
2121 group_id = %group_id,
2122 sender = %peer,
2123 ?site,
2124 "Suppressing repeated unproven-leaf report within the rate-limit window"
2125 );
2126 return false;
2127 }
2128
2129 self.emit_event(Event::security_warning(
2130 peer.to_string(),
2131 crate::events::SecurityWarningCode::GroupLeafIdentityUnproven,
2132 detail,
2133 ));
2134 true
2135 }
2136
2137 /// Emits the rate-limited unauthorized-membership-change report.
2138 ///
2139 /// Shared by both outcomes so apps see one signal regardless of
2140 /// configuration: `enforced == false` means the change was applied and is
2141 /// reported after the fact (the default), `enforced == true` means the
2142 /// commit was refused before merging and no membership change occurred.
2143 ///
2144 /// Rate-limited per `(group, committer, enforced)` because divergent role
2145 /// metadata would otherwise re-fire on every commit from the same peer.
2146 /// `enforced` is part of the key so the two outcomes never suppress each
2147 /// other: a refusal is a partition alarm — the device stopped merging and
2148 /// now trails the group's epoch — and losing the first one of those behind
2149 /// an earlier report-only event would hide the very condition the app is
2150 /// told to act on. Within one outcome class the window still collapses
2151 /// repeats, which is what the limit is for. The tracking map is bounded:
2152 /// lapsed windows are dropped first, then the oldest live entry.
2153 fn report_unauthorized_membership_change(
2154 &mut self,
2155 group_id: &str,
2156 committer: &str,
2157 added: &[String],
2158 removed: &[String],
2159 reason: &str,
2160 enforced: bool,
2161 ) {
2162 let key = (group_id.to_string(), committer.to_string(), enforced);
2163 if !claim_report_window(&mut self.group_mesh.unauthorized_change_reports, key) {
2164 debug!(
2165 group_id = %group_id,
2166 sender = %committer,
2167 reason,
2168 enforced,
2169 "Suppressing repeated unauthorized-membership-change report within the rate-limit window"
2170 );
2171 return;
2172 }
2173 if enforced {
2174 warn!(
2175 group_id = %group_id,
2176 sender = %committer,
2177 reason,
2178 added = ?added,
2179 removed = ?removed,
2180 "SECURITY: Unauthorized group membership change refused — the commit was not merged, so our epoch now trails members that accepted it"
2181 );
2182 } else {
2183 warn!(
2184 group_id = %group_id,
2185 sender = %committer,
2186 reason,
2187 added = ?added,
2188 removed = ?removed,
2189 "SECURITY: Unauthorized group membership change applied — MLS accepted the commit, but the SDK's admin policy did not authorize it"
2190 );
2191 }
2192 self.emit_event(Event::group_unauthorized_membership_change(
2193 group_id.to_string(),
2194 committer.to_string(),
2195 added.to_vec(),
2196 removed.to_vec(),
2197 reason.to_string(),
2198 enforced,
2199 ));
2200 }
2201
2202 /// Releases every replay-protection record for a buffered entry dropped
2203 /// undelivered (cap eviction or TTL expiry), so a sender-side redelivery
2204 /// is accepted fresh instead of being rejected as a replay of a copy
2205 /// that no longer exists anywhere.
2206 ///
2207 /// Two layers must be released. The group-level dedup table rejects
2208 /// redeliveries inside the group handlers; the transport-level
2209 /// deduplicator rejects them earlier, at the receive loop — on the mesh
2210 /// path the envelope ID doubles as the group dedup key, so without this
2211 /// release a redelivery would be swallowed (and re-ACKed as delivered)
2212 /// for up to the deduplicator's retention window without ever reaching
2213 /// the group handlers. Relay-path IDs are relay payload IDs that never
2214 /// enter the transport deduplicator (the platform bridges mint a fresh
2215 /// envelope UUID per injected message), so `from_str` fails or the
2216 /// unmark is a no-op there — both harmless.
2217 pub(crate) fn release_replay_protection(&mut self, message_id: &str) {
2218 self.group_mesh.message_dedup.remove(message_id);
2219 if let Ok(envelope_id) = MessageId::from_str(message_id) {
2220 self.deduplicator.unmark_seen(&envelope_id);
2221 }
2222 }
2223
2224 /// Buffers a failed commit for deferred retry.
2225 ///
2226 /// The commit's message ID is already in the dedup table, so this
2227 /// buffered copy is its only path to processing — whenever a buffered
2228 /// commit is dropped unprocessed (cap eviction here, TTL expiry at
2229 /// drain), its replay protection is released so a redelivery gets a
2230 /// fresh chance instead of being permanently rejected, mirroring
2231 /// `buffer_pending_group_message`.
2232 pub(crate) fn buffer_pending_commit(
2233 &mut self,
2234 group_id: &str,
2235 message_id: &str,
2236 sender: &str,
2237 data: &str,
2238 ) {
2239 let pending = PendingCommit {
2240 sender: sender.to_string(),
2241 message_id: message_id.to_string(),
2242 data: data.to_string(),
2243 buffered_at: Instant::now(),
2244 retry_count: 0,
2245 };
2246 let Some(evicted) = enforce_global_buffer_bound(
2247 &mut self.group_mesh.pending_commits,
2248 group_id,
2249 &PENDING_COMMIT_CAPS,
2250 pending.data.len(),
2251 |c| c.data.len(),
2252 |c| c.buffered_at,
2253 ) else {
2254 // Only the ciphertext field is size-guarded at decode time; the
2255 // JSON around it is attacker-sized, so an oversized payload must
2256 // be dropped rather than allowed to purge the whole buffer.
2257 warn!(
2258 group_id = %group_id,
2259 size = pending.data.len(),
2260 "Pending commit exceeds the global byte budget, dropping"
2261 );
2262 return;
2263 };
2264 // An evicted commit was never processed — release its replay
2265 // protection so a sender-side redelivery is accepted fresh instead
2266 // of being rejected as a replay of a copy that no longer exists.
2267 for entry in &evicted {
2268 self.release_replay_protection(&entry.message_id);
2269 }
2270 let displaced = {
2271 let buf = self
2272 .group_mesh
2273 .pending_commits
2274 .entry(group_id.to_string())
2275 .or_default();
2276 let displaced = if buf.len() >= MAX_PENDING_COMMITS_PER_GROUP {
2277 warn!(
2278 group_id = %group_id,
2279 "Pending commit buffer full, dropping oldest"
2280 );
2281 buf.pop_front()
2282 } else {
2283 None
2284 };
2285 buf.push_back(pending);
2286 debug!(
2287 group_id = %group_id,
2288 buffered_count = buf.len(),
2289 "Buffered out-of-order commit for deferred retry"
2290 );
2291 displaced
2292 };
2293 if let Some(displaced) = displaced {
2294 self.release_replay_protection(&displaced.message_id);
2295 }
2296 }
2297
2298 /// Drains and retries buffered pending commits for a group after a
2299 /// successful commit advanced the epoch. Each successful retry triggers
2300 /// another drain pass (at most `MAX_PENDING_COMMITS_PER_GROUP` iterations).
2301 ///
2302 /// Call-site contract: a commit success inside this drain can unblock
2303 /// buffered application messages, but this function does not drain them —
2304 /// every caller must follow with `drain_pending_group_messages`.
2305 ///
2306 /// Uses `process_commit_core` (not `handle_group_mls_commit`) to avoid
2307 /// double-buffering: this method owns the retry lifecycle and re-buffers
2308 /// only via `still_pending`, never through the core processing path.
2309 pub(crate) fn drain_pending_commits(&mut self, group_id: &str) {
2310 // Track commits that were retried at least once and still expired —
2311 // these are strong evidence of epoch mismatch (true fork), not just
2312 // slow delivery from the mesh network.
2313 let mut retried_expired_count: usize = 0;
2314
2315 // Limit iterations to avoid unbounded looping
2316 for _ in 0..MAX_PENDING_COMMITS_PER_GROUP {
2317 let pending = match self.group_mesh.pending_commits.get_mut(group_id) {
2318 Some(buf) if !buf.is_empty() => std::mem::take(buf),
2319 _ => break,
2320 };
2321
2322 let mut any_succeeded = false;
2323 let mut still_pending = VecDeque::new();
2324
2325 for entry in pending {
2326 // Drop expired entries — only count retried ones for fork detection
2327 if entry.buffered_at.elapsed() > StdDuration::from_secs(PENDING_COMMIT_TTL_SECS) {
2328 debug!(
2329 group_id = %group_id,
2330 retry_count = entry.retry_count,
2331 "Dropping expired pending commit"
2332 );
2333 if entry.retry_count > 0 {
2334 retried_expired_count += 1;
2335 }
2336 // Never processed — release replay protection so a
2337 // redelivery is accepted fresh even before the periodic
2338 // dedup sweep runs.
2339 self.release_replay_protection(&entry.message_id);
2340 continue;
2341 }
2342 match self.process_commit_core(&entry.sender, &entry.data) {
2343 CommitOutcome::Success(_) => {
2344 any_succeeded = true;
2345 }
2346 CommitOutcome::Retriable(_) => {
2347 // Still out-of-order — keep for next pass with incremented retry count
2348 let mut entry = entry;
2349 entry.retry_count += 1;
2350 still_pending.push_back(entry);
2351 }
2352 CommitOutcome::Rejected => {
2353 // Permanently bad — drop silently
2354 }
2355 }
2356 }
2357
2358 // Re-buffer commits that still failed
2359 if !still_pending.is_empty() {
2360 self.group_mesh
2361 .pending_commits
2362 .entry(group_id.to_string())
2363 .or_default()
2364 .extend(still_pending);
2365 }
2366
2367 if !any_succeeded {
2368 break;
2369 }
2370 // Another commit succeeded — loop again in case it unblocked more
2371 }
2372
2373 // Clean up empty entries
2374 if self
2375 .group_mesh
2376 .pending_commits
2377 .get(group_id)
2378 .is_none_or(|v| v.is_empty())
2379 {
2380 self.group_mesh.pending_commits.remove(group_id);
2381 }
2382
2383 // If commits that were retried (and kept failing) expired, this is a
2384 // strong signal of epoch fork — the commits were valid but for a
2385 // different epoch branch. Commits that expired without any retries
2386 // are more likely just slow mesh delivery and are not flagged.
2387 if retried_expired_count > 0 && !self.group_mesh.epoch_forks.contains_key(group_id) {
2388 self.flag_potential_epoch_fork(group_id);
2389 }
2390 }
2391
2392 /// Buffers a group application message whose decryption failed because
2393 /// local group state lagged (Welcome not yet processed / epoch behind).
2394 ///
2395 /// The message ID is already in the dedup table, so redeliveries via
2396 /// other transports are rejected there — this buffered copy is the
2397 /// message's only path to delivery, via `drain_pending_group_messages`.
2398 /// Correspondingly, whenever a buffered copy is dropped undelivered
2399 /// (cap eviction here, TTL expiry at drain), its replay protection is
2400 /// released so a redelivery gets a fresh chance instead of being
2401 /// permanently lost.
2402 pub(crate) fn buffer_pending_group_message(
2403 &mut self,
2404 group_id: &str,
2405 pending: PendingGroupMessage,
2406 ) {
2407 let Some(evicted) = enforce_global_buffer_bound(
2408 &mut self.group_mesh.pending_group_messages,
2409 group_id,
2410 &PENDING_GROUP_MESSAGE_CAPS,
2411 pending.ciphertext_b64.len(),
2412 |m| m.ciphertext_b64.len(),
2413 |m| m.buffered_at,
2414 ) else {
2415 // Unreachable in practice — the ciphertext passed the 1 MiB
2416 // base64_decode guard on both inbound paths — but the invariant
2417 // belongs here, not in the callers.
2418 warn!(
2419 group_id = %group_id,
2420 size = pending.ciphertext_b64.len(),
2421 "Pending group message exceeds the global byte budget, dropping"
2422 );
2423 return;
2424 };
2425 // An evicted message was never delivered — release its replay
2426 // protection so a sender-side redelivery is accepted fresh instead
2427 // of being rejected as a replay of a copy that no longer exists.
2428 for entry in &evicted {
2429 self.release_replay_protection(&entry.message_id);
2430 }
2431 let displaced = {
2432 let buf = self
2433 .group_mesh
2434 .pending_group_messages
2435 .entry(group_id.to_string())
2436 .or_default();
2437 let displaced = if buf.len() >= MAX_PENDING_GROUP_MESSAGES_PER_GROUP {
2438 warn!(
2439 group_id = %group_id,
2440 "Pending group message buffer full, dropping oldest"
2441 );
2442 buf.pop_front()
2443 } else {
2444 None
2445 };
2446 buf.push_back(pending);
2447 debug!(
2448 group_id = %group_id,
2449 buffered_count = buf.len(),
2450 "Buffered out-of-order group message for deferred retry"
2451 );
2452 displaced
2453 };
2454 if let Some(displaced) = displaced {
2455 self.release_replay_protection(&displaced.message_id);
2456 }
2457 }
2458
2459 /// Whether a message id is currently buffered awaiting decryption for the
2460 /// given group. Drives the deferred-ACK decision in
2461 /// [`Self::handle_group_mls_msg_via`]'s duplicate branch: a retransmit of a
2462 /// still-pending message must be deferred (no ACK), while a duplicate of an
2463 /// already-delivered one is re-ACKed so the sender can stop.
2464 /// Matches both the envelope id and (for re-issued broadcast copies) the
2465 /// logical id, so a cross-path retransmit of a still-pending logical
2466 /// message is deferred rather than re-ACKed as delivered.
2467 fn is_group_message_pending(&self, group_id: &str, message_id: &str) -> bool {
2468 self.group_mesh
2469 .pending_group_messages
2470 .get(group_id)
2471 .is_some_and(|buf| {
2472 buf.iter().any(|m| {
2473 m.message_id == message_id || m.logical_id.as_deref() == Some(message_id)
2474 })
2475 })
2476 }
2477
2478 /// Sends the deferred delivery ACK for a group message surfaced by the
2479 /// drain, on the transport it originally arrived on.
2480 ///
2481 /// Mirrors the DM `ack_drained_message`: it closes the ACK-latency window
2482 /// so a sender that exhausts its retry budget before local group state
2483 /// catches up still learns of delivery, instead of marking a
2484 /// locally-delivered message undeliverable. `received_via` is `None` for
2485 /// relay-path entries (the relay sender is not ACK-gated) and transport-less
2486 /// test enqueue, in which case this is a correct no-op and recovery falls
2487 /// back to the sender's next resend hitting the duplicate re-ACK path.
2488 fn ack_drained_group_message(
2489 &mut self,
2490 ack_to: &str,
2491 acked_message_id: &str,
2492 received_via: Option<TransportType>,
2493 ) {
2494 let Some(transport) = received_via else {
2495 return;
2496 };
2497 if let Err(err) = self.send_group_delivery_ack(ack_to, acked_message_id, transport) {
2498 error!(
2499 msg_id = %acked_message_id,
2500 error = %err,
2501 "Failed to send deferred delivery ACK for drained group message"
2502 );
2503 }
2504 }
2505
2506 /// Drains buffered group application messages after local MLS state for
2507 /// the group advanced (successful Welcome join or commit). Messages are
2508 /// retried in arrival order; entries that still fail retriably are
2509 /// re-buffered until their TTL expires.
2510 ///
2511 /// A buffered entry can turn out to be a commit or proposal riding the
2512 /// application channel (`NonApplication`): MLS consumes it, so group
2513 /// state may have advanced mid-drain. When that happens, buffered
2514 /// commits are drained and another pass runs, since entries re-buffered
2515 /// earlier in the same pass may now decrypt. Terminates: a pass loops
2516 /// only after consuming a `NonApplication` entry, which is never
2517 /// re-buffered, so each looping pass shrinks the buffer.
2518 ///
2519 /// Double delivery is impossible by construction rather than by
2520 /// bookkeeping: MLS consumes a ratchet generation on decrypt, so a copy
2521 /// of a ciphertext that was already decrypted elsewhere fails here
2522 /// (`Decryption` → `Retriable`) and never reaches the emit. Reaching the
2523 /// `Plaintext` branch *proves* this logical message has not been
2524 /// delivered before. `delivered_here` exists only to stop a second
2525 /// buffered copy from burning that pointless decrypt within one drain —
2526 /// see the declaration.
2527 pub(crate) fn drain_pending_group_messages(&mut self, group_id: &str) {
2528 // Ids (envelope and logical) this drain has already delivered. Both
2529 // the mesh re-issue of a relay broadcast and the relay's own copy of
2530 // it can sit buffered at once while group state lags, and they carry
2531 // the same ciphertext. Once one is delivered, the other must be
2532 // dropped without a decrypt attempt: its generation is spent, so MLS
2533 // would classify it `Retriable` and re-buffer it as noise until its
2534 // TTL — whose expiry would then call `release_replay_protection` on
2535 // an id that WAS delivered, re-opening a replay window for it.
2536 let mut delivered_here: HashSet<String> = HashSet::new();
2537 loop {
2538 let pending = match self.group_mesh.pending_group_messages.get_mut(group_id) {
2539 Some(buf) if !buf.is_empty() => std::mem::take(buf),
2540 _ => break,
2541 };
2542
2543 let mut state_advanced = false;
2544 let mut still_pending = VecDeque::new();
2545 for entry in pending {
2546 // A second buffered copy of something this drain just
2547 // delivered. Checked before the TTL branch so it can never
2548 // release replay protection for a delivered id.
2549 if delivered_here.contains(&entry.message_id)
2550 || entry
2551 .logical_id
2552 .as_deref()
2553 .is_some_and(|l| delivered_here.contains(l))
2554 {
2555 debug!(
2556 group_id = %group_id,
2557 msg_id = %entry.message_id,
2558 "Buffered copy of a message this drain already delivered, dropping"
2559 );
2560 // The frame *is* delivered (as a duplicate), so ACK it or
2561 // the sender keeps retransmitting a message we hold.
2562 // `received_via` is `None` on the relay path, where the
2563 // sender is not ACK-gated — a correct no-op there.
2564 self.ack_drained_group_message(
2565 &entry.sender,
2566 &entry.message_id,
2567 entry.received_via,
2568 );
2569 continue;
2570 }
2571 if entry.buffered_at.elapsed()
2572 > StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS)
2573 {
2574 debug!(
2575 group_id = %group_id,
2576 msg_id = %entry.message_id,
2577 "Dropping expired pending group message"
2578 );
2579 // Never delivered — release replay protection so a
2580 // redelivery is accepted fresh even before the periodic
2581 // dedup sweep runs.
2582 self.release_replay_protection(&entry.message_id);
2583 continue;
2584 }
2585 // Validated at buffer time; failure here is defensive only.
2586 let Ok(ciphertext_bytes) = base64_decode(&entry.ciphertext_b64) else {
2587 continue;
2588 };
2589 match self.decrypt_group_application(group_id, ciphertext_bytes, &entry.sender) {
2590 GroupDecryptOutcome::Plaintext(plaintext) => {
2591 let Ok(text) = String::from_utf8(plaintext) else {
2592 warn!(
2593 group_id = %group_id,
2594 "Decrypted group payload is not valid UTF-8, dropping"
2595 );
2596 continue;
2597 };
2598 info!(
2599 group_id = %group_id,
2600 msg_id = %entry.message_id,
2601 "Delivered buffered group message after group state caught up"
2602 );
2603 // Capture the ACK targets before the fields move into
2604 // the event: the message is delivered now, so the drain
2605 // sends the deferred delivery ACK directly on the
2606 // recorded arrival transport instead of waiting for the
2607 // sender's next resend. The ACK is always keyed on the
2608 // envelope id (`message_id`) — that is what the sender's
2609 // ack_manager awaits.
2610 let received_via = entry.received_via;
2611 let ack_sender = entry.sender.clone();
2612 let ack_message_id = entry.message_id.clone();
2613 // A re-issued broadcast copy carries the logical id the
2614 // whole group knows this message by: emit that id and
2615 // mark it delivered, so the relay path's copy of the
2616 // same logical message dedups against this one.
2617 //
2618 // No "already delivered elsewhere?" check is needed
2619 // here, and none would be correct: the successful
2620 // decrypt above already proves this ciphertext's
2621 // ratchet generation was unspent, i.e. that no other
2622 // copy was delivered before it. A copy that *was*
2623 // beaten to it fails decrypt and lands in `Retriable`,
2624 // never here.
2625 let emit_id = match entry.logical_id {
2626 Some(logical) => {
2627 self.group_mesh
2628 .message_dedup
2629 .insert(logical.clone(), Instant::now());
2630 logical
2631 }
2632 None => entry.message_id,
2633 };
2634 delivered_here.insert(ack_message_id.clone());
2635 delivered_here.insert(emit_id.clone());
2636 let (content, media_metadata, content_type, forward_info_event) =
2637 Self::restore_group_rich(text, entry.forward_info, &entry.sender);
2638 let timestamp = entry
2639 .timestamp
2640 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
2641 self.emit_event(Event::group_message_received(
2642 group_id.to_string(),
2643 entry.sender,
2644 content,
2645 timestamp,
2646 emit_id,
2647 entry.reply_to,
2648 forward_info_event,
2649 media_metadata,
2650 content_type,
2651 ));
2652 self.ack_drained_group_message(&ack_sender, &ack_message_id, received_via);
2653 }
2654 GroupDecryptOutcome::Retriable => {
2655 still_pending.push_back(entry);
2656 }
2657 GroupDecryptOutcome::NonApplication => {
2658 state_advanced = true;
2659 }
2660 GroupDecryptOutcome::PolicyRejected {
2661 committer,
2662 added,
2663 removed,
2664 } => {
2665 // Dropped, not re-buffered: the refusal is permanent,
2666 // so re-queueing would retry it until the entry ages
2667 // out and then misreport the expiry as an epoch fork.
2668 self.report_unauthorized_membership_change(
2669 group_id,
2670 &committer,
2671 &added,
2672 &removed,
2673 "sender_not_admin",
2674 true,
2675 );
2676 }
2677 GroupDecryptOutcome::SecurityRejected
2678 | GroupDecryptOutcome::NotMlsCiphertext
2679 | GroupDecryptOutcome::Failed => {
2680 // Dropped unprocessed — release replay protection, for
2681 // the same reason the arrival path unmarks a rejected
2682 // relay copy (see the marking note atop
2683 // `handle_relay_group_message_with_mls`) and the TTL
2684 // arm above releases an expired one. A relay copy can
2685 // outrun its Welcome, so a mis-attributed one is judged
2686 // *here* rather than at arrival — and a hostile relay
2687 // picks that ordering for free. Left marked, the id
2688 // reads as delivered while no longer being pending, so
2689 // the per-member re-issue that is this message's own
2690 // delivery safety net is absorbed as an
2691 // already-delivered duplicate and re-ACKed: delivered
2692 // nowhere, sender told otherwise.
2693 //
2694 // `PolicyRejected` above deliberately does not release:
2695 // that refusal is permanent, so a later copy could only
2696 // waste work.
2697 self.release_replay_protection(&entry.message_id);
2698 }
2699 }
2700 }
2701
2702 if !still_pending.is_empty() {
2703 self.group_mesh
2704 .pending_group_messages
2705 .entry(group_id.to_string())
2706 .or_default()
2707 .extend(still_pending);
2708 }
2709
2710 if !state_advanced {
2711 break;
2712 }
2713 // The consumed commit may also unblock buffered commit-channel
2714 // commits; drain them before the next message pass (the pass itself
2715 // satisfies drain_pending_commits' call-site contract).
2716 self.drain_pending_commits(group_id);
2717 }
2718
2719 // Clean up empty entries
2720 if self
2721 .group_mesh
2722 .pending_group_messages
2723 .get(group_id)
2724 .is_none_or(|v| v.is_empty())
2725 {
2726 self.group_mesh.pending_group_messages.remove(group_id);
2727 }
2728 }
2729
2730 /// Flags a group as having a potential epoch fork.
2731 ///
2732 /// Called when buffered commits expire without resolution, indicating
2733 /// the group may have diverged due to concurrent commits.
2734 fn flag_potential_epoch_fork(&mut self, group_id: &str) {
2735 if self.group_mesh.epoch_forks.len() >= MAX_EPOCH_FORK_ENTRIES {
2736 // Evict the oldest entry to prevent unbounded growth
2737 if let Some(oldest_key) = self
2738 .group_mesh
2739 .epoch_forks
2740 .values()
2741 .min_by_key(|f| f.detected_at)
2742 .map(|f| f.group_id.clone())
2743 {
2744 self.group_mesh.epoch_forks.remove(&oldest_key);
2745 }
2746 }
2747
2748 let local_epoch = self.refresh_group_members(group_id).ok().and_then(|_| {
2749 let mls_guard = self.read_mls_guard().ok()?;
2750 let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
2751 mls_guard
2752 .get_group_info(&gid)
2753 .ok()
2754 .flatten()
2755 .map(|i| i.epoch)
2756 });
2757
2758 if local_epoch.is_none() {
2759 warn!(
2760 group_id = %group_id,
2761 "Could not read local MLS epoch during fork detection — MLS unavailable or group not found"
2762 );
2763 }
2764
2765 warn!(
2766 group_id = %group_id,
2767 local_epoch = ?local_epoch,
2768 "Potential epoch fork detected — buffered commits expired without resolution"
2769 );
2770
2771 self.group_mesh.epoch_forks.insert(
2772 group_id.to_string(),
2773 EpochForkState {
2774 group_id: group_id.to_string(),
2775 local_epoch,
2776 detected_at: Instant::now(),
2777 resolution_attempted: false,
2778 },
2779 );
2780
2781 self.emit_event(Event::group_epoch_fork_detected(
2782 group_id.to_string(),
2783 local_epoch,
2784 ));
2785 }
2786
2787 /// Handles an incoming group leave notification.
2788 ///
2789 /// After verifying the sender, the lexicographically-first remaining member
2790 /// (deterministic election) issues an MLS remove-commit to advance the group
2791 /// epoch and revoke the leaving member's keys. Other members will receive
2792 /// the commit via `handle_group_mls_commit`.
2793 ///
2794 /// # Security limitations
2795 ///
2796 /// Leave notifications are **not** MLS-authenticated because a member cannot
2797 /// issue a self-removal Commit in MLS. The `sender` field comes from the
2798 /// `Message` envelope which is not cryptographically bound. In an adversarial
2799 /// mesh environment a relay node could forge a leave notification to force-
2800 /// remove a legitimate member. Mitigations:
2801 ///
2802 /// 1. `sender == leaving_member` check prevents cross-member impersonation
2803 /// when the transport layer preserves sender identity.
2804 /// 2. Membership is verified against the **MLS group state** (authoritative),
2805 /// not just the local cache.
2806 /// 3. The elected remover issues a real MLS Commit that all members verify
2807 /// cryptographically.
2808 ///
2809 /// For fully adversarial environments, consider requiring admin-only removal
2810 /// or adding an MLS application-message-signed leave proof.
2811 pub(crate) fn handle_group_mls_leave(&mut self, message_id: &str, sender: &str, data: &str) {
2812 let payload = match serde_json::from_str::<GroupMlsLeavePayload>(data) {
2813 Ok(p) => p,
2814 Err(_) => {
2815 warn!(sender = %sender, "Failed to parse GroupMlsLeave payload");
2816 return;
2817 }
2818 };
2819
2820 // Dedup check — prevents duplicate leave notifications from resetting
2821 // the election timer or triggering redundant remove-commit attempts.
2822 let dedup_key = message_id.to_string();
2823 if self.group_mesh.message_dedup.contains_key(&dedup_key) {
2824 debug!(
2825 group_id = %payload.group_id,
2826 msg_id = %dedup_key,
2827 "Duplicate leave notification, skipping"
2828 );
2829 return;
2830 }
2831 self.group_mesh
2832 .message_dedup
2833 .insert(dedup_key, Instant::now());
2834 if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
2835 self.cleanup_group_message_dedup();
2836 }
2837
2838 // Verify sender matches the claimed leaving member to prevent spoofing
2839 if payload.leaving_member != sender {
2840 error!(
2841 sender = %sender,
2842 claimed = %payload.leaving_member,
2843 group_id = %payload.group_id,
2844 "SECURITY: Leave notification sender mismatch — possible spoofing attempt, ignoring"
2845 );
2846 return;
2847 }
2848
2849 // Verify sender is actually a member of the group using MLS state
2850 // (authoritative) rather than only the local cache.
2851 let self_id = self.local_id.clone();
2852 let members = self
2853 .refresh_group_members(&payload.group_id)
2854 .ok()
2855 .or_else(|| self.group_mesh.members.get(&payload.group_id).cloned())
2856 .unwrap_or_default();
2857
2858 if !members.iter().any(|m| m == sender) {
2859 error!(
2860 sender = %sender,
2861 group_id = %payload.group_id,
2862 "SECURITY: Leave notification from non-member, ignoring"
2863 );
2864 return;
2865 }
2866
2867 info!(
2868 group_id = %payload.group_id,
2869 leaving_member = %payload.leaving_member,
2870 "Received mesh group leave notification"
2871 );
2872
2873 // Deterministic election: lexicographically-first remaining member
2874 // (excluding the leaver) issues the MLS remove-commit to advance epoch.
2875 let mut remaining: Vec<String> = members
2876 .iter()
2877 .filter(|m| m.as_str() != sender)
2878 .cloned()
2879 .collect();
2880 remaining.sort();
2881
2882 let should_remove = remaining
2883 .first()
2884 .map(|first| first == &self_id)
2885 .unwrap_or(false);
2886
2887 if should_remove {
2888 debug!(
2889 group_id = %payload.group_id,
2890 leaving_member = %sender,
2891 "Elected to issue MLS remove-commit for leaving member"
2892 );
2893 // Issue MLS remove + distribute commit to advance epoch and revoke keys.
2894 // remove_from_group emits GroupMemberRemoved, so we skip the event below.
2895 if let Err(e) = self.remove_from_group(&payload.group_id, sender) {
2896 warn!(
2897 group_id = %payload.group_id,
2898 leaving_member = %sender,
2899 error = %e,
2900 "Failed to issue MLS remove-commit for leaving member"
2901 );
2902 }
2903 // Clear any pending election for this leave (we handled it)
2904 let key = (payload.group_id.clone(), sender.to_string());
2905 self.group_mesh.pending_leave_elections.remove(&key);
2906 } else {
2907 // We're not the elected remover — record the election so we can
2908 // take over if the elected member fails to issue the commit in time.
2909 if self.group_mesh.pending_leave_elections.len() >= MAX_PENDING_LEAVE_ELECTIONS {
2910 // Evict the oldest entry to prevent unbounded growth.
2911 if let Some(oldest_key) = self
2912 .group_mesh
2913 .pending_leave_elections
2914 .iter()
2915 .min_by_key(|(_, e)| e.received_at)
2916 .map(|(k, _)| k.clone())
2917 {
2918 self.group_mesh.pending_leave_elections.remove(&oldest_key);
2919 }
2920 }
2921 let key = (payload.group_id.clone(), sender.to_string());
2922 self.group_mesh.pending_leave_elections.insert(
2923 key,
2924 PendingLeaveElection {
2925 group_id: payload.group_id.clone(),
2926 leaving_member: sender.to_string(),
2927 received_at: Instant::now(),
2928 last_attempt_at: None,
2929 },
2930 );
2931 }
2932
2933 // Do NOT emit GroupMemberRemoved here for non-elected nodes.
2934 // The elected node issues an MLS remove-commit which all members
2935 // (including us) will process via `handle_group_mls_commit` →
2936 // `process_commit_core`, which emits the event based on the actual
2937 // MLS membership delta. Emitting here would cause a duplicate event
2938 // because we'd emit once now (premature, before MLS state changes)
2939 // and once when the commit arrives.
2940 }
2941
2942 /// Refreshes the cached member list for a group from MlsManager.
2943 ///
2944 /// Also the reporting seam for leaves already seated in local group state:
2945 /// `get_group_info` filters out any leaf that does not derive its own
2946 /// credential and hands back the count, and a non-zero one is surfaced here
2947 /// as `GroupLeafIdentityUnproven`. This is the chokepoint for it because it
2948 /// is the roster read that runs after every membership change and before
2949 /// every fan-out, and — unlike the public `get_group_info` getter beside it
2950 /// — it holds `&mut self`, so it can emit.
2951 ///
2952 /// Rate-limited like the two wire sites, which matters more here than
2953 /// there: a seated leaf is *persistent*, so without suppression every
2954 /// commit, send and drain in the group would re-report the same finding.
2955 pub(crate) fn refresh_group_members(&mut self, group_id: &str) -> Result<Vec<String>> {
2956 let mls_guard = self.read_mls_guard()?;
2957 let gid = offline_protocol_mls::GroupId::new(group_id)?;
2958 let info = mls_guard
2959 .get_group_info(&gid)?
2960 .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
2961 let members = info.members.clone();
2962 let unproven_members = info.unproven_members;
2963 drop(mls_guard);
2964 self.group_mesh
2965 .members
2966 .insert(group_id.to_string(), members.clone());
2967
2968 if unproven_members > 0 {
2969 let peer = self.local_id.clone();
2970 // The log rides the rate limit rather than the condition, which the
2971 // other three sites do not need to do: they log once per refused
2972 // *frame*, so inbound traffic bounds them. This one is driven by our
2973 // own activity — a roster read runs on every commit, send and drain
2974 // — and the finding is persistent, so an unthrottled line here would
2975 // put an `error!` in the log for every message the user sends, for
2976 // as long as the group exists. Same placement as
2977 // `report_unauthorized_membership_change`, which also logs only
2978 // after claiming the window.
2979 let reported = self.report_unproven_leaf(
2980 group_id,
2981 &peer,
2982 UnprovenLeafSite::RosterEntry,
2983 // Identifier-free like the other two sites, and count-free for
2984 // one further reason: the count is a property of local state,
2985 // not of anything a peer did, so it says nothing a sink needs
2986 // and narrows which install a record came from. It is in the
2987 // `error!` below.
2988 "This group holds membership state that could not be verified against the keys \
2989 it claims, so messages in this group cannot be reliably attributed. The group \
2990 should be treated as compromised and abandoned rather than repaired."
2991 .to_string(),
2992 );
2993 if reported {
2994 error!(
2995 group_id = %group_id,
2996 unproven_members,
2997 "SECURITY: local group state holds leaves that do not prove the identity \
2998 they claim; they are kept out of the roster and cannot speak, but they \
2999 have read everything sent to this group"
3000 );
3001 }
3002 }
3003 Ok(members)
3004 }
3005
3006 /// Creates a new MLS group.
3007 ///
3008 /// The group is created locally via MLS. Members can be invited with
3009 /// `invite_to_group()`. Messages sent via `send_group_message()` are
3010 /// MLS-encrypted and route through whichever transport DORS selects.
3011 /// If Internet is available the group is also registered with the
3012 /// relay server for optimized fan-out.
3013 pub fn create_group(&mut self, group_name: &str) -> Result<offline_protocol_mls::GroupInfo> {
3014 let trimmed = group_name.trim();
3015 if trimmed.is_empty() {
3016 return Err(Error::InvalidArgument(
3017 "Group name cannot be empty".to_string(),
3018 ));
3019 }
3020 let mls_guard = self.read_mls_guard()?;
3021 let group_info = mls_guard.create_group(group_name)?;
3022 let group_id = group_info.group_id.as_str().to_string();
3023 let members = group_info.members.clone();
3024 drop(mls_guard);
3025
3026 self.group_mesh
3027 .members
3028 .insert(group_id.clone(), members.clone());
3029
3030 self.emit_event(Event::group_created(
3031 group_id.clone(),
3032 group_name.to_string(),
3033 ));
3034
3035 // Best-effort relay registration
3036 let _ = self.try_relay_register_group(&group_id, Some(group_name), &members);
3037
3038 Ok(group_info)
3039 }
3040
3041 /// Invites a user to an MLS group.
3042 ///
3043 /// Requires the invitee's key package to be available in `pending_key_packages`.
3044 /// Sends a Welcome to the invitee and a Commit to all existing members.
3045 ///
3046 /// Admission is where recipient validation belongs. The removal and role
3047 /// paths deliberately do *not* validate: a roster entry admitted by an
3048 /// older build (before the id rules tightened) must stay removable, and a
3049 /// gate there would turn "a bad member exists" into "a bad member cannot be
3050 /// removed" — the wrong direction for a moderation control.
3051 pub fn invite_to_group(&mut self, group_id: &str, invitee_user_id: &str) -> Result<()> {
3052 Self::validate_outbound_recipient(invitee_user_id)?;
3053
3054 // Admin check
3055 let self_id = self.local_id.clone();
3056 if !self.check_is_admin(group_id, &self_id)? {
3057 return Err(Error::PermissionDenied(
3058 "Only admins can invite members".to_string(),
3059 ));
3060 }
3061
3062 // Check group member cap before adding
3063 let current_count = self
3064 .group_mesh
3065 .members
3066 .get(group_id)
3067 .map(|m| m.len())
3068 .or_else(|| {
3069 self.read_mls_guard().ok().and_then(|g| {
3070 g.get_group_info(&offline_protocol_mls::GroupId::new(group_id).ok()?)
3071 .ok()
3072 .flatten()
3073 .map(|info| info.members.len())
3074 })
3075 })
3076 .unwrap_or(0);
3077 let max_members = self.config.group.max_group_members;
3078 if current_count >= max_members {
3079 return Err(Error::InvalidState(format!(
3080 "Group has {} members, cannot exceed {} limit",
3081 current_count, max_members
3082 )));
3083 }
3084
3085 // Try loading key package from storage (e.g. after restart or rapid
3086 // re-invite where a fresh package arrived between calls).
3087 self.try_load_key_package_from_storage_into_memory(invitee_user_id);
3088
3089 // Get the invitee's key package and check expiry
3090 let now_ms = Utc::now().timestamp_millis() as u64;
3091 let received_pkg = self
3092 .pending_key_packages
3093 .get(invitee_user_id)
3094 .ok_or_else(|| Error::NoKeyPackage(invitee_user_id.to_string()))?;
3095 if now_ms >= received_pkg.local_expires_at_ms {
3096 self.pending_key_packages.remove(invitee_user_id);
3097 return Err(Error::InvalidState(format!(
3098 "Key package for {} has expired",
3099 invitee_user_id
3100 )));
3101 }
3102 let key_pkg = received_pkg.key_package_data.clone();
3103
3104 // Add member via MLS — returns both Welcome (for invitee) and Commit (for existing members)
3105 let mls_guard = self.read_mls_guard()?;
3106 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3107 let (welcome, commit) = mls_guard.add_group_member(&gid, invitee_user_id, &key_pkg)?;
3108 let group_name = welcome.group_name.clone();
3109 drop(mls_guard);
3110
3111 // MLS key packages are single-use (RFC 9420). Now that add_group_member
3112 // consumed the package, remove it so subsequent group invites for the
3113 // same peer don't reuse a stale package.
3114 self.pending_key_packages.remove(invitee_user_id);
3115 self.delete_peer_key_package_from_storage(invitee_user_id);
3116
3117 // Clear key-package-sent tracking so the invitee can reciprocate.
3118 // Without this, the invitee's handler sees us in `key_package_sent_to`
3119 // and skips reciprocation, leaving us without a key package for
3120 // future group invites.
3121 self.key_package_sent_to.remove(invitee_user_id);
3122
3123 // Send our key package to the invitee. Their message dispatch handler
3124 // will reciprocate with a fresh key package, replenishing our supply
3125 // for future group invites or 1:1 session creation.
3126 if let Err(e) = self.send_key_package_to(invitee_user_id, false) {
3127 debug!(
3128 invitee = %invitee_user_id,
3129 error = %e,
3130 "Key package send to invitee deferred (will retry on next discovery)"
3131 );
3132 }
3133
3134 // Refresh member list after add
3135 let members = self.refresh_group_members(group_id)?;
3136
3137 // Store invitee as member role, then read back both role-overlay
3138 // fields the Welcome carries (roles and the creator of record) from a
3139 // single metadata load.
3140 let (member_roles, created_by) = {
3141 let mls_guard = self.read_mls_guard()?;
3142 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3143 if let Err(e) = mls_guard.set_member_role(&gid, invitee_user_id, GroupRole::Member) {
3144 warn!(invitee = %invitee_user_id, error = %e, "Failed to store invitee role");
3145 }
3146 mls_guard
3147 .get_group_metadata(&gid)
3148 .ok()
3149 .flatten()
3150 .map(|m| (m.get_all_roles(), m.created_by.clone()))
3151 .unwrap_or_default()
3152 };
3153
3154 // Attest rich-payload capability both directions of the knowledge
3155 // gap an add creates: existing members never exchange key packages
3156 // with the invitee (commit field below), and the invitee never
3157 // exchanges with anyone but us (welcome map here). Entries only
3158 // exist for members we know capable — directly or via an earlier
3159 // attestation, which is how knowledge chains across successive adds
3160 // — so absence means "no information", never a downgrade. Our own
3161 // entry self-advertises from config (belt to the key package sent
3162 // above, whose delivery is best-effort).
3163 let invitee_rich = self.attestable_rich_versions(invitee_user_id);
3164 let member_rich: HashMap<String, Vec<u8>> = members
3165 .iter()
3166 .filter(|m| m.as_str() != invitee_user_id)
3167 .filter_map(|m| {
3168 if *m == self_id {
3169 self.config
3170 .encryption
3171 .rich_payload_enabled
3172 .then(|| (m.clone(), vec![RICH_PAYLOAD_V1]))
3173 } else {
3174 self.attestable_rich_versions(m).map(|v| (m.clone(), v))
3175 }
3176 })
3177 .collect();
3178
3179 // Send Welcome to invitee
3180 let welcome_payload = GroupMlsWelcomePayload {
3181 group_id: group_id.to_string(),
3182 group_name,
3183 welcome_data: base64_encode(&welcome.welcome_data),
3184 member_list: members.clone(),
3185 member_roles,
3186 member_rich,
3187 // Carries our creator of record so the joiner's `check_is_admin`
3188 // has a fallback when the role snapshot above is incomplete.
3189 // Absent when we never learned it ourselves (joined via an older
3190 // SDK's Welcome) — absence is "no information", not a downgrade.
3191 created_by,
3192 };
3193 let welcome_content = format!(
3194 "{}{}",
3195 internal_prefixes::GROUP_MLS_WELCOME,
3196 serde_json::to_string(&welcome_payload)
3197 .map_err(|e| Error::Serialization(format!("Serialize welcome: {}", e)))?
3198 );
3199 self.send_internal_message(invitee_user_id, welcome_content, MessagePriority::High)?;
3200
3201 // Send Commit to all existing members (excluding self and invitee)
3202 // so they can process it and advance their MLS epoch
3203 let commit_payload = GroupMlsCommitPayload {
3204 group_id: group_id.to_string(),
3205 commit_type: GroupCommitType::Add,
3206 ciphertext: base64_encode(&commit.ciphertext),
3207 epoch: commit.epoch,
3208 affected_member: Some(invitee_user_id.to_string()),
3209 role: Some(GroupRole::Member),
3210 affected_member_rich: invitee_rich,
3211 };
3212 let commit_content = format!(
3213 "{}{}",
3214 internal_prefixes::GROUP_MLS_COMMIT,
3215 serde_json::to_string(&commit_payload)
3216 .map_err(|e| Error::Serialization(format!("Serialize commit: {}", e)))?
3217 );
3218 let mut failed_commit_members: Vec<String> = Vec::new();
3219 for member in &members {
3220 if member == &self_id || member == invitee_user_id {
3221 continue;
3222 }
3223 if let Err(e) =
3224 self.send_internal_message(member, commit_content.clone(), MessagePriority::High)
3225 {
3226 warn!(
3227 group_id = %group_id,
3228 member = %member,
3229 error = %e,
3230 "Failed to send commit to group member during invite"
3231 );
3232 failed_commit_members.push(member.clone());
3233 }
3234 }
3235 // Single best-effort retry pass for transient failures
3236 for member in &failed_commit_members {
3237 let _ =
3238 self.send_internal_message(member, commit_content.clone(), MessagePriority::High);
3239 }
3240
3241 self.emit_event(Event::group_member_added(
3242 group_id.to_string(),
3243 invitee_user_id.to_string(),
3244 self_id,
3245 None,
3246 // Our own invite, gated by the admin check at the top of this fn.
3247 Some(true),
3248 ));
3249
3250 // Sync membership update to relay
3251 let _ = self.try_relay_register_group(group_id, None, &members);
3252
3253 info!(group_id = %group_id, invitee = %invitee_user_id, "Invited member to group");
3254 Ok(())
3255 }
3256
3257 /// Removes a member from an MLS group.
3258 ///
3259 /// Sends a Commit to all remaining members.
3260 pub fn remove_from_group(&mut self, group_id: &str, member_id: &str) -> Result<()> {
3261 // Deliberately unvalidated — see `invite_to_group`. Removal must work
3262 // on whatever is actually on the roster.
3263 let self_id = self.local_id.clone();
3264
3265 // Admin check + last-admin guard (single metadata load)
3266 {
3267 let mls_guard = self.read_mls_guard()?;
3268 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3269 let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
3270
3271 let is_admin = if let Some(ref meta) = metadata {
3272 if meta.has_any_admin() {
3273 meta.get_role(&self_id) == GroupRole::Admin
3274 } else if let Some(ref creator) = meta.created_by {
3275 creator == &self_id
3276 } else {
3277 false
3278 }
3279 } else {
3280 false
3281 };
3282 if !is_admin {
3283 return Err(Error::PermissionDenied(
3284 "Only admins can remove members".to_string(),
3285 ));
3286 }
3287
3288 if let Some(ref meta) = metadata {
3289 if meta.get_role(member_id) == GroupRole::Admin {
3290 let admin_count = meta
3291 .get_all_roles()
3292 .values()
3293 .filter(|r| **r == GroupRole::Admin)
3294 .count();
3295 let member_count = self
3296 .group_mesh
3297 .members
3298 .get(group_id)
3299 .map(|m| m.len())
3300 .unwrap_or(0);
3301 // member_count > 2: the count is *before* removal, so > 2
3302 // means at least 2 members will remain after the removal.
3303 // When only 2 members exist (the admin being removed + one
3304 // other), the remaining sole member gets auto-promoted, so
3305 // we allow the removal.
3306 if admin_count <= 1 && member_count > 2 {
3307 return Err(Error::InvalidState(
3308 "Cannot remove the last admin while other members remain. Promote another member to admin first.".to_string(),
3309 ));
3310 }
3311 }
3312 }
3313 }
3314
3315 let mls_guard = self.read_mls_guard()?;
3316 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3317 let commit_msg = mls_guard.remove_group_member(&gid, member_id)?;
3318 drop(mls_guard);
3319
3320 // Refresh member list after removal
3321 let members = self.refresh_group_members(group_id)?;
3322 // Clean up removed member's role metadata
3323 if let Ok(mls_guard) = self.read_mls_guard() {
3324 if let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) {
3325 if let Err(e) = mls_guard.remove_member_role(&gid, member_id) {
3326 warn!(member = %member_id, error = %e, "Failed to clean up role metadata for removed member");
3327 }
3328 }
3329 }
3330 // Auto-promote if removal left zero admins
3331 self.auto_promote_if_no_admin(group_id, &members);
3332
3333 let commit_payload = GroupMlsCommitPayload {
3334 group_id: group_id.to_string(),
3335 commit_type: GroupCommitType::Remove,
3336 ciphertext: base64_encode(&commit_msg.ciphertext),
3337 epoch: commit_msg.epoch,
3338 affected_member: Some(member_id.to_string()),
3339 role: None,
3340 affected_member_rich: None,
3341 };
3342 let commit_content = format!(
3343 "{}{}",
3344 internal_prefixes::GROUP_MLS_COMMIT,
3345 serde_json::to_string(&commit_payload)
3346 .map_err(|e| Error::Serialization(format!("Serialize commit: {}", e)))?
3347 );
3348 let mut failed_commit_members: Vec<String> = Vec::new();
3349 for member in &members {
3350 if member == &self_id {
3351 continue;
3352 }
3353 if let Err(e) =
3354 self.send_internal_message(member, commit_content.clone(), MessagePriority::High)
3355 {
3356 warn!(
3357 group_id = %group_id,
3358 member = %member,
3359 error = %e,
3360 "Failed to send commit to group member during remove"
3361 );
3362 failed_commit_members.push(member.clone());
3363 }
3364 }
3365 // Single best-effort retry pass for transient failures
3366 for member in &failed_commit_members {
3367 let _ =
3368 self.send_internal_message(member, commit_content.clone(), MessagePriority::High);
3369 }
3370
3371 // Send a plaintext removal notification directly to the removed member.
3372 // The MLS commit alone is insufficient: the removed member cannot decrypt
3373 // it (they're no longer in the group), so they would never learn they were
3374 // removed. The plaintext notification uses the existing GROUP_MEMBER_REMOVED
3375 // prefix which the message dispatcher handles by emitting a
3376 // GroupMemberRemoved event and cleaning up local state.
3377 let removed_payload = GroupMemberRemovedPayload {
3378 group_id: group_id.to_string(),
3379 user_id: member_id.to_string(),
3380 removed_by: self_id.clone(),
3381 };
3382 if let Ok(json) = serde_json::to_string(&removed_payload) {
3383 let removed_content = format!("{}{}", internal_prefixes::GROUP_MEMBER_REMOVED, json);
3384 if let Err(e) =
3385 self.send_internal_message(member_id, removed_content, MessagePriority::High)
3386 {
3387 warn!(
3388 group_id = %group_id,
3389 member = %member_id,
3390 error = %e,
3391 "Failed to send removal notification to removed member"
3392 );
3393 }
3394 }
3395
3396 self.emit_event(Event::group_member_removed(
3397 group_id.to_string(),
3398 member_id.to_string(),
3399 self_id,
3400 // Our own removal, gated by the admin check at the top of this fn.
3401 Some(true),
3402 ));
3403
3404 // Sync membership update to relay
3405 let _ = self.try_relay_register_group(group_id, None, &members);
3406
3407 info!(group_id = %group_id, member = %member_id, "Removed member from group");
3408 Ok(())
3409 }
3410
3411 /// Leaves an MLS group.
3412 ///
3413 /// Notifies remaining members and then removes local group state.
3414 /// Note: The MLS layer does not support self-removal Commits, so remaining
3415 /// members receive a plaintext leave notification. The deterministic election
3416 /// in `handle_group_mls_leave` will select one member to issue the MLS
3417 /// remove-commit to properly advance the epoch.
3418 ///
3419 /// **Ordering note:** Leave notifications are sent *before* deleting local
3420 /// MLS state. If all notification sends fail, local state is preserved and
3421 /// the caller receives an error so the leave can be retried. This prevents
3422 /// orphaned membership where the leaver is gone locally but peers never
3423 /// learn about the departure.
3424 pub fn leave_group(&mut self, group_id: &str) -> Result<()> {
3425 // Get members before leaving
3426 let members = self
3427 .group_mesh
3428 .members
3429 .get(group_id)
3430 .cloned()
3431 .or_else(|| self.refresh_group_members(group_id).ok())
3432 .unwrap_or_default();
3433
3434 let self_id = self.local_id.clone();
3435
3436 // Block last admin from leaving if other members remain
3437 let other_members_exist = members.iter().any(|m| m != &self_id);
3438 if other_members_exist {
3439 let mls_guard = self.read_mls_guard()?;
3440 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3441 if let Some(metadata) = mls_guard.get_group_metadata(&gid)? {
3442 let is_admin = metadata.get_role(&self_id) == GroupRole::Admin;
3443 if is_admin {
3444 let admin_count = metadata
3445 .get_all_roles()
3446 .values()
3447 .filter(|r| **r == GroupRole::Admin)
3448 .count();
3449 if admin_count <= 1 {
3450 return Err(Error::InvalidState(
3451 "Cannot leave group as the last admin while other members remain. Promote another member to admin first.".to_string(),
3452 ));
3453 }
3454 }
3455 }
3456 drop(mls_guard);
3457 }
3458
3459 // Build leave notification payload before touching MLS state
3460 let leave_payload = GroupMlsLeavePayload {
3461 group_id: group_id.to_string(),
3462 leaving_member: self_id.clone(),
3463 };
3464 let leave_content = format!(
3465 "{}{}",
3466 internal_prefixes::GROUP_MLS_LEAVE,
3467 serde_json::to_string(&leave_payload)
3468 .map_err(|e| Error::Serialization(format!("Serialize leave: {}", e)))?
3469 );
3470
3471 // Send notifications first — if all fail, keep local state intact for retry
3472 let mut any_sent = false;
3473 let mut had_recipients = false;
3474 for member in &members {
3475 if member == &self_id {
3476 continue;
3477 }
3478 had_recipients = true;
3479 match self.send_internal_message(member, leave_content.clone(), MessagePriority::Medium)
3480 {
3481 Ok(_) => {
3482 any_sent = true;
3483 }
3484 Err(e) => {
3485 warn!(
3486 group_id = %group_id,
3487 member = %member,
3488 error = %e,
3489 "Failed to send leave notification to group member"
3490 );
3491 }
3492 }
3493 }
3494
3495 // If there were other members but no notification succeeded, fail so the
3496 // caller can retry rather than silently orphaning the membership.
3497 if had_recipients && !any_sent {
3498 return Err(Error::Transport(
3499 offline_protocol_transport::Error::SendFailed(
3500 "All leave notifications failed — local state preserved for retry".to_string(),
3501 ),
3502 ));
3503 }
3504
3505 // Now safe to delete local MLS state — at least one peer was notified
3506 let mls_guard = self.read_mls_guard()?;
3507 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3508 mls_guard.leave_group(&gid)?;
3509 drop(mls_guard);
3510
3511 // Remove from caches. Buffered commits go too — they can never apply
3512 // after we leave, and a stale one retried after a rapid re-invite
3513 // would expire with retry_count > 0 and falsely flag an epoch fork.
3514 self.group_mesh.members.remove(group_id);
3515 let was_synced = self.group_mesh.relay_synced.remove(group_id);
3516 let was_pending = self
3517 .group_mesh
3518 .relay_register_pending
3519 .remove(group_id)
3520 .is_some();
3521 if was_synced || was_pending {
3522 self.emit_event(Event::group_relay_sync_changed(
3523 group_id.to_string(),
3524 false,
3525 "left",
3526 ));
3527 }
3528 self.group_mesh.pending_group_messages.remove(group_id);
3529 self.group_mesh.pending_commits.remove(group_id);
3530
3531 info!(group_id = %group_id, "Left group");
3532 Ok(())
3533 }
3534
3535 /// Sends a message to all members of an MLS group.
3536 ///
3537 /// If Internet transport is available and relay-enabled, sends a single
3538 /// relay broadcast (`__GRP_RELAY_BCAST__`). Otherwise falls back to
3539 /// per-member fan-out via `send_internal_message()` where each member's
3540 /// delivery goes through the full DORS/ACK/retry stack independently.
3541 pub fn send_group_message(
3542 &mut self,
3543 group_id: &str,
3544 content: &str,
3545 priority: Option<MessagePriority>,
3546 reply_to_msg: Option<&str>,
3547 ) -> Result<Vec<MessageId>> {
3548 // Reject reserved internal prefixes: receivers parse decrypted group
3549 // plaintext for the sealed `__RICH_V1__` body, so user content must
3550 // never be able to impersonate one (same rule as `send_message` and
3551 // the forward paths).
3552 if Self::is_internal_prefix(content) {
3553 return Err(Error::InvalidArgument(
3554 "Message content must not start with a reserved internal prefix".to_string(),
3555 ));
3556 }
3557 self.send_group_message_inner(
3558 group_id,
3559 content,
3560 priority.unwrap_or(MessagePriority::Medium),
3561 reply_to_msg,
3562 None,
3563 None,
3564 ContentType::Text,
3565 )
3566 }
3567
3568 /// Like [`Self::send_group_message`], with rich media fields.
3569 ///
3570 /// The rich fields travel inside the MLS-sealed `__RICH_V1__` body of
3571 /// the group ciphertext, and only when every other member advertised
3572 /// `rich_versions` (see [`GroupSendOptions`]); otherwise they are
3573 /// silently dropped, never cleartext.
3574 pub fn send_group_message_with(
3575 &mut self,
3576 group_id: &str,
3577 content: &str,
3578 options: GroupSendOptions,
3579 ) -> Result<Vec<MessageId>> {
3580 // Same internal-prefix rejection as `send_group_message`.
3581 if Self::is_internal_prefix(content) {
3582 return Err(Error::InvalidArgument(
3583 "Message content must not start with a reserved internal prefix".to_string(),
3584 ));
3585 }
3586 // FileChunk is an internal transport content type — same boundary
3587 // rule as `send_message_with`.
3588 if options.content_type == Some(ContentType::FileChunk) {
3589 return Err(Error::InvalidArgument(
3590 "FileChunk is an internal content type and cannot be sent directly".to_string(),
3591 ));
3592 }
3593 Self::check_group_rich_extras_size(options.media_metadata.as_ref(), None)?;
3594 self.send_group_message_inner(
3595 group_id,
3596 content,
3597 options.priority.unwrap_or(MessagePriority::Medium),
3598 options.reply_to_msg.as_deref(),
3599 None,
3600 options.media_metadata,
3601 options.content_type.unwrap_or_default(),
3602 )
3603 }
3604
3605 /// Forwards a message to all members of a group with forwarding attribution.
3606 ///
3607 /// Similar to `send_group_message` but attaches `ForwardInfo` to preserve
3608 /// the original sender and forward count. The message content is encrypted
3609 /// via MLS for the group and fan-out follows the same path as regular
3610 /// group messages (including relay broadcast when available).
3611 ///
3612 /// When every other group member advertised the sealed rich payload, the
3613 /// attribution and the original `media_metadata` — including cloud-media
3614 /// `encryption_key`/`iv` secrets, which only the sealed body may carry —
3615 /// travel inside the group MLS ciphertext, so forwarded cloud media stays
3616 /// openable. Otherwise the media metadata is dropped (never cleartext)
3617 /// and only the hop-visible payload attribution survives.
3618 pub fn forward_message_to_group(
3619 &mut self,
3620 original_message: &Message,
3621 group_id: &str,
3622 priority: Option<MessagePriority>,
3623 ) -> Result<Vec<MessageId>> {
3624 // Reject content that starts with an internal control prefix to prevent
3625 // injection of protocol-level messages through the forwarding API.
3626 if Self::is_internal_prefix(&original_message.content) {
3627 return Err(Error::InvalidArgument(
3628 "Cannot forward a message with reserved internal prefix content".to_string(),
3629 ));
3630 }
3631
3632 // FileChunk is an internal transport content type — same boundary
3633 // rule as the DM forward path.
3634 if original_message.content_type == ContentType::FileChunk {
3635 return Err(Error::InvalidArgument(
3636 "FileChunk is an internal content type and cannot be forwarded".to_string(),
3637 ));
3638 }
3639
3640 let forward_info = ForwardInfo::from_message(original_message);
3641
3642 if forward_info.forward_count > crate::constants::MAX_FORWARD_COUNT {
3643 return Err(Error::InvalidArgument(format!(
3644 "Forward count {} exceeds maximum of {}",
3645 forward_info.forward_count,
3646 crate::constants::MAX_FORWARD_COUNT,
3647 )));
3648 }
3649
3650 Self::check_group_rich_extras_size(
3651 original_message.media_metadata.as_ref(),
3652 Some(&forward_info),
3653 )?;
3654
3655 self.send_group_message_inner(
3656 group_id,
3657 &original_message.content,
3658 priority.unwrap_or(MessagePriority::Medium),
3659 None,
3660 Some(forward_info),
3661 original_message.media_metadata.clone(),
3662 original_message.content_type,
3663 )
3664 }
3665
3666 /// Boundary cap for group rich extras, mirroring `send_message_with`:
3667 /// bound the serialized extras here so the seal (or a relay-broadcast
3668 /// fallback re-send) can never fail on size later.
3669 fn check_group_rich_extras_size(
3670 media_metadata: Option<&MediaMetadata>,
3671 forward_info: Option<&ForwardInfo>,
3672 ) -> Result<()> {
3673 RichSendExtras {
3674 reply_context: None,
3675 media_metadata: media_metadata.cloned(),
3676 forward_info: forward_info.cloned(),
3677 }
3678 .check_size()
3679 }
3680
3681 /// Whether a rich group send right now would seal its extras, and which
3682 /// members are in the way. See [`GroupRichReadiness`]. Read-only: uses
3683 /// the fan-out cache with an MLS fallback but never mutates state, and
3684 /// does not probe unknown members (the send path's drop branch does).
3685 pub fn group_rich_readiness(&self, group_id: &str) -> Result<GroupRichReadiness> {
3686 let members = match self.group_mesh.members.get(group_id) {
3687 Some(m) => m.clone(),
3688 None => {
3689 let mls_guard = self.read_mls_guard()?;
3690 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3691 mls_guard
3692 .get_group_info(&gid)?
3693 .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?
3694 .members
3695 }
3696 };
3697 let unknown_members = if self.config.encryption.rich_payload_enabled {
3698 self.group_rich_unknown_members(&members)
3699 } else {
3700 Vec::new()
3701 };
3702 Ok(GroupRichReadiness {
3703 ready: self.group_rich_seal_active(&members),
3704 unknown_members,
3705 })
3706 }
3707
3708 /// The relay-side registration state of a group. See [`RelaySyncState`].
3709 ///
3710 /// `Synced` wins over an outstanding re-registration: once the relay has
3711 /// positively acknowledged the group on this connection, its registry
3712 /// entry survives regardless of any in-flight idempotent re-sync, so
3713 /// relay-dependent commands are already safe to issue.
3714 pub fn group_relay_sync_state(&self, group_id: &str) -> RelaySyncState {
3715 if self.group_mesh.relay_synced.contains(group_id) {
3716 RelaySyncState::Synced
3717 } else if self
3718 .group_mesh
3719 .relay_register_pending
3720 .contains_key(group_id)
3721 {
3722 RelaySyncState::Pending
3723 } else {
3724 RelaySyncState::Unsynced
3725 }
3726 }
3727
3728 /// Registers (or re-registers) a group with the relay server on demand.
3729 ///
3730 /// The supported path for making a mesh-created group known to the
3731 /// relay before issuing relay-dependent server commands for it (invite
3732 /// links, server-side fan-out) — never raw-send `CreateGroup`. The
3733 /// outcome arrives asynchronously as `GroupRelaySyncChanged`:
3734 /// `synced: true, reason: "registered"` on the relay's ack, `false`
3735 /// with `"error"` / `"ack_timeout"` on denial or a relay that never
3736 /// answers. Idempotent: an already-`Synced` group returns `Ok(true)`
3737 /// without re-sending (the automatic re-sync on membership changes
3738 /// covers roster updates).
3739 ///
3740 /// Returns `Ok(true)` when the registration frame was queued (or the
3741 /// group is already synced), `Ok(false)` when relay grouping is
3742 /// disabled or the Internet transport is unavailable, `Err` when the
3743 /// group is unknown locally.
3744 pub fn request_group_relay_registration(&mut self, group_id: &str) -> Result<bool> {
3745 if self.group_mesh.relay_synced.contains(group_id) {
3746 return Ok(true);
3747 }
3748 // Fresh membership, like the retry processor: the MLS roster is
3749 // authoritative, the fan-out cache is the fallback.
3750 let members = self
3751 .refresh_group_members(group_id)
3752 .ok()
3753 .or_else(|| self.group_mesh.members.get(group_id).cloned())
3754 .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
3755 self.try_relay_register_group(group_id, None, &members)
3756 }
3757
3758 /// Shared implementation for sending and forwarding group messages.
3759 ///
3760 /// Handles the sealed-rich decision, MLS encryption, member list
3761 /// caching, relay broadcast attempt, per-member fan-out, and event
3762 /// emission.
3763 #[allow(clippy::too_many_arguments)]
3764 fn send_group_message_inner(
3765 &mut self,
3766 group_id: &str,
3767 content: &str,
3768 priority: MessagePriority,
3769 reply_to_msg: Option<&str>,
3770 forward_info: Option<ForwardInfo>,
3771 media_metadata: Option<MediaMetadata>,
3772 content_type: ContentType,
3773 ) -> Result<Vec<MessageId>> {
3774 // Read member list from cache, falling back to MLS on cache miss.
3775 // Fetched before encryption because the sealed-rich decision below
3776 // needs the full membership: one ciphertext serves every member.
3777 let members = match self.group_mesh.members.get(group_id) {
3778 Some(m) => m.clone(),
3779 None => {
3780 let mls_guard = self.read_mls_guard()?;
3781 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3782 let info = mls_guard
3783 .get_group_info(&gid)?
3784 .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
3785 info.members.clone()
3786 }
3787 };
3788
3789 // Update cache if it was a miss
3790 if !self.group_mesh.members.contains_key(group_id) {
3791 self.group_mesh
3792 .members
3793 .insert(group_id.to_string(), members.clone());
3794 }
3795
3796 // Rich extras travel only inside the sealed `__RICH_V1__` body of
3797 // the group ciphertext (media secrets must never leave the AEAD
3798 // boundary), and only when every other member advertised the
3799 // capability — a legacy member would render the sealed body as
3800 // literal JSON text. For unsealed sends the attribution rides the
3801 // hop-visible payload below as the fallback; media metadata has no
3802 // such fallback and simply drops (surfaced to the app via
3803 // `GroupRichExtrasDropped` — the send itself still succeeds). A
3804 // non-Text hint seals a hint-only body even without extras
3805 // (mirroring the DM path): the group payload has no outer
3806 // content_type carrier, so an unsealed hint would not merely go
3807 // unprotected — it would be lost.
3808 let extras = RichSendExtras {
3809 reply_context: None,
3810 media_metadata,
3811 forward_info: forward_info.clone(),
3812 };
3813 let wants_seal = extras.is_any() || content_type != ContentType::Text;
3814 let sealed = wants_seal && self.group_rich_seal_active(&members);
3815 let sealed_body;
3816 let plaintext: &str = if sealed {
3817 sealed_body = Self::seal_rich_payload(content, &extras, content_type)?;
3818 &sealed_body
3819 } else {
3820 // Members holding the gate closed (empty when the local kill
3821 // switch is the cause — with recording disabled, listing every
3822 // member would be noise, and probing them could not reopen the
3823 // gate anyway). Probing sends them our key package so their
3824 // auto-exchange reply teaches us their capability: unknown
3825 // members are typically ones somebody else added, healed here
3826 // for groups predating attestation or behind an old-SDK inviter.
3827 // Only sends that wanted to seal consume the set (backfill here,
3828 // blame on the drop event below) — plain text sends skip the
3829 // membership scan entirely.
3830 let unknown = if wants_seal && self.config.encryption.rich_payload_enabled {
3831 self.group_rich_unknown_members(&members)
3832 } else {
3833 Vec::new()
3834 };
3835 if !unknown.is_empty() {
3836 self.backfill_group_rich_capabilities(&unknown);
3837 }
3838 if extras.media_metadata.is_some() {
3839 warn!(
3840 group_id = %group_id,
3841 "Group not fully rich-capable, dropping rich media metadata (members \
3842 will receive the text without the media attachment)"
3843 );
3844 self.emit_event(Event::group_rich_extras_dropped(
3845 group_id.to_string(),
3846 unknown,
3847 ));
3848 }
3849 content
3850 };
3851
3852 // When the body sealed, every member reads the sealed attribution
3853 // and the receiver ignores the payload copy wholesale — omit it
3854 // rather than expose original-sender metadata to relays and mesh
3855 // hops for nobody's benefit. (A stale-cache just-added non-capable
3856 // member loses degraded-display attribution in that window;
3857 // accepted, consistent with them rendering the sealed body as
3858 // literal JSON anyway.)
3859 let payload_forward_info = if sealed { None } else { forward_info };
3860
3861 // Encrypt via MLS — release the guard immediately after encryption
3862 // to minimize lock contention during the fan-out phase.
3863 let encrypted = {
3864 let mls_guard = self.read_mls_guard()?;
3865 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3866 mls_guard.encrypt_for_group(&gid, plaintext.as_bytes())?
3867 };
3868
3869 let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
3870 let epoch = encrypted.epoch;
3871
3872 let self_id = self.local_id.clone();
3873
3874 // --- Attempt relay broadcast first ---
3875 // If the group is registered, the app opted in, and the relay
3876 // advertised the v3 group-delivery contract, a single relay
3877 // broadcast is O(1) instead of O(N) individual sends.
3878 //
3879 // The capability gate is what makes the broadcast safe to take by
3880 // default: a `group_delivery_v3` relay answers every broadcast with
3881 // a settled per-recipient delivery report, which the sender consumes
3882 // (`handle_relay_group_delivery_report`) to re-send per-member
3883 // copies to anyone the relay did not reach — so the broadcast keeps
3884 // a delivery contract instead of being fire-and-forget — and names
3885 // members in the same address space this SDK registered, so that
3886 // report is comparable against the MLS roster at all (see
3887 // [`RELAY_CAP_GROUP_DELIVERY_V3`] for why a v2 relay must fail this
3888 // gate). Against an older relay the gate simply fails and sends take
3889 // the always-correct per-member fan-out.
3890 //
3891 // `is_internet_available()` is re-checked here and not inferred from
3892 // `relay_synced`: sync state is cleared by the `process()` tick, so
3893 // between Internet dropping and the next tick a stale `relay_synced`
3894 // would otherwise route the broadcast into the mesh.
3895 if self.config.group.relay_broadcast_enabled
3896 && self.group_mesh.relay_synced.contains(group_id)
3897 && self
3898 .group_mesh
3899 .relay_capabilities
3900 .contains(RELAY_CAP_GROUP_DELIVERY_V3)
3901 && self.is_internet_available()
3902 {
3903 if let Ok(mid) = self.try_relay_broadcast(
3904 group_id,
3905 &ciphertext_b64,
3906 epoch,
3907 reply_to_msg,
3908 payload_forward_info.clone(),
3909 priority,
3910 ) {
3911 let member_count = members.iter().filter(|m| m.as_str() != self_id).count() as u32;
3912 self.emit_event(Event::group_message_sent(
3913 group_id.to_string(),
3914 vec![mid.as_str().to_string()],
3915 member_count,
3916 ));
3917 return Ok(vec![mid]);
3918 }
3919 // Relay broadcast failed — fall through to per-member fan-out
3920 }
3921
3922 // Per-member fan-out (mesh or DORS-selected transport)
3923 let (message_ids, succeeded_members, failed_members) = self
3924 .fanout_group_frames_per_member(
3925 group_id,
3926 &members,
3927 None,
3928 &ciphertext_b64,
3929 epoch,
3930 reply_to_msg,
3931 payload_forward_info,
3932 priority,
3933 )?;
3934
3935 let member_count = succeeded_members.len() as u32;
3936
3937 // Check for total delivery failure: there were members to send to but
3938 // every send failed. Return an error so callers don't confuse this with
3939 // a solo-group scenario (which legitimately returns Ok(vec![])).
3940 let had_recipients = members.iter().any(|m| m != &self_id);
3941 if had_recipients && message_ids.is_empty() {
3942 self.emit_event(Event::group_message_partial_failure(
3943 group_id.to_string(),
3944 failed_members,
3945 succeeded_members,
3946 ));
3947 return Err(Error::Transport(
3948 offline_protocol_transport::Error::SendFailed(
3949 "All group message sends failed".to_string(),
3950 ),
3951 ));
3952 }
3953
3954 // Emit appropriate event
3955 if failed_members.is_empty() {
3956 self.emit_event(Event::group_message_sent(
3957 group_id.to_string(),
3958 message_ids.iter().map(|m| m.as_str().to_string()).collect(),
3959 member_count,
3960 ));
3961 } else {
3962 self.emit_event(Event::group_message_partial_failure(
3963 group_id.to_string(),
3964 failed_members,
3965 succeeded_members,
3966 ));
3967 }
3968
3969 Ok(message_ids)
3970 }
3971
3972 /// Lists all MLS groups (excluding 1:1 sessions).
3973 pub fn list_groups(&self) -> Result<Vec<String>> {
3974 let mls_guard = self.read_mls_guard()?;
3975 let groups = mls_guard.list_groups()?;
3976 Ok(groups.into_iter().map(|g| g.as_str().to_string()).collect())
3977 }
3978
3979 /// Gets information about an MLS group.
3980 pub fn get_group_info(
3981 &self,
3982 group_id: &str,
3983 ) -> Result<Option<offline_protocol_mls::GroupInfo>> {
3984 let mls_guard = self.read_mls_guard()?;
3985 let gid = offline_protocol_mls::GroupId::new(group_id)?;
3986 Ok(mls_guard.get_group_info(&gid)?)
3987 }
3988
3989 // ========================================================================
3990 // GROUP ROLES
3991 // ========================================================================
3992
3993 /// Deterministically promotes the lexicographically-first remaining member
3994 /// to admin if no admin role remains. All nodes use the same sort order,
3995 /// so the promotion converges to the same candidate network-wide.
3996 fn auto_promote_if_no_admin(&self, group_id: &str, members: &[String]) {
3997 if members.is_empty() {
3998 return;
3999 }
4000 if let Ok(mls_guard) = self.read_mls_guard() {
4001 let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) else {
4002 return;
4003 };
4004 if let Some(metadata) = mls_guard.get_group_metadata(&gid).ok().flatten() {
4005 if metadata.has_any_admin() {
4006 return;
4007 }
4008 let mut sorted = members.to_vec();
4009 sorted.sort();
4010 let promote_id = &sorted[0];
4011 if let Err(e) = mls_guard.set_member_role(&gid, promote_id, GroupRole::Admin) {
4012 warn!(user_id = %promote_id, error = %e, "Failed to auto-promote member to admin");
4013 } else {
4014 info!(user_id = %promote_id, group_id = %group_id, "Auto-promoted member to admin after last admin was removed");
4015 }
4016 }
4017 }
4018 }
4019
4020 /// Loads a group's role metadata, failing with [`Error::GroupNotFound`]
4021 /// when the group has neither metadata nor MLS state. Admin-gated
4022 /// operations use this so a missing group is not misreported as a
4023 /// permissions failure.
4024 fn group_metadata_or_not_found(
4025 mls_guard: &offline_protocol_mls::MlsManager,
4026 gid: &offline_protocol_mls::GroupId,
4027 group_id: &str,
4028 ) -> Result<Option<offline_protocol_mls::GroupMetadata>> {
4029 let metadata = mls_guard.get_group_metadata(gid)?;
4030 if metadata.is_none() && mls_guard.get_group_info(gid)?.is_none() {
4031 return Err(Error::GroupNotFound(group_id.to_string()));
4032 }
4033 Ok(metadata)
4034 }
4035
4036 /// Checks if a user is an admin of the given group.
4037 ///
4038 /// Falls back to `created_by` when no admin role has been stored yet
4039 /// (handles groups created before role tracking was introduced).
4040 ///
4041 /// Returns `GroupNotFound` when no MLS group exists locally, so
4042 /// admin-gated operations don't misreport a missing group as a
4043 /// permissions failure. Inbound handlers that verify senders treat
4044 /// this error the same as a deny.
4045 pub(crate) fn check_is_admin(&self, group_id: &str, user_id: &str) -> Result<bool> {
4046 let mls_guard = self.read_mls_guard()?;
4047 let gid = offline_protocol_mls::GroupId::new(group_id)?;
4048 let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
4049 drop(mls_guard);
4050
4051 if let Some(meta) = &metadata {
4052 if meta.has_any_admin() {
4053 return Ok(meta.get_role(user_id) == GroupRole::Admin);
4054 }
4055 // No admin role stored — fall back to created_by if available
4056 if let Some(creator) = &meta.created_by {
4057 return Ok(creator == user_id);
4058 }
4059 }
4060
4061 // Group exists but has no role metadata — deny by default
4062 Ok(false)
4063 }
4064
4065 /// Changes a member's role in a group (admin only).
4066 /// Broadcasts the change to all group members.
4067 pub fn set_member_role(
4068 &mut self,
4069 group_id: &str,
4070 target_user_id: &str,
4071 role: GroupRole,
4072 ) -> Result<()> {
4073 // Deliberately unvalidated — see `invite_to_group`. Demoting a member
4074 // admitted under older id rules must stay possible.
4075 let self_id = self.local_id.clone();
4076 if !self.check_is_admin(group_id, &self_id)? {
4077 return Err(Error::PermissionDenied(
4078 "Only admins can change roles".to_string(),
4079 ));
4080 }
4081
4082 // Verify target is a member (before acquiring guard for role operations)
4083 let members = self
4084 .group_mesh
4085 .members
4086 .get(group_id)
4087 .cloned()
4088 .or_else(|| self.refresh_group_members(group_id).ok())
4089 .unwrap_or_default();
4090 if !members.iter().any(|m| m == target_user_id) {
4091 return Err(Error::InvalidState(format!(
4092 "User {} is not a member of group {}",
4093 target_user_id, group_id
4094 )));
4095 }
4096
4097 // Single guard acquisition for both last-admin validation and role write
4098 {
4099 let mls_guard = self.read_mls_guard()?;
4100 let gid = offline_protocol_mls::GroupId::new(group_id)?;
4101
4102 // Prevent demoting the last admin (whether self or another admin)
4103 if role == GroupRole::Member {
4104 if let Some(metadata) = mls_guard.get_group_metadata(&gid).ok().flatten() {
4105 if metadata.get_role(target_user_id) == GroupRole::Admin {
4106 let admin_count = metadata
4107 .get_all_roles()
4108 .values()
4109 .filter(|r| **r == GroupRole::Admin)
4110 .count();
4111 if admin_count <= 1 {
4112 return Err(Error::InvalidState(
4113 "Cannot demote the last admin".to_string(),
4114 ));
4115 }
4116 }
4117 }
4118 }
4119
4120 // Store locally
4121 mls_guard.set_member_role(&gid, target_user_id, role)?;
4122 }
4123
4124 // Broadcast to all members
4125 let payload = GroupRoleChangePayload {
4126 group_id: group_id.to_string(),
4127 target_user_id: target_user_id.to_string(),
4128 new_role: role,
4129 changed_by: self_id.clone(),
4130 };
4131 let content = format!(
4132 "{}{}",
4133 internal_prefixes::GROUP_ROLE_CHANGE,
4134 serde_json::to_string(&payload)
4135 .map_err(|e| Error::Serialization(format!("Serialize role change: {}", e)))?
4136 );
4137 for member in &members {
4138 if member == &self_id {
4139 continue;
4140 }
4141 if let Err(e) =
4142 self.send_internal_message(member, content.clone(), MessagePriority::High)
4143 {
4144 warn!(member = %member, group_id = %group_id, error = %e, "Failed to send role change broadcast");
4145 }
4146 }
4147
4148 self.emit_event(Event::group_role_changed(
4149 group_id.to_string(),
4150 target_user_id.to_string(),
4151 role.to_string(),
4152 self_id,
4153 ));
4154
4155 info!(group_id = %group_id, target = %target_user_id, role = %role, "Changed member role");
4156 Ok(())
4157 }
4158
4159 /// Gets a member's role in a group.
4160 ///
4161 /// A group that exists but has no role metadata (created before role
4162 /// tracking) reports the same default an unrecorded user gets: `Member`.
4163 pub fn get_member_role(&self, group_id: &str, user_id: &str) -> Result<GroupRole> {
4164 let mls_guard = self.read_mls_guard()?;
4165 let gid = offline_protocol_mls::GroupId::new(group_id)?;
4166 let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
4167 Ok(metadata.map(|m| m.get_role(user_id)).unwrap_or_default())
4168 }
4169
4170 /// Gets all member roles in a group.
4171 ///
4172 /// A group that exists but has no role metadata (created before role
4173 /// tracking) reports no explicit roles.
4174 pub fn get_group_roles(&self, group_id: &str) -> Result<HashMap<String, GroupRole>> {
4175 let mls_guard = self.read_mls_guard()?;
4176 let gid = offline_protocol_mls::GroupId::new(group_id)?;
4177 let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
4178 Ok(metadata.map(|m| m.get_all_roles()).unwrap_or_default())
4179 }
4180
4181 /// Handles an incoming group role change notification.
4182 pub(crate) fn handle_group_role_change(&mut self, message_id: &str, sender: &str, data: &str) {
4183 let payload = match serde_json::from_str::<GroupRoleChangePayload>(data) {
4184 Ok(p) => p,
4185 Err(_) => {
4186 warn!(sender = %sender, "Failed to parse GroupRoleChange payload");
4187 return;
4188 }
4189 };
4190
4191 // Dedup
4192 let dedup_key = message_id.to_string();
4193 if self.group_mesh.message_dedup.contains_key(&dedup_key) {
4194 return;
4195 }
4196 self.group_mesh
4197 .message_dedup
4198 .insert(dedup_key, Instant::now());
4199
4200 // Verify sender is admin (use transport-authenticated sender, not payload.changed_by)
4201 match self.check_is_admin(&payload.group_id, sender) {
4202 Ok(true) => {}
4203 Ok(false) => {
4204 error!(
4205 sender = %sender,
4206 group_id = %payload.group_id,
4207 "SECURITY: Role change from non-admin, ignoring"
4208 );
4209 return;
4210 }
4211 Err(e) => {
4212 warn!(
4213 sender = %sender,
4214 group_id = %payload.group_id,
4215 error = %e,
4216 "Failed to verify admin status for role change"
4217 );
4218 return;
4219 }
4220 }
4221
4222 // Store role locally
4223 let Ok(gid) = offline_protocol_mls::GroupId::new(&payload.group_id) else {
4224 warn!(group_id = %payload.group_id, "Dropping role change with invalid group id");
4225 return;
4226 };
4227 if let Ok(mls_guard) = self.read_mls_guard() {
4228 if let Err(e) =
4229 mls_guard.set_member_role(&gid, &payload.target_user_id, payload.new_role)
4230 {
4231 warn!(
4232 target = %payload.target_user_id,
4233 error = %e,
4234 "Failed to store role from incoming role change"
4235 );
4236 }
4237 }
4238
4239 // Use transport-authenticated sender as changed_by, not the untrusted payload field
4240 self.emit_event(Event::group_role_changed(
4241 payload.group_id,
4242 payload.target_user_id,
4243 payload.new_role.to_string(),
4244 sender.to_string(),
4245 ));
4246 }
4247
4248 // ========================================================================
4249 // GROUP RENAME
4250 // ========================================================================
4251
4252 /// Renames a group (admin only).
4253 /// Updates the local group name and broadcasts the change to all members.
4254 pub fn rename_group(&mut self, group_id: &str, new_name: &str) -> Result<()> {
4255 let trimmed = new_name.trim();
4256 if trimmed.is_empty() {
4257 return Err(Error::InvalidArgument(
4258 "Group name cannot be empty".to_string(),
4259 ));
4260 }
4261 let self_id = self.local_id.clone();
4262 if !self.check_is_admin(group_id, &self_id)? {
4263 return Err(Error::PermissionDenied(
4264 "Only admins can rename groups".to_string(),
4265 ));
4266 }
4267
4268 // Load old name before updating
4269 let old_name = {
4270 let mls_guard = self.read_mls_guard()?;
4271 let gid = offline_protocol_mls::GroupId::new(group_id)?;
4272 mls_guard
4273 .get_group_metadata(&gid)?
4274 .and_then(|m| m.name.clone())
4275 };
4276
4277 // Update locally
4278 {
4279 let mls_guard = self.read_mls_guard()?;
4280 let gid = offline_protocol_mls::GroupId::new(group_id)?;
4281 mls_guard.set_group_name(&gid, new_name)?;
4282 }
4283
4284 // Broadcast to all members
4285 let members = self
4286 .group_mesh
4287 .members
4288 .get(group_id)
4289 .cloned()
4290 .or_else(|| self.refresh_group_members(group_id).ok())
4291 .unwrap_or_default();
4292
4293 let payload = GroupRenamePayload {
4294 group_id: group_id.to_string(),
4295 new_name: new_name.to_string(),
4296 renamed_by: self_id.clone(),
4297 };
4298 let content = format!(
4299 "{}{}",
4300 internal_prefixes::GROUP_RENAME,
4301 serde_json::to_string(&payload)
4302 .map_err(|e| Error::Serialization(format!("Serialize group rename: {}", e)))?
4303 );
4304 for member in &members {
4305 if member == &self_id {
4306 continue;
4307 }
4308 if let Err(e) =
4309 self.send_internal_message(member, content.clone(), MessagePriority::High)
4310 {
4311 warn!(member = %member, group_id = %group_id, error = %e, "Failed to send group rename broadcast");
4312 }
4313 }
4314
4315 self.emit_event(Event::group_renamed(
4316 group_id.to_string(),
4317 new_name.to_string(),
4318 old_name,
4319 self_id,
4320 ));
4321
4322 // Update relay registration with new name
4323 let _ = self.try_relay_register_group(group_id, Some(new_name), &members);
4324
4325 info!(group_id = %group_id, new_name = %new_name, "Renamed group");
4326 Ok(())
4327 }
4328
4329 /// Handles an incoming group rename notification.
4330 pub(crate) fn handle_group_rename(&mut self, message_id: &str, sender: &str, data: &str) {
4331 let payload = match serde_json::from_str::<GroupRenamePayload>(data) {
4332 Ok(p) => p,
4333 Err(_) => {
4334 warn!(sender = %sender, "Failed to parse GroupRename payload");
4335 return;
4336 }
4337 };
4338
4339 // Dedup
4340 let dedup_key = message_id.to_string();
4341 if self.group_mesh.message_dedup.contains_key(&dedup_key) {
4342 return;
4343 }
4344 self.group_mesh
4345 .message_dedup
4346 .insert(dedup_key, Instant::now());
4347
4348 // Verify sender is admin (use transport-authenticated sender)
4349 match self.check_is_admin(&payload.group_id, sender) {
4350 Ok(true) => {}
4351 Ok(false) => {
4352 error!(
4353 sender = %sender,
4354 group_id = %payload.group_id,
4355 "SECURITY: Group rename from non-admin, ignoring"
4356 );
4357 return;
4358 }
4359 Err(e) => {
4360 warn!(
4361 sender = %sender,
4362 group_id = %payload.group_id,
4363 error = %e,
4364 "Failed to verify admin status for group rename"
4365 );
4366 return;
4367 }
4368 }
4369
4370 let Ok(gid) = offline_protocol_mls::GroupId::new(&payload.group_id) else {
4371 warn!(group_id = %payload.group_id, "Dropping group rename with invalid group id");
4372 return;
4373 };
4374
4375 // Load old name before updating
4376 let old_name = if let Ok(mls_guard) = self.read_mls_guard() {
4377 mls_guard
4378 .get_group_metadata(&gid)
4379 .ok()
4380 .flatten()
4381 .and_then(|m| m.name.clone())
4382 } else {
4383 None
4384 };
4385
4386 // Store new name locally
4387 if let Ok(mls_guard) = self.read_mls_guard() {
4388 if let Err(e) = mls_guard.set_group_name(&gid, &payload.new_name) {
4389 warn!(
4390 group_id = %payload.group_id,
4391 error = %e,
4392 "Failed to store new group name from incoming rename"
4393 );
4394 }
4395 }
4396
4397 // Use transport-authenticated sender as renamed_by
4398 self.emit_event(Event::group_renamed(
4399 payload.group_id,
4400 payload.new_name,
4401 old_name,
4402 sender.to_string(),
4403 ));
4404 }
4405
4406 /// Cleans up expired group message dedup entries and enforces size cap.
4407 pub(crate) fn cleanup_group_message_dedup(&mut self) {
4408 // Ages are compared with `saturating_duration_since` rather than a
4409 // precomputed `now - TTL` cutoff: on platforms where the monotonic
4410 // clock starts at boot, that subtraction underflows (and panics)
4411 // when the process is younger than the TTL.
4412 let now = Instant::now();
4413 let dedup_ttl = StdDuration::from_secs(GROUP_MESSAGE_DEDUP_TTL_SECS);
4414 self.group_mesh
4415 .message_dedup
4416 .retain(|_, seen_at| now.saturating_duration_since(*seen_at) < dedup_ttl);
4417 // If still over cap after TTL cleanup, drop oldest entries using O(N) selection
4418 let len = self.group_mesh.message_dedup.len();
4419 if len > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
4420 let mut entries: Vec<_> = self.group_mesh.message_dedup.drain().collect();
4421 // Partition so the newest MAX entries are in [..MAX]
4422 entries.select_nth_unstable_by_key(MAX_GROUP_MESSAGE_DEDUP_ENTRIES, |(_, ts)| {
4423 std::cmp::Reverse(*ts)
4424 });
4425 entries.truncate(MAX_GROUP_MESSAGE_DEDUP_ENTRIES);
4426 self.group_mesh.message_dedup = entries.into_iter().collect();
4427 }
4428
4429 // Expire stale pending commits, tracking groups where retried commits
4430 // expired — these are strong epoch fork signals (same as drain_pending_commits).
4431 // Expired IDs are collected for replay-protection release below: the
4432 // group-level dedup entry is at least as old as the buffered copy and
4433 // was already dropped by the retain above (same `now`, same TTL), but
4434 // the transport-level deduplicator has a longer retention window and
4435 // must be released explicitly or a redelivery is swallowed at the
4436 // receive loop.
4437 let commit_ttl = StdDuration::from_secs(PENDING_COMMIT_TTL_SECS);
4438 let mut fork_candidates: Vec<String> = Vec::new();
4439 let mut expired_ids: Vec<String> = Vec::new();
4440 self.group_mesh.pending_commits.retain(|group_id, commits| {
4441 let mut retried_expired = false;
4442 commits.retain(|c| {
4443 let alive = now.saturating_duration_since(c.buffered_at) < commit_ttl;
4444 if !alive {
4445 if c.retry_count > 0 {
4446 retried_expired = true;
4447 }
4448 expired_ids.push(c.message_id.clone());
4449 }
4450 alive
4451 });
4452 if retried_expired {
4453 fork_candidates.push(group_id.clone());
4454 }
4455 !commits.is_empty()
4456 });
4457 // Flag forks for groups where retried commits expired during periodic
4458 // cleanup — this covers the case where drain_pending_commits is never
4459 // called because no new commits succeed for the group.
4460 for group_id in fork_candidates {
4461 if !self.group_mesh.epoch_forks.contains_key(&group_id) {
4462 self.flag_potential_epoch_fork(&group_id);
4463 }
4464 }
4465
4466 // Expire stale pending group messages — covers groups where no
4467 // Welcome or commit ever arrives to trigger a drain.
4468 let msg_ttl = StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS);
4469 self.group_mesh.pending_group_messages.retain(|_, msgs| {
4470 msgs.retain(|m| {
4471 let alive = now.saturating_duration_since(m.buffered_at) < msg_ttl;
4472 if !alive {
4473 expired_ids.push(m.message_id.clone());
4474 }
4475 alive
4476 });
4477 !msgs.is_empty()
4478 });
4479
4480 // Entries that expired here were never processed/delivered — release
4481 // their replay protection so a redelivery is accepted fresh.
4482 for message_id in expired_ids {
4483 self.release_replay_protection(&message_id);
4484 }
4485 }
4486
4487 // ========================================================================
4488 // RELAY OPTIMIZATION
4489 // ========================================================================
4490
4491 /// Best-effort admin hint for relay group registration: `Some` only when
4492 /// role metadata (or the creator fallback) makes the answer definite,
4493 /// `None` when it cannot be determined. Unlike [`Self::check_is_admin`],
4494 /// this must not deny by default — a wrong `false` would make the bridge
4495 /// silently skip member deltas the relay would have accepted, and the
4496 /// relay registry would never learn the membership.
4497 fn relay_registration_admin_hint(&self, group_id: &str) -> Option<bool> {
4498 let mls_guard = self.read_mls_guard().ok()?;
4499 let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
4500 let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id).ok()??;
4501 if metadata.has_any_admin() {
4502 return Some(metadata.get_role(&self.local_id) == GroupRole::Admin);
4503 }
4504 metadata
4505 .created_by
4506 .as_ref()
4507 .map(|creator| creator == &self.local_id)
4508 }
4509
4510 /// Attempts to register (or update) a group with the relay server.
4511 ///
4512 /// Sends a `__GRP_RELAY_REG__` message to the user's own ID via Internet
4513 /// transport, for a relay (or platform bridge acting as relay adapter)
4514 /// that translates the prefix into a server-side group registration.
4515 ///
4516 /// Enqueueing this frame proves nothing about relay support: a relay
4517 /// without prefix interception simply echoes a self-addressed message
4518 /// back to the sender. `relay_synced` must therefore only be set by a
4519 /// positive acknowledgment from the relay (an inbound `__GROUP_CREATED__`
4520 /// for the group), never here — otherwise `send_group_message` takes the
4521 /// O(1) broadcast path and the messages vanish into the echo.
4522 ///
4523 /// Fire-and-forget: returns `Ok(true)` if sent, `Ok(false)` if Internet
4524 /// is unavailable, `Err` on serialization failure.
4525 fn try_relay_register_group(
4526 &mut self,
4527 group_id: &str,
4528 group_name: Option<&str>,
4529 members: &[String],
4530 ) -> Result<bool> {
4531 if !self.config.group.relay_enabled || !self.is_internet_available() {
4532 return Ok(false);
4533 }
4534
4535 let payload = RelayGroupRegistrationPayload {
4536 group_id: group_id.to_string(),
4537 group_name: group_name.map(|s| s.to_string()),
4538 members: members.to_vec(),
4539 is_admin: self.relay_registration_admin_hint(group_id),
4540 };
4541 let content = format!(
4542 "{}{}",
4543 internal_prefixes::GROUP_RELAY_REGISTER,
4544 serde_json::to_string(&payload).map_err(|e| Error::Serialization(format!(
4545 "Serialize relay registration: {}",
4546 e
4547 )))?
4548 );
4549
4550 // Send to self — a prefix-aware relay (or bridge adapter) translates it.
4551 // Deliberately does NOT set `relay_synced`: Ok here only means the frame
4552 // was queued locally, and a prefix-unaware relay echoes it back instead
4553 // of registering anything. Sync is confirmed only by the relay's
4554 // `__GROUP_CREATED__` acknowledgment.
4555 //
4556 // Un-ACKed and Internet-pinned (see `send_relay_hint_message`): the
4557 // retry policy for registration is the `relay_register_pending`
4558 // tracker armed just below, not the message ACK ladder — which could
4559 // never settle this frame and would re-send it ~10 times.
4560 match self.send_relay_hint_message(content, MessagePriority::Medium) {
4561 Ok(_) => {
4562 // Arm the ack correlation: only a `__GROUP_CREATED__` that
4563 // answers an outstanding registration may set `relay_synced`.
4564 // Attempts accumulate across re-sends on this connection so
4565 // the expiry processor can give up on a relay that never
4566 // answers; the counter resets whenever the entry is removed
4567 // (ack, error, leave, internet drop).
4568 let entry = self
4569 .group_mesh
4570 .relay_register_pending
4571 .entry(group_id.to_string())
4572 .or_insert(RelayRegisterPending {
4573 armed_at: Utc::now(),
4574 attempts: 0,
4575 });
4576 entry.armed_at = Utc::now();
4577 entry.attempts = entry.attempts.saturating_add(1);
4578 debug!(group_id = %group_id, attempts = entry.attempts, "Sent group registration to relay server");
4579 Ok(true)
4580 }
4581 Err(e) => {
4582 debug!(group_id = %group_id, error = %e, "Failed to register group with relay");
4583 Ok(false)
4584 }
4585 }
4586 }
4587
4588 /// Applies the sealed `__RICH_V1__` restore to a decrypted group
4589 /// message plaintext, returning the event-ready fields: content, media
4590 /// metadata, content-type hint, and forward attribution.
4591 ///
4592 /// Parsing is never capability-gated (whatever a peer chose to seal, we
4593 /// try to read). When the body parses, the sealed attribution is
4594 /// authoritative wholesale — absence included: a sealing sender always
4595 /// seals its `forward_info` when one exists, so the hop-visible payload
4596 /// copy is consulted only for unsealed messages. Falling back to it on
4597 /// a sealed body would let a relay attach fabricated attribution to a
4598 /// rich message that carried none (mirrors the DM restore, which
4599 /// overwrites the outer fields wholesale). A body that fails to parse
4600 /// surfaces as raw text with nothing restored: never drop an
4601 /// authenticated message.
4602 fn restore_group_rich(
4603 text: String,
4604 payload_forward_info: Option<ForwardInfo>,
4605 sender: &str,
4606 ) -> (
4607 String,
4608 Option<MediaMetadata>,
4609 Option<String>,
4610 Option<crate::events::ForwardInfoEvent>,
4611 ) {
4612 if let Some(rich) = RichPayloadV1::parse_sealed(&text, sender) {
4613 return (
4614 rich.text,
4615 rich.media_metadata,
4616 rich.content_type.map(|ct| ct.to_string()),
4617 rich.forward_info
4618 .as_ref()
4619 .map(crate::events::ForwardInfoEvent::from),
4620 );
4621 }
4622 (
4623 text,
4624 None,
4625 None,
4626 payload_forward_info
4627 .as_ref()
4628 .map(crate::events::ForwardInfoEvent::from),
4629 )
4630 }
4631
4632 /// Attempts to send a group message via relay broadcast.
4633 ///
4634 /// Sends a single `__GRP_RELAY_BCAST__` message to the user's own ID.
4635 /// The bridge translator (not the relay — see
4636 /// [`RelayGroupBroadcastPayload`]) turns it into a relay-native
4637 /// `SendGroupMessage`; the relay fans out to registered members, whose
4638 /// bridges inject the delivery as `__GROUP_MSG__` frames.
4639 ///
4640 /// Callers must only take this path when all four of
4641 /// `GroupConfig::relay_broadcast_enabled`, `relay_synced` (the relay
4642 /// acknowledged the group; otherwise a prefix-unaware relay swallows the
4643 /// frame), the relay's [`RELAY_CAP_GROUP_DELIVERY_V3`] capability, and
4644 /// `is_internet_available()` hold.
4645 ///
4646 /// Mints the logical message id the whole group will know this message
4647 /// by, carries it in the payload (the bridge stamps it onto the relay
4648 /// frame), and arms a [`RelayBroadcastPending`] entry keyed by it: the
4649 /// relay's settled `GroupMessageSent` report settles the entry and
4650 /// re-sends per-member copies to members the relay did not reach; a
4651 /// lost report re-sends the broadcast (bounded) and finally downgrades
4652 /// to full per-member fan-out. Returns that logical id on success — it
4653 /// is what `group_message_sent` reports to the app.
4654 ///
4655 /// On error (relay unreachable) the caller falls back to per-member
4656 /// fan-out. The frame goes out via [`Self::send_relay_hint_message`], so
4657 /// it is a pinned, un-ACKed one-shot: a failure surfaces here
4658 /// immediately instead of being deferred into a retry ladder that could
4659 /// never settle — the *report* tracker above is the bounded retry.
4660 fn try_relay_broadcast(
4661 &mut self,
4662 group_id: &str,
4663 ciphertext_b64: &str,
4664 epoch: u64,
4665 reply_to: Option<&str>,
4666 forward_info: Option<ForwardInfo>,
4667 priority: MessagePriority,
4668 ) -> Result<MessageId> {
4669 let logical_id = MessageId::new();
4670 let payload = RelayGroupBroadcastPayload {
4671 group_id: group_id.to_string(),
4672 ciphertext: ciphertext_b64.to_string(),
4673 epoch,
4674 reply_to: reply_to.map(|s| s.to_string()),
4675 forward_info: forward_info.clone(),
4676 message_id: logical_id.as_str(),
4677 };
4678 self.send_relay_broadcast_frame(&payload)?;
4679 // Keep the tracker bounded (see `MAX_RELAY_BROADCAST_PENDING`).
4680 // Terminates: every iteration removes exactly one entry.
4681 while self.group_mesh.relay_broadcast_pending.len() >= MAX_RELAY_BROADCAST_PENDING {
4682 let Some(oldest) = self
4683 .group_mesh
4684 .relay_broadcast_pending
4685 .iter()
4686 .min_by_key(|(_, pending)| pending.armed_at)
4687 .map(|(id, _)| id.clone())
4688 else {
4689 break;
4690 };
4691 let Some(stale) = self.group_mesh.relay_broadcast_pending.remove(&oldest) else {
4692 break;
4693 };
4694 warn!(
4695 msg_id = %oldest,
4696 "Relay broadcast tracker full, downgrading oldest to per-member fan-out"
4697 );
4698 self.downgrade_broadcast_to_per_member(&oldest, stale);
4699 }
4700 self.group_mesh.relay_broadcast_pending.insert(
4701 logical_id.as_str(),
4702 RelayBroadcastPending {
4703 group_id: group_id.to_string(),
4704 ciphertext_b64: ciphertext_b64.to_string(),
4705 epoch,
4706 reply_to: reply_to.map(|s| s.to_string()),
4707 forward_info,
4708 priority,
4709 armed_at: Utc::now(),
4710 attempts: 1,
4711 },
4712 );
4713 debug!(
4714 group_id = %group_id,
4715 msg_id = %logical_id,
4716 "Sent group message via relay broadcast, awaiting delivery report"
4717 );
4718 Ok(logical_id)
4719 }
4720
4721 /// Serializes and sends one `__GRP_RELAY_BCAST__` hint frame. Shared by
4722 /// the initial broadcast and the lost-report re-send, which must reuse
4723 /// the same payload (same logical `message_id`) so the relay's push
4724 /// dedup and every receiver's dedup hold across attempts.
4725 fn send_relay_broadcast_frame(&mut self, payload: &RelayGroupBroadcastPayload) -> Result<()> {
4726 let content = format!(
4727 "{}{}",
4728 internal_prefixes::GROUP_RELAY_BROADCAST,
4729 serde_json::to_string(payload)
4730 .map_err(|e| Error::Serialization(format!("Serialize relay broadcast: {}", e)))?
4731 );
4732 self.send_relay_hint_message(content, MessagePriority::Medium)?;
4733 Ok(())
4734 }
4735
4736 /// Fans a group message's MLS ciphertext out as one `__GRP_MLS_MSG__`
4737 /// frame per member (skipping self), each through
4738 /// `send_internal_message` and therefore the whole per-member delivery
4739 /// ladder. `logical_id` is `Some` when the copies re-issue a relay
4740 /// broadcast — receivers then dedup/emit under that id (see
4741 /// [`GroupMlsMessagePayload::message_id`]).
4742 ///
4743 /// Returns `(frame ids, succeeded members, failed members)`; errors only
4744 /// on payload serialization.
4745 #[allow(clippy::too_many_arguments)]
4746 fn fanout_group_frames_per_member(
4747 &mut self,
4748 group_id: &str,
4749 members: &[String],
4750 logical_id: Option<&str>,
4751 ciphertext_b64: &str,
4752 epoch: u64,
4753 reply_to: Option<&str>,
4754 forward_info: Option<ForwardInfo>,
4755 priority: MessagePriority,
4756 ) -> Result<(Vec<MessageId>, Vec<String>, Vec<String>)> {
4757 let msg_payload = GroupMlsMessagePayload {
4758 group_id: group_id.to_string(),
4759 ciphertext: ciphertext_b64.to_string(),
4760 epoch,
4761 reply_to: reply_to.map(|s| s.to_string()),
4762 forward_info,
4763 message_id: logical_id.map(|s| s.to_string()),
4764 };
4765 let base_content = format!(
4766 "{}{}",
4767 internal_prefixes::GROUP_MLS_MSG,
4768 serde_json::to_string(&msg_payload)
4769 .map_err(|e| Error::Serialization(format!("Serialize group message: {}", e)))?
4770 );
4771
4772 let self_id = self.local_id.clone();
4773 let mut message_ids = Vec::new();
4774 let mut succeeded_members = Vec::new();
4775 let mut failed_members = Vec::new();
4776 for member in members {
4777 if member == &self_id {
4778 continue;
4779 }
4780 match self.send_internal_message(member, base_content.clone(), priority) {
4781 Ok(mid) => {
4782 message_ids.push(mid);
4783 succeeded_members.push(member.clone());
4784 }
4785 Err(e) => {
4786 warn!(
4787 group_id = %group_id,
4788 member = %member,
4789 error = %e,
4790 "Failed to send group message to member"
4791 );
4792 failed_members.push(member.clone());
4793 }
4794 }
4795 }
4796 Ok((message_ids, succeeded_members, failed_members))
4797 }
4798
4799 /// Replaces the set of capability tokens advertised by the connected
4800 /// relay.
4801 ///
4802 /// Called by the platform internet bridge with the `capabilities` list
4803 /// from the relay's `Authenticated` answer, **before** it reports the
4804 /// transport up via `internet_status_changed(true)` — the false→true
4805 /// flush must already see the capabilities, or the first sends after a
4806 /// reconnect would take the capability-less path. Wholesale replace:
4807 /// each authentication describes the relay actually connected now.
4808 /// Relays that predate the advertisement yield an empty list, which
4809 /// reads as "no v2 contract" and keeps group sends on per-member
4810 /// fan-out.
4811 ///
4812 /// The list is wire-supplied: it is bounded to
4813 /// `MAX_RELAY_CAPABILITIES` tokens of at most
4814 /// `MAX_RELAY_CAPABILITY_TOKEN_BYTES` bytes each; excess entries and
4815 /// oversized tokens are dropped.
4816 pub fn set_relay_capabilities(&mut self, capabilities: Vec<String>) {
4817 let caps: HashSet<String> = capabilities
4818 .into_iter()
4819 .filter(|c| !c.is_empty() && c.len() <= MAX_RELAY_CAPABILITY_TOKEN_BYTES)
4820 .take(MAX_RELAY_CAPABILITIES)
4821 .collect();
4822 debug!(count = caps.len(), "Relay capabilities updated");
4823 self.group_mesh.relay_capabilities = caps;
4824 }
4825
4826 /// Consumes a relay `GroupMessageSent` settled delivery report
4827 /// (forwarded verbatim by the platform bridge) for a broadcast this
4828 /// sender has in flight.
4829 ///
4830 /// Settles the `RelayBroadcastPending` entry correlated by
4831 /// `message_id`, then re-sends a per-member `__GRP_MLS_MSG__` copy —
4832 /// through the ordinary outbox/ACK/park ladder — to every MLS roster
4833 /// member the relay did not reach over a socket or a push: the members
4834 /// it names in `missed`, plus every roster member it does not name at
4835 /// all (the relay's registered roster can be a strict subset of the MLS
4836 /// roster, so "not mentioned" must read as "not reached", never as
4837 /// "covered"). Duplicates are safe: the copies carry the logical id, so
4838 /// a member that did receive the relay's copy absorbs them by dedup.
4839 ///
4840 /// Reports that correlate with nothing (already settled by a previous
4841 /// attempt's report, or downgraded meanwhile) are ignored. A report
4842 /// whose `group_id` does not match the armed entry is ignored too — the
4843 /// entry stays armed and the timeout path recovers.
4844 ///
4845 /// Emits [`Event::group_message_delivery_report`] with the relay's
4846 /// `delivered`/`pushed` lists and the members re-issued per-member.
4847 pub fn handle_relay_group_delivery_report(&mut self, report_json: &str) -> Result<()> {
4848 let report: RelayGroupDeliveryReport = serde_json::from_str(report_json)
4849 .map_err(|e| Error::Serialization(format!("Parse group delivery report: {}", e)))?;
4850
4851 let Some(entry) = self
4852 .group_mesh
4853 .relay_broadcast_pending
4854 .get(&report.message_id)
4855 else {
4856 debug!(
4857 group_id = %report.group_id,
4858 msg_id = %report.message_id,
4859 "Group delivery report correlates with no in-flight broadcast, ignoring"
4860 );
4861 return Ok(());
4862 };
4863 if entry.group_id != report.group_id {
4864 warn!(
4865 report_group = %report.group_id,
4866 armed_group = %entry.group_id,
4867 msg_id = %report.message_id,
4868 "Group delivery report names a different group than the armed broadcast, ignoring"
4869 );
4870 return Ok(());
4871 }
4872 let entry = self
4873 .group_mesh
4874 .relay_broadcast_pending
4875 .remove(&report.message_id)
4876 .expect("checked above");
4877
4878 for miss in &report.missed {
4879 debug!(
4880 group_id = %report.group_id,
4881 member = %miss.username,
4882 reason = %miss.reason,
4883 "Relay reports group member missed"
4884 );
4885 }
4886
4887 // Authoritative roster from MLS, cache as fallback (idiom shared
4888 // with the relay registration paths).
4889 let members = self
4890 .refresh_group_members(&entry.group_id)
4891 .ok()
4892 .or_else(|| self.group_mesh.members.get(&entry.group_id).cloned())
4893 .unwrap_or_default();
4894 let reached: HashSet<&str> = report
4895 .delivered
4896 .iter()
4897 .chain(report.pushed.iter())
4898 .map(String::as_str)
4899 .collect();
4900 let self_id = self.local_id.clone();
4901 let unreached: Vec<String> = members
4902 .iter()
4903 .filter(|m| m.as_str() != self_id && !reached.contains(m.as_str()))
4904 .cloned()
4905 .collect();
4906
4907 let mut reissued = Vec::new();
4908 if !unreached.is_empty() {
4909 info!(
4910 group_id = %entry.group_id,
4911 msg_id = %report.message_id,
4912 count = unreached.len(),
4913 "Re-sending group message per-member to members the relay did not reach"
4914 );
4915 // Never `?` here: the tracker entry is already removed, so
4916 // propagating would strand the whole contract — no re-issue, no
4917 // event, and no timeout left to recover it. Report what happened
4918 // instead and let the app see an empty `missed_reissued`.
4919 match self.fanout_group_frames_per_member(
4920 &entry.group_id,
4921 &unreached,
4922 Some(&report.message_id),
4923 &entry.ciphertext_b64,
4924 entry.epoch,
4925 entry.reply_to.as_deref(),
4926 entry.forward_info.clone(),
4927 entry.priority,
4928 ) {
4929 Ok((_ids, succeeded, _failed)) => reissued = succeeded,
4930 Err(e) => {
4931 error!(
4932 group_id = %entry.group_id,
4933 msg_id = %report.message_id,
4934 error = %e,
4935 "Failed to re-issue per-member copies for unreached members"
4936 );
4937 }
4938 }
4939 }
4940
4941 self.emit_event(Event::group_message_delivery_report(
4942 entry.group_id,
4943 report.message_id,
4944 report.delivered,
4945 report.pushed,
4946 reissued,
4947 ));
4948 Ok(())
4949 }
4950
4951 /// Returns `true` if the Internet transport is currently available.
4952 pub(crate) fn is_internet_available(&self) -> bool {
4953 self.transport_manager
4954 .get_available_transports()
4955 .contains_key(&TransportType::Internet)
4956 }
4957
4958 /// Reads the current MLS epoch for a group, if available.
4959 fn read_current_epoch(&self, group_id: &str) -> Option<u64> {
4960 let guard = self.read_mls_guard().ok()?;
4961 let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
4962 let info = guard.get_group_info(&gid).ok()??;
4963 Some(info.epoch)
4964 }
4965
4966 // ========================================================================
4967 // EPOCH FORK RESOLUTION
4968 // ========================================================================
4969
4970 /// Checks for pending epoch forks and attempts resolution.
4971 ///
4972 /// Called from the `process()` tick. After the resolution delay, the
4973 /// deterministic leader (lex-first member) issues an `update_keys` commit
4974 /// to re-establish a canonical epoch. Members on the same branch will
4975 /// process the commit normally. Members on a different branch will fail
4976 /// to process it, at which point they need a re-invite (the leader
4977 /// emits `GroupEpochForkResolved` and the application layer can trigger
4978 /// re-invites for unreachable members).
4979 ///
4980 /// ## Stuck fork recovery
4981 ///
4982 /// If `update_keys` fails (e.g., the leader's MLS state is corrupted),
4983 /// `resolution_attempted` is set to `true` and no further automatic
4984 /// resolution is tried. The fork entry remains until the 5-minute stale
4985 /// cleanup removes it, at which point the fork may be re-detected if
4986 /// the underlying issue persists (creating a detect → fail → stale-cleanup
4987 /// → re-detect cycle). This is intentional: a persistent `update_keys`
4988 /// failure indicates the group likely needs manual intervention (e.g.,
4989 /// re-creating the group or re-inviting all members). The application
4990 /// layer should monitor `GroupEpochForkDetected` events and escalate if
4991 /// the same group is repeatedly detected.
4992 pub(crate) fn check_epoch_forks(&mut self) {
4993 let delay = StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS);
4994 let self_id = self.local_id.clone();
4995
4996 // Collect forks ready for resolution
4997 let ready: Vec<EpochForkState> = self
4998 .group_mesh
4999 .epoch_forks
5000 .values()
5001 .filter(|f| !f.resolution_attempted && f.detected_at.elapsed() > delay)
5002 .cloned()
5003 .collect();
5004
5005 for fork in ready {
5006 // Re-check: if the epoch has advanced since detection, the fork
5007 // was a delayed commit, not a real fork — cancel it.
5008 if let Some(detected_epoch) = fork.local_epoch {
5009 let current_epoch = self.read_current_epoch(&fork.group_id);
5010 if let Some(current) = current_epoch {
5011 if current > detected_epoch {
5012 debug!(
5013 group_id = %fork.group_id,
5014 detected_epoch,
5015 current_epoch = current,
5016 "Epoch fork auto-cancelled: epoch advanced since detection"
5017 );
5018 self.group_mesh.epoch_forks.remove(&fork.group_id);
5019 continue;
5020 }
5021 }
5022 }
5023
5024 // Check if we're the leader for this group
5025 let members = self
5026 .refresh_group_members(&fork.group_id)
5027 .ok()
5028 .or_else(|| self.group_mesh.members.get(&fork.group_id).cloned())
5029 .unwrap_or_default();
5030
5031 let mut sorted = members.clone();
5032 sorted.sort();
5033 let am_leader = sorted.first().map(|f| f == &self_id).unwrap_or(false);
5034
5035 // Mark as attempted regardless — only the leader acts, but all
5036 // members should stop re-checking.
5037 if let Some(state) = self.group_mesh.epoch_forks.get_mut(&fork.group_id) {
5038 state.resolution_attempted = true;
5039 }
5040
5041 if !am_leader {
5042 continue;
5043 }
5044
5045 info!(
5046 group_id = %fork.group_id,
5047 local_epoch = ?fork.local_epoch,
5048 "Attempting epoch fork resolution as elected leader"
5049 );
5050
5051 // Issue a key update commit to establish a canonical next epoch.
5052 // Members on our branch will process this normally and advance.
5053 // Members on a forked branch will fail — the app layer should
5054 // handle re-inviting them.
5055 //
5056 // NOTE: We use `read_mls_guard` (RwLock read lock) even though
5057 // `update_keys` mutates MLS state. This is correct because
5058 // `MlsManager` uses interior mutability (internal Mutex on the
5059 // group store), so a read guard is sufficient for all operations.
5060 //
5061 // Acquire MLS lock, perform key update, and fully release the
5062 // guard before touching any other &mut self state.
5063 let update_result = {
5064 if let Ok(guard) = self.read_mls_guard() {
5065 let r = offline_protocol_mls::GroupId::new(&fork.group_id)
5066 .and_then(|gid| guard.update_keys(&gid));
5067 drop(guard);
5068 Some(r)
5069 } else {
5070 None
5071 }
5072 };
5073 // Guard is fully dropped here — safe to use &mut self.
5074 let update_result = match update_result {
5075 Some(r) => r,
5076 None => {
5077 warn!(group_id = %fork.group_id, "MLS unavailable during epoch fork resolution");
5078 if let Some(state) = self.group_mesh.epoch_forks.get_mut(&fork.group_id) {
5079 state.resolution_attempted = false;
5080 }
5081 continue;
5082 }
5083 };
5084
5085 match update_result {
5086 Ok(commit_msg) => {
5087 // Distribute the key-update commit to all members
5088 let commit_payload = GroupMlsCommitPayload {
5089 group_id: fork.group_id.clone(),
5090 commit_type: GroupCommitType::KeyUpdate,
5091 ciphertext: base64_encode(&commit_msg.ciphertext),
5092 epoch: commit_msg.epoch,
5093 affected_member: None,
5094 role: None,
5095 affected_member_rich: None,
5096 };
5097 let commit_content = match serde_json::to_string(&commit_payload) {
5098 Ok(json) => format!("{}{}", internal_prefixes::GROUP_MLS_COMMIT, json),
5099 Err(e) => {
5100 warn!(
5101 group_id = %fork.group_id,
5102 error = %e,
5103 "Failed to serialize fork resolution commit"
5104 );
5105 continue;
5106 }
5107 };
5108 let mut failed_members = Vec::new();
5109 for member in &members {
5110 if member == &self_id {
5111 continue;
5112 }
5113 if let Err(e) = self.send_internal_message(
5114 member,
5115 commit_content.clone(),
5116 MessagePriority::High,
5117 ) {
5118 warn!(
5119 group_id = %fork.group_id,
5120 member = %member,
5121 error = %e,
5122 "Failed to send fork resolution commit to member"
5123 );
5124 failed_members.push(member.clone());
5125 }
5126 }
5127
5128 info!(
5129 group_id = %fork.group_id,
5130 new_epoch = commit_msg.epoch,
5131 failed_count = failed_members.len(),
5132 "Epoch fork resolution commit distributed"
5133 );
5134
5135 self.emit_event(Event::group_epoch_fork_resolved(
5136 fork.group_id.clone(),
5137 commit_msg.epoch,
5138 failed_members,
5139 ));
5140
5141 // Clean up the fork state
5142 self.group_mesh.epoch_forks.remove(&fork.group_id);
5143 }
5144 Err(e) => {
5145 warn!(
5146 group_id = %fork.group_id,
5147 error = %e,
5148 "Failed to issue key update for epoch fork resolution"
5149 );
5150 // Leave resolution_attempted = true — a persistent failure
5151 // means the group may need manual intervention.
5152 }
5153 }
5154 }
5155
5156 // Clean up stale fork entries (older than 5 minutes)
5157 let stale_threshold = StdDuration::from_secs(300);
5158 self.group_mesh
5159 .epoch_forks
5160 .retain(|_, f| f.detected_at.elapsed() < stale_threshold);
5161 }
5162
5163 // ========================================================================
5164 // LEAVE ELECTION FALLBACK
5165 // ========================================================================
5166
5167 /// Checks for timed-out leave elections and re-elects the next eligible
5168 /// member to issue the MLS remove-commit.
5169 ///
5170 /// Called from the `process()` tick. If the originally elected remover
5171 /// hasn't issued a commit within `LEAVE_ELECTION_TIMEOUT_SECS`, we check
5172 /// whether the leaving member is still in the group (via MLS state). If
5173 /// so, the next eligible member in the sorted remaining list takes over.
5174 pub(crate) fn check_leave_election_timeouts(&mut self) {
5175 let timeout = StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS);
5176 let max_lifetime = StdDuration::from_secs(LEAVE_ELECTION_MAX_LIFETIME_SECS);
5177 let attempt_cooldown = StdDuration::from_secs(LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS);
5178 let self_id = self.local_id.clone();
5179
5180 // Collect timed-out elections (keys + values) for processing.
5181 let timed_out: Vec<((String, String), PendingLeaveElection)> = self
5182 .group_mesh
5183 .pending_leave_elections
5184 .iter()
5185 .filter(|(_, e)| e.received_at.elapsed() > timeout)
5186 .map(|(k, v)| (k.clone(), v.clone()))
5187 .collect();
5188
5189 for (election_key, election) in timed_out {
5190 // Circuit breaker: abandon the election after max lifetime to
5191 // prevent infinite per-tick retry loops when all candidates fail.
5192 if election.received_at.elapsed() > max_lifetime {
5193 warn!(
5194 group_id = %election.group_id,
5195 leaving_member = %election.leaving_member,
5196 "Leave election exceeded max lifetime, abandoning — member may need manual removal"
5197 );
5198 self.group_mesh
5199 .pending_leave_elections
5200 .remove(&election_key);
5201 continue;
5202 }
5203
5204 // Refresh membership from MLS (authoritative) to avoid using
5205 // stale election-time lists where members may have since left.
5206 let current_members = self
5207 .refresh_group_members(&election.group_id)
5208 .ok()
5209 .or_else(|| self.group_mesh.members.get(&election.group_id).cloned())
5210 .unwrap_or_default();
5211
5212 let still_member = current_members
5213 .iter()
5214 .any(|id| id == &election.leaving_member);
5215
5216 if !still_member {
5217 // Already removed — clean up
5218 self.group_mesh
5219 .pending_leave_elections
5220 .remove(&election_key);
5221 continue;
5222 }
5223
5224 // Use current membership for candidate selection, not the stale
5225 // election-time list — members may have left since the election.
5226 let mut remaining: Vec<String> = current_members
5227 .iter()
5228 .filter(|m| m.as_str() != election.leaving_member)
5229 .cloned()
5230 .collect();
5231 remaining.sort();
5232
5233 // Re-elect: walk the sorted remaining members. For each timeout
5234 // interval that has passed, advance one position in the list.
5235 // This staggers re-election so members don't all fire at once.
5236 // Interval 1 = first fallback (idx 0), interval 2 = second (idx 1), etc.
5237 // The original elected member (lex-first) already had their shot
5238 // during handle_group_mls_leave; this path only fires after timeout.
5239 let elapsed_intervals =
5240 (election.received_at.elapsed().as_secs() / LEAVE_ELECTION_TIMEOUT_SECS) as usize;
5241 let candidate_idx = elapsed_intervals.min(remaining.len().saturating_sub(1));
5242
5243 if remaining
5244 .get(candidate_idx)
5245 .map(|c| c == &self_id)
5246 .unwrap_or(false)
5247 {
5248 // Rate-limit: skip if we already attempted within the cooldown window.
5249 if let Some(last) = election.last_attempt_at {
5250 if last.elapsed() < attempt_cooldown {
5251 continue;
5252 }
5253 }
5254
5255 info!(
5256 group_id = %election.group_id,
5257 leaving_member = %election.leaving_member,
5258 attempt = candidate_idx + 1,
5259 "Re-elected to issue MLS remove-commit (prior elected member timed out)"
5260 );
5261
5262 // Record the attempt timestamp before trying, so the cooldown
5263 // applies even on failure.
5264 if let Some(state) = self
5265 .group_mesh
5266 .pending_leave_elections
5267 .get_mut(&election_key)
5268 {
5269 state.last_attempt_at = Some(Instant::now());
5270 }
5271
5272 if let Err(e) = self.remove_from_group(&election.group_id, &election.leaving_member)
5273 {
5274 warn!(
5275 group_id = %election.group_id,
5276 leaving_member = %election.leaving_member,
5277 error = %e,
5278 "Failed to issue MLS remove-commit during re-election"
5279 );
5280 // Don't remove from pending — cooldown will prevent spam,
5281 // next interval will advance to the next candidate.
5282 } else {
5283 self.group_mesh
5284 .pending_leave_elections
5285 .remove(&election_key);
5286 }
5287 }
5288 // If we're not the candidate at this interval, leave the election
5289 // in place — either the current candidate will handle it, or the
5290 // next interval will advance to us.
5291 }
5292 }
5293
5294 // ========================================================================
5295 // TRANSPORT-RESILIENT SYNC
5296 // ========================================================================
5297
5298 /// Checks for Internet availability transitions and syncs group state.
5299 ///
5300 /// Called from the `process()` tick. On a 0→1 transition (Internet just
5301 /// became available), all unsynced groups are registered with the relay.
5302 /// On a 1→0 transition (Internet lost), relay sync flags are cleared so
5303 /// groups are re-registered when connectivity returns.
5304 pub(crate) fn check_relay_group_sync(&mut self) {
5305 if !self.config.group.relay_enabled {
5306 return;
5307 }
5308 let internet_available = self.is_internet_available();
5309 let was_available = self.group_mesh.internet_was_available;
5310 self.group_mesh.internet_was_available = internet_available;
5311
5312 if internet_available && !was_available {
5313 // Internet just became available — sync all groups
5314 self.sync_groups_to_relay();
5315 } else if !internet_available && was_available {
5316 // Internet just went down — clear sync state. In-flight
5317 // registration acks can never arrive on this connection, so the
5318 // pending set goes too (re-armed by the 0→1 re-sync). Each
5319 // affected group gets a sync-changed event: apps gating
5320 // relay-dependent commands (invite links) must re-wait for the
5321 // reconnect's re-registration ack.
5322 let mut affected: Vec<String> = self.group_mesh.relay_synced.drain().collect();
5323 for group_id in self.group_mesh.relay_register_pending.drain() {
5324 if !affected.contains(&group_id.0) {
5325 affected.push(group_id.0);
5326 }
5327 }
5328 for group_id in affected {
5329 self.emit_event(Event::group_relay_sync_changed(
5330 group_id,
5331 false,
5332 "internet_dropped",
5333 ));
5334 }
5335 // Capabilities described the relay on the dead connection; a
5336 // reconnect re-injects them (possibly from a different relay)
5337 // before the transport is reported up again.
5338 self.group_mesh.relay_capabilities.clear();
5339 // In-flight broadcast delivery reports can never arrive on this
5340 // connection either. Downgrade each to per-member fan-out now
5341 // rather than after the report timeout: the copies enter the
5342 // ordinary outbox/park machinery, which flushes on whatever
5343 // transport (mesh now, Internet on reconnect) becomes usable —
5344 // and members the relay did reach absorb them via the logical
5345 // id.
5346 let stranded: Vec<(String, RelayBroadcastPending)> =
5347 self.group_mesh.relay_broadcast_pending.drain().collect();
5348 for (logical_id, entry) in stranded {
5349 self.downgrade_broadcast_to_per_member(&logical_id, entry);
5350 }
5351 }
5352 }
5353
5354 /// Expires outstanding relay registrations the relay never answered.
5355 ///
5356 /// Called from the `process()` tick. An entry older than
5357 /// [`RELAY_REGISTER_ACK_TIMEOUT_SECS`] either gets its registration
5358 /// re-sent (the frame may have been lost) or, past
5359 /// [`RELAY_REGISTER_MAX_ATTEMPTS`], is dropped so the ack-acceptance
5360 /// window closes: against a relay that never answers (prefix-unaware
5361 /// echo relay, legacy server, or a `__GROUP_ERROR__` that arrived
5362 /// without a `group_id` and so could not consume the correlation) an
5363 /// armed entry would otherwise sit indefinitely for a forged
5364 /// `__GROUP_CREATED__` to claim. Giving up is safe — the group stays
5365 /// unsynced and sends take the always-correct per-member fan-out path.
5366 pub(crate) fn process_relay_register_retries(&mut self) {
5367 if !self.config.group.relay_enabled
5368 || self.group_mesh.relay_register_pending.is_empty()
5369 || !self.is_internet_available()
5370 {
5371 return;
5372 }
5373 let cutoff = Utc::now() - chrono::Duration::seconds(RELAY_REGISTER_ACK_TIMEOUT_SECS);
5374 let expired: Vec<(String, u32)> = self
5375 .group_mesh
5376 .relay_register_pending
5377 .iter()
5378 .filter(|(_, pending)| pending.armed_at <= cutoff)
5379 .map(|(group_id, pending)| (group_id.clone(), pending.attempts))
5380 .collect();
5381 for (group_id, attempts) in expired {
5382 if attempts >= RELAY_REGISTER_MAX_ATTEMPTS
5383 || !self.group_mesh.members.contains_key(&group_id)
5384 || self.group_mesh.relay_synced.contains(&group_id)
5385 {
5386 self.group_mesh.relay_register_pending.remove(&group_id);
5387 debug!(
5388 group_id = %group_id,
5389 attempts,
5390 "Expired unanswered relay group registration"
5391 );
5392 // Giving up on a still-tracked, unsynced group is an
5393 // app-visible outcome: without this event a caller awaiting
5394 // the registration ack (`ensure_group_registered`) only
5395 // learns via its own timeout. The other two removal causes
5396 // stay silent — a vanished group already surfaced its
5397 // teardown, and an already-synced group's stale entry is
5398 // pure bookkeeping.
5399 if attempts >= RELAY_REGISTER_MAX_ATTEMPTS
5400 && self.group_mesh.members.contains_key(&group_id)
5401 && !self.group_mesh.relay_synced.contains(&group_id)
5402 {
5403 self.emit_event(Event::group_relay_sync_changed(
5404 group_id.clone(),
5405 false,
5406 "ack_timeout",
5407 ));
5408 }
5409 continue;
5410 }
5411 // Re-send with membership refreshed from MLS, like the 0→1
5412 // re-sync; try_relay_register_group re-arms the entry in place
5413 // with the attempt count carried forward.
5414 let members = self
5415 .refresh_group_members(&group_id)
5416 .ok()
5417 .or_else(|| self.group_mesh.members.get(&group_id).cloned());
5418 if let Some(members) = members {
5419 let _ = self.try_relay_register_group(&group_id, None, &members);
5420 } else {
5421 self.group_mesh.relay_register_pending.remove(&group_id);
5422 }
5423 }
5424 }
5425
5426 /// Expires in-flight relay broadcasts whose settled delivery report
5427 /// never arrived.
5428 ///
5429 /// Called from the `process()` tick. An entry older than
5430 /// [`RELAY_BROADCAST_REPORT_TIMEOUT_SECS`] gets its broadcast re-sent
5431 /// under the same logical id (safe: the relay echoes the id, so
5432 /// receiver and push dedup absorb the duplicate fan-out) while the
5433 /// attempt budget and the broadcast gate still hold; past
5434 /// [`RELAY_BROADCAST_MAX_ATTEMPTS`] — or as soon as the gate fails —
5435 /// the message is downgraded to full per-member fan-out, which needs no
5436 /// report to be correct.
5437 pub(crate) fn process_relay_broadcast_report_timeouts(&mut self) {
5438 if self.group_mesh.relay_broadcast_pending.is_empty() {
5439 return;
5440 }
5441 let cutoff = Utc::now() - chrono::Duration::seconds(RELAY_BROADCAST_REPORT_TIMEOUT_SECS);
5442 let expired: Vec<String> = self
5443 .group_mesh
5444 .relay_broadcast_pending
5445 .iter()
5446 .filter(|(_, pending)| pending.armed_at <= cutoff)
5447 .map(|(logical_id, _)| logical_id.clone())
5448 .collect();
5449 for logical_id in expired {
5450 let Some(mut entry) = self.group_mesh.relay_broadcast_pending.remove(&logical_id)
5451 else {
5452 continue;
5453 };
5454 let gate_holds = self.config.group.relay_broadcast_enabled
5455 && self.group_mesh.relay_synced.contains(&entry.group_id)
5456 && self
5457 .group_mesh
5458 .relay_capabilities
5459 .contains(RELAY_CAP_GROUP_DELIVERY_V3)
5460 && self.is_internet_available();
5461 if entry.attempts < RELAY_BROADCAST_MAX_ATTEMPTS && gate_holds {
5462 let payload = RelayGroupBroadcastPayload {
5463 group_id: entry.group_id.clone(),
5464 ciphertext: entry.ciphertext_b64.clone(),
5465 epoch: entry.epoch,
5466 reply_to: entry.reply_to.clone(),
5467 forward_info: entry.forward_info.clone(),
5468 message_id: logical_id.clone(),
5469 };
5470 if self.send_relay_broadcast_frame(&payload).is_ok() {
5471 warn!(
5472 group_id = %entry.group_id,
5473 msg_id = %logical_id,
5474 attempts = entry.attempts + 1,
5475 "Group broadcast delivery report lost, re-sent broadcast"
5476 );
5477 entry.attempts += 1;
5478 entry.armed_at = Utc::now();
5479 self.group_mesh
5480 .relay_broadcast_pending
5481 .insert(logical_id, entry);
5482 continue;
5483 }
5484 // Re-send failed — fall through to per-member downgrade.
5485 }
5486 self.downgrade_broadcast_to_per_member(&logical_id, entry);
5487 }
5488 }
5489
5490 /// Downgrades one in-flight broadcast to full per-member fan-out (every
5491 /// roster member; receivers that did get the relay's copy absorb the
5492 /// duplicate via the logical id). Terminal for the broadcast attempt:
5493 /// the per-member copies own delivery from here through the ordinary
5494 /// outbox/ACK/park ladder.
5495 fn downgrade_broadcast_to_per_member(
5496 &mut self,
5497 logical_id: &str,
5498 entry: RelayBroadcastPending,
5499 ) {
5500 let members = self
5501 .refresh_group_members(&entry.group_id)
5502 .ok()
5503 .or_else(|| self.group_mesh.members.get(&entry.group_id).cloned())
5504 .unwrap_or_default();
5505 info!(
5506 group_id = %entry.group_id,
5507 msg_id = %logical_id,
5508 members = members.len(),
5509 "Downgrading relay group broadcast to per-member fan-out"
5510 );
5511 if let Err(e) = self.fanout_group_frames_per_member(
5512 &entry.group_id,
5513 &members,
5514 Some(logical_id),
5515 &entry.ciphertext_b64,
5516 entry.epoch,
5517 entry.reply_to.as_deref(),
5518 entry.forward_info.clone(),
5519 entry.priority,
5520 ) {
5521 error!(
5522 group_id = %entry.group_id,
5523 msg_id = %logical_id,
5524 error = %e,
5525 "Failed to downgrade group broadcast to per-member fan-out"
5526 );
5527 }
5528 }
5529
5530 /// Re-registers all unsynced groups with the relay server.
5531 ///
5532 /// Refreshes each group's member list from MLS state before syncing so
5533 /// that the relay receives authoritative membership, not a stale cache.
5534 fn sync_groups_to_relay(&mut self) {
5535 let group_ids: Vec<String> = self.group_mesh.members.keys().cloned().collect();
5536 for group_id in group_ids {
5537 if self.group_mesh.relay_synced.contains(&group_id) {
5538 continue;
5539 }
5540 // Refresh from MLS to avoid syncing stale membership to relay
5541 let members = self
5542 .refresh_group_members(&group_id)
5543 .ok()
5544 .or_else(|| self.group_mesh.members.get(&group_id).cloned());
5545 if let Some(members) = members {
5546 let _ = self.try_relay_register_group(&group_id, None, &members);
5547 }
5548 }
5549 }
5550
5551 // ========================================================================
5552 // RELAY INBOUND — MLS-AWARE ROUTING
5553 // ========================================================================
5554
5555 /// Returns `true` if we hold local MLS state for `group_id`.
5556 ///
5557 /// The raw-emit fallbacks in [`Self::handle_relay_group_message_with_mls`]
5558 /// attribute unauthenticated plaintext to a caller-supplied `sender`. That
5559 /// is only ever acceptable for a genuine legacy relay-only (unencrypted)
5560 /// group, which has no MLS state. If we secure this group with MLS, a real
5561 /// member always sends MLS ciphertext, so plaintext naming the group is a
5562 /// spoof — this test lets the caller drop it rather than surface a message
5563 /// forged in a trusted member's name.
5564 ///
5565 /// Fail-closed: when MLS is initialized but the lookup cannot complete
5566 /// (lock poisoned, invalid id), assume state may exist and return `true`
5567 /// so a transient fault can never be leveraged to force a plaintext spoof
5568 /// through. When MLS is not initialized at all, there is no group to
5569 /// secure, so return `false` (genuine legacy).
5570 fn has_mls_group_state(&self, group_id: &str) -> bool {
5571 if !self.is_mls_initialized() {
5572 return false;
5573 }
5574 let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) else {
5575 // An id we cannot even construct names no real MLS group.
5576 return false;
5577 };
5578 match self.read_mls_guard() {
5579 Ok(guard) => guard.has_group(&gid).unwrap_or(true),
5580 Err(_) => true,
5581 }
5582 }
5583
5584 /// Handles an inbound relay group message by routing through MLS
5585 /// decryption.
5586 ///
5587 /// Called from `process_internal_message` for every relay `__GROUP_MSG__`
5588 /// when MLS is initialized (or when the group already has local MLS
5589 /// state) — including groups we have not joined yet, because a relay
5590 /// group message can outrun its Welcome exactly like a mesh one; such
5591 /// messages are buffered for deferred retry. Content that is not MLS
5592 /// ciphertext (base64-undecodable, or base64 that is not MLS wire
5593 /// framing for a group without local state) is a legacy relay-only
5594 /// group message and is emitted raw — but only for a group we do *not*
5595 /// secure with MLS. Plaintext naming a group we hold MLS state for is a
5596 /// sender-spoofing attempt (a real member sends ciphertext) and is
5597 /// dropped, since the raw emit would attribute attacker content to a
5598 /// trusted member with no authentication.
5599 #[allow(clippy::too_many_arguments)]
5600 pub(crate) fn handle_relay_group_message_with_mls(
5601 &mut self,
5602 group_id: &str,
5603 sender: &str,
5604 content: &str,
5605 timestamp: &str,
5606 message_id: &str,
5607 reply_to_msg: Option<String>,
5608 forward_info: Option<offline_protocol_core::ForwardInfo>,
5609 ) {
5610 // Dedup check — same pattern as handle_group_mls_msg, with one
5611 // deliberate difference. The relay-supplied id IS the logical id on
5612 // this path, and it is marked here, pre-decrypt, as the replay-
5613 // amplification defense (one MLS crypto op per id). But the mesh
5614 // handler's marking discipline exists for a reason this path is not
5615 // exempt from: a marked id suppresses every later copy of the same
5616 // logical message, including the per-member re-issue that is this
5617 // message's own delivery safety net. So every arm below that ends
5618 // with the frame neither delivered, nor buffered, nor consumed by
5619 // MLS must unmark before returning — otherwise a copy that was
5620 // *rejected* (e.g. mis-attributed by the relay) reads as *delivered*
5621 // to the duplicate check, and the genuine copy that follows is
5622 // absorbed and re-ACKed without ever being surfaced: silent loss.
5623 let dedup_key = message_id.to_string();
5624 if self.group_mesh.message_dedup.contains_key(&dedup_key) {
5625 debug!(group_id = %group_id, msg_id = %dedup_key, "Duplicate relay group message, skipping");
5626 return;
5627 }
5628 self.group_mesh
5629 .message_dedup
5630 .insert(dedup_key.clone(), Instant::now());
5631 if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
5632 self.cleanup_group_message_dedup();
5633 }
5634
5635 let forward_info_event = forward_info
5636 .as_ref()
5637 .map(crate::events::ForwardInfoEvent::from);
5638
5639 // Attempt base64 decode — if it fails, the content is plaintext (legacy)
5640 let ciphertext_bytes = match base64_decode(content) {
5641 Ok(bytes) => bytes,
5642 Err(_) => {
5643 // Plaintext content naming a group we secure with MLS cannot be
5644 // legitimate: a real member always sends MLS ciphertext (which
5645 // is base64 and decodes here). Emitting it raw would attribute
5646 // attacker-chosen content to the unauthenticated inner `sender`
5647 // — a message forged in a trusted member's name. Drop it. Only
5648 // a genuine legacy relay-only group (no local MLS state) may be
5649 // emitted as plaintext.
5650 if self.has_mls_group_state(group_id) {
5651 warn!(
5652 group_id = %group_id,
5653 sender = %sender,
5654 "SECURITY: dropping non-MLS plaintext naming an MLS-secured group (sender spoofing)"
5655 );
5656 self.emit_event(Event::security_warning(
5657 sender.to_string(),
5658 crate::events::SecurityWarningCode::PlaintextReceiveRejected,
5659 "Plaintext group message rejected: the named group is secured with MLS, \
5660 so unauthenticated plaintext cannot be attributed to the claimed sender"
5661 .to_string(),
5662 ));
5663 // Nothing was delivered and no MLS state was touched: the
5664 // id on this frame is attacker-chosen wire input, and
5665 // leaving it marked would let a spoofed frame suppress the
5666 // genuine logical message it names (see the marking note
5667 // at the top of this function).
5668 self.group_mesh.message_dedup.remove(&dedup_key);
5669 return;
5670 }
5671 // Not ciphertext and no MLS state — legacy relay-only group.
5672 self.emit_event(Event::group_message_received(
5673 group_id.to_string(),
5674 sender.to_string(),
5675 content.to_string(),
5676 timestamp.to_string(),
5677 message_id.to_string(),
5678 reply_to_msg,
5679 forward_info_event,
5680 None,
5681 None,
5682 ));
5683 return;
5684 }
5685 };
5686
5687 match self.decrypt_group_application(group_id, ciphertext_bytes, sender) {
5688 GroupDecryptOutcome::Plaintext(pt) => match String::from_utf8(pt) {
5689 Ok(text) => {
5690 let (content, media_metadata, content_type, forward_info_event) =
5691 Self::restore_group_rich(text, forward_info, sender);
5692 self.emit_event(Event::group_message_received(
5693 group_id.to_string(),
5694 sender.to_string(),
5695 content,
5696 timestamp.to_string(),
5697 message_id.to_string(),
5698 reply_to_msg,
5699 forward_info_event,
5700 media_metadata,
5701 content_type,
5702 ));
5703 }
5704 Err(_) => {
5705 warn!(
5706 group_id = %group_id,
5707 "Decrypted relay group payload is not valid UTF-8, dropping"
5708 );
5709 }
5710 },
5711 GroupDecryptOutcome::Retriable => {
5712 self.buffer_pending_group_message(
5713 group_id,
5714 PendingGroupMessage {
5715 sender: sender.to_string(),
5716 message_id: message_id.to_string(),
5717 // The relay-supplied id IS the logical id on this
5718 // path (a v2 relay echoes the broadcast's id), so a
5719 // separate copy would be redundant.
5720 logical_id: None,
5721 ciphertext_b64: content.to_string(),
5722 timestamp: Some(timestamp.to_string()),
5723 reply_to: reply_to_msg,
5724 forward_info,
5725 buffered_at: Instant::now(),
5726 // Relay path: the relay sender is not ACK-gated (it uses
5727 // try_relay_broadcast, not per-member ensure_ack_registration),
5728 // so there is no deferred ACK to send on drain.
5729 received_via: None,
5730 },
5731 );
5732 }
5733 GroupDecryptOutcome::NonApplication => {
5734 // Same as the mesh path: MLS consumed a commit or proposal
5735 // riding the message channel — group state may have advanced.
5736 self.drain_pending_commits(group_id);
5737 self.drain_pending_group_messages(group_id);
5738 }
5739 GroupDecryptOutcome::PolicyRejected {
5740 committer,
5741 added,
5742 removed,
5743 } => {
5744 // Same as the mesh path: a commit reframed onto the message
5745 // channel, refused before merging. Dropped, never buffered.
5746 self.report_unauthorized_membership_change(
5747 group_id,
5748 &committer,
5749 &added,
5750 &removed,
5751 "sender_not_admin",
5752 true,
5753 );
5754 }
5755 GroupDecryptOutcome::NotMlsCiphertext => {
5756 // Valid base64 that is not MLS framing, for a group without
5757 // local MLS state: a legacy relay-only group message whose
5758 // plaintext happens to decode (e.g. a base64 media blob).
5759 // Emit it raw, exactly like the base64-undecodable path.
5760 self.emit_event(Event::group_message_received(
5761 group_id.to_string(),
5762 sender.to_string(),
5763 content.to_string(),
5764 timestamp.to_string(),
5765 message_id.to_string(),
5766 reply_to_msg,
5767 forward_info_event,
5768 None,
5769 None,
5770 ));
5771 }
5772 GroupDecryptOutcome::SecurityRejected | GroupDecryptOutcome::Failed => {
5773 warn!(
5774 group_id = %group_id,
5775 "Failed to decrypt relay group message via MLS, dropping"
5776 );
5777 // Rejected, not delivered — unmark so a later copy of the
5778 // same logical message is processed instead of absorbed.
5779 // `SecurityRejected` is the load-bearing case: a v2 relay
5780 // attributes group frames by username, which fails the
5781 // SEC-M1 wire-sender/credential match after the decrypt —
5782 // leaving the id marked would then swallow the per-member
5783 // re-issue of the very same message (delivered exactly
5784 // nowhere, ACKed as a duplicate). `Failed` covers pre-decrypt
5785 // refusals (MLS unavailable, invalid group id) that a later
5786 // copy may outlive. The cost of unmarking is one MLS crypto
5787 // op per replayed copy — bounded by the same reasoning as
5788 // the mesh handler's post-decrypt-only logical-id marking.
5789 self.group_mesh.message_dedup.remove(&dedup_key);
5790 }
5791 }
5792 }
5793}