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