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