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}