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