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