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