Skip to main content

offline_protocol_mls/
group.rs

1//! MLS group management.
2//!
3//! This module handles creation, modification, and state management of MLS groups.
4
5use crate::error::{LeafSource, MlsError, Result};
6use crate::provider::MlsProvider;
7use crate::storage::{MlsStorage, StorageError};
8use crate::types::{GroupId, GroupInfo, GroupMetadata, GroupRole, StorageKeyType};
9
10use openmls::framing::errors::{MessageDecryptionError, SecretTreeError};
11use openmls::prelude::*;
12use openmls_traits::signatures::Signer;
13use openmls_traits::OpenMlsProvider;
14use std::sync::Arc;
15use tracing::{debug, warn};
16
17/// Default ciphersuite for MLS operations.
18pub const DEFAULT_CIPHERSUITE: Ciphersuite =
19    Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
20
21/// The sender-ratchet bounds every created and joined group is configured
22/// with, re-exported from the sealed layer.
23///
24/// They live there rather than here because the peer at the other end of a
25/// session may be a leaf node running a different MLS implementation, and the
26/// interop harness configures its OpenMLS side with the same two numbers. A
27/// drifted copy does not fail a build or a test: it produces a session that
28/// stops decrypting under load, on a device, later.
29///
30/// The leaf's own receiver window is not these. mls-rs fixes it at
31/// [`LEAF_SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE`] and offers no setting, so
32/// whatever the phone derives from how far ahead a receiver tolerates (the
33/// re-seal budget in `offline-protocol`) takes the smaller of the two.
34pub use offline_protocol_sealed::{
35    LEAF_SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE, SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE,
36    SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE,
37};
38
39/// Sender ratchet configuration applied to every created and joined group.
40fn sender_ratchet_configuration() -> SenderRatchetConfiguration {
41    SenderRatchetConfiguration::new(
42        SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE,
43        SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE,
44    )
45}
46
47/// Requires a leaf's credential to be the address its own signature key
48/// derives to.
49///
50/// This is the Authentication Service RFC 9420 §5.3.1 delegates to the
51/// application, and for this SDK the whole service is one derivation: an
52/// address *is* `bech32m(0x01 ‖ SHA-256(signature_key)[..20])`, so a leaf
53/// carries its own proof and no prior contact is needed to check it. §7.3
54/// requires it wherever a ratchet tree is validated — on joining, and after
55/// processing a Commit — which is why this is called from three places rather
56/// than one.
57///
58/// # Why a non-address credential is refused, not skipped
59///
60/// The same reason [`crate::MlsManager::verify_address_binding`] and the
61/// control gate's `verify_sender_derivation` refuse one: a credential with no
62/// derivation to check is not a leaf that needs waving through, it is the
63/// bypass. Skip it and an impostor writes a nickname into their credential
64/// instead of an address and lands in the tree unjudged — and on the
65/// `__GROUP_MSG__` data-plane path, where the wire sender carries no signature,
66/// being attributed that nickname costs nothing further.
67///
68/// Nothing in this SDK produces such a leaf: production always constructs the
69/// manager at a derived address (`initialize_mls_inner`), and the only leaf
70/// this crate ever writes comes from the local credential or from a key package
71/// that already passed the same derivation.
72///
73/// Delegates to [`crate::MlsManager::derive_address`] rather than hashing here,
74/// because that is the SDK's single address derivation and a second one is a
75/// second format.
76fn verify_leaf_binding(
77    credential: &Credential,
78    signature_key: &[u8],
79    context: LeafSource,
80) -> Result<()> {
81    // A key that cannot derive at all (wrong length) is refused with the same
82    // verdict as one that derives elsewhere: either way the leaf has not
83    // proved the identity it claims.
84    //
85    // `claimed` is rendered only on the failure arms. It is attacker-controlled
86    // and this runs per inbound group message and per member of every roster
87    // read, so the success path must not pay for a string it discards.
88    let derived = match crate::MlsManager::derive_address(signature_key) {
89        Ok(address) => address,
90        Err(e) => {
91            return Err(MlsError::LeafAddressMismatch {
92                claimed: rendered_credential(credential),
93                derived: format!("<no address: {}>", e),
94                context,
95            });
96        }
97    };
98
99    if derived.to_string().as_bytes() != credential.serialized_content() {
100        return Err(MlsError::LeafAddressMismatch {
101            claimed: rendered_credential(credential),
102            derived: derived.to_string(),
103            context,
104        });
105    }
106    Ok(())
107}
108
109/// Renders a credential's content for a diagnostic, bounded.
110///
111/// A credential is TLS `VLBytes` — the wire permits megabytes of it, and a leaf
112/// that reaches [`verify_leaf_binding`] has not proved anything, so its content
113/// is attacker-chosen. That string is `error!`-logged and interpolated verbatim
114/// into the user-facing `security_warning` reason, so it is bounded here rather
115/// than at each of those sinks. An honest address is 62 characters.
116fn rendered_credential(credential: &Credential) -> String {
117    const MAX_RENDERED_CREDENTIAL_BYTES: usize = 80;
118
119    let content = credential.serialized_content();
120    let shown = content
121        .get(..MAX_RENDERED_CREDENTIAL_BYTES)
122        .unwrap_or(content);
123    let mut rendered = String::from_utf8_lossy(shown).into_owned();
124    if shown.len() < content.len() {
125        rendered.push_str("… (truncated)");
126    }
127    rendered
128}
129
130/// Names the OpenMLS failure behind an ordinary decrypt failure, from the
131/// closed set [`MlsError::decryption_detail`] reads back.
132///
133/// The failures this tells apart look identical to an application otherwise:
134/// an AEAD failure is what a pair holding two different groups for one slot
135/// produces on every frame, while a frame too far in the past is a healthy
136/// session whose sender's re-sealed resend recovers it. A fixed name rather
137/// than the error's rendering, because the reason reaches telemetry verbatim.
138///
139/// Exhaustive, with no catch-all arm (ADR 0013): none of these OpenMLS enums
140/// is `#[non_exhaustive]`, so a variant a future OpenMLS adds fails to compile
141/// here, and is named, instead of being filed silently under a neighbour's
142/// name.
143fn decryption_detail<E>(e: &ProcessMessageError<E>) -> &'static str {
144    match e {
145        ProcessMessageError::ValidationError(validation) => match validation {
146            ValidationError::UnableToDecrypt(decryption) => match decryption {
147                MessageDecryptionError::AeadError => "aead",
148                MessageDecryptionError::GenerationOutOfBound => "generation_out_of_bound",
149                MessageDecryptionError::MalformedContent => "malformed_content",
150                MessageDecryptionError::SecretTreeError(tree) => match tree {
151                    SecretTreeError::TooDistantInThePast => "too_distant_in_the_past",
152                    SecretTreeError::SecretReuseError => "secret_reuse",
153                    SecretTreeError::TooDistantInTheFuture
154                    | SecretTreeError::IndexOutOfBounds
155                    | SecretTreeError::RatchetTypeError
156                    | SecretTreeError::RatchetTooLong
157                    | SecretTreeError::LibraryError
158                    | SecretTreeError::CodecError(_)
159                    | SecretTreeError::CryptoError(_) => "secret_tree",
160                },
161                MessageDecryptionError::LibraryError(_)
162                | MessageDecryptionError::WrongWireFormat => "unable_to_decrypt",
163            },
164            ValidationError::LibraryError(_)
165            | ValidationError::WrongGroupId
166            | ValidationError::WrongEpoch
167            | ValidationError::NotACommit
168            | ValidationError::NotAnExternalAddProposal
169            | ValidationError::NoPath
170            | ValidationError::UnencryptedApplicationMessage
171            | ValidationError::UnknownMember
172            | ValidationError::MissingMembershipTag
173            | ValidationError::InvalidMembershipTag
174            | ValidationError::MissingConfirmationTag
175            | ValidationError::WrongWireFormat
176            | ValidationError::InvalidSignature
177            | ValidationError::NonMemberApplicationMessage
178            | ValidationError::NoPastEpochData
179            | ValidationError::UnauthorizedExternalSender
180            | ValidationError::NoExternalSendersExtension
181            | ValidationError::KeyPackageVerifyError(_)
182            | ValidationError::UpdatePathError(_)
183            | ValidationError::InvalidLeafNodeSignature
184            | ValidationError::InvalidLeafNodeSourceType
185            | ValidationError::InvalidSenderType
186            | ValidationError::CommitterIncludedOwnUpdate
187            | ValidationError::InvalidAddProposalCiphersuite
188            | ValidationError::CannotDecryptOwnMessage
189            | ValidationError::ExternalCommitValidation(_) => "validation",
190        },
191        ProcessMessageError::LibraryError(_)
192        | ProcessMessageError::StorageError(_)
193        | ProcessMessageError::IncompatibleWireFormat
194        | ProcessMessageError::GroupStateError(_)
195        | ProcessMessageError::InvalidCommit(_)
196        | ProcessMessageError::UnauthorizedExternalApplicationMessage
197        | ProcessMessageError::UnauthorizedExternalCommitMessage
198        | ProcessMessageError::UnsupportedProposalType => "other",
199    }
200}
201
202/// Manages MLS groups for encrypted messaging.
203pub struct GroupManager {
204    /// Storage backend for persisting group state.
205    storage: Arc<dyn MlsStorage>,
206
207    /// OpenMLS crypto provider.
208    provider: MlsProvider,
209
210    /// Whether membership commits are checked against the local admin
211    /// overlay before merging. Default `false`; see
212    /// [`Self::set_enforce_admin_commits`].
213    enforce_admin_commits: bool,
214}
215
216impl GroupManager {
217    /// Creates a new group manager.
218    pub fn new(storage: Arc<dyn MlsStorage>, provider: MlsProvider) -> Self {
219        Self {
220            storage,
221            provider,
222            enforce_admin_commits: false,
223        }
224    }
225
226    /// Enables or disables receive-side authorization of membership commits.
227    ///
228    /// **Off by default, and changing that is a group-partitioning decision,
229    /// not a hardening toggle.** Refusing a commit means not merging it: our
230    /// epoch stays behind every member who did merge, which MLS cannot heal —
231    /// the app has to re-invite us. Enforcement is therefore safe only if
232    /// every member reaches the *same* verdict, and the admin overlay is
233    /// replicated best-effort (role changes ride unreconciled mesh
234    /// notifications; joiners get a point-in-time snapshot). Members whose
235    /// role maps merely disagree will partition each other with no attacker
236    /// involved.
237    ///
238    /// `authorize_membership_commit` (private) fails open on *absent* knowledge
239    /// to keep the common lag case harmless, but it cannot detect *divergent*
240    /// knowledge. Enable this only for a closed deployment that controls role
241    /// distribution.
242    pub fn set_enforce_admin_commits(&mut self, enforce: bool) {
243        self.enforce_admin_commits = enforce;
244    }
245
246    /// Returns a reference to the crypto provider.
247    pub fn provider(&self) -> &MlsProvider {
248        &self.provider
249    }
250
251    /// Creates a new MLS group.
252    pub fn create_group(
253        &self,
254        group_id: &GroupId,
255        credential_with_key: &CredentialWithKey,
256        signer: &impl Signer,
257    ) -> Result<MlsGroup> {
258        let group_config = MlsGroupCreateConfig::builder()
259            .ciphersuite(DEFAULT_CIPHERSUITE)
260            .use_ratchet_tree_extension(true)
261            .sender_ratchet_configuration(sender_ratchet_configuration())
262            .build();
263
264        let mls_group_id = openmls::group::GroupId::from_slice(group_id.as_str().as_bytes());
265
266        let group = MlsGroup::new_with_group_id(
267            &self.provider,
268            signer,
269            &group_config,
270            mls_group_id,
271            credential_with_key.clone(),
272        )
273        .map_err(|e| MlsError::GroupCreation(e.to_string()))?;
274
275        // Persist group state
276        self.save_group(group_id, &group)?;
277
278        debug!(group_id = %group_id, "Created new MLS group");
279        Ok(group)
280    }
281
282    /// Loads a group from storage.
283    pub fn load_group(&self, group_id: &GroupId) -> Result<Option<MlsGroup>> {
284        let key_type = StorageKeyType::GroupState.as_str();
285        let data = self.storage.load(key_type, group_id.as_str())?;
286
287        match data {
288            Some(_bytes) => {
289                // MlsGroup in OpenMLS 0.7 uses the storage provider pattern
290                let mls_group_id =
291                    openmls::group::GroupId::from_slice(group_id.as_str().as_bytes());
292
293                // Try to load from the provider's storage
294                match MlsGroup::load(self.provider.storage(), &mls_group_id) {
295                    Ok(Some(group)) => Ok(Some(group)),
296                    Ok(None) => {
297                        debug!(group_id = %group_id, "Group not found in crypto storage");
298                        Ok(None)
299                    }
300                    Err(e) => Err(MlsError::Deserialization(format!(
301                        "Failed to load group: {:?}",
302                        e
303                    ))),
304                }
305            }
306            None => Ok(None),
307        }
308    }
309
310    /// Saves a group marker to storage.
311    ///
312    /// OpenMLS persists group state through the provider during mutations.
313    /// This marker is kept in our storage layer for listing/enumeration.
314    pub fn save_group(&self, group_id: &GroupId, group: &MlsGroup) -> Result<()> {
315        let key_type = StorageKeyType::GroupState.as_str();
316        let marker = group.epoch().as_u64().to_le_bytes();
317        self.storage.store(key_type, group_id.as_str(), &marker)?;
318        Ok(())
319    }
320
321    /// Deletes a group from storage, including all OpenMLS provider state.
322    pub fn delete_group(&self, group_id: &GroupId) -> Result<()> {
323        let mls_group_id = openmls::group::GroupId::from_slice(group_id.as_str().as_bytes());
324
325        match MlsGroup::load(self.provider.storage(), &mls_group_id) {
326            Ok(Some(mut group)) => {
327                group.delete(self.provider.storage()).map_err(|e| {
328                    MlsError::Storage(StorageError::DeleteFailed(format!(
329                        "Failed to delete group from provider storage: {:?}",
330                        e
331                    )))
332                })?;
333            }
334            // No group at this id — nothing to clean up.
335            Ok(None) => {}
336            // We could not load the group to delete its provider-side state
337            // (epoch keypairs, secrets); that state may now leak. Warn rather
338            // than abort and still drop the marker below: callers such as
339            // `join_group_replacing` deliberately rely on `delete_group` not
340            // failing here once the one-time key package is already consumed.
341            Err(e) => {
342                warn!(
343                    group_id = %group_id,
344                    error = ?e,
345                    "delete_group: could not load group to clean provider state; dropping marker only"
346                );
347            }
348        }
349
350        let key_type = StorageKeyType::GroupState.as_str();
351        self.storage.delete(key_type, group_id.as_str())?;
352
353        Ok(())
354    }
355
356    /// Lists all groups.
357    ///
358    /// Stored keys that fail group-id validation (possible only for state
359    /// written before validation was enforced) are skipped with a warning
360    /// rather than resurrected as live group ids.
361    pub fn list_groups(&self) -> Result<Vec<GroupId>> {
362        let key_type = StorageKeyType::GroupState.as_str();
363        let keys = self.storage.list_keys(key_type)?;
364        Ok(keys
365            .into_iter()
366            .filter_map(|key| match GroupId::new(key) {
367                Ok(group_id) => Some(group_id),
368                Err(e) => {
369                    warn!(error = %e, "Skipping stored group with invalid id");
370                    None
371                }
372            })
373            .collect())
374    }
375
376    /// Adds a member to a group using their key package.
377    pub fn add_member(
378        &self,
379        group: &mut MlsGroup,
380        key_package: KeyPackage,
381        signer: &impl Signer,
382    ) -> Result<(MlsMessageOut, MlsMessageOut)> {
383        let (commit, welcome, _group_info) = group
384            .add_members(&self.provider, signer, &[key_package])
385            .map_err(|e| MlsError::AddMember(e.to_string()))?;
386
387        // Merge the pending commit
388        group
389            .merge_pending_commit(&self.provider)
390            .map_err(|e| MlsError::AddMember(format!("Failed to merge commit: {}", e)))?;
391
392        Ok((commit, welcome))
393    }
394
395    /// Removes members from a group.
396    ///
397    /// Takes every leaf to remove in one commit rather than one leaf per call,
398    /// because removing "that member" has to mean all of them or the peer stays
399    /// in the group holding live keys while every roster read shows them gone.
400    ///
401    /// Two leaves for one identity cannot arrive through the wire gates — MLS
402    /// requires unique signature keys and the binding ties credential to key —
403    /// but they can exist in a tampered provider store, where a forged leaf
404    /// claims a peer's address while carrying the attacker's key. See
405    /// [`crate::MlsManager::remove_group_member`] for the full argument; do not
406    /// restate it here, and do not reduce this to a first match.
407    pub fn remove_member(
408        &self,
409        group: &mut MlsGroup,
410        member_indices: &[LeafNodeIndex],
411        signer: &impl Signer,
412    ) -> Result<MlsMessageOut> {
413        let (commit, _welcome, _group_info) = group
414            .remove_members(&self.provider, signer, member_indices)
415            .map_err(|e| MlsError::RemoveMember(e.to_string()))?;
416
417        group
418            .merge_pending_commit(&self.provider)
419            .map_err(|e| MlsError::RemoveMember(format!("Failed to merge commit: {}", e)))?;
420
421        Ok(commit)
422    }
423
424    /// Creates an encrypted application message.
425    pub fn encrypt_message(
426        &self,
427        group: &mut MlsGroup,
428        plaintext: &[u8],
429        signer: &impl Signer,
430    ) -> Result<MlsMessageOut> {
431        let message = group
432            .create_message(&self.provider, signer, plaintext)
433            .map_err(|e| MlsError::Encryption(e.to_string()))?;
434
435        Ok(message)
436    }
437
438    /// Decrypts an incoming MLS message.
439    ///
440    /// Security (SEC-M1): `claimed_sender` is the identity the caller will
441    /// attribute this message to (the transport-level sender). It is
442    /// cross-checked against the MLS-authenticated credential of the
443    /// processed message *before* any plaintext is surfaced or any commit
444    /// is merged, so a group member cannot impersonate another member by
445    /// lying in the wire envelope.
446    pub fn decrypt_message(
447        &self,
448        group: &mut MlsGroup,
449        group_id: &GroupId,
450        message: MlsMessageIn,
451        claimed_sender: &str,
452    ) -> Result<Option<Vec<u8>>> {
453        let protocol_message = message
454            .try_into_protocol_message()
455            .map_err(|e| MlsError::Decryption(format!("Invalid protocol message: {:?}", e)))?;
456
457        let processed = group
458            .process_message(&self.provider, protocol_message)
459            .map_err(|e| {
460                // Distinguish a *recoverable* epoch desync (the two sides
461                // disagree on the MLS epoch — e.g. after a fork) from a genuine
462                // decrypt failure. Only the epoch-mismatch family is signalled
463                // as `SessionDesync` so the protocol layer can re-key rather
464                // than silently drop. AEAD/corrupt failures, ratchet-generation
465                // failures (a discarded past generation — the session is
466                // healthy, so re-keying would not help), and everything else
467                // stay `Decryption`; widening the recoverable arm to cover them
468                // would open a second, unbounded re-key-storm vector.
469                //
470                // It does NOT make the recoverable arm authenticated. OpenMLS
471                // validates framing — group id, then epoch — before any AEAD,
472                // sender-data or signature check, so `WrongEpoch` is produced
473                // with the sender still entirely unverified and a frame forged
474                // from nothing reaches this arm (see, in `manager.rs`,
475                // `test_forged_frame_reaches_session_desync_without_any_key_material`).
476                // `SessionDesync` is an unauthenticated hint by construction;
477                // the mitigation lives in what the protocol layer hangs off it.
478                //
479                // A frame more than `SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE`
480                // generations ahead of this receiver's ratchet is the third
481                // member of the family, and the one that is never transient:
482                // the sender's every later frame (and every re-sealed resend)
483                // is further ahead still, so without a re-key the session
484                // never decrypts from that sender again. The 2026-10-19 volume
485                // test lost its whole second half to exactly that, as 3,915
486                // per-frame failures with nothing ever healing them.
487                //
488                // Unlike the epoch checks, this one IS authenticated: OpenMLS
489                // decrypts the sender data under AEAD before it reads the
490                // generation from it, so only a holder of this epoch's
491                // sender-data secret can produce a frame that fails here.
492                // `TooDistantInThePast` stays `Decryption`: the session is
493                // healthy, and the sender's re-sealed resend recovers it.
494                match &e {
495                    ProcessMessageError::ValidationError(ValidationError::WrongEpoch)
496                    | ProcessMessageError::ValidationError(ValidationError::NoPastEpochData)
497                    | ProcessMessageError::ValidationError(ValidationError::UnableToDecrypt(
498                        MessageDecryptionError::SecretTreeError(
499                            SecretTreeError::TooDistantInTheFuture,
500                        ),
501                    )) => MlsError::SessionDesync(e.to_string()),
502                    _ => MlsError::Decryption(format!("{}: {e}", decryption_detail(&e))),
503                }
504            })?;
505
506        // SEC-M1 compares the wire sender against the MLS credential, so the
507        // credential has to be worth comparing against. Bind the sender's leaf
508        // first: a basic credential is a self-asserted string, and until its
509        // own signature key is shown to hash to it, matching it proves only
510        // that the forger typed the name they wanted to be called.
511        //
512        // The entry-point gates (`verify_staged_welcome_tree`,
513        // `verify_staged_commit_leaves`) mean a leaf in the tree has already
514        // passed this once. Re-checking here is the same import-time plus
515        // use-time pairing `MlsManager::get_contact_key_package` documents, and
516        // it exists for the same window: group state is re-read from the
517        // install-scoped provider store long after the gate that admitted it,
518        // and anything able to write that store could otherwise seat a leaf the
519        // gates never saw.
520        Self::verify_sender_leaf(group, &processed)?;
521
522        // Credentials in this SDK are basic credentials carrying the user id
523        // as raw bytes (see `MlsManager::create_identity`).
524        let authenticated = processed.credential().serialized_content();
525        if authenticated != claimed_sender.as_bytes() {
526            return Err(MlsError::SenderIdentityMismatch {
527                claimed: claimed_sender.to_string(),
528                authenticated: String::from_utf8_lossy(authenticated).into_owned(),
529            });
530        }
531
532        match processed.into_content() {
533            ProcessedMessageContent::ApplicationMessage(app_msg) => Ok(Some(app_msg.into_bytes())),
534            ProcessedMessageContent::ProposalMessage(_) => {
535                debug!("Received proposal message");
536                Ok(None)
537            }
538            ProcessedMessageContent::StagedCommitMessage(staged_commit) => {
539                debug!("Received commit message, merging");
540                // Authorize before merging — this is the only point at which
541                // a commit can still be refused. `merge_staged_commit`
542                // advances our epoch irreversibly, and every group decrypt
543                // path in the SDK funnels through here (commit frames,
544                // commits riding the application channel, `__MLS_ENC__`
545                // envelopes naming a group id), so a check anywhere further
546                // up would be bypassable by reframing the same ciphertext.
547                //
548                // Identity first, policy second. The admin check reads the
549                // credentials this one validates — its `added` list, and the
550                // `GroupUnauthorizedMembershipChange` report built from it, are
551                // Add-proposal credential strings — so running it against
552                // unvalidated leaves would have it name identities nobody
553                // proved.
554                Self::verify_staged_commit_leaves(&staged_commit)?;
555                self.authorize_membership_commit(group, group_id, &staged_commit, claimed_sender)?;
556                group
557                    .merge_staged_commit(&self.provider, *staged_commit)
558                    .map_err(|e| MlsError::Decryption(format!("Failed to merge commit: {}", e)))?;
559                Ok(None)
560            }
561            // Unreachable since the leaf binding landed: an external join
562            // proposal is authenticated to `Sender::NewMemberProposal`, which
563            // `verify_sender_leaf` refuses above with
564            // `MlsError::UnsupportedSender` before `into_content` is reached.
565            // Kept because the enum is `#[non_exhaustive]`-shaped in practice
566            // and a match arm is cheaper than a panic, but note the disposition
567            // is now *refuse*, not tolerate-and-ignore — do not read this arm as
568            // the policy.
569            ProcessedMessageContent::ExternalJoinProposalMessage(_) => {
570                warn!(
571                    "Received external join proposal (not supported); \
572                     expected `verify_sender_leaf` to have refused it already"
573                );
574                Ok(None)
575            }
576        }
577    }
578
579    /// Requires the leaf a processed message is authenticated to to carry the
580    /// address its own signature key derives to.
581    ///
582    /// The use-time half of the binding, and the one that actually closes the
583    /// attribution hole: it holds regardless of how the leaf reached the tree,
584    /// including paths no entry gate covers (a leaf seated by a direct write to
585    /// the provider store, or by a build predating the gates).
586    ///
587    /// O(1) — the sender's leaf is resolved by index, not by walking the
588    /// roster — so it costs one SHA-256 per inbound group message even in a
589    /// large group.
590    ///
591    /// # Non-member senders
592    ///
593    /// `Sender::Member` is the only shape this SDK sends or expects. The other
594    /// three are refused rather than skipped, because skipping would hand back
595    /// exactly what the check just took away: an external joiner's leaf is not
596    /// in the pre-merge tree, so there is no entry to resolve, and answering
597    /// "no leaf, nothing to check" would let a `NewMemberCommit` be attributed
598    /// to whatever its credential claims. The commit's own update-path leaf is
599    /// still validated by [`Self::verify_staged_commit_leaves`], so refusing
600    /// here costs nothing an honest peer was doing — this SDK issues no
601    /// external commits, no external proposals, and configures no external
602    /// senders.
603    fn verify_sender_leaf(group: &MlsGroup, processed: &ProcessedMessage) -> Result<()> {
604        let leaf_index = match processed.sender() {
605            Sender::Member(index) => *index,
606            other => {
607                return Err(MlsError::UnsupportedSender {
608                    detail: format!(
609                        "a message was authenticated to {:?}, which is not a group member; \
610                         this SDK issues and expects only member senders",
611                        other
612                    ),
613                });
614            }
615        };
616
617        // A sender index with no leaf cannot happen for a message OpenMLS just
618        // authenticated against that leaf — but resolving it is fallible, and
619        // the safe answer to "we cannot see the leaf" is the same as to "the
620        // leaf is wrong".
621        let member = group
622            .member_at(leaf_index)
623            .ok_or_else(|| MlsError::UnsupportedSender {
624                detail: format!(
625                    "tree integrity: a message was authenticated to leaf {}, which is not \
626                     in the tree",
627                    leaf_index.u32()
628                ),
629            })?;
630
631        verify_leaf_binding(
632            &member.credential,
633            &member.signature_key,
634            LeafSource::MessageSender,
635        )
636    }
637
638    /// Requires every leaf a staged commit would install to carry the address
639    /// its own signature key derives to (RFC 9420 §7.3, "after processing a
640    /// Commit").
641    ///
642    /// # Why this is unconditional, when the admin check beside it is opt-in
643    ///
644    /// Both refuse a commit, and refusing a commit means not merging it, which
645    /// forks us from every member who did merge. For
646    /// [`Self::authorize_membership_commit`] that risk is what keeps it behind
647    /// [`GroupManager::set_enforce_admin_commits`]: its verdict depends on the
648    /// admin overlay, which replicates best-effort, so two honest members can
649    /// legitimately disagree and partition each other with no attacker present.
650    ///
651    /// This verdict has no such input. It is computed from the commit's own
652    /// bytes — a hash of a key in the commit, compared to a string in the
653    /// commit — so every honest member reaches the same answer from the same
654    /// message. A refused commit does not split the group; it forks the
655    /// *attacker* off a group that stays consistent. That is why it is safe to
656    /// run for everyone, and why it must: a check the fleet does not run is a
657    /// check an attacker chooses to be judged by.
658    ///
659    /// It also runs for `session:` groups, which the admin check skips. A 1:1
660    /// slot is already bound to its pair by `verify_welcome_slot` and the
661    /// session-id derivation, so there is nothing extra to catch there today —
662    /// but the cost is one hash and the alternative is a seam whose coverage
663    /// depends on the group id's spelling.
664    ///
665    /// # The four sources
666    ///
667    /// Taken from OpenMLS's own [`StagedCommit::credentials_to_verify`], which
668    /// enumerates exactly where a credential can enter through a commit. That
669    /// helper hands back bare `Credential`s, which is not enough here — the
670    /// whole check is credential *against its signature key* — so the leaves
671    /// are walked directly, but the enumeration is copied from it deliberately:
672    ///
673    /// 1. the commit's update-path leaf (the committer's own, rotated in place);
674    /// 2. Update proposals (a member replacing their leaf);
675    /// 3. Add proposals (a new member's key package leaf);
676    /// 4. `GroupContextExtensions` carrying `ExternalSenders` — not a leaf, and
677    ///    refused outright rather than bound, see [`MlsError::UnsupportedSender`].
678    ///
679    /// Validating only (3) would leave (1) and (2) as bypasses. The OpenMLS
680    /// book names only "add & update proposals"; its own helper is the more
681    /// complete list, and this follows the helper.
682    fn verify_staged_commit_leaves(staged_commit: &StagedCommit) -> Result<()> {
683        // 1. The committer's own leaf, rotated in place by the update path.
684        if let Some(leaf) = staged_commit.update_path_leaf_node() {
685            verify_leaf_binding(
686                leaf.credential(),
687                leaf.signature_key().as_slice(),
688                LeafSource::CommitPath,
689            )?;
690        }
691
692        // 2. Members replacing their own leaf.
693        //
694        // Defensive, and unreachable through this SDK today — noted because an
695        // untested loop that cannot fire looks identical to one that is merely
696        // untested, and a future change re-opens it silently. An Update
697        // proposal reaches a commit two ways, and both are currently closed:
698        // inline, where MLS attributes the proposal to the *committer* and
699        // forbids committing your own Update (OpenMLS drops it when building
700        // and answers `CommitterIncludedOwnUpdate` when validating); or by
701        // reference, which requires the *receiver* to hold the proposal in its
702        // store, and this SDK drops every received `ProposalMessage` rather
703        // than storing it (see `decrypt_message`). Give the SDK a propose-only
704        // API — or start storing received proposals — and this loop becomes
705        // live, which is the point of keeping it.
706        for proposal in staged_commit.update_proposals() {
707            let leaf = proposal.update_proposal().leaf_node();
708            verify_leaf_binding(
709                leaf.credential(),
710                leaf.signature_key().as_slice(),
711                LeafSource::CommitUpdate,
712            )?;
713        }
714
715        // 3. New members. This is the source the impersonation rides: a member
716        // commits an Add whose key package credential names someone else, and
717        // from then on SEC-M1 attributes that leaf's messages to them.
718        for proposal in staged_commit.add_proposals() {
719            let leaf = proposal.add_proposal().key_package().leaf_node();
720            verify_leaf_binding(
721                leaf.credential(),
722                leaf.signature_key().as_slice(),
723                LeafSource::CommitAdd,
724            )?;
725        }
726
727        // 4. External senders authorize a non-member to send into the group.
728        // The SDK configures none and issues none, and their credentials are
729        // not leaves, so there is no binding to check — refuse instead.
730        for proposal in staged_commit.queued_proposals() {
731            if let Proposal::GroupContextExtensions(gce) = proposal.proposal() {
732                if gce.extensions().external_senders().is_some() {
733                    return Err(MlsError::UnsupportedSender {
734                        detail: "a commit proposes an ExternalSenders group-context extension, \
735                                 which would authorize a non-member to send into this group"
736                            .to_string(),
737                    });
738                }
739            }
740        }
741
742        Ok(())
743    }
744
745    /// Refuses a staged membership commit whose committer or proposal senders
746    /// the local admin overlay does not authorize.
747    ///
748    /// Runs only when [`Self::set_enforce_admin_commits`] is on. **Every
749    /// unknown is resolved in favour of merging**, because the cost of a
750    /// wrong rejection (a permanent epoch fork needing an app-level
751    /// re-invite) far exceeds the cost of a wrong acceptance (an insider
752    /// membership change, which the protocol layer still reports):
753    ///
754    /// - Not a membership commit (no Add/Remove proposals) → merge. A pure
755    ///   KeyUpdate changes no membership and needs no admin; the fork
756    ///   resolver's deterministic leader issues these and is often not one.
757    /// - A `session:` group → merge. 1:1 sessions have no admin overlay.
758    /// - Metadata unreadable or absent, or no admin role stored at all →
759    ///   merge. This is "we do not know who the admins are", the exact state
760    ///   a lagging replica is in, and rejecting on it would partition a
761    ///   healthy group with no attacker involved. Note this deliberately does
762    ///   *not* consult `created_by`: the creator fallback is a single
763    ///   unauthenticated claim, fine for gating our own sends and for
764    ///   reporting, too thin to fork a group over.
765    ///
766    /// Only a *positive* contradiction rejects: we hold a non-empty admin set
767    /// and a principal is not in it. The principals are the committer (already
768    /// authenticated against the wire sender by SEC-M1 above) and the sender of
769    /// each Add/Remove proposal, since MLS lets a member commit a proposal
770    /// another member made. Update and PSK proposals are deliberately excluded
771    /// — they change no membership.
772    ///
773    /// It cannot detect *divergent* admin views, only absent ones — which is
774    /// why enforcement stays opt-in.
775    fn authorize_membership_commit(
776        &self,
777        group: &MlsGroup,
778        group_id: &GroupId,
779        staged_commit: &StagedCommit,
780        committer: &str,
781    ) -> Result<()> {
782        if !self.enforce_admin_commits || group_id.as_str().starts_with("session:") {
783            return Ok(());
784        }
785
786        // Resolve the membership delta from the proposals rather than the
787        // post-merge roster, which does not exist yet. Leaf indices resolve
788        // against the *pre-merge* tree, which is the roster the removes name.
789        let mut added: Vec<String> = staged_commit
790            .add_proposals()
791            .filter_map(|p| {
792                String::from_utf8(
793                    p.add_proposal()
794                        .key_package()
795                        .leaf_node()
796                        .credential()
797                        .serialized_content()
798                        .to_vec(),
799                )
800                .ok()
801            })
802            .collect();
803        let removed_indices: Vec<LeafNodeIndex> = staged_commit
804            .remove_proposals()
805            .map(|p| p.remove_proposal().removed())
806            .collect();
807
808        if added.is_empty() && removed_indices.is_empty() {
809            return Ok(());
810        }
811
812        // Identity first, policy second — the same rule that puts
813        // `verify_staged_commit_leaves` ahead of this call, applied to the
814        // *pre-merge tree* rather than to the commit's own leaves. The roster
815        // feeds two things below: the `removed` names that go into a
816        // `GroupUnauthorizedMembershipChange` report, and — through the
817        // proposal-sender loop — the authorization verdict itself. Neither
818        // should ever resolve an index to a name nobody proved, for the same
819        // reason `get_group_info` keeps one out of the roster it hands the app.
820        //
821        // Unreachable today, and stated plainly rather than implied, like the
822        // Update-proposal loop in `verify_staged_commit_leaves`. Both uses
823        // require an unbound leaf in *this* tree, which the entry gates refuse;
824        // a store tampered with locally could seat one, but the index would
825        // then also have to be named by a committer whose own tree agrees with
826        // ours, i.e. the forgery is in the whole group's tree and this report is
827        // the least of it. A propose-only API, or any future path that admits a
828        // leaf the gates did not, makes it live without touching this function
829        // — which is the argument for filtering now rather than re-deriving the
830        // reachability later.
831        //
832        // Filtering fails closed either way: an index that no longer resolves
833        // makes `resolve` return `None`, which the `unwrap_or(true)` below reads
834        // as unauthorized.
835        let roster: Vec<(LeafNodeIndex, String)> = group
836            .members()
837            .filter(|m| {
838                verify_leaf_binding(&m.credential, &m.signature_key, LeafSource::RosterEntry)
839                    .is_ok()
840            })
841            .filter_map(|m| {
842                String::from_utf8(m.credential.serialized_content().to_vec())
843                    .ok()
844                    .map(|id| (m.index, id))
845            })
846            .collect();
847        let resolve = |index: LeafNodeIndex| -> Option<String> {
848            roster
849                .iter()
850                .find(|(i, _)| *i == index)
851                .map(|(_, id)| id.clone())
852        };
853
854        let mut removed: Vec<String> = removed_indices
855            .iter()
856            .copied()
857            .filter_map(resolve)
858            .collect();
859
860        let metadata = match self.load_group_metadata(group_id) {
861            Ok(Some(metadata)) => metadata,
862            Ok(None) => {
863                warn!(
864                    group_id = %group_id,
865                    "Commit enforcement: no group metadata — merging (unknown admin set fails open)"
866                );
867                return Ok(());
868            }
869            Err(e) => {
870                warn!(
871                    group_id = %group_id,
872                    error = ?e,
873                    "Commit enforcement: could not read group metadata — merging (unknown admin set fails open)"
874                );
875                return Ok(());
876            }
877        };
878        if !metadata.has_any_admin() {
879            warn!(
880                group_id = %group_id,
881                "Commit enforcement: no admin role stored — merging (unknown admin set fails open)"
882            );
883            return Ok(());
884        }
885
886        // Every principal behind the *membership* change must be an admin:
887        // the committer, plus the sender of each Add/Remove proposal, since
888        // MLS lets a member commit a proposal another member made. A
889        // non-member proposal sender (external, new-member) can never be an
890        // admin of this group.
891        //
892        // Scoped to membership proposals on purpose, defensively: a commit may
893        // also carry Update or PSK proposals, and an Update is legitimate
894        // self-service that needs no admin, so rejecting an admin's Add because
895        // it batched a member's key update would fork the group over a proposal
896        // that changes no membership. No SDK client produces that shape today —
897        // there is no propose-only API, and a received `ProposalMessage` is
898        // dropped rather than stored — but the check should not become the
899        // thing that forks a group if that ever changes.
900        let membership_proposal_senders = staged_commit
901            .add_proposals()
902            .map(|p| p.sender().clone())
903            .chain(staged_commit.remove_proposals().map(|p| p.sender().clone()));
904        let mut unauthorized = metadata.get_role(committer) != GroupRole::Admin;
905        if !unauthorized {
906            unauthorized = membership_proposal_senders.into_iter().any(|s| match s {
907                Sender::Member(index) => resolve(index)
908                    .map(|id| metadata.get_role(&id) != GroupRole::Admin)
909                    .unwrap_or(true),
910                _ => true,
911            });
912        }
913
914        if unauthorized {
915            added.sort();
916            removed.sort();
917            warn!(
918                group_id = %group_id,
919                committer = %committer,
920                added = ?added,
921                removed = ?removed,
922                "Commit enforcement: refusing an unauthorized membership commit before merge"
923            );
924            return Err(MlsError::CommitNotAuthorized {
925                committer: committer.to_string(),
926                added,
927                removed,
928            });
929        }
930
931        Ok(())
932    }
933
934    /// Loads a group's metadata overlay (roles, creator) for the admin check.
935    ///
936    /// Deliberately duplicated from `MlsManager` rather than reached through
937    /// it: the manager holds this `GroupManager`, so calling back up would
938    /// invert ownership, and the protocol layer holds the manager behind an
939    /// `RwLock` that is already locked while decryption runs.
940    fn load_group_metadata(&self, group_id: &GroupId) -> Result<Option<GroupMetadata>> {
941        let key_type = StorageKeyType::GroupMetadata.as_str();
942        match self.storage.load(key_type, group_id.as_str())? {
943            Some(data) => {
944                let mut metadata: GroupMetadata = serde_json::from_slice(&data)
945                    .map_err(|e| MlsError::Deserialization(e.to_string()))?;
946                metadata.migrate_legacy_roles();
947                Ok(Some(metadata))
948            }
949            None => Ok(None),
950        }
951    }
952
953    /// Binds a staged Welcome to the `group_id` the caller authenticated.
954    ///
955    /// OpenMLS derives the id it persists the joined group under from the
956    /// Welcome's embedded `GroupContext` — a value the inviter picks freely at
957    /// group creation (`MlsGroup::new_with_group_id`). Our storage marker
958    /// ([`save_group`]) and every [`load_group`]/`delete_group` lookup are
959    /// instead keyed by the caller-supplied wire `group_id`, and every
960    /// SEC-M5/M6 binding (`verify_welcome_slot`, the `session:`
961    /// reserved-namespace check) validates only that wire field. If the two
962    /// diverge, `into_group` writes the group under the *embedded* id, letting
963    /// an authenticated inviter seed or overwrite an arbitrary slot the
964    /// wire-id checks never inspected (e.g. embed `session:alice:bob` behind a
965    /// benign wire id that passes `verify_welcome_slot`) and hijack the
966    /// victim's session. Assert the two agree *before* `into_group` so no
967    /// cross-slot state is ever persisted. Legitimate Welcomes always carry
968    /// embedded == wire (both created via `new_with_group_id(group_id)`), so
969    /// this only rejects forgeries.
970    ///
971    /// Staging has already consumed the one-time key package by this point;
972    /// that only burns the package (inherent to any Welcome processing) and
973    /// does not touch existing group state.
974    fn verify_staged_group_id(staged: &StagedWelcome, group_id: &GroupId) -> Result<()> {
975        let embedded = staged.group_context().group_id().as_slice();
976        if embedded != group_id.as_str().as_bytes() {
977            return Err(MlsError::WelcomeGroupIdMismatch {
978                expected: group_id.to_string(),
979                embedded: String::from_utf8_lossy(embedded).into_owned(),
980            });
981        }
982        Ok(())
983    }
984
985    /// Requires every leaf in a staged Welcome's ratchet tree to carry the
986    /// address its own signature key derives to (RFC 9420 §7.3, "when a client
987    /// validates a ratchet tree, e.g., when joining a group").
988    ///
989    /// The inviter chooses this tree wholesale. Nothing else validates it: the
990    /// wire `group_id` is bound by [`Self::verify_staged_group_id`] and the
991    /// inviter is authenticated a layer up, but neither says anything about
992    /// *who else* the inviter claims is in the room. Without this, an inviter
993    /// hands you a group whose roster names your real contacts on leaves the
994    /// inviter holds the keys to — and then speaks as them, since the only
995    /// thing standing between a leaf and an attributed message is SEC-M1's
996    /// comparison against that same unvalidated credential.
997    ///
998    /// Runs before `into_group`, alongside the group-id binding and for the
999    /// same reason: `into_group` is the step that persists, so a tree judged
1000    /// after it would already be installed. Staging has consumed the one-time
1001    /// key package by then, which costs the package and nothing else — the same
1002    /// trade [`Self::verify_staged_group_id`] documents.
1003    ///
1004    /// All-or-nothing on purpose. Dropping the offending leaves and joining
1005    /// anyway would leave us in a group whose tree we know to be forged, at an
1006    /// epoch every other member computed over the full tree — decrypting
1007    /// nothing and holding a roster that agrees with nobody.
1008    fn verify_staged_welcome_tree(staged: &StagedWelcome) -> Result<()> {
1009        for member in staged.members() {
1010            verify_leaf_binding(
1011                &member.credential,
1012                &member.signature_key,
1013                LeafSource::WelcomeTree,
1014            )?;
1015        }
1016
1017        // The commit gate refuses a proposal that would *add* an
1018        // `ExternalSenders` extension; a group created with one already in its
1019        // context arrives this way instead. Refused here too, so the two entry
1020        // gates are symmetric rather than differing by which one the inviter
1021        // chose. Inert today — `verify_sender_leaf` refuses `Sender::External`
1022        // at use time — but an entry gate that admits what the use gate always
1023        // refuses is a group we can never fully participate in, and it is
1024        // better to decline the invite than to hold it.
1025        if staged
1026            .group_context()
1027            .extensions()
1028            .external_senders()
1029            .is_some()
1030        {
1031            return Err(MlsError::UnsupportedSender {
1032                detail: "a Welcome's group context carries an ExternalSenders extension, \
1033                         which would authorize a non-member to send into this group"
1034                    .to_string(),
1035            });
1036        }
1037
1038        Ok(())
1039    }
1040
1041    /// Joins a group using a Welcome message.
1042    pub fn join_group(&self, welcome: Welcome, group_id: &GroupId) -> Result<MlsGroup> {
1043        let group_config = MlsGroupJoinConfig::builder()
1044            .use_ratchet_tree_extension(true)
1045            .sender_ratchet_configuration(sender_ratchet_configuration())
1046            .build();
1047
1048        let staged = StagedWelcome::new_from_welcome(&self.provider, &group_config, welcome, None)
1049            .map_err(|e| MlsError::WelcomeProcessing(format!("Failed to stage welcome: {}", e)))?;
1050
1051        // Bind the Welcome's embedded group id to the authenticated wire id
1052        // before persisting — otherwise `into_group` would install under the
1053        // attacker-chosen embedded id. See `verify_staged_group_id`.
1054        Self::verify_staged_group_id(&staged, group_id)?;
1055
1056        // The inviter picks the whole ratchet tree; judge every identity in it
1057        // before adopting the roster. See `verify_staged_welcome_tree`.
1058        Self::verify_staged_welcome_tree(&staged)?;
1059
1060        let group = staged
1061            .into_group(&self.provider)
1062            .map_err(|e| MlsError::WelcomeProcessing(format!("Failed to join group: {}", e)))?;
1063
1064        self.save_group(group_id, &group)?;
1065
1066        debug!(group_id = %group_id, "Joined group via Welcome");
1067        Ok(group)
1068    }
1069
1070    /// Joins a group from a Welcome, replacing any existing group at the same
1071    /// `group_id`. **Non-destructive on the common failure (a duplicate
1072    /// Welcome); best-effort — NOT atomic — on the rare storage failure.**
1073    ///
1074    /// Staging the incoming Welcome (`StagedWelcome::new_from_welcome`) is the
1075    /// step that consumes the one-time key package, so it is the natural failure
1076    /// point: if the key package is unavailable — e.g. a retransmitted Welcome we
1077    /// already adopted, or a first-contact key-package race — staging returns
1078    /// `Err` **before** we touch the existing group, which is therefore left
1079    /// completely intact. This makes both-create adoption idempotent: a duplicate
1080    /// Welcome is a safe no-op rather than a re-brick, the case this method exists
1081    /// to handle.
1082    ///
1083    /// Caveat — once staging succeeds the swap is **not atomic**. We delete the
1084    /// prior group (to avoid orphaning its old-leaf epoch encryption keypairs,
1085    /// which are keyed by `(group_id, epoch, leaf_index)`; the adopted group
1086    /// installs at a different leaf and so would not overwrite them) and then call
1087    /// `into_group`, which performs several sequential, non-transactional storage
1088    /// writes. If one of those fails (e.g. a Keychain/Keystore write error), the
1089    /// old group is already gone and the new one is absent — a lost session. The
1090    /// key package is spent by then, so the same Welcome cannot simply be retried;
1091    /// recovery is a fresh key-package exchange. The caller distinguishes this
1092    /// "lost group" outcome from a benign duplicate by observing `has_session`
1093    /// (false here, true for a duplicate) and surfaces it as a session failure.
1094    pub fn join_group_replacing(&self, welcome: Welcome, group_id: &GroupId) -> Result<MlsGroup> {
1095        let group_config = MlsGroupJoinConfig::builder()
1096            .use_ratchet_tree_extension(true)
1097            .sender_ratchet_configuration(sender_ratchet_configuration())
1098            .build();
1099
1100        // Stage first — non-destructive failure point (consumes the key package).
1101        // A failure here leaves the existing group untouched (benign duplicate).
1102        let staged = StagedWelcome::new_from_welcome(&self.provider, &group_config, welcome, None)
1103            .map_err(|e| MlsError::WelcomeProcessing(format!("Failed to stage welcome: {}", e)))?;
1104
1105        // Bind the Welcome's embedded group id to the authenticated wire id
1106        // before the destructive replace below. A forged Welcome whose embedded
1107        // id differs from `group_id` would otherwise `into_group` under the
1108        // embedded id and hijack that slot; rejecting here also leaves the
1109        // existing group at `group_id` untouched (no `delete_group` runs). See
1110        // `verify_staged_group_id`.
1111        Self::verify_staged_group_id(&staged, group_id)?;
1112
1113        // Same placement, same reason: refusing a forged tree here leaves the
1114        // existing group intact, because the destructive `delete_group` below
1115        // has not run yet. See `verify_staged_welcome_tree`.
1116        Self::verify_staged_welcome_tree(&staged)?;
1117
1118        // Staging consumed the key package; from here a failure is not
1119        // recoverable by retrying the same Welcome. Drop the prior group so its
1120        // old-leaf epoch keypairs are not orphaned. `delete_group` already
1121        // no-ops on absence and tolerates a corrupt prior group, so call it
1122        // unconditionally — a redundant `load_group` here would only add another
1123        // fallible storage read that could abort the join with the key package
1124        // already spent.
1125        self.delete_group(group_id)?;
1126
1127        let group = staged.into_group(&self.provider).map_err(|e| {
1128            // Distinct message from the staging failure above: the prior group is
1129            // already deleted and the key package is spent, so this is the
1130            // non-atomic "lost group" window, not a benign duplicate.
1131            MlsError::WelcomeProcessing(format!(
1132                "Failed to install adopted group (prior group removed, key package consumed): {}",
1133                e
1134            ))
1135        })?;
1136
1137        self.save_group(group_id, &group)?;
1138
1139        debug!(group_id = %group_id, "Joined/adopted group via Welcome (non-destructive stage, best-effort swap)");
1140        Ok(group)
1141    }
1142
1143    /// Gets information about a group.
1144    ///
1145    /// Members whose leaf does not carry the address its own signature key
1146    /// derives to are skipped with a warning, the same disposition
1147    /// [`Self::list_groups`] gives a stored group id that fails validation.
1148    ///
1149    /// Post-gates such a leaf cannot arrive over the wire, so one appearing
1150    /// here means local group state was written behind the SDK's back. Keeping
1151    /// it out of the roster matters because the roster is not only what the app
1152    /// displays: it addresses the per-member fan-out, feeds the group's
1153    /// rich-payload capability gate, and supplies the candidates for the
1154    /// address-ordered tiebreakers (leave election, admin auto-promotion, fork
1155    /// leader). A forged entry in it is not cosmetic.
1156    ///
1157    /// Skipping rather than failing keeps a tampered store from taking the
1158    /// whole group down, and the message it would have carried is refused
1159    /// anyway — `Self::verify_sender_leaf` judges the same leaf at decrypt.
1160    ///
1161    /// Skipping silently would be the mistake, though: this is the *only* seam
1162    /// at which a leaf already seated in local state surfaces at all, and a
1163    /// `warn!` reaches no app. Every other refusal in this family is reported.
1164    /// So the count rides out on [`GroupInfo::unproven_members`], and the
1165    /// protocol layer turns a non-zero one into the same
1166    /// `GroupLeafIdentityUnproven` warning the wire gates emit. The count and
1167    /// not the identities, for the reason that field documents.
1168    ///
1169    /// O(N) in the roster, against `Self::verify_sender_leaf`'s O(1): one
1170    /// SHA-256 over 32 bytes plus a bech32m encode per member, per call, and
1171    /// `process_commit_core` calls this twice per commit to derive a membership
1172    /// delta. Immaterial next to the MLS crypto on the same path — microseconds
1173    /// at a hundred members — but this is the expensive seam of the two, so do
1174    /// not reach for it in a loop.
1175    pub fn get_group_info(&self, group: &MlsGroup, group_id: &GroupId) -> GroupInfo {
1176        let mut unproven_members: u32 = 0;
1177        let members: Vec<String> = group
1178            .members()
1179            .filter_map(|m| {
1180                if let Err(e) =
1181                    verify_leaf_binding(&m.credential, &m.signature_key, LeafSource::RosterEntry)
1182                {
1183                    warn!(
1184                        group_id = %group_id,
1185                        error = %e,
1186                        "Skipping a group member whose leaf does not prove its own identity"
1187                    );
1188                    unproven_members = unproven_members.saturating_add(1);
1189                    return None;
1190                }
1191                let credential = m.credential.serialized_content();
1192                String::from_utf8(credential.to_vec()).ok()
1193            })
1194            .collect();
1195
1196        let is_session = group_id.as_str().starts_with("session:");
1197        let members_count = members.len() as u32;
1198
1199        GroupInfo {
1200            group_id: group_id.clone(),
1201            name: None,
1202            members,
1203            members_count,
1204            epoch: group.epoch().as_u64(),
1205            is_session,
1206            created_at_ms: 0,
1207            last_activity_ms: chrono::Utc::now().timestamp_millis() as u64,
1208            unproven_members,
1209        }
1210    }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215    use super::*;
1216    use crate::storage::InMemoryStorage;
1217    use crate::storage_adapter::MlsStorageAdapter;
1218
1219    /// Every name the classifier writes is one the reader accepts. A name
1220    /// missing from the list reads back as no detail at all, silently.
1221    #[test]
1222    fn every_decryption_detail_is_on_the_list() {
1223        use crate::error::DECRYPTION_DETAILS;
1224        let unable = |d| {
1225            ProcessMessageError::<StorageError>::ValidationError(ValidationError::UnableToDecrypt(
1226                d,
1227            ))
1228        };
1229        let errors = [
1230            unable(MessageDecryptionError::AeadError),
1231            unable(MessageDecryptionError::GenerationOutOfBound),
1232            unable(MessageDecryptionError::MalformedContent),
1233            unable(MessageDecryptionError::WrongWireFormat),
1234            unable(MessageDecryptionError::SecretTreeError(
1235                SecretTreeError::TooDistantInThePast,
1236            )),
1237            unable(MessageDecryptionError::SecretTreeError(
1238                SecretTreeError::SecretReuseError,
1239            )),
1240            unable(MessageDecryptionError::SecretTreeError(
1241                SecretTreeError::IndexOutOfBounds,
1242            )),
1243            ProcessMessageError::ValidationError(ValidationError::WrongGroupId),
1244            ProcessMessageError::IncompatibleWireFormat,
1245        ];
1246        for err in &errors {
1247            let name = decryption_detail(err);
1248            assert!(DECRYPTION_DETAILS.contains(&name), "{name} is not listed");
1249        }
1250    }
1251
1252    fn create_test_group_manager() -> GroupManager {
1253        let storage = Arc::new(InMemoryStorage::new());
1254        let adapter = MlsStorageAdapter::new(storage.clone());
1255        let provider = MlsProvider::new(adapter);
1256        GroupManager::new(storage, provider)
1257    }
1258
1259    #[test]
1260    fn test_group_manager_creation() {
1261        let manager = create_test_group_manager();
1262        assert!(manager.list_groups().unwrap().is_empty());
1263    }
1264
1265    /// An oversized credential is bounded before it reaches a log line or a
1266    /// user-facing `security_warning` reason.
1267    ///
1268    /// A credential is TLS `VLBytes` and a leaf reaching the binding check has
1269    /// proved nothing, so its content is attacker-chosen and unbounded on the
1270    /// wire. Both sinks interpolate it verbatim.
1271    #[test]
1272    fn an_oversized_credential_is_truncated_in_the_error() {
1273        let huge = vec![b'A'; 4096];
1274        let credential = Credential::new(CredentialType::Basic, huge);
1275
1276        let err = verify_leaf_binding(&credential, &[0u8; 31], LeafSource::WelcomeTree)
1277            .expect_err("a leaf claiming 4 KiB of nonsense must not pass");
1278        let MlsError::LeafAddressMismatch { claimed, .. } = &err else {
1279            panic!("got {err:?}");
1280        };
1281        assert!(
1282            claimed.len() < 128,
1283            "an unbounded credential reached the error text: {} bytes",
1284            claimed.len()
1285        );
1286        assert!(claimed.ends_with("… (truncated)"), "{claimed}");
1287
1288        // An honest address is well under the cap and must survive intact.
1289        let keys = openmls_basic_credential::SignatureKeyPair::new(
1290            DEFAULT_CIPHERSUITE.signature_algorithm(),
1291        )
1292        .unwrap();
1293        let address = crate::MlsManager::derive_address(keys.public()).unwrap();
1294        let honest = Credential::new(
1295            CredentialType::Basic,
1296            address.to_string().as_bytes().to_vec(),
1297        );
1298        let err = verify_leaf_binding(&honest, &[0u8; 31], LeafSource::WelcomeTree)
1299            .expect_err("a key that cannot derive must fail regardless");
1300        let MlsError::LeafAddressMismatch { claimed, .. } = &err else {
1301            panic!("got {err:?}");
1302        };
1303        assert_eq!(
1304            *claimed,
1305            address.to_string(),
1306            "a normal address must be reported verbatim"
1307        );
1308    }
1309
1310    /// A leaf whose signature key cannot yield an address at all is refused,
1311    /// not waved through.
1312    ///
1313    /// Unreachable through the wire — OpenMLS validates a key package's
1314    /// signature key against the ciphersuite before any of this runs, so a
1315    /// wrong-length key never reaches a tree — which is exactly why it is
1316    /// tested directly: the arm is defensive, and an untested fail-open written
1317    /// there would look identical to an untested fail-closed one. "No address
1318    /// could be derived" is not "nothing to check", it is a leaf that proved
1319    /// nothing.
1320    #[test]
1321    fn undecodable_signature_key_is_refused_not_skipped() {
1322        let credential = Credential::new(CredentialType::Basic, b"off1whatever".to_vec());
1323        // 31 bytes: one short of an Ed25519 public key.
1324        let err = verify_leaf_binding(&credential, &[0u8; 31], LeafSource::WelcomeTree)
1325            .expect_err("a key that cannot derive an address must not pass");
1326        assert!(
1327            matches!(err, MlsError::LeafAddressMismatch { ref derived, .. }
1328                if derived.contains("no address")),
1329            "got {err:?}"
1330        );
1331    }
1332
1333    /// The honest case, pinned next to it so the helper is not merely always
1334    /// failing.
1335    #[test]
1336    fn a_leaf_carrying_its_own_derived_address_passes() {
1337        use openmls_basic_credential::SignatureKeyPair;
1338
1339        let keys = SignatureKeyPair::new(DEFAULT_CIPHERSUITE.signature_algorithm()).unwrap();
1340        let address = crate::MlsManager::derive_address(keys.public()).unwrap();
1341        let credential = Credential::new(
1342            CredentialType::Basic,
1343            address.to_string().as_bytes().to_vec(),
1344        );
1345        verify_leaf_binding(&credential, keys.public(), LeafSource::WelcomeTree)
1346            .expect("a leaf that derives its own credential must pass");
1347    }
1348}