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