Skip to main content

offline_protocol_mls/
manager.rs

1//! Main MLS manager - the primary interface for MLS operations.
2
3use crate::error::{MlsError, Result};
4use crate::group::{GroupManager, DEFAULT_CIPHERSUITE};
5use crate::provider::MlsProvider;
6use crate::session::SessionManager;
7use crate::storage::MlsStorage;
8use crate::storage_adapter::MlsStorageAdapter;
9use crate::types::{
10    EncryptedMessage, GroupId, GroupInfo, GroupMetadata, GroupRole, KeyPackageBundle,
11    MlsMessageType, StorageKeyType, WelcomeMessage,
12};
13
14use offline_protocol_core::Address;
15use openmls::prelude::tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize};
16use openmls::prelude::*;
17use openmls_basic_credential::SignatureKeyPair;
18use openmls_traits::OpenMlsProvider;
19use std::sync::{Arc, RwLock};
20use tracing::{debug, info, warn};
21use uuid::Uuid;
22
23/// Length of an Ed25519 public key, in bytes.
24const ED25519_PUBLIC_KEY_LEN: usize = 32;
25
26/// Default lifetime for key packages (30 days in seconds).
27const DEFAULT_KEY_PACKAGE_LIFETIME_SECS: u64 = 30 * 24 * 60 * 60;
28
29/// How long an expired key package's private init key is kept before it is
30/// destroyed (7 days in seconds).
31///
32/// Expiry stops a package being advertised; this window is what still lets a
33/// Welcome built against it just before that moment be processed. Past it the
34/// material is genuinely deleted — without this, every package this device ever
35/// minted would keep its init key in provider storage for the life of the
36/// install, because the only other thing that removes one is a peer actually
37/// using it.
38const KEY_PACKAGE_PURGE_GRACE_SECS: u64 = 7 * 24 * 60 * 60;
39
40/// Ceiling on live, unconsumed key packages the push path will keep in flight.
41///
42/// The pool is normally self-limiting: one package per peer, minted only when
43/// that peer's previous one was consumed or expired, so its size tracks the
44/// number of peers with an outstanding unused advertisement. This is the
45/// backstop for the case where it does not — a device meeting an unbounded
46/// stream of peers that never establish sessions. Reaching it degrades the
47/// push path to sharing one package again, which is what it did unconditionally
48/// before, so the failure mode is the old behaviour rather than a new one; it
49/// is reported (see `PushKeyPackagePoolExhausted`) rather than left silent.
50pub const MAX_PUSH_KEY_PACKAGES: usize = 64;
51
52/// A key package handed to the push path, plus how healthy the pool was.
53///
54/// The flag is not an error: the package is usable either way. It reports that
55/// the pool hit [`MAX_PUSH_KEY_PACKAGES`] and this package is therefore shared
56/// with another peer, which is the one condition under which the push path
57/// still has the reuse shape it otherwise no longer has.
58#[derive(Debug, Clone)]
59pub struct PushKeyPackage {
60    /// The package to advertise.
61    pub bundle: KeyPackageBundle,
62    /// Whether the pool ceiling forced this package to be shared.
63    pub pool_exhausted: bool,
64}
65
66/// Whether `candidate` was minted more recently than `current`, treating an
67/// absent `current` as older than anything.
68fn newer_than(candidate: &KeyPackageBundle, current: Option<&KeyPackageBundle>) -> bool {
69    current.is_none_or(|existing| candidate.created_at_ms > existing.created_at_ms)
70}
71
72/// Main MLS manager for end-to-end encryption.
73pub struct MlsManager {
74    /// The local user's ID.
75    user_id: String,
76
77    /// Storage backend for persisting MLS state.
78    storage: Arc<dyn MlsStorage>,
79
80    /// OpenMLS provider.
81    provider: MlsProvider,
82
83    /// Cached credential.
84    credential: RwLock<Option<CredentialWithKey>>,
85
86    /// Cached signature key pair (avoids re-reading storage on every crypto op).
87    cached_signer: RwLock<Option<SignatureKeyPair>>,
88
89    /// Session manager for 1:1 messaging.
90    session_manager: SessionManager,
91
92    /// Group manager for multi-party groups.
93    group_manager: GroupManager,
94}
95
96impl MlsManager {
97    /// Creates a new MLS manager.
98    pub fn new(user_id: impl Into<String>, storage: Arc<dyn MlsStorage>) -> Result<Self> {
99        let user_id = user_id.into();
100
101        let adapter = MlsStorageAdapter::new(storage.clone());
102        let provider = MlsProvider::new(adapter);
103
104        let session_manager =
105            SessionManager::new(user_id.clone(), storage.clone(), provider.clone());
106        let group_manager = GroupManager::new(storage.clone(), provider.clone());
107
108        let manager = Self {
109            user_id,
110            storage,
111            provider,
112            credential: RwLock::new(None),
113            cached_signer: RwLock::new(None),
114            session_manager,
115            group_manager,
116        };
117
118        manager.ensure_identity()?;
119
120        Ok(manager)
121    }
122
123    /// Returns the local user's ID.
124    pub fn user_id(&self) -> &str {
125        &self.user_id
126    }
127
128    /// Loads this device's identity key from `storage`, minting and persisting
129    /// one on first call, and returns the address it derives to.
130    ///
131    /// This is the bootstrap entry point: it exists so the address can be known
132    /// *before* an [`MlsManager`] is constructed. The manager needs its own id
133    /// at construction time (it goes into the credential and every session
134    /// slot), but under self-certifying addressing that id is a function of the
135    /// identity key — which used to be minted inside the constructor. Callers
136    /// break the circle by calling this first and passing the resulting address
137    /// to [`Self::new`].
138    ///
139    /// Idempotent: a second call returns the stored key and the same address.
140    /// The keypair is written before the address is derived, so a caller that
141    /// crashes between the two finds the same identity on the next start rather
142    /// than minting a second one.
143    pub fn load_or_create_identity(
144        storage: &Arc<dyn MlsStorage>,
145    ) -> Result<(SignatureKeyPair, Address)> {
146        let key_type = StorageKeyType::Identity.as_str();
147
148        let signature_keys = match storage.load(key_type, "key_pair")? {
149            Some(json) => serde_json::from_slice(&json).map_err(|e| {
150                MlsError::Deserialization(format!("Failed to deserialize signature keys: {}", e))
151            })?,
152            None => {
153                let keys = SignatureKeyPair::new(DEFAULT_CIPHERSUITE.signature_algorithm())
154                    .map_err(|e| MlsError::CryptoGeneration(format!("{:?}", e)))?;
155                let keys_json = serde_json::to_vec(&keys)
156                    .map_err(|e| MlsError::Serialization(e.to_string()))?;
157                storage.store(key_type, "key_pair", &keys_json)?;
158                info!("Minted a new identity key");
159                keys
160            }
161        };
162
163        let address = Self::derive_address(signature_keys.public())?;
164        Ok((signature_keys, address))
165    }
166
167    /// Ensures the user has an identity.
168    fn ensure_identity(&self) -> Result<()> {
169        if self.load_identity()? {
170            return Ok(());
171        }
172        self.create_identity()?;
173        info!(user_id = %self.user_id, "Created new MLS identity");
174        Ok(())
175    }
176
177    /// Requires the stored identity key to derive to `self.user_id` when that
178    /// id is an address.
179    ///
180    /// A nickname id (tests, legacy) has no derivation to check, so it passes
181    /// unchecked — the check is about catching a *broken* self-certifying
182    /// identity, not about enforcing that ids are addresses.
183    fn verify_identity_binding(&self, public_key: &[u8]) -> Result<()> {
184        let Ok(expected) = self.user_id.parse::<Address>() else {
185            return Ok(());
186        };
187
188        let derived = Self::derive_address(public_key)?;
189        if derived != expected {
190            return Err(MlsError::IdentityAddressMismatch {
191                expected: expected.to_string(),
192                derived: derived.to_string(),
193            });
194        }
195        Ok(())
196    }
197
198    /// Loads the identity from storage.
199    fn load_identity(&self) -> Result<bool> {
200        let key_type = StorageKeyType::Identity.as_str();
201        let keys_data = self.storage.load(key_type, "key_pair")?;
202
203        match keys_data {
204            Some(json) => {
205                let signature_keys: SignatureKeyPair =
206                    serde_json::from_slice(&json).map_err(|e| {
207                        MlsError::Deserialization(format!(
208                            "Failed to deserialize signature keys: {}",
209                            e
210                        ))
211                    })?;
212
213                let public_key = signature_keys.public();
214
215                // Under self-certifying addressing the id is this key's hash,
216                // so a disagreement means the wrong namespace was opened or the
217                // stored key was replaced. Caught here rather than shipping a
218                // credential every peer would reject.
219                self.verify_identity_binding(public_key)?;
220
221                let credential =
222                    Credential::new(CredentialType::Basic, self.user_id.as_bytes().to_vec());
223
224                let credential_with_key = CredentialWithKey {
225                    credential,
226                    signature_key: public_key.into(),
227                };
228
229                {
230                    let mut guard = self
231                        .credential
232                        .write()
233                        .map_err(|_| MlsError::NotInitialized)?;
234                    *guard = Some(credential_with_key);
235                }
236                {
237                    let mut guard = self
238                        .cached_signer
239                        .write()
240                        .map_err(|_| MlsError::NotInitialized)?;
241                    *guard = Some(signature_keys);
242                }
243
244                debug!(user_id = %self.user_id, "Loaded existing MLS identity");
245                Ok(true)
246            }
247            None => Ok(false),
248        }
249    }
250
251    /// Creates a new identity.
252    fn create_identity(&self) -> Result<()> {
253        let signature_keys = SignatureKeyPair::new(DEFAULT_CIPHERSUITE.signature_algorithm())
254            .map_err(|e| MlsError::CryptoGeneration(format!("{:?}", e)))?;
255
256        // A freshly minted key derives to a fresh address, so an
257        // address-shaped `user_id` reaching here means the caller asked this
258        // manager to *claim* an address whose key it does not hold — the
259        // storage was empty when it should have held that identity. Checked
260        // before the key is persisted so the refusal leaves nothing behind.
261        // The supported bootstrap is `load_or_create_identity` first, then
262        // `new` with the address it returned.
263        self.verify_identity_binding(signature_keys.public())?;
264
265        let keys_json = serde_json::to_vec(&signature_keys)
266            .map_err(|e| MlsError::Serialization(e.to_string()))?;
267
268        let key_type = StorageKeyType::Identity.as_str();
269        self.storage.store(key_type, "key_pair", &keys_json)?;
270
271        let public_key = signature_keys.public();
272
273        let credential = Credential::new(CredentialType::Basic, self.user_id.as_bytes().to_vec());
274
275        let credential_with_key = CredentialWithKey {
276            credential,
277            signature_key: public_key.into(),
278        };
279
280        {
281            let mut guard = self
282                .credential
283                .write()
284                .map_err(|_| MlsError::NotInitialized)?;
285            *guard = Some(credential_with_key);
286        }
287        {
288            let mut guard = self
289                .cached_signer
290                .write()
291                .map_err(|_| MlsError::NotInitialized)?;
292            *guard = Some(signature_keys);
293        }
294
295        Ok(())
296    }
297
298    /// Gets the credential with key.
299    fn get_credential(&self) -> Result<CredentialWithKey> {
300        let guard = self
301            .credential
302            .read()
303            .map_err(|_| MlsError::NotInitialized)?;
304        guard.clone().ok_or(MlsError::NotInitialized)
305    }
306
307    /// Gets a signer for MLS operations, using the in-memory cache.
308    ///
309    /// `SignatureKeyPair` doesn't implement `Clone`, so we serialize from
310    /// the cached copy to avoid hitting storage on every crypto operation.
311    fn get_signer(&self) -> Result<SignatureKeyPair> {
312        let guard = self
313            .cached_signer
314            .read()
315            .map_err(|_| MlsError::NotInitialized)?;
316
317        let cached = guard.as_ref().ok_or(MlsError::NotInitialized)?;
318        let bytes =
319            serde_json::to_vec(cached).map_err(|e| MlsError::Serialization(e.to_string()))?;
320        serde_json::from_slice(&bytes).map_err(|e| MlsError::Deserialization(e.to_string()))
321    }
322
323    // ========================================================================
324    // KEY PACKAGE MANAGEMENT
325    // ========================================================================
326
327    /// Generates a new key package for distribution over the push path.
328    pub fn generate_key_package(&self) -> Result<KeyPackageBundle> {
329        self.generate_key_package_inner(false)
330    }
331
332    /// Generates a key package reserved for a publication slot.
333    ///
334    /// Distinct from [`Self::generate_key_package`] only in that the result is
335    /// withheld from [`Self::get_or_create_key_package`] — see
336    /// [`KeyPackageBundle::reserved_for_publication`] for why sharing one
337    /// package between a published record and a pushed exchange corrupts both.
338    pub fn generate_publication_key_package(&self) -> Result<KeyPackageBundle> {
339        self.generate_key_package_inner(true)
340    }
341
342    fn generate_key_package_inner(&self, reserved: bool) -> Result<KeyPackageBundle> {
343        let credential = self.get_credential()?;
344        let signature_keys = self.get_signer()?;
345
346        let key_package_bundle = KeyPackage::builder()
347            .build(
348                DEFAULT_CIPHERSUITE,
349                &self.provider,
350                &signature_keys,
351                credential,
352            )
353            .map_err(|e| MlsError::KeyPackageCreation(e.to_string()))?;
354
355        let key_package_data = key_package_bundle
356            .key_package()
357            .tls_serialize_detached()
358            .map_err(|e| MlsError::Serialization(e.to_string()))?;
359
360        // Stamped now because deriving it later from the serialized bytes
361        // costs a parse plus a signature validation, which the pool scan
362        // would pay per stored package per push. Best-effort: a record
363        // without it takes the derive path once and is backfilled on load.
364        let provider_hash_ref = key_package_bundle
365            .key_package()
366            .hash_ref(self.provider.crypto())
367            .ok()
368            .and_then(|hash_ref| hash_ref.tls_serialize_detached().ok());
369
370        let package_id = Uuid::new_v4().to_string();
371
372        let key_type = StorageKeyType::KeyPackage.as_str();
373        let mut bundle = KeyPackageBundle::new(
374            package_id,
375            self.user_id.clone(),
376            key_package_data,
377            DEFAULT_KEY_PACKAGE_LIFETIME_SECS,
378        );
379        bundle.reserved_for_publication = reserved;
380        bundle.provider_hash_ref = provider_hash_ref;
381        let serialized =
382            serde_json::to_vec(&bundle).map_err(|e| MlsError::Serialization(e.to_string()))?;
383        self.storage
384            .store(key_type, &bundle.package_id, &serialized)?;
385
386        debug!(package_id = %bundle.package_id, "Generated new key package");
387        Ok(bundle)
388    }
389
390    /// Gets an existing *unclaimed* key package or generates a new one.
391    ///
392    /// Two kinds of package are skipped, for the same underlying reason — an
393    /// MLS init key is consumed by its first user, so two parties must never be
394    /// pointed at one:
395    ///
396    /// - packages reserved for publication slots, or a pushed-to peer and a
397    ///   stranger who fetched the published record would race for it;
398    /// - packages already claimed by a peer over the push path
399    ///   ([`Self::take_push_key_package`]), or this peer-less entry point would
400    ///   hand out a key another peer is expected to use.
401    ///
402    /// This is the peer-less escape hatch (the FFI surface and tests). Callers
403    /// that know the recipient should use [`Self::take_push_key_package`],
404    /// which is what actually holds the one-key-per-peer property; a package
405    /// handed out here is unclaimed, so the push path may later claim it for
406    /// the first peer it is pushed to.
407    pub fn get_or_create_key_package(&self) -> Result<KeyPackageBundle> {
408        let key_type = StorageKeyType::KeyPackage.as_str();
409        let packages = self.storage.list_keys(key_type)?;
410
411        for package_id in packages {
412            if let Some(bundle) = self.load_stored_key_package(&package_id)? {
413                if bundle.reserved_for_publication || bundle.assigned_peer.is_some() {
414                    continue;
415                }
416                return Ok(bundle);
417            }
418        }
419
420        self.generate_key_package()
421    }
422
423    /// Hands `peer_id` the key package to advertise to it, minting one if it
424    /// has no live package of its own.
425    ///
426    /// This is the push path's entry point, and the whole point of it is that
427    /// two peers never receive the same init key. Before this existed the push
428    /// path returned the first stored package to every caller until somebody's
429    /// Welcome consumed it — the LastResort-style reuse RFC 9420 §16.8 permits
430    /// only as a denial-of-service fallback, and which the Least Authority MDK
431    /// audit flagged as enabling unsolicited joins and cross-group linkage. It
432    /// also made a second peer's Welcome unprocessable once the first had used
433    /// the key, with nothing to re-drive the exchange.
434    ///
435    /// Assignment is stored on the package rather than in a separate map, so it
436    /// survives restarts for free and cannot disagree with the pool it
437    /// describes. Resolution order:
438    ///
439    /// 1. this peer's own live package — a repeat push costs no new material;
440    /// 2. an unclaimed package (minted by the peer-less entry point, or by a
441    ///    build predating assignment), claimed here so upgrading does not
442    ///    strand it;
443    /// 3. a freshly minted package, claimed for this peer.
444    ///
445    /// Past [`MAX_PUSH_KEY_PACKAGES`] live packages it stops growing the pool
446    /// and reuses the newest one, reporting that through
447    /// [`PushKeyPackage::pool_exhausted`] so the caller can surface it. That is
448    /// a deliberate degradation to the old behaviour rather than an error: the
449    /// alternative — refusing to advertise, or evicting a package a peer may
450    /// still be about to use — costs session establishment, which is worse than
451    /// the weakened forward secrecy it would buy.
452    ///
453    /// Only step 3 is gated by the ceiling, because only minting grows the
454    /// pool: claiming an unclaimed package relabels one that already exists, so
455    /// degrading to a shared package while a claimable one sits idle would buy
456    /// nothing. A corollary is that the shared package is always assigned to
457    /// *another* peer — reaching that branch means neither an own nor an
458    /// unclaimed package was found — and "newest" makes it the most recently
459    /// assigned one, which has the most lifetime left but is also the peer most
460    /// likely to be mid-establishment. If the over-ceiling peer's Welcome lands
461    /// first, that peer's held advertisement becomes unprocessable until the
462    /// next push to it mints a successor. Sharing the oldest instead would
463    /// trade that for the shortest remaining lifetime; neither is free, and the
464    /// exhaustion report is what makes the condition visible either way.
465    ///
466    /// Consumption is what rotates a peer's key: a Welcome built against the
467    /// package removes its init key from provider storage, the loader then
468    /// reports the package gone, and the next push to that peer mints a fresh
469    /// one.
470    pub fn take_push_key_package(&self, peer_id: &str) -> Result<PushKeyPackage> {
471        let key_type = StorageKeyType::KeyPackage.as_str();
472        let package_ids = self.storage.list_keys(key_type)?;
473
474        let mut live = 0usize;
475        let mut assigned_here: Option<KeyPackageBundle> = None;
476        let mut unclaimed: Option<KeyPackageBundle> = None;
477        let mut newest: Option<KeyPackageBundle> = None;
478
479        // One pass, which also does the pruning: `load_stored_key_package`
480        // drops consumed and long-expired entries as it reads them, so the
481        // count below is of genuinely usable packages.
482        for package_id in package_ids {
483            let Some(bundle) = self.load_stored_key_package(&package_id)? else {
484                continue;
485            };
486            if bundle.reserved_for_publication {
487                continue;
488            }
489            live += 1;
490
491            if bundle.assigned_peer.as_deref() == Some(peer_id) {
492                // Newest wins if a past interruption left this peer two.
493                if newer_than(&bundle, assigned_here.as_ref()) {
494                    assigned_here = Some(bundle.clone());
495                }
496            } else if bundle.assigned_peer.is_none() && newer_than(&bundle, unclaimed.as_ref()) {
497                unclaimed = Some(bundle.clone());
498            }
499
500            if newer_than(&bundle, newest.as_ref()) {
501                newest = Some(bundle);
502            }
503        }
504
505        if let Some(bundle) = assigned_here {
506            return Ok(PushKeyPackage {
507                bundle,
508                pool_exhausted: false,
509            });
510        }
511
512        // Before the ceiling check, not after: claiming relabels a package that
513        // already exists, so it cannot breach a bound on how many exist.
514        if let Some(bundle) = unclaimed {
515            return Ok(PushKeyPackage {
516                bundle: self.claim_key_package(bundle, peer_id)?,
517                pool_exhausted: false,
518            });
519        }
520
521        // Minting is the only thing left that would grow the pool, so it is the
522        // only thing the ceiling gates.
523        if live >= MAX_PUSH_KEY_PACKAGES {
524            // `live` is non-zero here, so `newest` is `Some`; minting on the
525            // unreachable branch keeps this total without an unwrap.
526            if let Some(bundle) = newest {
527                debug!(
528                    peer_id = %peer_id,
529                    live = live,
530                    "Push key-package pool at capacity; reusing an existing package"
531                );
532                return Ok(PushKeyPackage {
533                    bundle,
534                    pool_exhausted: true,
535                });
536            }
537        }
538
539        let bundle = self.claim_key_package(self.generate_key_package()?, peer_id)?;
540
541        Ok(PushKeyPackage {
542            bundle,
543            pool_exhausted: false,
544        })
545    }
546
547    /// Records `peer_id` as the owner of `bundle` and persists the claim.
548    fn claim_key_package(
549        &self,
550        mut bundle: KeyPackageBundle,
551        peer_id: &str,
552    ) -> Result<KeyPackageBundle> {
553        bundle.assigned_peer = Some(peer_id.to_string());
554        let serialized =
555            serde_json::to_vec(&bundle).map_err(|e| MlsError::Serialization(e.to_string()))?;
556        self.storage.store(
557            StorageKeyType::KeyPackage.as_str(),
558            &bundle.package_id,
559            &serialized,
560        )?;
561        debug!(
562            package_id = %bundle.package_id,
563            peer_id = %peer_id,
564            "Assigned key package to peer"
565        );
566        Ok(bundle)
567    }
568
569    /// Loads a specific key package this device owns, or `None` if it is gone.
570    ///
571    /// `None` is the *consumption signal* the publication slots run on: the
572    /// load prunes and reports missing any package whose private init key has
573    /// left the OpenMLS provider, which is exactly what processing a Welcome
574    /// built against it does. A slot whose package reads back `None` has been
575    /// used by somebody and needs replacing.
576    pub fn key_package_by_id(&self, package_id: &str) -> Result<Option<KeyPackageBundle>> {
577        self.load_stored_key_package(package_id)
578    }
579
580    /// Deletes a key package this device owns.
581    ///
582    /// Exists to reclaim a *publication* package that was minted but whose
583    /// record was never built or queued. Such a package is reserved, so
584    /// [`Self::get_or_create_key_package`] will never hand it out, and no slot
585    /// references it — nothing else would remove it before its lifetime runs
586    /// out, so a repeatedly failing publish would otherwise strand fresh
587    /// provider key material every refresh.
588    ///
589    /// "Strand" is meant literally, which is why this destroys the private init
590    /// key too: deleting the bundle record alone leaves the material OpenMLS
591    /// holds in place with nothing left pointing at it.
592    pub fn delete_key_package(&self, package_id: &str) -> Result<()> {
593        let Some(data) = self
594            .storage
595            .load(StorageKeyType::KeyPackage.as_str(), package_id)?
596        else {
597            return Ok(());
598        };
599
600        match serde_json::from_slice::<KeyPackageBundle>(&data) {
601            Ok(bundle) => self.purge_key_package_material(package_id, &bundle),
602            // Legacy raw storage: the record *is* the serialized key package,
603            // the same reading `load_stored_key_package` applies when it
604            // upgrades one. Wrapping it lets the purge derive the provider ref
605            // and destroy the init key instead of stranding it — and if the
606            // bytes are not a key package at all, the purge finds no ref and
607            // falls back to deleting the record, which is all that is left to
608            // do for a record that names nothing.
609            Err(_) => {
610                let legacy = KeyPackageBundle::new(
611                    package_id.to_string(),
612                    self.user_id.clone(),
613                    data,
614                    DEFAULT_KEY_PACKAGE_LIFETIME_SECS,
615                );
616                self.purge_key_package_material(package_id, &legacy)
617            }
618        }
619    }
620
621    /// Imports a contact's key package for later use.
622    ///
623    /// Security (SEC-M5): the caller-supplied `user_id` becomes both the
624    /// storage key and — at session creation — the identity this key
625    /// package is trusted for. Reject storage-hostile ids (the id is a raw
626    /// `key_id`), cryptographically validate the key package, and require
627    /// the embedded credential identity to equal `user_id` so a key
628    /// package generated by one user can never be imported under
629    /// another's name.
630    ///
631    /// The identity claim is then *proved* rather than trusted: see
632    /// `Self::verify_address_binding`. No Authentication Service verdict
633    /// travels with the call any more, because there is nothing for the caller
634    /// to know — the key package carries its own proof.
635    pub fn import_key_package(&self, user_id: &str, key_package_data: &[u8]) -> Result<()> {
636        offline_protocol_core::validate_id_chars(user_id, "User ID")
637            .map_err(|e| MlsError::InvalidUserId(e.to_string()))?;
638
639        let key_package_in = KeyPackageIn::tls_deserialize_exact(key_package_data)
640            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?;
641        let key_package = key_package_in
642            .validate(self.provider.crypto(), ProtocolVersion::Mls10)
643            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?;
644
645        Self::verify_credential_identity(&key_package, user_id)?;
646        Self::verify_address_binding(&key_package, user_id)?;
647
648        let key_type = StorageKeyType::ContactKeyPackage.as_str();
649        self.storage.store(key_type, user_id, key_package_data)?;
650
651        debug!(user_id = %user_id, "Imported key package for contact");
652        Ok(())
653    }
654
655    /// Gets a contact's key package.
656    fn get_contact_key_package(&self, user_id: &str) -> Result<KeyPackage> {
657        let key_type = StorageKeyType::ContactKeyPackage.as_str();
658        let data = self
659            .storage
660            .load(key_type, user_id)?
661            .ok_or_else(|| MlsError::NoKeyPackage(user_id.to_string()))?;
662
663        let key_package_in = KeyPackageIn::tls_deserialize_exact(&data)
664            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?;
665
666        // Validate using the crypto backend
667        let key_package = key_package_in
668            .validate(self.provider.crypto(), ProtocolVersion::Mls10)
669            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?;
670
671        // Defense in depth: entries written to storage out-of-band must not
672        // come back attributed to the wrong user. Both checks run again here
673        // rather than only at import, because a cached package is re-read long
674        // after the receive-time gate that admitted it — which is exactly the
675        // window a container write aims at.
676        Self::verify_credential_identity(&key_package, user_id)?;
677        Self::verify_address_binding(&key_package, user_id)?;
678
679        Ok(key_package)
680    }
681
682    /// Requires the key package's leaf signature key to derive to the address
683    /// in `user_id`.
684    ///
685    /// This is the check that makes a key package's identity claim mean
686    /// something. [`Self::verify_credential_identity`] compares the leaf's
687    /// *basic credential*, which per RFC 9420 is "a bare assertion of an
688    /// identity" — self-asserted, so an attacker who generates their own
689    /// signature keypair and writes someone else's address into the credential
690    /// passes it. The leaf signature key is not self-asserted relative to an
691    /// address: the address *is* its hash, so re-deriving it either reproduces
692    /// the claim or refutes it.
693    ///
694    /// This matters because a validated key package is not necessarily a
695    /// *freshly received* one. Packages are cached in the install-scoped
696    /// protocol-state store and re-read later, and the receive-time signature
697    /// gate does not travel with them. Without this comparison, anyone able to
698    /// write that store could swap in their own package and have the local node
699    /// build the session — and encrypt to — their leaf instead of the peer's.
700    ///
701    /// # Why a non-address `user_id` is an error, not a skip
702    ///
703    /// Answering `Ok(())` for an id that does not parse as an [`Address`] would
704    /// hand every attacker the bypass: claim a nickname and the check that
705    /// distinguishes you from its owner never runs. The predecessor of this
706    /// function *was* conditional — it checked only when the caller held a pin
707    /// — and that conditionality is precisely what an unpinned peer exploited.
708    /// It is unconditional here, which is what makes it the whole trust
709    /// mechanism rather than a supplement to one.
710    fn verify_address_binding(key_package: &KeyPackage, user_id: &str) -> Result<()> {
711        let claimed = user_id.parse::<Address>().map_err(|e| {
712            MlsError::InvalidUserId(format!("'{}' is not an address: {}", user_id, e))
713        })?;
714
715        let derived = Self::derive_address(key_package.leaf_node().signature_key().as_slice())?;
716        if derived != claimed {
717            return Err(MlsError::KeyPackageAddressMismatch {
718                claimed: claimed.to_string(),
719                derived: derived.to_string(),
720            });
721        }
722        Ok(())
723    }
724
725    /// Requires the key package's leaf credential identity to equal
726    /// `user_id`. Credentials in this SDK are basic credentials carrying
727    /// the owner's user id as raw bytes (see `IdentityManager`).
728    fn verify_credential_identity(key_package: &KeyPackage, user_id: &str) -> Result<()> {
729        let identity = key_package.leaf_node().credential().serialized_content();
730        if identity != user_id.as_bytes() {
731            return Err(MlsError::CredentialIdentityMismatch {
732                expected: user_id.to_string(),
733                found: String::from_utf8_lossy(identity).into_owned(),
734            });
735        }
736        Ok(())
737    }
738
739    /// Gets pending key packages.
740    pub fn get_pending_key_packages(&self) -> Result<Vec<KeyPackageBundle>> {
741        let key_type = StorageKeyType::KeyPackage.as_str();
742        let package_ids = self.storage.list_keys(key_type)?;
743
744        let mut bundles = Vec::new();
745        for package_id in package_ids {
746            if let Some(bundle) = self.load_stored_key_package(&package_id)? {
747                bundles.push(bundle);
748            }
749        }
750
751        Ok(bundles)
752    }
753
754    /// Marks a key package as synced.
755    pub fn mark_key_package_synced(&self, package_id: &str) -> Result<()> {
756        let key_type = StorageKeyType::KeyPackage.as_str();
757        self.storage.delete(key_type, package_id)?;
758        debug!(package_id = %package_id, "Marked key package as synced");
759        Ok(())
760    }
761
762    // ========================================================================
763    // 1:1 ENCRYPTED MESSAGING
764    // ========================================================================
765
766    /// Checks if a session exists with another user.
767    pub fn has_session(&self, other_user_id: &str) -> Result<bool> {
768        self.session_manager.has_session(other_user_id)
769    }
770
771    /// Creates a new 1:1 session with another user.
772    pub fn create_session(&self, other_user_id: &str) -> Result<WelcomeMessage> {
773        let their_key_package = self.get_contact_key_package(other_user_id)?;
774        let credential = self.get_credential()?;
775        let signature_keys = self.get_signer()?;
776
777        let welcome = self.session_manager.create_session(
778            other_user_id,
779            their_key_package,
780            &credential,
781            &signature_keys,
782        )?;
783
784        let key_type = StorageKeyType::ContactKeyPackage.as_str();
785        self.storage.delete(key_type, other_user_id)?;
786
787        Ok(welcome)
788    }
789
790    /// Joins a session using a Welcome message.
791    pub fn join_session(&self, welcome: &WelcomeMessage) -> Result<GroupInfo> {
792        self.session_manager.join_session(welcome)
793    }
794
795    /// Replaces an existing session with an incoming Welcome message.
796    ///
797    /// This implements the "welcome-wins" strategy for race condition resolution.
798    /// When both peers simultaneously create a session, this method allows one peer
799    /// to replace their own session with the other peer's Welcome, ensuring both
800    /// end up with the same cryptographic state.
801    pub fn replace_session_with_welcome(&self, welcome: &WelcomeMessage) -> Result<GroupInfo> {
802        let other_user_id = &welcome.inviter_id;
803
804        // Security: `inviter_id` arrives on the wire and is used below as a
805        // raw storage key for deletes — reject storage-hostile values just
806        // like `import_key_package` does for its user id.
807        offline_protocol_core::validate_id_chars(other_user_id, "User ID")
808            .map_err(|e| MlsError::InvalidUserId(e.to_string()))?;
809
810        // SEC-M6: reject a mismatched session slot BEFORE the best-effort
811        // deletes below, so a forged Welcome that squats a third party's slot
812        // performs no mutation. `join_session` re-checks this as its own
813        // boundary; hoisting it here keeps the reject side effect-free.
814        self.session_manager.verify_welcome_slot(welcome)?;
815
816        // Clear any pending welcome we were about to send
817        let _ = self.clear_pending_welcome(other_user_id);
818
819        // Delete conflicting contact key package (we no longer need it)
820        let key_type = StorageKeyType::ContactKeyPackage.as_str();
821        let _ = self.storage.delete(key_type, other_user_id);
822
823        // Join using their Welcome. `join_session` adopts non-destructively
824        // (stage-then-swap), so a retransmitted Welcome that re-stages is a safe
825        // no-op rather than deleting and re-creating our existing session.
826        self.session_manager.join_session(welcome)
827    }
828
829    /// Encrypts a message for a 1:1 session.
830    pub fn encrypt_for_user(
831        &self,
832        other_user_id: &str,
833        plaintext: &[u8],
834    ) -> Result<EncryptedMessage> {
835        if !self.has_session(other_user_id)? {
836            let welcome = self.create_session(other_user_id)?;
837            warn!(
838                other_user_id = %other_user_id,
839                "Created new session - Welcome message needs to be sent"
840            );
841            let key_type = StorageKeyType::PendingWelcome.as_str();
842            let welcome_data =
843                serde_json::to_vec(&welcome).map_err(|e| MlsError::Serialization(e.to_string()))?;
844            self.storage.store(key_type, other_user_id, &welcome_data)?;
845        }
846
847        let signature_keys = self.get_signer()?;
848        self.session_manager
849            .encrypt_message(other_user_id, plaintext, &signature_keys)
850    }
851
852    /// Encrypts a message for a 1:1 session that is known to exist.
853    ///
854    /// Unlike `encrypt_for_user`, this skips the `has_session()` storage check
855    /// and goes directly to `encrypt_message()`. The caller must guarantee the
856    /// session exists (e.g., via an in-memory cache). If the session was deleted
857    /// externally, `SessionNotFound` is returned.
858    pub fn encrypt_for_existing_session(
859        &self,
860        other_user_id: &str,
861        plaintext: &[u8],
862    ) -> Result<EncryptedMessage> {
863        let signature_keys = self.get_signer()?;
864        self.session_manager
865            .encrypt_message(other_user_id, plaintext, &signature_keys)
866    }
867
868    /// Gets a pending Welcome message.
869    pub fn get_pending_welcome(&self, other_user_id: &str) -> Result<Option<WelcomeMessage>> {
870        let key_type = StorageKeyType::PendingWelcome.as_str();
871        match self.storage.load(key_type, other_user_id)? {
872            Some(data) => {
873                let welcome: WelcomeMessage = serde_json::from_slice(&data)
874                    .map_err(|e| MlsError::Deserialization(e.to_string()))?;
875                Ok(Some(welcome))
876            }
877            None => Ok(None),
878        }
879    }
880
881    /// Clears a pending Welcome message.
882    pub fn clear_pending_welcome(&self, other_user_id: &str) -> Result<()> {
883        let key_type = StorageKeyType::PendingWelcome.as_str();
884        self.storage.delete(key_type, other_user_id)?;
885        Ok(())
886    }
887
888    /// Decrypts a message from a 1:1 session.
889    ///
890    /// `claimed_sender` is the transport-level sender this message will be
891    /// attributed to; decryption fails with
892    /// [`MlsError::SenderIdentityMismatch`] if it does not match the
893    /// MLS-authenticated credential (SEC-M1).
894    pub fn decrypt_from_user(
895        &self,
896        encrypted: &EncryptedMessage,
897        claimed_sender: &str,
898    ) -> Result<Option<Vec<u8>>> {
899        self.session_manager
900            .decrypt_message(encrypted, claimed_sender)
901    }
902
903    /// Lists all active 1:1 sessions.
904    pub fn list_sessions(&self) -> Result<Vec<String>> {
905        self.session_manager.list_sessions()
906    }
907
908    /// Deletes a 1:1 session.
909    pub fn delete_session(&self, other_user_id: &str) -> Result<()> {
910        self.session_manager.delete_session(other_user_id)
911    }
912
913    // ========================================================================
914    // GROUP MESSAGING
915    // ========================================================================
916
917    /// Creates a new group.
918    pub fn create_group(&self, group_name: &str) -> Result<GroupInfo> {
919        let group_id = GroupId::new(format!("group:{}", Uuid::new_v4()))?;
920        let credential = self.get_credential()?;
921        let signature_keys = self.get_signer()?;
922
923        let group = self
924            .group_manager
925            .create_group(&group_id, &credential, &signature_keys)?;
926
927        // Store group metadata with creator as admin
928        let metadata = GroupMetadata::new_with_creator(Some(group_name.to_string()), &self.user_id);
929        self.save_group_metadata(&group_id, &metadata)?;
930
931        let mut info = self.group_manager.get_group_info(&group, &group_id);
932        info.name = metadata.name;
933        info.created_at_ms = metadata.created_at_ms;
934        info.last_activity_ms = metadata.last_activity_ms;
935
936        info!(group_id = %group_id, name = %group_name, "Created new group");
937        Ok(info)
938    }
939
940    /// Adds a member to a group.
941    ///
942    /// Returns a tuple of (WelcomeMessage, EncryptedMessage) where the
943    /// WelcomeMessage should be sent to the invitee and the EncryptedMessage
944    /// (Commit) should be distributed to all existing group members so they
945    /// can advance their MLS epoch.
946    ///
947    /// `invitee_user_id` binds the package to the identity it is being added
948    /// under. This path previously ran neither check — not even the
949    /// credential-identity one `import_key_package` has always had — so a
950    /// package could be admitted to a group under a roster label that had
951    /// nothing to do with it. The 1:1 path and the group path apply the same
952    /// two checks.
953    pub fn add_group_member(
954        &self,
955        group_id: &GroupId,
956        invitee_user_id: &str,
957        member_key_package: &[u8],
958    ) -> Result<(WelcomeMessage, EncryptedMessage)> {
959        let key_package = KeyPackageIn::tls_deserialize_exact(member_key_package)
960            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?
961            .validate(self.provider.crypto(), ProtocolVersion::Mls10)
962            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?;
963
964        Self::verify_credential_identity(&key_package, invitee_user_id)?;
965        Self::verify_address_binding(&key_package, invitee_user_id)?;
966
967        let mut group = self
968            .group_manager
969            .load_group(group_id)?
970            .ok_or_else(|| MlsError::GroupNotFound(group_id.to_string()))?;
971
972        let signature_keys = self.get_signer()?;
973        let (commit, welcome) =
974            self.group_manager
975                .add_member(&mut group, key_package, &signature_keys)?;
976
977        self.group_manager.save_group(group_id, &group)?;
978
979        let welcome_bytes = welcome
980            .tls_serialize_detached()
981            .map_err(|e| MlsError::Serialization(e.to_string()))?;
982
983        let commit_bytes = commit
984            .tls_serialize_detached()
985            .map_err(|e| MlsError::Serialization(e.to_string()))?;
986
987        // Include group name in welcome for the invitee
988        let group_name = self.load_group_metadata(group_id)?.and_then(|m| m.name);
989
990        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
991
992        let welcome_msg = WelcomeMessage {
993            group_id: group_id.clone(),
994            welcome_data: welcome_bytes,
995            inviter_id: self.user_id.clone(),
996            group_name,
997            timestamp_ms: now_ms,
998        };
999
1000        let commit_msg = EncryptedMessage {
1001            group_id: group_id.clone(),
1002            message_type: MlsMessageType::Commit,
1003            epoch: group.epoch().as_u64(),
1004            ciphertext: commit_bytes,
1005            sender_id: self.user_id.clone(),
1006            timestamp_ms: now_ms,
1007        };
1008
1009        Ok((welcome_msg, commit_msg))
1010    }
1011
1012    /// Removes a member from a group.
1013    ///
1014    /// Removes **every** leaf whose credential names `member_id`, in one
1015    /// commit, rather than the first one found.
1016    ///
1017    /// Two leaves cannot name one identity in a tree whose leaves all passed
1018    /// the binding, and the reason is worth writing down because it is not
1019    /// obvious: RFC 9420 requires signature keys to be unique across a group
1020    /// (OpenMLS enforces it — "Duplicate signature key in proposals and
1021    /// group"), and since `verify_leaf_binding` ties a credential to the hash
1022    /// of its own signature key, two leaves with the same credential would have
1023    /// to carry the same key. So through the wire gates the duplicate case is
1024    /// refused one layer down, twice over.
1025    ///
1026    /// That argument covers the gates, not the tree. It says nothing about the
1027    /// case `verify_sender_leaf` and the `get_group_info` filter exist for — a
1028    /// leaf written straight into the install-scoped provider store. A forged
1029    /// leaf claiming a peer's address carries the *attacker's* signature key,
1030    /// so it violates no uniqueness rule and sits alongside that peer's real
1031    /// leaf quite happily. There the loop is doing real work: first-match would
1032    /// remove one of the two and leave the other in the group holding live
1033    /// keys, while every roster read shows the member gone — an
1034    /// access-revocation failure that would surface as nothing at all.
1035    ///
1036    /// Removing every match costs one `Vec` and cannot have that failure
1037    /// mode.
1038    pub fn remove_group_member(
1039        &self,
1040        group_id: &GroupId,
1041        member_id: &str,
1042    ) -> Result<EncryptedMessage> {
1043        let mut group = self
1044            .group_manager
1045            .load_group(group_id)?
1046            .ok_or_else(|| MlsError::GroupNotFound(group_id.to_string()))?;
1047
1048        let member_indices: Vec<LeafNodeIndex> = group
1049            .members()
1050            .filter(|m| m.credential.serialized_content() == member_id.as_bytes())
1051            .map(|m| m.index)
1052            .collect();
1053
1054        if member_indices.is_empty() {
1055            return Err(MlsError::UserNotInGroup(member_id.to_string()));
1056        }
1057
1058        let signature_keys = self.get_signer()?;
1059        let commit =
1060            self.group_manager
1061                .remove_member(&mut group, &member_indices, &signature_keys)?;
1062
1063        self.group_manager.save_group(group_id, &group)?;
1064
1065        let ciphertext = commit
1066            .tls_serialize_detached()
1067            .map_err(|e| MlsError::Serialization(e.to_string()))?;
1068
1069        Ok(EncryptedMessage {
1070            group_id: group_id.clone(),
1071            message_type: MlsMessageType::Commit,
1072            epoch: group.epoch().as_u64(),
1073            ciphertext,
1074            sender_id: self.user_id.clone(),
1075            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1076        })
1077    }
1078
1079    /// Leaves a group.
1080    pub fn leave_group(&self, group_id: &GroupId) -> Result<()> {
1081        self.group_manager.delete_group(group_id)?;
1082        info!(group_id = %group_id, "Left group");
1083        Ok(())
1084    }
1085
1086    /// Encrypts a message for a group.
1087    pub fn encrypt_for_group(
1088        &self,
1089        group_id: &GroupId,
1090        plaintext: &[u8],
1091    ) -> Result<EncryptedMessage> {
1092        let mut group = self
1093            .group_manager
1094            .load_group(group_id)?
1095            .ok_or_else(|| MlsError::GroupNotFound(group_id.to_string()))?;
1096
1097        let signature_keys = self.get_signer()?;
1098        let mls_message =
1099            self.group_manager
1100                .encrypt_message(&mut group, plaintext, &signature_keys)?;
1101
1102        self.group_manager.save_group(group_id, &group)?;
1103
1104        let ciphertext = mls_message
1105            .tls_serialize_detached()
1106            .map_err(|e| MlsError::Serialization(e.to_string()))?;
1107
1108        Ok(EncryptedMessage {
1109            group_id: group_id.clone(),
1110            message_type: MlsMessageType::Application,
1111            epoch: group.epoch().as_u64(),
1112            ciphertext,
1113            sender_id: self.user_id.clone(),
1114            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1115        })
1116    }
1117
1118    /// Decrypts a message from a group.
1119    ///
1120    /// `claimed_sender` is the transport-level sender this message will be
1121    /// attributed to; decryption fails with
1122    /// [`MlsError::SenderIdentityMismatch`] if it does not match the
1123    /// MLS-authenticated credential (SEC-M1). The check runs before any
1124    /// commit is merged, so a spoofed commit cannot advance group state.
1125    pub fn decrypt_from_group(
1126        &self,
1127        encrypted: &EncryptedMessage,
1128        claimed_sender: &str,
1129    ) -> Result<Option<Vec<u8>>> {
1130        let mut group = self
1131            .group_manager
1132            .load_group(&encrypted.group_id)?
1133            .ok_or_else(|| MlsError::GroupNotFound(encrypted.group_id.to_string()))?;
1134
1135        let mls_message = MlsMessageIn::tls_deserialize_exact(&encrypted.ciphertext)
1136            .map_err(|e| MlsError::Deserialization(e.to_string()))?;
1137
1138        let result = self.group_manager.decrypt_message(
1139            &mut group,
1140            &encrypted.group_id,
1141            mls_message,
1142            claimed_sender,
1143        )?;
1144
1145        self.group_manager.save_group(&encrypted.group_id, &group)?;
1146
1147        Ok(result)
1148    }
1149
1150    /// Joins a group using a Welcome message.
1151    pub fn join_group(&self, welcome: &WelcomeMessage) -> Result<GroupInfo> {
1152        // SEC-M6 (group-Welcome side): the `session:` namespace is owned
1153        // exclusively by identity-bound 1:1 sessions installed via
1154        // `join_session`. A group Welcome carries an attacker-controllable
1155        // `group_id` with no (self, inviter) binding and writes the same
1156        // storage/OpenMLS keyspace, so one naming a `session:*` slot would seed
1157        // or overwrite a third party's 1:1 session and hijack the victim's
1158        // outbound encryption — the exact hijack SEC-M6 blocks on the
1159        // session-Welcome path. Reject before staging. Enforced here (rather
1160        // than only in the mesh handler) so *every* caller of `join_group` is
1161        // covered. Legitimate mesh groups are always `group:<uuid>`
1162        // (see `create_group`), so this rejects only forged Welcomes.
1163        if welcome.group_id.as_str().starts_with("session:") {
1164            return Err(MlsError::ReservedSessionNamespace {
1165                group_id: welcome.group_id.to_string(),
1166            });
1167        }
1168
1169        let mls_msg = MlsMessageIn::tls_deserialize_exact(&welcome.welcome_data)
1170            .map_err(|e| MlsError::Deserialization(e.to_string()))?;
1171
1172        let welcome_msg = match mls_msg.extract() {
1173            MlsMessageBodyIn::Welcome(w) => w,
1174            _ => {
1175                return Err(MlsError::WelcomeProcessing(
1176                    "Not a Welcome message".to_string(),
1177                ))
1178            }
1179        };
1180
1181        let group = self
1182            .group_manager
1183            .join_group(welcome_msg, &welcome.group_id)?;
1184        let mut info = self.group_manager.get_group_info(&group, &welcome.group_id);
1185        info.name = welcome.group_name.clone();
1186
1187        info!(group_id = %welcome.group_id, "Joined group");
1188        Ok(info)
1189    }
1190
1191    /// Lists all groups.
1192    pub fn list_groups(&self) -> Result<Vec<GroupId>> {
1193        let all_groups = self.group_manager.list_groups()?;
1194        Ok(all_groups
1195            .into_iter()
1196            .filter(|g| !g.as_str().starts_with("session:"))
1197            .collect())
1198    }
1199
1200    /// Gets information about a group.
1201    pub fn get_group_info(&self, group_id: &GroupId) -> Result<Option<GroupInfo>> {
1202        let group = match self.group_manager.load_group(group_id)? {
1203            Some(g) => g,
1204            None => return Ok(None),
1205        };
1206
1207        let mut info = self.group_manager.get_group_info(&group, group_id);
1208
1209        // Merge stored metadata
1210        if let Some(metadata) = self.load_group_metadata(group_id)? {
1211            info.name = metadata.name;
1212            info.created_at_ms = metadata.created_at_ms;
1213            info.last_activity_ms = metadata.last_activity_ms;
1214        }
1215
1216        Ok(Some(info))
1217    }
1218
1219    /// Returns `true` if local MLS group state exists for `group_id`.
1220    ///
1221    /// This is the authoritative test for "do we actually participate in this
1222    /// group via MLS" — it gates on the stored group marker, not the member
1223    /// send-cache (which relay reconciliation can populate without any MLS
1224    /// state). Callers use it to distinguish a genuine legacy relay-only
1225    /// (unencrypted) group, which has no MLS state, from an unauthenticated
1226    /// plaintext frame spoofed against a group the node secures with MLS.
1227    pub fn has_group(&self, group_id: &GroupId) -> Result<bool> {
1228        Ok(self.group_manager.load_group(group_id)?.is_some())
1229    }
1230
1231    /// Updates the group name.
1232    pub fn set_group_name(&self, group_id: &GroupId, name: &str) -> Result<()> {
1233        let mut metadata = self
1234            .load_group_metadata(group_id)?
1235            .unwrap_or_else(|| GroupMetadata::new(None));
1236        metadata.name = Some(name.to_string());
1237        metadata.touch();
1238        self.save_group_metadata(group_id, &metadata)
1239    }
1240
1241    /// Gets group metadata.
1242    pub fn get_group_metadata(&self, group_id: &GroupId) -> Result<Option<GroupMetadata>> {
1243        self.load_group_metadata(group_id)
1244    }
1245
1246    /// Sets custom metadata for a group.
1247    pub fn set_group_custom_metadata(
1248        &self,
1249        group_id: &GroupId,
1250        key: &str,
1251        value: &str,
1252    ) -> Result<()> {
1253        let mut metadata = self
1254            .load_group_metadata(group_id)?
1255            .unwrap_or_else(|| GroupMetadata::new(None));
1256        metadata.custom.insert(key.to_string(), value.to_string());
1257        metadata.touch();
1258        self.save_group_metadata(group_id, &metadata)
1259    }
1260
1261    /// Removes a custom metadata key for a group.
1262    pub fn remove_group_custom_metadata(&self, group_id: &GroupId, key: &str) -> Result<()> {
1263        let mut metadata = self
1264            .load_group_metadata(group_id)?
1265            .unwrap_or_else(|| GroupMetadata::new(None));
1266        metadata.custom.remove(key);
1267        metadata.touch();
1268        self.save_group_metadata(group_id, &metadata)
1269    }
1270
1271    /// Sets a member's role in a group.
1272    pub fn set_member_role(
1273        &self,
1274        group_id: &GroupId,
1275        user_id: &str,
1276        role: GroupRole,
1277    ) -> Result<()> {
1278        let mut metadata = self
1279            .load_group_metadata(group_id)?
1280            .unwrap_or_else(|| GroupMetadata::new(None));
1281        metadata.set_role(user_id, role);
1282        metadata.touch();
1283        self.save_group_metadata(group_id, &metadata)
1284    }
1285
1286    /// Enables or disables receive-side authorization of group membership
1287    /// commits. See [`crate::group::GroupManager::set_enforce_admin_commits`]
1288    /// for the fork consequence — this is off by default and enabling it is a
1289    /// deployment decision, not a hardening toggle.
1290    ///
1291    /// Applies to group commits only. The session manager owns a separate
1292    /// group manager and is left untouched, so 1:1 sessions are unaffected
1293    /// regardless of this setting.
1294    pub fn set_enforce_admin_commits(&mut self, enforce: bool) {
1295        self.group_manager.set_enforce_admin_commits(enforce);
1296    }
1297
1298    /// Records the group's creator, **only if none is on record yet**.
1299    ///
1300    /// `created_by` is the fallback [`GroupMetadata`] consults when no admin
1301    /// role is stored (see the protocol layer's `check_is_admin`), so it must
1302    /// behave monotonically: a device that created the group, or already
1303    /// adopted a creator from the Welcome that admitted it, keeps what it has.
1304    /// Only the "no information" state is fillable. That makes the write
1305    /// idempotent under duplicate Welcomes and stops a later invite — from an
1306    /// inviter whose own metadata disagrees — from rewriting an established
1307    /// admin fallback.
1308    ///
1309    /// Returns `Ok(())` whether or not the value was adopted; use
1310    /// [`Self::get_group_metadata`] to observe the result.
1311    pub fn set_group_creator(&self, group_id: &GroupId, creator_id: &str) -> Result<()> {
1312        let mut metadata = self
1313            .load_group_metadata(group_id)?
1314            .unwrap_or_else(|| GroupMetadata::new(None));
1315        if metadata.created_by.is_some() {
1316            return Ok(());
1317        }
1318        metadata.created_by = Some(creator_id.to_string());
1319        metadata.touch();
1320        self.save_group_metadata(group_id, &metadata)
1321    }
1322
1323    /// Removes a member's role metadata from a group.
1324    pub fn remove_member_role(&self, group_id: &GroupId, user_id: &str) -> Result<()> {
1325        let mut metadata = self
1326            .load_group_metadata(group_id)?
1327            .unwrap_or_else(|| GroupMetadata::new(None));
1328        metadata.remove_role(user_id);
1329        metadata.touch();
1330        self.save_group_metadata(group_id, &metadata)
1331    }
1332
1333    // ========================================================================
1334    // GENERIC MESSAGE HANDLING
1335    // ========================================================================
1336
1337    /// Decrypts any incoming encrypted message.
1338    ///
1339    /// `claimed_sender` is the transport-level sender this message will be
1340    /// attributed to; it must match the MLS-authenticated credential
1341    /// (SEC-M1).
1342    pub fn decrypt(
1343        &self,
1344        encrypted: &EncryptedMessage,
1345        claimed_sender: &str,
1346    ) -> Result<Option<Vec<u8>>> {
1347        if encrypted.group_id.as_str().starts_with("session:") {
1348            self.decrypt_from_user(encrypted, claimed_sender)
1349        } else {
1350            self.decrypt_from_group(encrypted, claimed_sender)
1351        }
1352    }
1353
1354    /// Processes a Welcome message.
1355    pub fn process_welcome(&self, welcome: &WelcomeMessage) -> Result<GroupInfo> {
1356        if welcome.group_id.as_str().starts_with("session:") {
1357            self.join_session(welcome)
1358        } else {
1359            self.join_group(welcome)
1360        }
1361    }
1362}
1363
1364impl MlsManager {
1365    /// Loads a stored key package bundle, handling legacy raw storage and expiration.
1366    ///
1367    /// Also validates that the key package's private key still exists in the
1368    /// OpenMLS provider storage. Key packages whose private keys have been
1369    /// consumed (e.g. by a previous Welcome processing) are pruned.
1370    fn load_stored_key_package(&self, package_id: &str) -> Result<Option<KeyPackageBundle>> {
1371        let key_type = StorageKeyType::KeyPackage.as_str();
1372        let data = match self.storage.load(key_type, package_id)? {
1373            Some(data) => data,
1374            None => return Ok(None),
1375        };
1376
1377        let mut bundle = match serde_json::from_slice::<KeyPackageBundle>(&data) {
1378            Ok(bundle) => bundle,
1379            Err(_) => {
1380                let legacy_bundle = KeyPackageBundle::new(
1381                    package_id.to_string(),
1382                    self.user_id.clone(),
1383                    data,
1384                    DEFAULT_KEY_PACKAGE_LIFETIME_SECS,
1385                );
1386                let serialized = serde_json::to_vec(&legacy_bundle)
1387                    .map_err(|e| MlsError::Serialization(e.to_string()))?;
1388                self.storage.store(key_type, package_id, &serialized)?;
1389                legacy_bundle
1390            }
1391        };
1392
1393        if bundle.is_expired() {
1394            // Expiry and key destruction are separate moments on purpose. A
1395            // peer handed this package shortly before it expired may still
1396            // Welcome us, and that Welcome is only processable while the init
1397            // key is resident — so the record is kept (invisible to every
1398            // caller, since they all read through here) until the grace window
1399            // closes, and only then is the material destroyed.
1400            if bundle.expired_past_grace(KEY_PACKAGE_PURGE_GRACE_SECS) {
1401                self.purge_key_package_material(package_id, &bundle)?;
1402            }
1403            return Ok(None);
1404        }
1405
1406        // Usability means the private init key is still in provider storage.
1407        // The lookup key is cached on the record; deriving it instead is the
1408        // expensive path (a parse plus a signature validation), paid once for
1409        // records written before the cache existed and stamped back below so
1410        // their next load is cheap.
1411        let needs_backfill = bundle.provider_hash_ref.is_none();
1412        let Some(hash_ref) = self.bundle_hash_ref(&bundle) else {
1413            warn!(
1414                package_id = %package_id,
1415                "Key package bytes no longer parse as a key package, pruning stale entry"
1416            );
1417            self.storage.delete(key_type, package_id)?;
1418            return Ok(None);
1419        };
1420        if !self.provider_has_init_key(&hash_ref) {
1421            warn!(
1422                package_id = %package_id,
1423                "Key package private key no longer in provider storage, pruning stale entry"
1424            );
1425            self.storage.delete(key_type, package_id)?;
1426            return Ok(None);
1427        }
1428
1429        if needs_backfill {
1430            if let Ok(bytes) = hash_ref.tls_serialize_detached() {
1431                bundle.provider_hash_ref = Some(bytes);
1432                let serialized = serde_json::to_vec(&bundle)
1433                    .map_err(|e| MlsError::Serialization(e.to_string()))?;
1434                self.storage.store(key_type, package_id, &serialized)?;
1435            }
1436        }
1437
1438        Ok(Some(bundle))
1439    }
1440
1441    /// Destroys a key package: its bundle record *and* the private init key
1442    /// OpenMLS holds for it.
1443    ///
1444    /// Deleting the record alone — which is all this crate used to do — leaves
1445    /// the init key resident forever, because the only other thing that removes
1446    /// one is OpenMLS consuming it to process a Welcome. That turns every
1447    /// expired or reclaimed package into permanently retained key material,
1448    /// which is exactly the property the lifetime was supposed to bound.
1449    ///
1450    /// The provider key goes first: if that fails, the record is deliberately
1451    /// left in place so the next scan retries rather than orphaning key
1452    /// material no record points at any more. The package is already out of
1453    /// every caller's reach by the time this runs, so a retained record costs
1454    /// one storage read per scan and nothing else.
1455    fn purge_key_package_material(
1456        &self,
1457        package_id: &str,
1458        bundle: &KeyPackageBundle,
1459    ) -> Result<()> {
1460        if let Some(hash_ref) = self.bundle_hash_ref(bundle) {
1461            use openmls_traits::storage::StorageProvider;
1462            if let Err(e) = self.provider.storage().delete_key_package(&hash_ref) {
1463                warn!(
1464                    package_id = %package_id,
1465                    error = %e,
1466                    "Failed to delete a key package's private init key; keeping the record to retry"
1467                );
1468                return Ok(());
1469            }
1470        }
1471
1472        self.storage
1473            .delete(StorageKeyType::KeyPackage.as_str(), package_id)?;
1474        debug!(package_id = %package_id, "Purged key package and its init key");
1475        Ok(())
1476    }
1477
1478    /// Computes the OpenMLS hash reference a key package's private material is
1479    /// stored under, or `None` if the bytes no longer parse as a key package.
1480    ///
1481    /// This is the expensive path — a TLS parse plus a signature validation —
1482    /// that [`KeyPackageBundle::provider_hash_ref`] caches the result of; go
1483    /// through [`Self::bundle_hash_ref`] when a bundle is in hand.
1484    fn key_package_hash_ref(&self, key_package_data: &[u8]) -> Option<KeyPackageRef> {
1485        let kp_in = KeyPackageIn::tls_deserialize_exact(key_package_data).ok()?;
1486        let kp: KeyPackage = kp_in
1487            .validate(self.provider.crypto(), ProtocolVersion::Mls10)
1488            .ok()?;
1489        kp.hash_ref(self.provider.crypto()).ok()
1490    }
1491
1492    /// Resolves the provider hash reference for one of this device's bundles:
1493    /// the copy cached at mint time when present and decodable, else derived
1494    /// from the serialized package bytes.
1495    fn bundle_hash_ref(&self, bundle: &KeyPackageBundle) -> Option<KeyPackageRef> {
1496        bundle
1497            .provider_hash_ref
1498            .as_deref()
1499            .and_then(|bytes| KeyPackageRef::tls_deserialize_exact(bytes).ok())
1500            .or_else(|| self.key_package_hash_ref(&bundle.key_package_data))
1501    }
1502
1503    /// Whether the private init key `hash_ref` names is still in the OpenMLS
1504    /// provider storage. Absent means consumed (a Welcome was processed
1505    /// against it) or never stored.
1506    fn provider_has_init_key(&self, hash_ref: &KeyPackageRef) -> bool {
1507        use openmls_traits::storage::StorageProvider;
1508        let found: std::result::Result<Option<openmls::key_packages::KeyPackageBundle>, _> =
1509            self.provider.storage().key_package(hash_ref);
1510        matches!(found, Ok(Some(_)))
1511    }
1512
1513    /// Ground-truth usability check from the serialized package bytes alone,
1514    /// bypassing the cached provider ref. Test-only so tests observe the
1515    /// provider directly rather than a cache they may be manipulating;
1516    /// production reads go through [`Self::bundle_hash_ref`].
1517    #[cfg(test)]
1518    fn is_key_package_usable(&self, key_package_data: &[u8]) -> bool {
1519        self.key_package_hash_ref(key_package_data)
1520            .map(|hash_ref| self.provider_has_init_key(&hash_ref))
1521            .unwrap_or(false)
1522    }
1523
1524    /// Loads group metadata from storage.
1525    fn load_group_metadata(&self, group_id: &GroupId) -> Result<Option<GroupMetadata>> {
1526        let key_type = StorageKeyType::GroupMetadata.as_str();
1527        match self.storage.load(key_type, group_id.as_str())? {
1528            Some(data) => {
1529                let mut metadata: GroupMetadata = serde_json::from_slice(&data)
1530                    .map_err(|e| MlsError::Deserialization(e.to_string()))?;
1531                // Migrate legacy "role:*" keys from `custom` into `roles`
1532                if metadata.roles.is_empty()
1533                    && metadata
1534                        .custom
1535                        .keys()
1536                        .any(|k| k.starts_with(GroupMetadata::LEGACY_ROLE_KEY_PREFIX))
1537                {
1538                    metadata.migrate_legacy_roles();
1539                    // Persist the migration so it only runs once
1540                    if let Err(e) = self.save_group_metadata(group_id, &metadata) {
1541                        warn!(group_id = %group_id.as_str(), error = %e, "Failed to persist legacy role migration");
1542                    }
1543                }
1544                Ok(Some(metadata))
1545            }
1546            None => Ok(None),
1547        }
1548    }
1549
1550    /// Saves group metadata to storage.
1551    fn save_group_metadata(&self, group_id: &GroupId, metadata: &GroupMetadata) -> Result<()> {
1552        let key_type = StorageKeyType::GroupMetadata.as_str();
1553        let data =
1554            serde_json::to_vec(metadata).map_err(|e| MlsError::Serialization(e.to_string()))?;
1555        self.storage.store(key_type, group_id.as_str(), &data)?;
1556        Ok(())
1557    }
1558}
1559
1560// ============================================================================
1561// KEY ROTATION AND KEY PACKAGE MANAGEMENT
1562// ============================================================================
1563
1564impl MlsManager {
1565    /// Updates the cryptographic keys for a group (triggers MLS self-update).
1566    ///
1567    /// This provides post-compromise security by rotating keys. The returned
1568    /// commit message must be sent to all other group members.
1569    ///
1570    /// # Returns
1571    ///
1572    /// Returns the commit message that must be distributed to all group members.
1573    pub fn update_keys(&self, group_id: &GroupId) -> Result<EncryptedMessage> {
1574        use openmls::treesync::LeafNodeParameters;
1575
1576        let mut group = self
1577            .group_manager
1578            .load_group(group_id)?
1579            .ok_or_else(|| MlsError::GroupNotFound(group_id.to_string()))?;
1580
1581        let signature_keys = self.get_signer()?;
1582
1583        let bundle = group
1584            .self_update(
1585                &self.provider,
1586                &signature_keys,
1587                LeafNodeParameters::default(),
1588            )
1589            .map_err(|e| MlsError::OpenMls(format!("Self-update failed: {}", e)))?;
1590
1591        let (commit, _welcome, _group_info) = bundle.into_contents();
1592
1593        group
1594            .merge_pending_commit(&self.provider)
1595            .map_err(|e| MlsError::OpenMls(format!("Failed to merge self-update commit: {}", e)))?;
1596
1597        self.group_manager.save_group(group_id, &group)?;
1598
1599        // Update metadata last activity
1600        if let Some(mut metadata) = self.load_group_metadata(group_id)? {
1601            metadata.touch();
1602            self.save_group_metadata(group_id, &metadata)?;
1603        }
1604
1605        let ciphertext = commit
1606            .tls_serialize_detached()
1607            .map_err(|e| MlsError::Serialization(e.to_string()))?;
1608
1609        debug!(group_id = %group_id, epoch = %group.epoch().as_u64(), "Updated group keys");
1610
1611        Ok(EncryptedMessage {
1612            group_id: group_id.clone(),
1613            message_type: MlsMessageType::Commit,
1614            epoch: group.epoch().as_u64(),
1615            ciphertext,
1616            sender_id: self.user_id.clone(),
1617            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1618        })
1619    }
1620
1621    /// Ensures at least `min` valid key packages are available.
1622    ///
1623    /// Generates new key packages if the current count is below the minimum.
1624    /// This is useful for offline scenarios where multiple key packages should
1625    /// be pre-generated for distribution.
1626    ///
1627    /// # Arguments
1628    ///
1629    /// * `min` - Minimum number of key packages to maintain, capped at
1630    ///   [`MAX_PUSH_KEY_PACKAGES`]. The packages this mints are unclaimed, so
1631    ///   [`Self::take_push_key_package`] draws them down one per peer; asking
1632    ///   for more than the pool ceiling would only mint key material that
1633    ///   ceiling stops it ever handing out, while holding the pool at capacity
1634    ///   so every peer past the last claim is advertised a shared package.
1635    ///
1636    /// # Returns
1637    ///
1638    /// Returns the total number of valid key packages after ensuring minimum.
1639    pub fn ensure_min_key_packages(&self, min: usize) -> Result<usize> {
1640        let min = min.min(MAX_PUSH_KEY_PACKAGES);
1641        let key_type = StorageKeyType::KeyPackage.as_str();
1642        let package_ids = self.storage.list_keys(key_type)?;
1643
1644        // Count valid (non-expired) packages
1645        let mut valid_count = 0;
1646        for package_id in &package_ids {
1647            if self.load_stored_key_package(package_id)?.is_some() {
1648                valid_count += 1;
1649            }
1650        }
1651
1652        // Generate more if needed
1653        let to_generate = min.saturating_sub(valid_count);
1654        for _ in 0..to_generate {
1655            self.generate_key_package()?;
1656            valid_count += 1;
1657        }
1658
1659        debug!(
1660            valid_count = valid_count,
1661            generated = to_generate,
1662            "Ensured minimum key packages"
1663        );
1664
1665        Ok(valid_count)
1666    }
1667
1668    /// Returns the number of valid (non-expired) key packages available.
1669    pub fn count_valid_key_packages(&self) -> Result<usize> {
1670        let key_type = StorageKeyType::KeyPackage.as_str();
1671        let package_ids = self.storage.list_keys(key_type)?;
1672
1673        let mut count = 0;
1674        for package_id in package_ids {
1675            if self.load_stored_key_package(&package_id)?.is_some() {
1676                count += 1;
1677            }
1678        }
1679
1680        Ok(count)
1681    }
1682
1683    // ========================================================================
1684    // IDENTITY AND SIGNING OPERATIONS
1685    // ========================================================================
1686
1687    /// Returns the identity public key as raw bytes.
1688    ///
1689    /// This is the Ed25519 public key used for MLS operations. It can be shared
1690    /// with others to establish your identity and verify signatures.
1691    pub fn get_identity_public_key(&self) -> Result<Vec<u8>> {
1692        let credential = self.get_credential()?;
1693        Ok(credential.signature_key.as_slice().to_vec())
1694    }
1695
1696    /// Signs arbitrary data with the identity private key.
1697    ///
1698    /// Uses Ed25519 signatures (the same algorithm used for MLS operations).
1699    /// The signature can be verified by anyone with the corresponding public key.
1700    ///
1701    /// # Arguments
1702    ///
1703    /// * `data` - The data to sign
1704    ///
1705    /// # Returns
1706    ///
1707    /// Returns the signature as raw bytes.
1708    pub fn sign_data(&self, data: &[u8]) -> Result<Vec<u8>> {
1709        use openmls_traits::signatures::Signer;
1710
1711        let signer = self.get_signer()?;
1712        let signature = signer
1713            .sign(data)
1714            .map_err(|e| MlsError::Signing(format!("Failed to sign data: {:?}", e)))?;
1715        Ok(signature.as_slice().to_vec())
1716    }
1717
1718    /// Verifies a signature against a public key.
1719    ///
1720    /// # Arguments
1721    ///
1722    /// * `public_key` - The Ed25519 public key bytes (32 bytes)
1723    /// * `data` - The original data that was signed
1724    /// * `signature` - The signature to verify (64 bytes)
1725    ///
1726    /// # Returns
1727    ///
1728    /// Returns `true` if the signature is valid, `false` otherwise.
1729    pub fn verify_signature(public_key: &[u8], data: &[u8], signature: &[u8]) -> Result<bool> {
1730        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
1731
1732        // Parse the public key (Ed25519 public keys are 32 bytes)
1733        let verifying_key = VerifyingKey::try_from(public_key).map_err(|e| {
1734            MlsError::InvalidPublicKey(format!("Invalid Ed25519 public key: {}", e))
1735        })?;
1736
1737        // Parse the signature (Ed25519 signatures are 64 bytes)
1738        let sig = Signature::try_from(signature).map_err(|e| {
1739            MlsError::VerificationFailed(format!("Invalid signature format: {}", e))
1740        })?;
1741
1742        // Verify the signature
1743        match verifying_key.verify(data, &sig) {
1744            Ok(()) => Ok(true),
1745            Err(_) => Ok(false),
1746        }
1747    }
1748
1749    /// Derives the canonical self-certifying address of an Ed25519 identity
1750    /// key: `off1…`, the bech32m encoding of
1751    /// `0x01 ‖ SHA-256(public_key)[..20]`.
1752    ///
1753    /// This is the only address derivation in the SDK. Bridges and apps reach
1754    /// it through the `derive_address` FFI function rather than reimplementing
1755    /// it, so that every platform agrees byte for byte.
1756    ///
1757    /// # Arguments
1758    ///
1759    /// * `public_key` - The Ed25519 public key bytes (exactly 32)
1760    ///
1761    /// # Errors
1762    ///
1763    /// Returns [`MlsError::InvalidPublicKey`] if `public_key` is not 32 bytes.
1764    /// The length is part of the format contract: hashing a differently-sized
1765    /// input would yield a different address for the same identity. The bytes
1766    /// are deliberately *not* checked against the curve — an address is
1767    /// defined over key bytes, and what proves ownership is the signature
1768    /// verification that accompanies it (see [`Self::verify_signature`]), so
1769    /// binding the address format to a signature library's parsing strictness
1770    /// would only risk the derivation drifting between versions.
1771    pub fn derive_address(public_key: &[u8]) -> Result<Address> {
1772        use sha2::{Digest, Sha256};
1773
1774        if public_key.len() != ED25519_PUBLIC_KEY_LEN {
1775            return Err(MlsError::InvalidPublicKey(format!(
1776                "Ed25519 public key must be {} bytes, got {}",
1777                ED25519_PUBLIC_KEY_LEN,
1778                public_key.len()
1779            )));
1780        }
1781
1782        let hash = Sha256::digest(public_key);
1783        let mut truncated = [0u8; Address::HASH_LEN];
1784        truncated.copy_from_slice(&hash[..Address::HASH_LEN]);
1785        Ok(Address::from_hash_bytes(truncated))
1786    }
1787
1788    /// Derives a deterministic user ID from a public key.
1789    ///
1790    /// # Arguments
1791    ///
1792    /// * `public_key` - The Ed25519 public key bytes
1793    ///
1794    /// # Returns
1795    ///
1796    /// Returns the derived address in its canonical `off1…` string form.
1797    /// Unlike [`Self::derive_address`] this accepts any input length, which
1798    /// is precisely why it is deprecated: a caller passing a truncated or
1799    /// padded key gets a plausible-looking address for an identity that
1800    /// cannot exist.
1801    #[deprecated(
1802        note = "use derive_address; this returns the self-certifying off1… address, no longer base58"
1803    )]
1804    pub fn derive_user_id_from_public_key(public_key: &[u8]) -> String {
1805        use sha2::{Digest, Sha256};
1806
1807        let hash = Sha256::digest(public_key);
1808        let mut truncated = [0u8; Address::HASH_LEN];
1809        truncated.copy_from_slice(&hash[..Address::HASH_LEN]);
1810        Address::from_hash_bytes(truncated).to_string()
1811    }
1812}
1813
1814/// Adversarial fixtures for tests in other crates.
1815///
1816/// Forging a leaf needs raw OpenMLS, which the protocol crate deliberately does
1817/// not depend on, and needs to go *past* `add_group_member`'s binding check —
1818/// which is the whole point, since the attacker runs their own build and that
1819/// check is theirs to delete. Feature-gated so no release build in this
1820/// workspace enables it — a downstream consumer could still turn the feature on
1821/// for itself — and kept in this module because it touches private state.
1822#[cfg(feature = "test-utils")]
1823impl MlsManager {
1824    /// Seats a leaf in `group_id` whose credential claims `impersonated` but
1825    /// whose signature key is minted here — an identity nobody can prove.
1826    ///
1827    /// The group must already exist and this manager must be able to commit to
1828    /// it. Every later Welcome for that group carries the forged leaf in its
1829    /// ratchet tree, which is what a receiver must refuse.
1830    ///
1831    /// Returns the Add commit, TLS-serialized. That is the *other* half of the
1832    /// attack and needs to be reachable from the protocol crate: a receiver
1833    /// already in the group is attacked by the commit, not by a Welcome, and
1834    /// what it must do with one differs — refuse it **permanently**, since a
1835    /// refusal parked on the retry path is re-decrypted until it expires and is
1836    /// then misread as an epoch fork.
1837    pub fn seat_forged_leaf_for_testing(
1838        &self,
1839        group_id: &GroupId,
1840        impersonated: &str,
1841    ) -> Result<Vec<u8>> {
1842        let forged_storage: Arc<dyn MlsStorage> = Arc::new(crate::storage::InMemoryStorage::new());
1843        let forged_provider = MlsProvider::new(MlsStorageAdapter::new(forged_storage));
1844        let forged_keys = SignatureKeyPair::new(DEFAULT_CIPHERSUITE.signature_algorithm())
1845            .map_err(|e| MlsError::CryptoGeneration(format!("{:?}", e)))?;
1846        forged_keys
1847            .store(forged_provider.storage())
1848            .map_err(|e| MlsError::CryptoGeneration(format!("storing forged key: {:?}", e)))?;
1849
1850        let credential = CredentialWithKey {
1851            credential: Credential::new(CredentialType::Basic, impersonated.as_bytes().to_vec()),
1852            signature_key: forged_keys.public().into(),
1853        };
1854        let key_package = KeyPackage::builder()
1855            .build(
1856                DEFAULT_CIPHERSUITE,
1857                &forged_provider,
1858                &forged_keys,
1859                credential,
1860            )
1861            .map_err(|e| MlsError::KeyPackageCreation(e.to_string()))?
1862            .key_package()
1863            .tls_serialize_detached()
1864            .map_err(|e| MlsError::Serialization(e.to_string()))?;
1865
1866        let validated = KeyPackageIn::tls_deserialize_exact(&key_package)
1867            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?
1868            .validate(self.provider.crypto(), ProtocolVersion::Mls10)
1869            .map_err(|e| MlsError::InvalidKeyPackage(e.to_string()))?;
1870
1871        let mut group = self
1872            .group_manager
1873            .load_group(group_id)?
1874            .ok_or_else(|| MlsError::GroupNotFound(group_id.to_string()))?;
1875        let signer = self.get_signer()?;
1876        let (commit, _welcome) = self
1877            .group_manager
1878            .add_member(&mut group, validated, &signer)?;
1879        self.group_manager.save_group(group_id, &group)?;
1880
1881        commit
1882            .tls_serialize_detached()
1883            .map_err(|e| MlsError::Serialization(e.to_string()))
1884    }
1885}
1886
1887/// Returns `true` when `bytes` parse as a well-formed MLS wire message
1888/// (`MlsMessageIn` TLS framing, consuming the input exactly).
1889///
1890/// Inbound routing uses this to distinguish MLS ciphertext from legacy
1891/// plaintext that merely happens to be valid base64: the strict TLS framing
1892/// (protocol version, wire format, exact-length body) makes an accidental
1893/// match against non-MLS bytes vanishingly unlikely. This is a framing
1894/// check only — it says nothing about whether the message can be decrypted.
1895pub fn is_mls_framed(bytes: &[u8]) -> bool {
1896    MlsMessageIn::tls_deserialize_exact(bytes).is_ok()
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901    use super::*;
1902    use crate::storage::InMemoryStorage;
1903
1904    /// A manager running as `label`, at the address `label`'s identity key
1905    /// derives to.
1906    ///
1907    /// Takes the production bootstrap, not a nickname: every peer id that
1908    /// crosses `import_key_package` or `add_group_member` is now checked
1909    /// against the key that signed the package, and a nickname has no
1910    /// derivation to check — so a fixture at `"bob"` would exercise a
1911    /// configuration that cannot exist.
1912    fn create_test_manager(label: &str) -> MlsManager {
1913        create_addressed_manager(label).0
1914    }
1915
1916    /// The address the identity behind `label` derives to, as a wire string.
1917    fn addr(label: &str) -> String {
1918        test_identity(label).1.to_string()
1919    }
1920
1921    /// The 1:1 session slot shared by `a` and `b`.
1922    ///
1923    /// Computed, never written out: the slot orders its halves by `Address`
1924    /// bytes, and the bech32 charset is not ASCII-monotonic, so a hand-written
1925    /// `session:<a>:<b>` literal is right only by accident.
1926    fn slot(a: &str, b: &str) -> GroupId {
1927        GroupId::for_session(&addr(a), &addr(b)).unwrap()
1928    }
1929
1930    /// A deterministic identity keypair for `label`, plus the address it
1931    /// derives to.
1932    ///
1933    /// Seeded from the label rather than generated so a test can name the same
1934    /// identity twice, and so the address in a failure message is stable across
1935    /// runs. Only the *derivation* is being pinned here — the seed is a test
1936    /// convenience, not a key-generation scheme anything ships.
1937    pub(crate) fn test_identity(label: &str) -> (SignatureKeyPair, Address) {
1938        use sha2::{Digest, Sha256};
1939
1940        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
1941        let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
1942        let public = signing.verifying_key().to_bytes().to_vec();
1943        let address = MlsManager::derive_address(&public).unwrap();
1944        let keys = SignatureKeyPair::from_raw(
1945            DEFAULT_CIPHERSUITE.signature_algorithm(),
1946            signing.to_bytes().to_vec(),
1947            public,
1948        );
1949        (keys, address)
1950    }
1951
1952    /// Writes `keys` where [`MlsManager::load_or_create_identity`] will find
1953    /// them, standing in for a previous run of this profile.
1954    pub(crate) fn seed_identity(storage: &Arc<dyn MlsStorage>, keys: &SignatureKeyPair) {
1955        storage
1956            .store(
1957                StorageKeyType::Identity.as_str(),
1958                "key_pair",
1959                &serde_json::to_vec(keys).unwrap(),
1960            )
1961            .unwrap();
1962    }
1963
1964    /// A manager bootstrapped the way production does it: mint/load the
1965    /// identity first, then construct at the address it derives to.
1966    fn create_addressed_manager(label: &str) -> (MlsManager, Address) {
1967        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
1968        let (keys, address) = test_identity(label);
1969        seed_identity(&storage, &keys);
1970        let manager = MlsManager::new(address.to_string(), storage).unwrap();
1971        (manager, address)
1972    }
1973
1974    /// Same, but keeps a handle on the storage so a test can age a package in
1975    /// place — the only way to reach the expiry and purge paths without either
1976    /// waiting 30 days or making the clock injectable.
1977    fn create_test_manager_with_storage(user_id: &str) -> (MlsManager, Arc<InMemoryStorage>) {
1978        let storage = Arc::new(InMemoryStorage::new());
1979        let manager = MlsManager::new(user_id, storage.clone()).unwrap();
1980        (manager, storage)
1981    }
1982
1983    /// Rewrites a stored package's expiry to `secs_ago` seconds in the past.
1984    fn expire_package(storage: &InMemoryStorage, package_id: &str, secs_ago: u64) {
1985        let key_type = StorageKeyType::KeyPackage.as_str();
1986        let data = storage
1987            .load(key_type, package_id)
1988            .unwrap()
1989            .expect("package to age is not in storage");
1990        let mut bundle: KeyPackageBundle = serde_json::from_slice(&data).unwrap();
1991        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1992        bundle.expires_at_ms = now_ms - (secs_ago * 1000);
1993        storage
1994            .store(key_type, package_id, &serde_json::to_vec(&bundle).unwrap())
1995            .unwrap();
1996    }
1997
1998    /// Whether the private init key for `bundle` is still in provider storage.
1999    fn init_key_present(manager: &MlsManager, bundle: &KeyPackageBundle) -> bool {
2000        manager.is_key_package_usable(&bundle.key_package_data)
2001    }
2002
2003    /// Rewrites a stored package's record without its cached provider hash
2004    /// ref, the way a build predating the cache would have written it.
2005    fn strip_cached_ref(storage: &InMemoryStorage, package_id: &str) {
2006        let key_type = StorageKeyType::KeyPackage.as_str();
2007        let data = storage
2008            .load(key_type, package_id)
2009            .unwrap()
2010            .expect("package to strip is not in storage");
2011        let mut bundle: KeyPackageBundle = serde_json::from_slice(&data).unwrap();
2012        bundle.provider_hash_ref = None;
2013        storage
2014            .store(key_type, package_id, &serde_json::to_vec(&bundle).unwrap())
2015            .unwrap();
2016    }
2017
2018    /// The provider hash ref is stamped at mint so the pool scan never pays a
2019    /// TLS parse plus a signature validation per stored package per push —
2020    /// unstamped, a scan-heavy path (many peers, full pool) costs tens of
2021    /// debug-build seconds.
2022    #[test]
2023    fn test_minted_package_carries_its_provider_hash_ref() {
2024        let manager = create_test_manager("alice");
2025        let bundle = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2026
2027        let cached = bundle
2028            .provider_hash_ref
2029            .as_deref()
2030            .expect("mint must stamp the provider hash ref");
2031        let derived = manager
2032            .key_package_hash_ref(&bundle.key_package_data)
2033            .expect("freshly minted package bytes must parse")
2034            .tls_serialize_detached()
2035            .unwrap();
2036        assert_eq!(
2037            cached,
2038            &derived[..],
2039            "cached ref disagrees with the one derived from the package bytes"
2040        );
2041    }
2042
2043    /// Records written before the cache existed take the derive path once and
2044    /// are stamped back, so every later load is cheap.
2045    #[test]
2046    fn test_record_without_cached_ref_is_backfilled_on_first_load() {
2047        let (manager, storage) = create_test_manager_with_storage("alice");
2048        let minted = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2049        strip_cached_ref(&storage, &minted.package_id);
2050
2051        let loaded = manager
2052            .key_package_by_id(&minted.package_id)
2053            .unwrap()
2054            .expect("a record without a cached ref must still load");
2055        assert_eq!(
2056            loaded.provider_hash_ref, minted.provider_hash_ref,
2057            "load must hand back a bundle stamped with the same ref the mint computed"
2058        );
2059
2060        let stored: KeyPackageBundle = serde_json::from_slice(
2061            &storage
2062                .load(StorageKeyType::KeyPackage.as_str(), &minted.package_id)
2063                .unwrap()
2064                .unwrap(),
2065        )
2066        .unwrap();
2067        assert_eq!(
2068            stored.provider_hash_ref, minted.provider_hash_ref,
2069            "the backfilled ref must be persisted, not only returned"
2070        );
2071    }
2072
2073    /// The purge destroys key material for pre-cache records too, deriving
2074    /// the ref they never carried.
2075    #[test]
2076    fn test_purge_destroys_init_key_for_a_record_without_cached_ref() {
2077        let (manager, storage) = create_test_manager_with_storage("alice");
2078        let bundle = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2079        strip_cached_ref(&storage, &bundle.package_id);
2080        expire_package(
2081            &storage,
2082            &bundle.package_id,
2083            KEY_PACKAGE_PURGE_GRACE_SECS + 60,
2084        );
2085
2086        assert!(manager
2087            .key_package_by_id(&bundle.package_id)
2088            .unwrap()
2089            .is_none());
2090        assert!(
2091            !init_key_present(&manager, &bundle),
2092            "the private init key outlived a pre-cache package"
2093        );
2094    }
2095
2096    /// An expired package stops being advertised immediately, but its init key
2097    /// has to outlive the last advertisement: a peer handed it just before
2098    /// expiry may still Welcome us, and that Welcome is only processable while
2099    /// the key is resident.
2100    #[test]
2101    fn test_expired_package_is_withdrawn_but_its_init_key_survives_the_grace_window() {
2102        let (manager, storage) = create_test_manager_with_storage("alice");
2103        let bundle = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2104
2105        expire_package(&storage, &bundle.package_id, 60);
2106
2107        assert!(
2108            manager
2109                .key_package_by_id(&bundle.package_id)
2110                .unwrap()
2111                .is_none(),
2112            "an expired package must not be handed out"
2113        );
2114        assert!(
2115            init_key_present(&manager, &bundle),
2116            "the init key was destroyed on the expiry edge, breaking a late Welcome"
2117        );
2118
2119        // The next push to that peer mints a replacement rather than reviving it.
2120        let replacement = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2121        assert_ne!(replacement.package_id, bundle.package_id);
2122    }
2123
2124    /// Past the grace window the key material is genuinely destroyed. Without
2125    /// this, every package this device ever minted keeps its init key for the
2126    /// life of the install — deleting the bundle record, which is all this
2127    /// crate used to do, never touched the key OpenMLS holds.
2128    #[test]
2129    fn test_package_past_the_grace_window_has_its_init_key_destroyed() {
2130        let (manager, storage) = create_test_manager_with_storage("alice");
2131        let bundle = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2132        assert!(init_key_present(&manager, &bundle));
2133
2134        expire_package(
2135            &storage,
2136            &bundle.package_id,
2137            KEY_PACKAGE_PURGE_GRACE_SECS + 60,
2138        );
2139
2140        // The purge runs as a side effect of the scan that reads it.
2141        assert!(manager
2142            .key_package_by_id(&bundle.package_id)
2143            .unwrap()
2144            .is_none());
2145
2146        assert!(
2147            !init_key_present(&manager, &bundle),
2148            "the private init key outlived its package"
2149        );
2150        assert!(
2151            storage
2152                .load(StorageKeyType::KeyPackage.as_str(), &bundle.package_id)
2153                .unwrap()
2154                .is_none(),
2155            "the bundle record outlived its key material"
2156        );
2157    }
2158
2159    /// Reclaiming an unpublished package has to reclaim the key too, for the
2160    /// same reason — the publication path mints one per failed refresh, and a
2161    /// record-only delete would strand fresh key material every time.
2162    #[test]
2163    fn test_delete_key_package_destroys_the_init_key() {
2164        let manager = create_test_manager("alice");
2165        let bundle = manager.generate_publication_key_package().unwrap();
2166        assert!(init_key_present(&manager, &bundle));
2167
2168        manager.delete_key_package(&bundle.package_id).unwrap();
2169
2170        assert!(
2171            !init_key_present(&manager, &bundle),
2172            "delete_key_package left the private init key in provider storage"
2173        );
2174    }
2175
2176    /// A legacy record — raw key package bytes rather than a serialized bundle,
2177    /// which `load_stored_key_package` still upgrades in place — must have its
2178    /// init key destroyed too. Reading the record as unparseable and deleting
2179    /// only the record is what strands key material.
2180    #[test]
2181    fn test_delete_key_package_destroys_the_init_key_of_a_legacy_record() {
2182        let (manager, storage) = create_test_manager_with_storage("alice");
2183        let bundle = manager.generate_publication_key_package().unwrap();
2184        // Rewrite the record the way a build predating the bundle wrote it.
2185        storage
2186            .store(
2187                StorageKeyType::KeyPackage.as_str(),
2188                &bundle.package_id,
2189                &bundle.key_package_data,
2190            )
2191            .unwrap();
2192
2193        manager.delete_key_package(&bundle.package_id).unwrap();
2194
2195        assert!(
2196            !init_key_present(&manager, &bundle),
2197            "a legacy record's private init key survived its deletion"
2198        );
2199        assert!(
2200            storage
2201                .load(StorageKeyType::KeyPackage.as_str(), &bundle.package_id)
2202                .unwrap()
2203                .is_none(),
2204            "the legacy record outlived its key material"
2205        );
2206    }
2207
2208    /// A record whose bytes name no key package at all still gets deleted —
2209    /// there is no material to destroy, and leaving it would make the pool scan
2210    /// carry it forever.
2211    #[test]
2212    fn test_delete_key_package_removes_an_unreadable_record() {
2213        let (manager, storage) = create_test_manager_with_storage("alice");
2214        storage
2215            .store(
2216                StorageKeyType::KeyPackage.as_str(),
2217                "junk",
2218                b"not a package",
2219            )
2220            .unwrap();
2221
2222        manager.delete_key_package("junk").unwrap();
2223
2224        assert!(storage
2225            .load(StorageKeyType::KeyPackage.as_str(), "junk")
2226            .unwrap()
2227            .is_none());
2228    }
2229
2230    /// `ensure_min_key_packages` mints *unclaimed* packages, which the push
2231    /// path draws down one per peer. Minting past the pool ceiling would put
2232    /// material in storage that the ceiling then stops it ever handing out,
2233    /// while pinning the pool at capacity so every peer past the last claim is
2234    /// advertised a shared init key.
2235    #[test]
2236    fn test_ensure_min_key_packages_is_capped_at_the_pool_ceiling() {
2237        let manager = create_test_manager("alice");
2238
2239        let count = manager
2240            .ensure_min_key_packages(MAX_PUSH_KEY_PACKAGES + 50)
2241            .unwrap();
2242
2243        assert_eq!(count, MAX_PUSH_KEY_PACKAGES);
2244        assert_eq!(
2245            manager.count_valid_key_packages().unwrap(),
2246            MAX_PUSH_KEY_PACKAGES,
2247            "pre-filling minted past the ceiling the push path enforces"
2248        );
2249    }
2250
2251    #[test]
2252    fn test_manager_creation() {
2253        let manager = create_test_manager("alice");
2254        assert_eq!(manager.user_id(), addr("alice"));
2255    }
2256
2257    #[test]
2258    fn test_key_package_generation() {
2259        let manager = create_test_manager("alice");
2260        let package = manager.generate_key_package().unwrap();
2261
2262        assert_eq!(package.user_id, addr("alice"));
2263        assert!(!package.key_package_data.is_empty());
2264        assert!(!package.is_expired());
2265    }
2266
2267    /// The push path must never hand out a package standing in a published
2268    /// slot. If it did, the pushed-to peer and a stranger who fetched the
2269    /// record would race for the same single-use init key, and the loser's
2270    /// Welcome is unprocessable.
2271    #[test]
2272    fn test_get_or_create_never_returns_a_publication_package() {
2273        let manager = create_test_manager("alice");
2274        let reserved = manager.generate_publication_key_package().unwrap();
2275        assert!(reserved.reserved_for_publication);
2276
2277        let pushed = manager.get_or_create_key_package().unwrap();
2278        assert_ne!(
2279            pushed.package_id, reserved.package_id,
2280            "the push path took the package a published record is standing on"
2281        );
2282        assert!(!pushed.reserved_for_publication);
2283
2284        // And it stays stable across calls — the reserved one is skipped every
2285        // time, not just minted around once.
2286        let pushed_again = manager.get_or_create_key_package().unwrap();
2287        assert_eq!(pushed.package_id, pushed_again.package_id);
2288    }
2289
2290    /// `key_package_by_id` returning `None` is the consumption signal the
2291    /// publication slots run on, so it must distinguish a package this device
2292    /// still holds from one it does not.
2293    #[test]
2294    fn test_key_package_by_id_reports_presence() {
2295        let manager = create_test_manager("alice");
2296        let bundle = manager.generate_publication_key_package().unwrap();
2297
2298        let found = manager.key_package_by_id(&bundle.package_id).unwrap();
2299        assert!(found.is_some());
2300        assert_eq!(found.unwrap().package_id, bundle.package_id);
2301
2302        assert!(manager
2303            .key_package_by_id("no-such-package")
2304            .unwrap()
2305            .is_none());
2306    }
2307
2308    /// Publication slots are independent: two slots must never end up holding
2309    /// the same package, or one Welcome consumes both.
2310    #[test]
2311    fn test_publication_packages_are_distinct_per_slot() {
2312        let manager = create_test_manager("alice");
2313        let a = manager.generate_publication_key_package().unwrap();
2314        let b = manager.generate_publication_key_package().unwrap();
2315        assert_ne!(a.package_id, b.package_id);
2316        assert_ne!(a.key_package_data, b.key_package_data);
2317    }
2318
2319    #[test]
2320    fn test_get_or_create_key_package() {
2321        let manager = create_test_manager("alice");
2322        let pkg1 = manager.get_or_create_key_package().unwrap();
2323        let pkg2 = manager.get_or_create_key_package().unwrap();
2324        // Since we are now properly persisting keys, pkg2 should be the same as pkg1
2325        // IF the logic reuses existing key packages.
2326        // get_or_create_key_package logic iterates list_keys.
2327        assert_eq!(pkg1.package_id, pkg2.package_id);
2328    }
2329
2330    // ========================================================================
2331    // PUSH-PATH KEY PACKAGE POOL
2332    // ========================================================================
2333
2334    /// The property the whole per-peer pool exists for: two peers must never be
2335    /// advertised the same init key. Sharing one weakens forward secrecy at
2336    /// session establishment (a single compromised init key opens every Welcome
2337    /// built against it) and is the LastResort-style reuse RFC 9420 §16.8
2338    /// permits only as a denial-of-service fallback.
2339    #[test]
2340    fn test_push_path_gives_each_peer_its_own_key_package() {
2341        let manager = create_test_manager("alice");
2342
2343        let for_bob = manager.take_push_key_package(&addr("bob")).unwrap();
2344        let for_carol = manager.take_push_key_package(&addr("carol")).unwrap();
2345
2346        assert_ne!(
2347            for_bob.bundle.package_id, for_carol.bundle.package_id,
2348            "two peers were advertised the same key package"
2349        );
2350        assert_ne!(
2351            for_bob.bundle.key_package_data, for_carol.bundle.key_package_data,
2352            "two peers were advertised the same init key"
2353        );
2354        assert!(!for_bob.pool_exhausted && !for_carol.pool_exhausted);
2355        assert_eq!(
2356            for_bob.bundle.assigned_peer.as_deref(),
2357            Some(addr("bob").as_str())
2358        );
2359        assert_eq!(
2360            for_carol.bundle.assigned_peer.as_deref(),
2361            Some(addr("carol").as_str())
2362        );
2363    }
2364
2365    /// The other half of the property: a repeat push to one peer must NOT mint
2366    /// fresh key material. Pushes to the same peer are routine (rediscovery,
2367    /// session establishment, group invites), and minting per push would grow
2368    /// the pool without bound and hit the ceiling within days.
2369    #[test]
2370    fn test_push_path_reuses_one_package_for_repeat_pushes_to_a_peer() {
2371        let manager = create_test_manager("alice");
2372
2373        let first = manager.take_push_key_package(&addr("bob")).unwrap();
2374        let second = manager.take_push_key_package(&addr("bob")).unwrap();
2375        let third = manager.take_push_key_package(&addr("bob")).unwrap();
2376
2377        assert_eq!(first.bundle.package_id, second.bundle.package_id);
2378        assert_eq!(first.bundle.package_id, third.bundle.package_id);
2379        assert_eq!(
2380            manager.count_valid_key_packages().unwrap(),
2381            1,
2382            "repeat pushes to one peer minted extra key material"
2383        );
2384    }
2385
2386    /// A peer's package is rotated by *consumption*, not by time: once a
2387    /// Welcome built against it has been processed the init key is gone, and
2388    /// the next push to that peer must mint a fresh one rather than re-handing
2389    /// a package nobody can use.
2390    #[test]
2391    fn test_push_path_mints_a_fresh_package_after_the_peer_consumed_one() {
2392        let alice = create_test_manager("alice");
2393        let bob = create_test_manager("bob");
2394
2395        let advertised = alice.take_push_key_package(&addr("bob")).unwrap().bundle;
2396        bob.import_key_package(&addr("alice"), &advertised.key_package_data)
2397            .unwrap();
2398        let welcome = bob.create_session(&addr("alice")).unwrap();
2399        alice.join_session(&welcome).unwrap();
2400
2401        let next = alice.take_push_key_package(&addr("bob")).unwrap();
2402        assert_ne!(
2403            next.bundle.package_id, advertised.package_id,
2404            "a consumed package was advertised again"
2405        );
2406        assert!(!next.pool_exhausted);
2407    }
2408
2409    /// The race the old shape created, end to end: one package handed to two
2410    /// peers means the second Welcome is built against an init key the first
2411    /// has already consumed, and it can never be processed. Nothing re-drives
2412    /// the exchange, so that peer is stuck.
2413    #[test]
2414    fn test_two_peers_can_both_establish_sessions_after_being_pushed_to() {
2415        let alice = create_test_manager("alice");
2416        let bob = create_test_manager("bob");
2417        let carol = create_test_manager("carol");
2418
2419        for (peer, manager) in [(addr("bob"), &bob), (addr("carol"), &carol)] {
2420            let advertised = alice.take_push_key_package(&peer).unwrap().bundle;
2421            manager
2422                .import_key_package(&addr("alice"), &advertised.key_package_data)
2423                .unwrap();
2424        }
2425
2426        // Both Welcomes are built before either is processed — the interleaving
2427        // that makes shared init keys fail.
2428        let from_bob = bob.create_session(&addr("alice")).unwrap();
2429        let from_carol = carol.create_session(&addr("alice")).unwrap();
2430
2431        alice.join_session(&from_bob).unwrap();
2432        alice
2433            .join_session(&from_carol)
2434            .expect("second peer's Welcome must still be processable");
2435
2436        assert!(alice.has_session(&addr("bob")).unwrap());
2437        assert!(alice.has_session(&addr("carol")).unwrap());
2438    }
2439
2440    /// Publication slots stay off-limits to the push path for the same
2441    /// single-use reason, now via the peer-keyed entry point.
2442    #[test]
2443    fn test_push_path_never_takes_a_publication_package() {
2444        let manager = create_test_manager("alice");
2445        let reserved = manager.generate_publication_key_package().unwrap();
2446
2447        for peer in [addr("bob"), addr("carol"), addr("dave")] {
2448            let taken = manager.take_push_key_package(&peer).unwrap();
2449            assert_ne!(
2450                taken.bundle.package_id, reserved.package_id,
2451                "the push path took the package a published record stands on"
2452            );
2453        }
2454    }
2455
2456    /// The peer-less entry point (the FFI surface and tests) must not hand out
2457    /// a package a peer is expected to use, or it re-opens the cross-peer
2458    /// sharing the pool prevents.
2459    #[test]
2460    fn test_get_or_create_never_returns_a_peer_assigned_package() {
2461        let manager = create_test_manager("alice");
2462        let for_bob = manager.take_push_key_package(&addr("bob")).unwrap().bundle;
2463
2464        let unclaimed = manager.get_or_create_key_package().unwrap();
2465        assert_ne!(unclaimed.package_id, for_bob.package_id);
2466        assert!(unclaimed.assigned_peer.is_none());
2467    }
2468
2469    /// An unclaimed package — minted by the peer-less entry point, or by a
2470    /// build predating assignment — is claimed rather than left behind, so
2471    /// upgrading does not strand the package already in storage.
2472    #[test]
2473    fn test_push_path_claims_an_unclaimed_package_instead_of_stranding_it() {
2474        let manager = create_test_manager("alice");
2475        let legacy = manager.get_or_create_key_package().unwrap();
2476        assert!(legacy.assigned_peer.is_none());
2477
2478        let taken = manager.take_push_key_package(&addr("bob")).unwrap();
2479        assert_eq!(
2480            taken.bundle.package_id, legacy.package_id,
2481            "the pre-existing package was stranded instead of claimed"
2482        );
2483        assert_eq!(
2484            taken.bundle.assigned_peer.as_deref(),
2485            Some(addr("bob").as_str())
2486        );
2487        assert_eq!(manager.count_valid_key_packages().unwrap(), 1);
2488    }
2489
2490    /// At the ceiling the pool stops growing and shares a package, which is the
2491    /// old behaviour — deliberately, since refusing to advertise would cost
2492    /// session establishment. It must say so, because that is the one condition
2493    /// under which the reuse this pool removes is back.
2494    #[test]
2495    fn test_push_pool_ceiling_shares_a_package_and_reports_it() {
2496        let manager = create_test_manager("alice");
2497
2498        for i in 0..MAX_PUSH_KEY_PACKAGES {
2499            let taken = manager.take_push_key_package(&format!("peer{i}")).unwrap();
2500            assert!(
2501                !taken.pool_exhausted,
2502                "pool reported exhausted at {i}, below the ceiling"
2503            );
2504        }
2505        assert_eq!(
2506            manager.count_valid_key_packages().unwrap(),
2507            MAX_PUSH_KEY_PACKAGES
2508        );
2509
2510        let over = manager.take_push_key_package("one-peer-too-many").unwrap();
2511        assert!(over.pool_exhausted, "ceiling breach was not reported");
2512        assert_eq!(
2513            manager.count_valid_key_packages().unwrap(),
2514            MAX_PUSH_KEY_PACKAGES,
2515            "the pool grew past its ceiling"
2516        );
2517
2518        // A peer that already holds a package keeps getting its own, even with
2519        // the pool at capacity — the ceiling must not downgrade existing peers.
2520        let established = manager.take_push_key_package("peer0").unwrap();
2521        assert!(!established.pool_exhausted);
2522        assert_eq!(established.bundle.assigned_peer.as_deref(), Some("peer0"));
2523    }
2524
2525    /// The ceiling bounds how many packages *exist*, so it must gate minting
2526    /// and nothing else. A full pool holding an unclaimed package has one to
2527    /// give: claiming it relabels a package rather than adding one, and
2528    /// degrading to a shared init key while it sat idle would weaken forward
2529    /// secrecy to stay under a bound the claim never approaches.
2530    #[test]
2531    fn test_ceiling_claims_an_unclaimed_package_rather_than_sharing() {
2532        let manager = create_test_manager("alice");
2533
2534        for i in 0..(MAX_PUSH_KEY_PACKAGES - 1) {
2535            manager.take_push_key_package(&format!("peer{i}")).unwrap();
2536        }
2537        // The last live slot is an unclaimed package — as the peer-less entry
2538        // point, `ensure_min_key_packages`, or a pre-upgrade record leaves one.
2539        let unclaimed = manager.get_or_create_key_package().unwrap();
2540        assert_eq!(
2541            manager.count_valid_key_packages().unwrap(),
2542            MAX_PUSH_KEY_PACKAGES,
2543            "test premise: the pool is at the ceiling"
2544        );
2545
2546        let taken = manager.take_push_key_package("late-arrival").unwrap();
2547        assert!(
2548            !taken.pool_exhausted,
2549            "shared a package while a claimable one was in the pool"
2550        );
2551        assert_eq!(
2552            taken.bundle.package_id, unclaimed.package_id,
2553            "the claimable package was passed over"
2554        );
2555        assert_eq!(taken.bundle.assigned_peer.as_deref(), Some("late-arrival"));
2556        assert_eq!(
2557            manager.count_valid_key_packages().unwrap(),
2558            MAX_PUSH_KEY_PACKAGES,
2559            "claiming grew the pool"
2560        );
2561
2562        // With the last unclaimed package now spoken for, the next new peer has
2563        // nothing left to claim and does take the shared-package degradation.
2564        let over = manager.take_push_key_package("one-peer-too-many").unwrap();
2565        assert!(over.pool_exhausted);
2566    }
2567
2568    #[test]
2569    fn test_import_key_package_binds_credential_identity() {
2570        let alice = create_test_manager("alice");
2571        let mallory = create_test_manager("mallory");
2572        let mallory_kp = mallory.generate_key_package().unwrap();
2573
2574        // Importing mallory's key package under "bob" must be rejected —
2575        // otherwise a session "with bob" would encrypt to mallory's keys.
2576        let err = alice
2577            .import_key_package(&addr("bob"), &mallory_kp.key_package_data)
2578            .unwrap_err();
2579        assert!(matches!(err, MlsError::CredentialIdentityMismatch { .. }));
2580
2581        // Importing under the matching identity succeeds.
2582        alice
2583            .import_key_package(&addr("mallory"), &mallory_kp.key_package_data)
2584            .unwrap();
2585    }
2586
2587    /// A key package whose basic credential claims `claimed` but whose leaf
2588    /// signature key is `keys`.
2589    ///
2590    /// Built against raw OpenMLS on purpose: [`MlsManager::new`] refuses to
2591    /// construct at an address its stored key does not derive to, so an
2592    /// impostor is no longer expressible through this crate's own API. That
2593    /// refusal is the point — but it means the substitution has to be forged
2594    /// one layer down to be tested at all, the same way the `SenderIdentityMismatch`
2595    /// tests do.
2596    fn substituted_key_package(claimed: &str, keys: &SignatureKeyPair) -> Vec<u8> {
2597        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
2598        let provider = MlsProvider::new(MlsStorageAdapter::new(storage));
2599        keys.store(provider.storage()).unwrap();
2600
2601        let credential = CredentialWithKey {
2602            credential: Credential::new(CredentialType::Basic, claimed.as_bytes().to_vec()),
2603            signature_key: keys.public().into(),
2604        };
2605
2606        KeyPackage::builder()
2607            .build(DEFAULT_CIPHERSUITE, &provider, keys, credential)
2608            .unwrap()
2609            .key_package()
2610            .tls_serialize_detached()
2611            .unwrap()
2612    }
2613
2614    #[test]
2615    fn test_credential_identity_check_alone_does_not_stop_a_substitution() {
2616        // The premise for the derivation check below, stated as a test so it
2617        // cannot quietly stop being true. `verify_credential_identity` compares
2618        // a self-asserted string, so an impostor who writes bob's address into
2619        // their own credential sails through *that* check on its own.
2620        let (mallorys_keys, _) = test_identity("mallory");
2621        let forged = substituted_key_package(&addr("bob"), &mallorys_keys);
2622
2623        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
2624        let provider = MlsProvider::new(MlsStorageAdapter::new(storage));
2625        let key_package = KeyPackageIn::tls_deserialize_exact(&forged)
2626            .unwrap()
2627            .validate(provider.crypto(), ProtocolVersion::Mls10)
2628            .unwrap();
2629
2630        MlsManager::verify_credential_identity(&key_package, &addr("bob"))
2631            .expect("the identity check cannot tell the two bobs apart");
2632    }
2633
2634    #[test]
2635    fn test_substituted_key_package_is_refused_by_the_derivation() {
2636        // The same substitution, refused — and refused on *first contact*,
2637        // which the pin it replaced could not do. There is nothing to have
2638        // learned about bob beforehand: his address is the hash of his key, so
2639        // a package signed by anyone else re-derives to a different address.
2640        let alice = create_test_manager("alice");
2641        let real_bob = create_test_manager("bob");
2642        let (mallorys_keys, _) = test_identity("mallory");
2643
2644        let forged = substituted_key_package(&addr("bob"), &mallorys_keys);
2645        let err = alice.import_key_package(&addr("bob"), &forged).unwrap_err();
2646        assert!(
2647            matches!(
2648                err,
2649                MlsError::KeyPackageAddressMismatch { ref claimed, .. } if *claimed == addr("bob")
2650            ),
2651            "expected an address mismatch, got {:?}",
2652            err
2653        );
2654
2655        // The genuine article still imports, with no prior contact either.
2656        let real_kp = real_bob.generate_key_package().unwrap();
2657        alice
2658            .import_key_package(&addr("bob"), &real_kp.key_package_data)
2659            .expect("the real peer's key package derives to its own address");
2660    }
2661
2662    #[test]
2663    fn test_a_nickname_peer_id_is_refused_rather_than_skipping_the_check() {
2664        // The bypass this check must not have. If a non-address id answered
2665        // "nothing to derive, pass", an attacker would simply claim one — so a
2666        // peer id that is not an address is an error, not a waiver.
2667        let alice = create_test_manager("alice");
2668        let bob_kp = create_test_manager("bob").generate_key_package().unwrap();
2669
2670        let err = alice
2671            .import_key_package("bob", &bob_kp.key_package_data)
2672            .unwrap_err();
2673        assert!(
2674            matches!(
2675                err,
2676                MlsError::CredentialIdentityMismatch { .. } | MlsError::InvalidUserId(_)
2677            ),
2678            "a nickname must be refused, got {:?}",
2679            err
2680        );
2681    }
2682
2683    #[test]
2684    fn test_add_group_member_binds_identity_and_derivation() {
2685        // The group path ran neither check before — not even the credential
2686        // identity one — so a package could join a group under a roster label
2687        // that had nothing to do with it.
2688        let alice = create_test_manager("alice");
2689        let bob = create_test_manager("bob");
2690        let mallory = create_test_manager("mallory");
2691
2692        let group_id = alice.create_group("Test Group").unwrap().group_id;
2693
2694        // Wrong identity outright.
2695        let mallory_kp = mallory.generate_key_package().unwrap();
2696        let err = alice
2697            .add_group_member(&group_id, &addr("bob"), &mallory_kp.key_package_data)
2698            .unwrap_err();
2699        assert!(matches!(err, MlsError::CredentialIdentityMismatch { .. }));
2700
2701        // Right address in the credential, wrong key behind it.
2702        let (mallorys_keys, _) = test_identity("mallory");
2703        let forged = substituted_key_package(&addr("bob"), &mallorys_keys);
2704        let err = alice
2705            .add_group_member(&group_id, &addr("bob"), &forged)
2706            .unwrap_err();
2707        assert!(matches!(err, MlsError::KeyPackageAddressMismatch { .. }));
2708
2709        // The genuine invitee joins.
2710        let bob_kp = bob.generate_key_package().unwrap();
2711        alice
2712            .add_group_member(&group_id, &addr("bob"), &bob_kp.key_package_data)
2713            .expect("the real invitee is admitted");
2714    }
2715
2716    #[test]
2717    fn test_import_key_package_rejects_hostile_user_id() {
2718        let alice = create_test_manager("alice");
2719        let bob = create_test_manager("bob");
2720        let bob_kp = bob.generate_key_package().unwrap();
2721
2722        for hostile in ["", "..", "bob/evil", "bob\\evil", "bob\0evil"] {
2723            let err = alice
2724                .import_key_package(hostile, &bob_kp.key_package_data)
2725                .unwrap_err();
2726            assert!(
2727                matches!(err, MlsError::InvalidUserId(_)),
2728                "expected InvalidUserId for {:?}, got {:?}",
2729                hostile,
2730                err
2731            );
2732        }
2733    }
2734
2735    #[test]
2736    fn test_get_contact_key_package_rejects_poisoned_store() {
2737        // A key package written to storage out-of-band (bypassing import
2738        // validation, e.g. persisted before this fix) under the wrong user
2739        // id must fail identity verification at use time.
2740        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
2741        let (alice_keys, alice_address) = test_identity("alice");
2742        seed_identity(&storage, &alice_keys);
2743        let alice = MlsManager::new(alice_address.to_string(), storage.clone()).unwrap();
2744        let mallory = create_test_manager("mallory");
2745        let mallory_kp = mallory.generate_key_package().unwrap();
2746
2747        storage
2748            .store(
2749                StorageKeyType::ContactKeyPackage.as_str(),
2750                &addr("bob"),
2751                &mallory_kp.key_package_data,
2752            )
2753            .unwrap();
2754
2755        // create_session(bob) loads the poisoned package and must refuse.
2756        let err = alice.create_session(&addr("bob")).unwrap_err();
2757        assert!(matches!(err, MlsError::CredentialIdentityMismatch { .. }));
2758    }
2759
2760    /// The *derivation* half of the cached-read check, which the poisoned-store
2761    /// test above cannot reach.
2762    ///
2763    /// That test stores mallory's own genuine package under bob's key, so the
2764    /// credential identity check ([`MlsManager::verify_credential_identity`])
2765    /// refuses it first and
2766    /// [`MlsManager::verify_address_binding`] never runs. A *substituted*
2767    /// package — credential claiming bob's address, leaf key mallory's — sails
2768    /// past the credential check by construction and can only be caught by
2769    /// re-deriving the address from the key.
2770    ///
2771    /// This is the call site the re-check exists for. Import-time validation
2772    /// does not travel with a cached package: it is re-read from the
2773    /// install-scoped store long after the receive-time gate admitted it, which
2774    /// is exactly the window a container write aims at. Deleting the
2775    /// `verify_address_binding` line from `get_contact_key_package` leaves
2776    /// every other test in this workspace green, so without this one the
2777    /// second call site could be dropped by a refactor in silence.
2778    #[test]
2779    fn test_get_contact_key_package_rejects_a_substituted_package() {
2780        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
2781        let (alice_keys, alice_address) = test_identity("alice");
2782        seed_identity(&storage, &alice_keys);
2783        let alice = MlsManager::new(alice_address.to_string(), storage.clone()).unwrap();
2784
2785        // Claims bob's address, signed by mallory's key.
2786        let (mallorys_keys, _) = test_identity("mallory");
2787        let forged = substituted_key_package(&addr("bob"), &mallorys_keys);
2788
2789        storage
2790            .store(
2791                StorageKeyType::ContactKeyPackage.as_str(),
2792                &addr("bob"),
2793                &forged,
2794            )
2795            .unwrap();
2796
2797        let err = alice.create_session(&addr("bob")).unwrap_err();
2798        assert!(
2799            matches!(
2800                err,
2801                MlsError::KeyPackageAddressMismatch { ref claimed, .. } if *claimed == addr("bob")
2802            ),
2803            "a substituted cached package must be refused at use time, got {:?}",
2804            err
2805        );
2806
2807        // And no session was built against mallory's leaf.
2808        assert!(!alice.has_session(&addr("bob")).unwrap());
2809    }
2810
2811    #[test]
2812    fn test_replace_session_with_welcome_rejects_hostile_inviter_id() {
2813        // `inviter_id` arrives on the wire and is used as a raw storage key
2814        // for deletes — hostile values must be rejected before any storage
2815        // operation runs.
2816        let alice = create_test_manager("alice");
2817        let welcome = WelcomeMessage {
2818            group_id: slot("alice", "bob"),
2819            welcome_data: vec![],
2820            inviter_id: "../../etc".to_string(),
2821            group_name: None,
2822            timestamp_ms: 0,
2823        };
2824        let err = alice.replace_session_with_welcome(&welcome).unwrap_err();
2825        assert!(matches!(err, MlsError::InvalidUserId(_)));
2826    }
2827
2828    /// Builds a converged alice/bob 1:1 session for sender-binding tests.
2829    fn create_test_session() -> (MlsManager, MlsManager) {
2830        let alice = create_test_manager("alice");
2831        let bob = create_test_manager("bob");
2832        let bob_kp = bob.generate_key_package().unwrap();
2833        alice
2834            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
2835            .unwrap();
2836        let welcome = alice.create_session(&addr("bob")).unwrap();
2837        bob.join_session(&welcome).unwrap();
2838        (alice, bob)
2839    }
2840
2841    #[test]
2842    fn test_session_decrypt_rejects_spoofed_sender() {
2843        let (alice, bob) = create_test_session();
2844
2845        // A message from alice's session must not be attributable to a
2846        // different wire sender. The envelope names `session:alice:bob`, which
2847        // is not the slot bob shares with "mallory", so the slot binding
2848        // rejects it *before* the group is loaded — strictly earlier than the
2849        // SEC-M1 credential check, which cannot run until a decrypt succeeds.
2850        let ct = alice.encrypt_for_user(&addr("bob"), b"hello").unwrap();
2851        let err = bob.decrypt_from_user(&ct, &addr("mallory")).unwrap_err();
2852        assert!(
2853            matches!(err, MlsError::SessionIdentityMismatch { .. }),
2854            "spoofed sender must be rejected by the slot binding, got {:?}",
2855            err
2856        );
2857
2858        // Rejecting pre-decrypt also means the forgery does not consume the
2859        // ratchet generation it named, so the *same* generation still decrypts
2860        // for the legitimate sender. (Before the binding, a spoofed frame
2861        // burned the generation and the genuine message was lost.)
2862        let pt = bob.decrypt_from_user(&ct, &addr("alice")).unwrap();
2863        assert_eq!(pt.as_deref(), Some(&b"hello"[..]));
2864
2865        // SEC-M1 itself (correct slot, wrong credential) stays reachable and is
2866        // covered by `test_group_decrypt_rejects_spoofed_sender`, where a group
2867        // has enough members for the two identities to differ.
2868    }
2869
2870    #[test]
2871    fn test_epoch_fork_classifies_as_session_desync() {
2872        // An established session that forks (one side advances its epoch without
2873        // the other merging the commit) must surface as the *recoverable*
2874        // `SessionDesync`, not an opaque `Decryption` — that is what lets the
2875        // protocol layer re-key instead of silently dropping.
2876        let (alice, bob) = create_test_session();
2877
2878        // Alice self-updates and merges locally, advancing only her epoch; bob
2879        // never sees the commit, so the two sides are now one epoch apart.
2880        let group_id = slot("alice", "bob");
2881        alice.update_keys(&group_id).unwrap();
2882
2883        // A message alice encrypts at her new epoch cannot decrypt at bob's old
2884        // epoch → OpenMLS `WrongEpoch` → `MlsError::SessionDesync`.
2885        let ct = alice.encrypt_for_user(&addr("bob"), b"after-fork").unwrap();
2886        let err = bob.decrypt_from_user(&ct, &addr("alice")).unwrap_err();
2887        assert!(
2888            matches!(err, MlsError::SessionDesync(_)),
2889            "epoch fork must classify as SessionDesync, got {:?}",
2890            err
2891        );
2892    }
2893
2894    /// Serializes an `MlsMessage(PrivateMessage)` from scratch — no group, no
2895    /// keys, no captured ciphertext. Wire format per RFC 9420:
2896    /// `version(u16) || wire_format(u16) || group_id<V> || epoch(u64) ||
2897    /// content_type(u8) || authenticated_data<V> || encrypted_sender_data<V> ||
2898    /// ciphertext<V>`, where `<V>` is a QUIC varint length (single byte below
2899    /// 64). Used to demonstrate what an off-path attacker can actually build.
2900    /// Appends `len` as a QUIC variable-length integer.
2901    ///
2902    /// Two-byte encoding is not optional here: a 1:1 slot over two `off1…`
2903    /// addresses is ~96 bytes, well past the 63-byte ceiling a single byte can
2904    /// express. Hardcoding one byte was fine while ids were nicknames and
2905    /// silently produced an unparseable frame the moment they stopped being.
2906    fn push_varint(out: &mut Vec<u8>, len: usize) {
2907        match len {
2908            0..=63 => out.push(len as u8),
2909            64..=16_383 => {
2910                out.push(0x40 | (len >> 8) as u8);
2911                out.push((len & 0xff) as u8);
2912            }
2913            _ => panic!("test payloads stay under the 2-byte varint ceiling"),
2914        }
2915    }
2916
2917    fn forge_private_message(group_id: &str, epoch: u64) -> Vec<u8> {
2918        let mut out = Vec::new();
2919        out.extend_from_slice(&1u16.to_be_bytes()); // ProtocolVersion::Mls10
2920        out.extend_from_slice(&2u16.to_be_bytes()); // WireFormat::PrivateMessage
2921        push_varint(&mut out, group_id.len());
2922        out.extend_from_slice(group_id.as_bytes());
2923        out.extend_from_slice(&epoch.to_be_bytes());
2924        out.push(1); // ContentType::Application
2925        out.push(0); // authenticated_data: empty
2926        out.push(8); // encrypted_sender_data: 8 bytes of nothing
2927        out.extend_from_slice(&[0u8; 8]);
2928        out.push(16); // ciphertext: 16 bytes of nothing
2929        out.extend_from_slice(&[0u8; 16]);
2930        out
2931    }
2932
2933    #[test]
2934    fn test_forged_frame_reaches_session_desync_without_any_key_material() {
2935        // Honest statement of the residual threat model. OpenMLS validates the
2936        // framing header (group id, then epoch) *before* any AEAD, sender-data
2937        // decryption, or signature check, and a 1:1 slot id is derivable from
2938        // two public user ids. So an attacker with no key material, no captured
2939        // ciphertext and no session can hand-serialize a frame that classifies
2940        // as the recoverable `SessionDesync` — the classification that drives a
2941        // re-key. This is inherent to MLS framing, not an OpenMLS bug.
2942        //
2943        // The mitigation is NOT that this is unreachable (it is reachable, as
2944        // asserted here) but that everything hung off it is bounded and
2945        // non-destructive: the slot binding below, the per-peer re-key floor,
2946        // and a heal that no longer discards queued plaintext.
2947        let (_alice, bob) = create_test_session();
2948
2949        let forged = EncryptedMessage {
2950            group_id: slot("alice", "bob"),
2951            message_type: MlsMessageType::Application,
2952            epoch: 9_999,
2953            ciphertext: forge_private_message(slot("alice", "bob").as_str(), 9_999),
2954            sender_id: addr("alice"),
2955            timestamp_ms: 0,
2956        };
2957
2958        let err = bob.decrypt_from_user(&forged, &addr("alice")).unwrap_err();
2959        assert!(
2960            matches!(err, MlsError::SessionDesync(_)),
2961            "a forged future-epoch frame reaches the desync classification pre-AEAD, got {:?}",
2962            err
2963        );
2964    }
2965
2966    #[test]
2967    fn test_forged_frame_naming_a_foreign_slot_is_rejected_before_desync() {
2968        // The slot binding is what stops the forgery above from being pointed
2969        // at *any* session while naming *any* sender. Without it the claimed
2970        // sender and the targeted group are independent, so one derivable slot
2971        // id yields a re-key (and a peer-keyed map entry, and a key-package
2972        // send) for arbitrary attacker-chosen identities.
2973        let (_alice, bob) = create_test_session();
2974
2975        let forged = EncryptedMessage {
2976            group_id: slot("alice", "bob"),
2977            message_type: MlsMessageType::Application,
2978            epoch: 9_999,
2979            ciphertext: forge_private_message(slot("alice", "bob").as_str(), 9_999),
2980            sender_id: addr("mallory"),
2981            timestamp_ms: 0,
2982        };
2983
2984        let err = bob
2985            .decrypt_from_user(&forged, &addr("mallory"))
2986            .unwrap_err();
2987        assert!(
2988            matches!(err, MlsError::SessionIdentityMismatch { .. }),
2989            "a frame naming another pair's slot must not classify as recoverable, got {:?}",
2990            err
2991        );
2992    }
2993
2994    #[test]
2995    fn test_corrupt_ciphertext_is_not_classified_as_session_desync() {
2996        // A malformed frame must stay a plain decrypt failure, which the
2997        // protocol layer fails closed on. Note this covers only *malformed*
2998        // input — it never reaches the framing validation, so it is not
2999        // evidence that forged frames cannot reach `SessionDesync`. They can:
3000        // see `test_forged_frame_reaches_session_desync_without_any_key_material`.
3001        let (alice, bob) = create_test_session();
3002        let mut ct = alice.encrypt_for_user(&addr("bob"), b"hello").unwrap();
3003        // Corrupt the AEAD-protected body.
3004        for b in ct.ciphertext.iter_mut() {
3005            *b ^= 0xFF;
3006        }
3007        let err = bob.decrypt_from_user(&ct, &addr("alice")).unwrap_err();
3008        assert!(
3009            !matches!(err, MlsError::SessionDesync(_)),
3010            "corrupt ciphertext must not be treated as a recoverable desync, got {:?}",
3011            err
3012        );
3013    }
3014
3015    #[test]
3016    fn test_join_session_binds_slot_to_inviter() {
3017        // Regression (session-hijack): a Welcome from an authenticated inviter
3018        // may only install the 1:1 session slot for (self, inviter). An inviter
3019        // that names a *third* party's slot must be rejected before any group
3020        // state is touched — otherwise the inviter overwrites/seeds the
3021        // victim's session with that third party, so the victim's outbound
3022        // messages to that party encrypt to the attacker's group.
3023        let bob = create_test_manager("bob");
3024
3025        // mallory, authenticating honestly as herself, sends bob a Welcome whose
3026        // group_id squats alice+bob's session slot.
3027        let hijack = WelcomeMessage {
3028            group_id: slot("alice", "bob"),
3029            welcome_data: vec![], // rejected before deserialization
3030            inviter_id: addr("mallory"),
3031            group_name: None,
3032            timestamp_ms: 0,
3033        };
3034
3035        // Both join entry points must reject the mismatched slot.
3036        let err = bob.join_session(&hijack).unwrap_err();
3037        assert!(
3038            matches!(err, MlsError::WelcomeIdentityMismatch { .. }),
3039            "join_session: expected WelcomeIdentityMismatch, got {:?}",
3040            err
3041        );
3042        let err = bob.replace_session_with_welcome(&hijack).unwrap_err();
3043        assert!(
3044            matches!(err, MlsError::WelcomeIdentityMismatch { .. }),
3045            "replace_session_with_welcome: expected WelcomeIdentityMismatch, got {:?}",
3046            err
3047        );
3048
3049        // Nothing was installed: bob has no session with either identity.
3050        assert!(!bob.has_session(&addr("alice")).unwrap());
3051        assert!(!bob.has_session(&addr("mallory")).unwrap());
3052
3053        // The check is precise, not over-broad: a correctly-slotted Welcome from
3054        // mallory (group_id == session:bob:mallory, inviter_id == mallory) still
3055        // joins normally.
3056        let bob_kp = bob.generate_key_package().unwrap();
3057        let mallory = create_test_manager("mallory");
3058        mallory
3059            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
3060            .unwrap();
3061        let legit = mallory.create_session(&addr("bob")).unwrap();
3062        bob.join_session(&legit).unwrap();
3063        assert!(bob.has_session(&addr("mallory")).unwrap());
3064    }
3065
3066    #[test]
3067    fn test_join_group_rejects_session_namespace() {
3068        // Regression (session-hijack via the group-Welcome path): the identity
3069        // binding on `join_session` only guards the session-Welcome path. A
3070        // group Welcome carries an attacker-controllable `group_id` and writes
3071        // the SAME storage/OpenMLS keyspace, so `join_group` must refuse the
3072        // reserved `session:` namespace outright — otherwise an authenticated
3073        // peer could seed/overwrite a third party's 1:1 session slot and
3074        // hijack the victim's outbound encryption.
3075        let bob = create_test_manager("bob");
3076
3077        let squat = WelcomeMessage {
3078            group_id: slot("alice", "bob"),
3079            welcome_data: vec![], // rejected before deserialization
3080            inviter_id: addr("mallory"),
3081            group_name: None,
3082            timestamp_ms: 0,
3083        };
3084
3085        let err = bob.join_group(&squat).unwrap_err();
3086        assert!(
3087            matches!(err, MlsError::ReservedSessionNamespace { .. }),
3088            "join_group: expected ReservedSessionNamespace, got {:?}",
3089            err
3090        );
3091
3092        // The squatted 1:1 slot was never installed.
3093        assert!(!bob.has_session(&addr("alice")).unwrap());
3094
3095        // Precision: a legitimately-namespaced group Welcome is not caught by
3096        // this guard (it fails later at deserialization of the empty blob, not
3097        // with ReservedSessionNamespace).
3098        let legit_ns = WelcomeMessage {
3099            group_id: GroupId::new("group:0bd6e5f2-3a70-4a4e-9c3f-1c1f2a3b4c5d").unwrap(),
3100            welcome_data: vec![],
3101            inviter_id: addr("mallory"),
3102            group_name: None,
3103            timestamp_ms: 0,
3104        };
3105        let err = bob.join_group(&legit_ns).unwrap_err();
3106        assert!(
3107            !matches!(err, MlsError::ReservedSessionNamespace { .. }),
3108            "a group:-namespaced Welcome must not trip the session-namespace guard, got {:?}",
3109            err
3110        );
3111    }
3112
3113    #[test]
3114    fn test_join_group_rejects_embedded_group_id_mismatch() {
3115        // Regression (HIGH-1): OpenMLS persists a joined group under the group
3116        // id embedded in the Welcome's GroupContext — a value the inviter picks
3117        // freely at creation (`new_with_group_id`) — while our storage marker
3118        // and every load/delete lookup key off the *wire* `group_id`, which is
3119        // all the SEC-M5/M6 bindings validate. If the two diverge, `into_group`
3120        // would install the group under the attacker's embedded id: an
3121        // arbitrary slot the wire-id checks never inspected. The join must bind
3122        // embedded == wire and reject before any state is written.
3123        let bob = create_test_manager("bob");
3124        let mallory = create_test_manager("mallory");
3125
3126        // Mallory builds a real group whose EMBEDDED id is one value...
3127        let bob_kp = bob.generate_key_package().unwrap();
3128        let embedded = GroupId::new("group:11111111-1111-4111-8111-111111111111").unwrap();
3129        let cred = mallory.get_credential().unwrap();
3130        let signer = mallory.get_signer().unwrap();
3131        mallory
3132            .group_manager
3133            .create_group(&embedded, &cred, &signer)
3134            .unwrap();
3135        let (welcome, _commit) = mallory
3136            .add_group_member(&embedded, &addr("bob"), &bob_kp.key_package_data)
3137            .unwrap();
3138        assert_eq!(welcome.group_id, embedded);
3139
3140        // ...but presents it under a DIFFERENT wire group_id. The wire id clears
3141        // the reserved-namespace guard (not `session:`), so only the embedded-id
3142        // binding stands between the attacker and an arbitrary slot.
3143        let wire = GroupId::new("group:22222222-2222-4222-8222-222222222222").unwrap();
3144        let tampered = WelcomeMessage {
3145            group_id: wire.clone(),
3146            ..welcome
3147        };
3148
3149        let err = bob.join_group(&tampered).unwrap_err();
3150        assert!(
3151            matches!(err, MlsError::WelcomeGroupIdMismatch { .. }),
3152            "expected WelcomeGroupIdMismatch, got {:?}",
3153            err
3154        );
3155
3156        // Nothing was installed under EITHER id — the reject precedes into_group.
3157        assert!(bob.group_manager.load_group(&wire).unwrap().is_none());
3158        assert!(bob.group_manager.load_group(&embedded).unwrap().is_none());
3159    }
3160
3161    #[test]
3162    fn test_welcome_embedded_id_cannot_hijack_session_slot() {
3163        // Regression (HIGH-1, the SEC-M6 hijack reached through the embedded id).
3164        // Bob has a live 1:1 session with Alice at `session:alice:bob`. Mallory,
3165        // authenticating honestly as herself with a wire `group_id` that PASSES
3166        // `verify_welcome_slot` (`session:bob:mallory`), sends a Welcome whose
3167        // *embedded* GroupContext id squats `session:alice:bob`. Without the
3168        // embedded-id binding, `into_group` overwrites Bob's Alice session so his
3169        // next `encrypt_for_user("alice")` encrypts to Mallory's group. The
3170        // binding must reject it and leave Bob's Alice session intact.
3171        let alice = create_test_manager("alice");
3172        let bob = create_test_manager("bob");
3173        let bob_kp = bob.generate_key_package().unwrap();
3174        alice
3175            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
3176            .unwrap();
3177        let welcome = alice.create_session(&addr("bob")).unwrap();
3178        bob.join_session(&welcome).unwrap();
3179        assert!(bob.has_session(&addr("alice")).unwrap());
3180
3181        // Mallory crafts a group whose embedded id squats bob's alice slot.
3182        let mallory = create_test_manager("mallory");
3183        let bob_kp2 = bob.generate_key_package().unwrap();
3184        let squat = slot("alice", "bob");
3185        let cred = mallory.get_credential().unwrap();
3186        let signer = mallory.get_signer().unwrap();
3187        mallory
3188            .group_manager
3189            .create_group(&squat, &cred, &signer)
3190            .unwrap();
3191        let (mal_welcome, _commit) = mallory
3192            .add_group_member(&squat, &addr("bob"), &bob_kp2.key_package_data)
3193            .unwrap();
3194
3195        // Present it under a wire slot that passes verify_welcome_slot for mallory.
3196        let attack = WelcomeMessage {
3197            group_id: slot("bob", "mallory"),
3198            welcome_data: mal_welcome.welcome_data,
3199            inviter_id: addr("mallory"),
3200            group_name: None,
3201            timestamp_ms: 0,
3202        };
3203
3204        let err = bob.join_session(&attack).unwrap_err();
3205        assert!(
3206            matches!(err, MlsError::WelcomeGroupIdMismatch { .. }),
3207            "expected WelcomeGroupIdMismatch, got {:?}",
3208            err
3209        );
3210
3211        // Bob's Alice session survived and still encrypts to Alice's group.
3212        assert!(bob.has_session(&addr("alice")).unwrap());
3213        let ct = bob
3214            .encrypt_for_user(&addr("alice"), b"still private")
3215            .unwrap();
3216        let pt = alice.decrypt_from_user(&ct, &addr("bob")).unwrap();
3217        assert_eq!(pt.as_deref(), Some(&b"still private"[..]));
3218    }
3219
3220    /// Builds a two-member group (alice admin, bob member) for group
3221    /// sender-binding tests. Returns (alice, bob, group_id).
3222    fn create_test_group_with_bob() -> (MlsManager, MlsManager, GroupId) {
3223        let alice = create_test_manager("alice");
3224        let bob = create_test_manager("bob");
3225        let bob_kp = bob.generate_key_package().unwrap();
3226        alice
3227            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
3228            .unwrap();
3229        let info = alice.create_group("Test Group").unwrap();
3230        let gid = info.group_id.clone();
3231        let (welcome, _commit) = alice
3232            .add_group_member(&gid, &addr("bob"), &bob_kp.key_package_data)
3233            .unwrap();
3234        bob.join_group(&welcome).unwrap();
3235        (alice, bob, gid)
3236    }
3237
3238    #[test]
3239    fn test_group_decrypt_rejects_spoofed_sender() {
3240        let (alice, bob, gid) = create_test_group_with_bob();
3241
3242        let ct = alice.encrypt_for_group(&gid, b"group message").unwrap();
3243        let err = bob.decrypt_from_group(&ct, &addr("mallory")).unwrap_err();
3244        assert!(matches!(err, MlsError::SenderIdentityMismatch { .. }));
3245
3246        let ct2 = alice.encrypt_for_group(&gid, b"another one").unwrap();
3247        let pt = bob.decrypt_from_group(&ct2, &addr("alice")).unwrap();
3248        assert_eq!(pt.as_deref(), Some(&b"another one"[..]));
3249    }
3250
3251    #[test]
3252    fn test_group_commit_with_spoofed_sender_rejected_before_merge() {
3253        let (alice, bob, gid) = create_test_group_with_bob();
3254
3255        // Alice issues a key-update commit; bob receives it with a spoofed
3256        // wire sender. The mismatch must be detected BEFORE the staged
3257        // commit is merged — bob's epoch must not advance.
3258        let commit = alice.update_keys(&gid).unwrap();
3259        let epoch_before = bob.get_group_info(&gid).unwrap().unwrap().epoch;
3260
3261        let err = bob
3262            .decrypt_from_group(&commit, &addr("mallory"))
3263            .unwrap_err();
3264        assert!(matches!(err, MlsError::SenderIdentityMismatch { .. }));
3265
3266        let epoch_after = bob.get_group_info(&gid).unwrap().unwrap().epoch;
3267        assert_eq!(
3268            epoch_before, epoch_after,
3269            "spoofed commit must not advance group state"
3270        );
3271    }
3272
3273    #[test]
3274    fn test_no_session_initially() {
3275        let manager = create_test_manager("alice");
3276        assert!(!manager.has_session(&addr("bob")).unwrap());
3277        assert!(manager.list_sessions().unwrap().is_empty());
3278    }
3279
3280    #[test]
3281    fn test_no_groups_initially() {
3282        let manager = create_test_manager("alice");
3283        assert!(manager.list_groups().unwrap().is_empty());
3284    }
3285
3286    #[test]
3287    fn test_group_creation() {
3288        let manager = create_test_manager("alice");
3289        let info = manager.create_group("Test Group").unwrap();
3290
3291        assert_eq!(info.name, Some("Test Group".to_string()));
3292        assert!(!info.is_session);
3293        assert_eq!(info.members.len(), 1);
3294
3295        let groups = manager.list_groups().unwrap();
3296        assert_eq!(groups.len(), 1);
3297    }
3298
3299    /// Regression test for the both-create split-brain re-brick.
3300    ///
3301    /// With auto key exchange, both peers create a `session:a:b` group and the
3302    /// higher-id peer adopts the lower-id "owner"'s Welcome. The owner keeps
3303    /// retransmitting its Welcome until it sees a group-aware proof, so the
3304    /// adopter receives the SAME Welcome again *after* it already adopted. MLS
3305    /// key packages are one-time, so re-staging that Welcome must fail — but it
3306    /// must fail NON-DESTRUCTIVELY, leaving the converged group intact. The old
3307    /// delete-then-stage path deleted the good group first and then could not
3308    /// re-stage, permanently bricking a working session.
3309    #[test]
3310    fn test_both_create_adopt_is_non_destructive_on_welcome_retransmit() {
3311        // alice = lexicographically-lower "owner"; bob = higher "adopter".
3312        let alice = create_test_manager("alice");
3313        let bob = create_test_manager("bob");
3314
3315        // Exchange key packages both ways (both sides will auto-create).
3316        let alice_kp = alice.generate_key_package().unwrap();
3317        let bob_kp = bob.generate_key_package().unwrap();
3318        alice
3319            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
3320            .unwrap();
3321        bob.import_key_package(&addr("alice"), &alice_kp.key_package_data)
3322            .unwrap();
3323
3324        // Both-create race: each peer creates its own session group + Welcome.
3325        let alice_welcome = alice.create_session(&addr("bob")).unwrap(); // owner's Welcome
3326        let _bob_welcome = bob.create_session(&addr("alice")).unwrap(); // adopter's own group
3327        assert!(bob.has_session(&addr("alice")).unwrap());
3328
3329        // Adopter adopts the owner's Welcome (first delivery): replaces its own
3330        // group with alice's, consuming bob's one-time key package.
3331        bob.join_session(&alice_welcome).unwrap();
3332        assert!(bob.has_session(&addr("alice")).unwrap());
3333
3334        // Convergence: alice encrypts and bob decrypts on the shared group.
3335        let ct = alice
3336            .encrypt_for_user(&addr("bob"), b"hello over the converged group")
3337            .unwrap();
3338        let pt = bob.decrypt_from_user(&ct, &addr("alice")).unwrap();
3339        assert_eq!(pt.as_deref(), Some(&b"hello over the converged group"[..]));
3340
3341        // Owner retransmits the SAME Welcome (its periodic retry). Re-staging
3342        // MUST fail (bob's key package is consumed) but MUST be non-destructive.
3343        let retransmit = bob.join_session(&alice_welcome);
3344        assert!(
3345            retransmit.is_err(),
3346            "re-staging a consumed key package should fail"
3347        );
3348
3349        // The converged group MUST survive the failed retransmit. This is the
3350        // regression: the old delete-then-stage path left bob with no session.
3351        assert!(
3352            bob.has_session(&addr("alice")).unwrap(),
3353            "duplicate Welcome must not brick the converged session"
3354        );
3355
3356        // ...and it must still be functional after the failed retransmit.
3357        let ct2 = alice
3358            .encrypt_for_user(&addr("bob"), b"still converged after retransmit")
3359            .unwrap();
3360        let pt2 = bob.decrypt_from_user(&ct2, &addr("alice")).unwrap();
3361        assert_eq!(
3362            pt2.as_deref(),
3363            Some(&b"still converged after retransmit"[..])
3364        );
3365    }
3366
3367    /// Windowed media transfers keep up to 8 encrypted chunks in flight
3368    /// (interleaved with text on the same session ratchet), so a delayed chunk
3369    /// can arrive many generations behind the newest decrypted message. The
3370    /// OpenMLS default tolerance (5) would delete its key and permanently stall
3371    /// the transfer; `SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE` (32) must cover it.
3372    #[test]
3373    fn test_out_of_order_decryption_within_sender_ratchet_tolerance() {
3374        let alice = create_test_manager("alice");
3375        let bob = create_test_manager("bob");
3376
3377        let bob_kp = bob.generate_key_package().unwrap();
3378        alice
3379            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
3380            .unwrap();
3381        let welcome = alice.create_session(&addr("bob")).unwrap();
3382        bob.join_session(&welcome).unwrap();
3383
3384        let ciphertexts: Vec<_> = (0..40)
3385            .map(|i| {
3386                alice
3387                    .encrypt_for_user(&addr("bob"), format!("chunk {}", i).as_bytes())
3388                    .unwrap()
3389            })
3390            .collect();
3391
3392        // Decrypt the newest message first, ratcheting bob's receive state far
3393        // ahead of every earlier generation.
3394        let pt = bob
3395            .decrypt_from_user(&ciphertexts[39], &addr("alice"))
3396            .unwrap();
3397        assert_eq!(pt.as_deref(), Some(&b"chunk 39"[..]));
3398
3399        // 29 generations behind: far beyond the OpenMLS default of 5, but
3400        // within our tolerance of 32 — must still decrypt.
3401        let pt = bob
3402            .decrypt_from_user(&ciphertexts[10], &addr("alice"))
3403            .unwrap();
3404        assert_eq!(pt.as_deref(), Some(&b"chunk 10"[..]));
3405
3406        // 39 generations behind: beyond the tolerance, the key is deleted and
3407        // the message must NOT decrypt (proves the configured bound applies).
3408        let res = bob.decrypt_from_user(&ciphertexts[0], &addr("alice"));
3409        assert!(
3410            !matches!(res, Ok(Some(_))),
3411            "generation beyond the tolerance must not decrypt"
3412        );
3413    }
3414
3415    /// RFC 8032 §7.1 TEST 1 Ed25519 public key.
3416    const RFC8032_TV1_PK: [u8; 32] = [
3417        0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07,
3418        0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07,
3419        0x51, 0x1a,
3420    ];
3421
3422    /// The address of that key, from the BIP-350 reference implementation.
3423    /// This literal is the cross-platform contract: bridges and apps must all
3424    /// produce it for this key. Re-derive it from the reference implementation
3425    /// if it ever needs to change — never edit it to match new code output.
3426    const RFC8032_TV1_ADDRESS: &str = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
3427
3428    #[test]
3429    fn derive_address_matches_the_pinned_vector() {
3430        let address = MlsManager::derive_address(&RFC8032_TV1_PK).expect("32-byte key derives");
3431        assert_eq!(address.to_string(), RFC8032_TV1_ADDRESS);
3432        // Self-certifying: the rendered form parses back to the same address,
3433        // so a peer can check a claimed address against a presented key.
3434        assert_eq!(
3435            RFC8032_TV1_ADDRESS.parse::<Address>().expect("parses"),
3436            address
3437        );
3438    }
3439
3440    #[test]
3441    fn derive_address_rejects_wrong_key_lengths() {
3442        for len in [0usize, 31, 33, 64] {
3443            let key = vec![0u8; len];
3444            assert!(
3445                matches!(
3446                    MlsManager::derive_address(&key),
3447                    Err(MlsError::InvalidPublicKey(_))
3448                ),
3449                "{len}-byte key must be rejected"
3450            );
3451        }
3452    }
3453
3454    #[test]
3455    fn deprecated_derive_agrees_with_derive_address() {
3456        // The deprecated entry point must not become a second addressing
3457        // format while it still exists.
3458        #[allow(deprecated)]
3459        let legacy = MlsManager::derive_user_id_from_public_key(&RFC8032_TV1_PK);
3460        assert_eq!(legacy, RFC8032_TV1_ADDRESS);
3461        assert_eq!(
3462            legacy,
3463            MlsManager::derive_address(&RFC8032_TV1_PK)
3464                .expect("derives")
3465                .to_string()
3466        );
3467    }
3468
3469    #[test]
3470    fn load_or_create_identity_mints_once_and_is_idempotent() {
3471        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
3472
3473        let (first_keys, first_address) = MlsManager::load_or_create_identity(&storage).unwrap();
3474        let (second_keys, second_address) = MlsManager::load_or_create_identity(&storage).unwrap();
3475
3476        // Second call loads, never mints: same key, therefore same address.
3477        assert_eq!(first_keys.public(), second_keys.public());
3478        assert_eq!(first_address, second_address);
3479        assert_eq!(
3480            MlsManager::derive_address(first_keys.public()).unwrap(),
3481            first_address,
3482            "the returned address must be the stored key's derivation"
3483        );
3484    }
3485
3486    #[test]
3487    fn load_or_create_identity_adopts_an_existing_key() {
3488        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
3489        let (keys, address) = test_identity("alice");
3490        seed_identity(&storage, &keys);
3491
3492        let (loaded, loaded_address) = MlsManager::load_or_create_identity(&storage).unwrap();
3493
3494        assert_eq!(loaded.public(), keys.public());
3495        assert_eq!(loaded_address, address);
3496    }
3497
3498    #[test]
3499    fn manager_bootstrapped_at_its_derived_address_loads_that_identity() {
3500        let (manager, address) = create_addressed_manager("alice");
3501
3502        assert_eq!(manager.user_id(), address.to_string());
3503        assert_eq!(
3504            MlsManager::derive_address(&manager.get_identity_public_key().unwrap()).unwrap(),
3505            address,
3506            "the manager's identity key must derive to the address it claims"
3507        );
3508    }
3509
3510    /// The address is the identity key's hash, so a manager pointed at storage
3511    /// holding a *different* identity is a broken bootstrap — the wrong
3512    /// namespace for this profile, or a replaced key. It must fail loudly
3513    /// rather than build a credential claiming an address it cannot prove.
3514    #[test]
3515    fn manager_refuses_an_address_whose_key_is_not_the_stored_one() {
3516        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
3517        let (alice_keys, alice_address) = test_identity("alice");
3518        let (_, bob_address) = test_identity("bob");
3519        seed_identity(&storage, &alice_keys);
3520
3521        let Err(err) = MlsManager::new(bob_address.to_string(), storage) else {
3522            panic!("a manager must not claim an address it holds no key for");
3523        };
3524
3525        match err {
3526            MlsError::IdentityAddressMismatch { expected, derived } => {
3527                assert_eq!(expected, bob_address.to_string());
3528                assert_eq!(derived, alice_address.to_string());
3529            }
3530            other => panic!("expected IdentityAddressMismatch, got {other:?}"),
3531        }
3532    }
3533
3534    /// Empty storage plus an address id means the caller skipped the bootstrap:
3535    /// a freshly minted key derives to a different address, so claiming this
3536    /// one would be unprovable. Refused before anything is persisted.
3537    #[test]
3538    fn manager_refuses_to_claim_an_address_with_no_stored_identity() {
3539        let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
3540        let (_, address) = test_identity("alice");
3541
3542        let Err(err) = MlsManager::new(address.to_string(), storage.clone()) else {
3543            panic!("a manager must not mint a key and claim a different address");
3544        };
3545        assert!(matches!(err, MlsError::IdentityAddressMismatch { .. }));
3546
3547        assert!(
3548            storage
3549                .load(StorageKeyType::Identity.as_str(), "key_pair")
3550                .unwrap()
3551                .is_none(),
3552            "a refused bootstrap must not leave a key behind"
3553        );
3554    }
3555
3556    /// Nickname ids have no derivation to check, so a manager can still be
3557    /// *constructed* at one — [`MlsManager::verify_identity_binding`] is about
3558    /// catching a broken self-certifying identity, not about forcing every id
3559    /// to be an address.
3560    ///
3561    /// Note the asymmetry with [`MlsManager::verify_address_binding`], which
3562    /// refuses a nickname outright. The two are different questions: this one
3563    /// asks whether *our own* stored key matches the id we were handed, where a
3564    /// nickname means "nothing was claimed"; that one asks whether a *peer* owns
3565    /// the id they claim, where a nickname means "the claim is unprovable" — and
3566    /// answering "pass" there would be the bypass the whole check exists to close.
3567    #[test]
3568    fn manager_with_a_non_address_id_skips_the_binding_check() {
3569        let manager = MlsManager::new("alice", Arc::new(InMemoryStorage::new())).unwrap();
3570        assert_eq!(manager.user_id(), "alice");
3571    }
3572
3573    // ========================================================================
3574    // LEAF IDENTITY BINDING (the Authentication Service, RFC 9420 §5.3.1/§7.3)
3575    // ========================================================================
3576
3577    /// An impostor holding a leaf that claims someone else's address.
3578    ///
3579    /// [`substituted_key_package`] throws its provider away, which is enough to
3580    /// test a key package being *refused* but not to test what a forged leaf
3581    /// does once it is in a tree: processing the Welcome addressed to that leaf
3582    /// needs the private init key, so the provider has to outlive the key
3583    /// package. This keeps it, and is otherwise the same forgery — a fresh
3584    /// keypair with a credential naming a victim.
3585    struct Impostor {
3586        provider: MlsProvider,
3587        keys: SignatureKeyPair,
3588        key_package: Vec<u8>,
3589        group: Option<MlsGroup>,
3590    }
3591
3592    impl Impostor {
3593        /// Mints a leaf whose credential claims `claimed` and whose signature
3594        /// key is `key_label`'s — i.e. the attacker's own.
3595        fn claiming(claimed: &str, key_label: &str) -> Self {
3596            let storage: Arc<dyn MlsStorage> = Arc::new(InMemoryStorage::new());
3597            let provider = MlsProvider::new(MlsStorageAdapter::new(storage));
3598            let (keys, _) = test_identity(key_label);
3599            keys.store(provider.storage()).unwrap();
3600
3601            let credential = CredentialWithKey {
3602                credential: Credential::new(CredentialType::Basic, claimed.as_bytes().to_vec()),
3603                signature_key: keys.public().into(),
3604            };
3605            let key_package = KeyPackage::builder()
3606                .build(DEFAULT_CIPHERSUITE, &provider, &keys, credential)
3607                .unwrap()
3608                .key_package()
3609                .tls_serialize_detached()
3610                .unwrap();
3611
3612            Self {
3613                provider,
3614                keys,
3615                key_package,
3616                group: None,
3617            }
3618        }
3619
3620        /// Joins the group via the Welcome minted against this leaf.
3621        fn join(&mut self, welcome: &MlsMessageOut) {
3622            let bytes = welcome.tls_serialize_detached().unwrap();
3623            let welcome = match MlsMessageIn::tls_deserialize_exact(&bytes)
3624                .unwrap()
3625                .extract()
3626            {
3627                MlsMessageBodyIn::Welcome(w) => w,
3628                _ => panic!("not a welcome"),
3629            };
3630            let cfg = MlsGroupJoinConfig::builder()
3631                .use_ratchet_tree_extension(true)
3632                .build();
3633            self.group = Some(
3634                StagedWelcome::new_from_welcome(&self.provider, &cfg, welcome, None)
3635                    .unwrap()
3636                    .into_group(&self.provider)
3637                    .unwrap(),
3638            );
3639        }
3640
3641        /// Merges a commit so the forged leaf tracks the group's epoch.
3642        ///
3643        /// Needed because a message is only decryptable at the epoch it was
3644        /// sealed in: without this the forgery fails as a stale generation and
3645        /// the test would pass for the wrong reason.
3646        fn merge_commit(&mut self, commit_bytes: &[u8]) {
3647            let group = self.group.as_mut().expect("join first");
3648            let message = MlsMessageIn::tls_deserialize_exact(commit_bytes)
3649                .unwrap()
3650                .try_into_protocol_message()
3651                .unwrap();
3652            let processed = group.process_message(&self.provider, message).unwrap();
3653            match processed.into_content() {
3654                ProcessedMessageContent::StagedCommitMessage(staged) => {
3655                    group.merge_staged_commit(&self.provider, *staged).unwrap();
3656                }
3657                _ => panic!("not a commit"),
3658            }
3659        }
3660
3661        /// Speaks from the forged leaf.
3662        fn encrypt(&mut self, plaintext: &[u8]) -> Vec<u8> {
3663            self.group
3664                .as_mut()
3665                .expect("join first")
3666                .create_message(&self.provider, &self.keys, plaintext)
3667                .unwrap()
3668                .tls_serialize_detached()
3669                .unwrap()
3670        }
3671    }
3672
3673    /// Adds `key_package_bytes` to `group_id` the way an attacker would: past
3674    /// `add_group_member`, which refuses it.
3675    ///
3676    /// The attacker runs their own build, so the send-side check is theirs to
3677    /// delete. Every test here that plants a forged leaf goes through this, so
3678    /// none of them are accidentally testing the send-side check instead of the
3679    /// receive-side one.
3680    fn add_member_bypassing_checks(
3681        manager: &MlsManager,
3682        group_id: &GroupId,
3683        key_package_bytes: &[u8],
3684    ) -> (MlsMessageOut, MlsMessageOut) {
3685        let mut group = manager
3686            .group_manager
3687            .load_group(group_id)
3688            .unwrap()
3689            .expect("group exists");
3690        let key_package = KeyPackageIn::tls_deserialize_exact(key_package_bytes)
3691            .unwrap()
3692            .validate(
3693                manager.group_manager.provider().crypto(),
3694                ProtocolVersion::Mls10,
3695            )
3696            .unwrap();
3697        let signer = manager.get_signer().unwrap();
3698        let (commit, welcome) = manager
3699            .group_manager
3700            .add_member(&mut group, key_package, &signer)
3701            .unwrap();
3702        manager.group_manager.save_group(group_id, &group).unwrap();
3703        (commit, welcome)
3704    }
3705
3706    fn commit_message(
3707        group_id: &GroupId,
3708        commit: &MlsMessageOut,
3709        sender: &str,
3710    ) -> EncryptedMessage {
3711        EncryptedMessage {
3712            group_id: group_id.clone(),
3713            message_type: MlsMessageType::Commit,
3714            epoch: 0,
3715            ciphertext: commit.tls_serialize_detached().unwrap(),
3716            sender_id: sender.to_string(),
3717            timestamp_ms: 0,
3718        }
3719    }
3720
3721    fn app_message(group_id: &GroupId, ciphertext: Vec<u8>, sender: &str) -> EncryptedMessage {
3722        EncryptedMessage {
3723            group_id: group_id.clone(),
3724            message_type: MlsMessageType::Application,
3725            epoch: 0,
3726            ciphertext,
3727            sender_id: sender.to_string(),
3728            timestamp_ms: 0,
3729        }
3730    }
3731
3732    /// The whole vulnerability, end to end, as a rejection.
3733    ///
3734    /// Mallory is in a group with Alice. She commits a leaf whose credential
3735    /// says "bob" but whose key is hers, then speaks from it as bob. Before the
3736    /// binding existed this reached Alice's app: the commit merged, bob showed
3737    /// up in her roster, and SEC-M1 waved the message through because the wire
3738    /// sender and the credential agreed — both being strings Mallory chose.
3739    #[test]
3740    fn test_committed_forged_leaf_is_refused_and_cannot_speak_as_its_victim() {
3741        let mallory = create_test_manager("mallory");
3742        let alice = create_test_manager("alice");
3743        let alice_kp = alice.generate_key_package().unwrap();
3744
3745        let gid = mallory.create_group("Team").unwrap().group_id;
3746        let (welcome, _) = mallory
3747            .add_group_member(&gid, &addr("alice"), &alice_kp.key_package_data)
3748            .unwrap();
3749        alice.join_group(&welcome).unwrap();
3750        let epoch_before = alice.get_group_info(&gid).unwrap().unwrap().epoch;
3751
3752        // The send-side check refuses the forgery, so Mallory goes around it.
3753        let mut impostor = Impostor::claiming(&addr("bob"), "mallorys-second-key");
3754        assert!(matches!(
3755            mallory
3756                .add_group_member(&gid, &addr("bob"), &impostor.key_package)
3757                .unwrap_err(),
3758            MlsError::KeyPackageAddressMismatch { .. }
3759        ));
3760        let (commit, forged_welcome) =
3761            add_member_bypassing_checks(&mallory, &gid, &impostor.key_package);
3762
3763        // Alice refuses the commit, pre-merge.
3764        let err = alice
3765            .decrypt_from_group(
3766                &commit_message(&gid, &commit, &addr("mallory")),
3767                &addr("mallory"),
3768            )
3769            .unwrap_err();
3770        assert!(
3771            matches!(
3772                err,
3773                MlsError::LeafAddressMismatch { ref claimed, ref context, .. }
3774                    if *claimed == addr("bob") && *context == crate::error::LeafSource::CommitAdd
3775            ),
3776            "expected a CommitAdd binding refusal, got {err:?}"
3777        );
3778
3779        // Nothing moved: the epoch is unchanged and bob is not in the roster.
3780        let after = alice.get_group_info(&gid).unwrap().unwrap();
3781        assert_eq!(
3782            after.epoch, epoch_before,
3783            "a refused commit advanced the epoch"
3784        );
3785        assert!(
3786            !after.members.contains(&addr("bob")),
3787            "roster: {:?}",
3788            after.members
3789        );
3790
3791        // And the leaf cannot speak. Note the mechanism here is epoch
3792        // divergence, not the sender-leaf check: Alice refused the commit, so
3793        // she never advanced to the epoch the impostor is sealing at. That is
3794        // the honest consequence of refusing, and it is what a real victim
3795        // experiences — the identity seam itself, on a leaf that *did* reach
3796        // the tree, is covered by
3797        // `test_forged_leaf_seated_behind_the_gates_still_cannot_speak`.
3798        impostor.join(&forged_welcome);
3799        let forged = impostor.encrypt(b"send me the recovery phrase");
3800        assert!(
3801            alice
3802                .decrypt_from_group(&app_message(&gid, forged, &addr("bob")), &addr("bob"))
3803                .is_err(),
3804            "a forged leaf's message was delivered"
3805        );
3806    }
3807
3808    /// The same forgery placed in a Welcome's ratchet tree, which is the
3809    /// cheaper attack: it needs no existing group, only that the victim accept
3810    /// one invite from anyone.
3811    #[test]
3812    fn test_welcome_carrying_a_forged_leaf_is_refused() {
3813        let mallory = create_test_manager("mallory");
3814        let alice = create_test_manager("alice");
3815
3816        let gid = mallory.create_group("Team").unwrap().group_id;
3817        let impostor = Impostor::claiming(&addr("bob"), "mallorys-second-key");
3818        add_member_bypassing_checks(&mallory, &gid, &impostor.key_package);
3819
3820        // Now invite Alice into the poisoned room.
3821        let alice_kp = alice.generate_key_package().unwrap();
3822        let (welcome, _) = mallory
3823            .add_group_member(&gid, &addr("alice"), &alice_kp.key_package_data)
3824            .unwrap();
3825
3826        let err = alice.join_group(&welcome).unwrap_err();
3827        assert!(
3828            matches!(
3829                err,
3830                MlsError::LeafAddressMismatch { ref claimed, ref context, .. }
3831                    if *claimed == addr("bob") && *context == crate::error::LeafSource::WelcomeTree
3832            ),
3833            "expected a WelcomeTree binding refusal, got {err:?}"
3834        );
3835
3836        // The refusal precedes `into_group`, so nothing was installed.
3837        assert!(alice.get_group_info(&gid).unwrap().is_none());
3838    }
3839
3840    /// A forged leaf in a *session* Welcome, refused on the same path.
3841    ///
3842    /// `join_session` routes through `join_group_replacing`, whose reject must
3843    /// also leave any pre-existing session at that slot untouched.
3844    #[test]
3845    fn test_session_welcome_carrying_a_forged_leaf_is_refused_non_destructively() {
3846        let alice = create_test_manager("alice");
3847        let bob = create_test_manager("bob");
3848        let mallory = create_test_manager("mallory");
3849
3850        // Bob has a real session with Alice.
3851        let bob_kp = bob.generate_key_package().unwrap();
3852        alice
3853            .import_key_package(&addr("bob"), &bob_kp.key_package_data)
3854            .unwrap();
3855        bob.join_session(&alice.create_session(&addr("bob")).unwrap())
3856            .unwrap();
3857        assert!(bob.has_session(&addr("alice")).unwrap());
3858
3859        // Mallory builds a session slot with Bob, then splices a third leaf
3860        // claiming carol into it before sending the Welcome.
3861        let bob_kp2 = bob.generate_key_package().unwrap();
3862        let slot_id = slot("bob", "mallory");
3863        let cred = mallory.get_credential().unwrap();
3864        let signer = mallory.get_signer().unwrap();
3865        mallory
3866            .group_manager
3867            .create_group(&slot_id, &cred, &signer)
3868            .unwrap();
3869        let impostor = Impostor::claiming(&addr("carol"), "mallorys-second-key");
3870        add_member_bypassing_checks(&mallory, &slot_id, &impostor.key_package);
3871        let (welcome, _) = mallory
3872            .add_group_member(&slot_id, &addr("bob"), &bob_kp2.key_package_data)
3873            .unwrap();
3874
3875        let err = bob.join_session(&welcome).unwrap_err();
3876        assert!(
3877            matches!(err, MlsError::LeafAddressMismatch { ref context, .. }
3878                if *context == crate::error::LeafSource::WelcomeTree),
3879            "expected a WelcomeTree binding refusal, got {err:?}"
3880        );
3881
3882        // Bob's real session with Alice is intact and still works.
3883        assert!(bob.has_session(&addr("alice")).unwrap());
3884        let ct = bob
3885            .encrypt_for_user(&addr("alice"), b"still private")
3886            .unwrap();
3887        assert_eq!(
3888            alice
3889                .decrypt_from_user(&ct, &addr("bob"))
3890                .unwrap()
3891                .as_deref(),
3892            Some(&b"still private"[..])
3893        );
3894    }
3895
3896    /// The use-time half, which is the only one that covers a leaf the entry
3897    /// gates never saw.
3898    ///
3899    /// Installs the poisoned group by staging the Welcome straight into the
3900    /// provider — the shape a direct write to the install-scoped store leaves,
3901    /// and the reason the binding is re-checked at decrypt rather than trusted
3902    /// from join time.
3903    #[test]
3904    fn test_forged_leaf_seated_behind_the_gates_still_cannot_speak() {
3905        let mallory = create_test_manager("mallory");
3906        let alice = create_test_manager("alice");
3907
3908        let gid = mallory.create_group("Team").unwrap().group_id;
3909        let mut impostor = Impostor::claiming(&addr("bob"), "mallorys-second-key");
3910        let (_, forged_welcome) =
3911            add_member_bypassing_checks(&mallory, &gid, &impostor.key_package);
3912        impostor.join(&forged_welcome);
3913
3914        let alice_kp = alice.generate_key_package().unwrap();
3915        let (welcome, add_alice_commit) = mallory
3916            .add_group_member(&gid, &addr("alice"), &alice_kp.key_package_data)
3917            .unwrap();
3918        // The forged leaf has to follow the group to Alice's epoch, or its
3919        // message fails as a stale generation instead of on its identity.
3920        impostor.merge_commit(&add_alice_commit.ciphertext);
3921
3922        // Bypass `join_group` entirely: stage and install directly, as a
3923        // tampered store (or a build predating the gates) would leave things.
3924        let bytes = welcome.welcome_data.clone();
3925        let staged_welcome = match MlsMessageIn::tls_deserialize_exact(&bytes)
3926            .unwrap()
3927            .extract()
3928        {
3929            MlsMessageBodyIn::Welcome(w) => w,
3930            _ => panic!("not a welcome"),
3931        };
3932        let cfg = MlsGroupJoinConfig::builder()
3933            .use_ratchet_tree_extension(true)
3934            .build();
3935        let group = StagedWelcome::new_from_welcome(&alice.provider, &cfg, staged_welcome, None)
3936            .unwrap()
3937            .into_group(&alice.provider)
3938            .unwrap();
3939        alice.group_manager.save_group(&gid, &group).unwrap();
3940
3941        // Premise: the forged leaf really is seated in Alice's tree.
3942        assert!(
3943            group
3944                .members()
3945                .any(|m| m.credential.serialized_content() == addr("bob").as_bytes()),
3946            "test premise: the forged leaf must be installed"
3947        );
3948
3949        // The message is still refused, at the sender-leaf seam.
3950        let forged = impostor.encrypt(b"send me the recovery phrase");
3951        let err = alice
3952            .decrypt_from_group(&app_message(&gid, forged, &addr("bob")), &addr("bob"))
3953            .unwrap_err();
3954        assert!(
3955            matches!(err, MlsError::LeafAddressMismatch { ref context, .. }
3956                if *context == crate::error::LeafSource::MessageSender),
3957            "expected a MessageSender binding refusal, got {err:?}"
3958        );
3959
3960        // And the roster read skips it rather than reporting a member that
3961        // proved nothing.
3962        let info = alice.get_group_info(&gid).unwrap().unwrap();
3963        assert!(
3964            !info.members.contains(&addr("bob")),
3965            "roster surfaced an unproven member: {:?}",
3966            info.members
3967        );
3968        assert!(
3969            info.members.contains(&addr("mallory")),
3970            "{:?}",
3971            info.members
3972        );
3973    }
3974
3975    /// A member renaming *their own* leaf to someone else's address, refused.
3976    ///
3977    /// The cheapest form of the attack, and the one an Add-only reading of the
3978    /// fix would have missed entirely: no new leaf, no key package, no invite.
3979    /// A member already in the group commits a self-update whose new credential
3980    /// names a different member, and from then on every message from their leaf
3981    /// is attributed to that member. MLS permits the credential change — RFC
3982    /// 9420 §5.3.1 says an Update may carry a new credential and leaves the
3983    /// verdict to the Authentication Service, which is this.
3984    ///
3985    /// This is the `CommitPath` source, the first entry in OpenMLS's own
3986    /// `credentials_to_verify` and the reason the commit walk follows that
3987    /// enumeration rather than the book's shorter "add & update proposals".
3988    #[test]
3989    fn test_member_renaming_their_own_leaf_to_a_peers_address_is_refused() {
3990        // `create_test_group_with_bob` seats alice as creator and bob as
3991        // member; bob is the insider here, renaming his own leaf to alice's
3992        // address.
3993        let (alice, bob, gid) = create_test_group_with_bob();
3994        let epoch_before = alice.get_group_info(&gid).unwrap().unwrap().epoch;
3995
3996        let mut group = bob.group_manager.load_group(&gid).unwrap().unwrap();
3997        let signer = bob.get_signer().unwrap();
3998        let forged = CredentialWithKey {
3999            // Claims alice, signed by bob's own key — which is the whole point:
4000            // the leaf is genuinely his, the name on it is not.
4001            credential: Credential::new(CredentialType::Basic, addr("alice").as_bytes().to_vec()),
4002            signature_key: signer.public().into(),
4003        };
4004        let params = LeafNodeParameters::builder()
4005            .with_credential_with_key(forged)
4006            .build();
4007        let bundle = group
4008            .self_update(&bob.provider, &signer, params)
4009            .expect("MLS permits a credential change; the AS is what refuses it");
4010        group.merge_pending_commit(&bob.provider).unwrap();
4011        bob.group_manager.save_group(&gid, &group).unwrap();
4012        let (commit, _, _) = bundle.into_contents();
4013
4014        let err = alice
4015            .decrypt_from_group(&commit_message(&gid, &commit, &addr("bob")), &addr("bob"))
4016            .unwrap_err();
4017        assert!(
4018            matches!(
4019                err,
4020                MlsError::LeafAddressMismatch { ref claimed, ref context, .. }
4021                    if *claimed == addr("alice") && *context == crate::error::LeafSource::CommitPath
4022            ),
4023            "expected a CommitPath binding refusal, got {err:?}"
4024        );
4025
4026        // Not merged: alice's epoch is untouched and her roster still names bob
4027        // as bob.
4028        let after = alice.get_group_info(&gid).unwrap().unwrap();
4029        assert_eq!(after.epoch, epoch_before);
4030        assert!(after.members.contains(&addr("bob")), "{:?}", after.members);
4031        assert_eq!(
4032            after
4033                .members
4034                .iter()
4035                .filter(|m| **m == addr("alice"))
4036                .count(),
4037            1,
4038            "alice must appear once, as herself: {:?}",
4039            after.members
4040        );
4041    }
4042
4043    /// A nickname credential must be refused, not waved through for having no
4044    /// derivation to check — the bypass every other gate in this SDK names.
4045    #[test]
4046    fn test_leaf_with_a_nickname_credential_is_refused() {
4047        let mallory = create_test_manager("mallory");
4048        let alice = create_test_manager("alice");
4049        let alice_kp = alice.generate_key_package().unwrap();
4050
4051        let gid = mallory.create_group("Team").unwrap().group_id;
4052        let impostor = Impostor::claiming("bob", "mallorys-second-key");
4053        add_member_bypassing_checks(&mallory, &gid, &impostor.key_package);
4054        let (welcome, _) = mallory
4055            .add_group_member(&gid, &addr("alice"), &alice_kp.key_package_data)
4056            .unwrap();
4057
4058        let err = alice.join_group(&welcome).unwrap_err();
4059        assert!(
4060            matches!(err, MlsError::LeafAddressMismatch { ref claimed, .. } if claimed == "bob"),
4061            "a nickname credential was accepted or misreported: {err:?}"
4062        );
4063    }
4064
4065    /// The honest paths this check could plausibly break, pinned.
4066    ///
4067    /// `update_keys` rotates the leaf's HPKE keys through the commit's update
4068    /// path but keeps the signature key and credential
4069    /// (`LeafNodeParameters::default()`). If that ever changed — or if someone
4070    /// reached for `self_update_with_new_signer` — every member would start
4071    /// refusing every honest key rotation, and this is what says so.
4072    #[test]
4073    fn test_key_rotation_and_ordinary_traffic_still_pass_the_binding() {
4074        let (alice, bob, gid) = create_test_group_with_bob();
4075
4076        let commit = alice.update_keys(&gid).unwrap();
4077        bob.decrypt_from_group(&commit, &addr("alice"))
4078            .expect("an honest key rotation must still merge");
4079
4080        let ct = alice.encrypt_for_group(&gid, b"after rotation").unwrap();
4081        assert_eq!(
4082            bob.decrypt_from_group(&ct, &addr("alice"))
4083                .unwrap()
4084                .as_deref(),
4085            Some(&b"after rotation"[..])
4086        );
4087
4088        // And a member added the ordinary way is in both rosters.
4089        assert!(bob
4090            .get_group_info(&gid)
4091            .unwrap()
4092            .unwrap()
4093            .members
4094            .contains(&addr("bob")));
4095    }
4096
4097    /// The Welcome-side mirror of the `ExternalSenders` refusal.
4098    ///
4099    /// The commit gate refuses a proposal that would *add* the extension; a
4100    /// group created with one already in its context arrives this way instead,
4101    /// and the two entry gates should not differ by which route the inviter
4102    /// took. Mallory merges the extension into her own group first, so the
4103    /// Welcome she then sends carries it in the group context.
4104    #[test]
4105    fn test_welcome_whose_group_context_has_external_senders_is_refused() {
4106        let mallory = create_test_manager("mallory");
4107        let alice = create_test_manager("alice");
4108
4109        let gid = mallory.create_group("Team").unwrap().group_id;
4110
4111        // Merge an ExternalSenders extension into mallory's own group context.
4112        {
4113            let mut group = mallory.group_manager.load_group(&gid).unwrap().unwrap();
4114            let signer = mallory.get_signer().unwrap();
4115            let outsider =
4116                SignatureKeyPair::new(DEFAULT_CIPHERSUITE.signature_algorithm()).unwrap();
4117            let external = ExternalSender::new(
4118                outsider.public().into(),
4119                Credential::new(CredentialType::Basic, b"off1outsider".to_vec()),
4120            );
4121            group
4122                .commit_builder()
4123                .propose_group_context_extensions(Extensions::single(Extension::ExternalSenders(
4124                    vec![external],
4125                )))
4126                .load_psks(mallory.provider.storage())
4127                .unwrap()
4128                .build(
4129                    mallory.provider.rand(),
4130                    mallory.provider.crypto(),
4131                    &signer,
4132                    |_| true,
4133                )
4134                .unwrap()
4135                .stage_commit(&mallory.provider)
4136                .unwrap();
4137            group.merge_pending_commit(&mallory.provider).unwrap();
4138            mallory.group_manager.save_group(&gid, &group).unwrap();
4139        }
4140
4141        let alice_kp = alice.generate_key_package().unwrap();
4142        let (welcome, _) = mallory
4143            .add_group_member(&gid, &addr("alice"), &alice_kp.key_package_data)
4144            .unwrap();
4145
4146        let err = alice.join_group(&welcome).unwrap_err();
4147        assert!(
4148            matches!(err, MlsError::UnsupportedSender { ref detail, .. }
4149                if detail.contains("ExternalSenders")),
4150            "expected the invite to be declined for its ExternalSenders context, got {err:?}"
4151        );
4152        assert!(
4153            alice.get_group_info(&gid).unwrap().is_none(),
4154            "a declined Welcome must not install the group"
4155        );
4156    }
4157
4158    /// Source 4 of the commit walk: a commit proposing an `ExternalSenders`
4159    /// group-context extension is refused.
4160    ///
4161    /// Not a leaf, so there is no binding to check — which is exactly why it
4162    /// needs its own arm rather than falling off the end of the walk. The
4163    /// extension authorizes a non-member to send into the group, and its
4164    /// credential would never be judged by any of the three leaf seams.
4165    /// Untested, this arm survives deletion.
4166    #[test]
4167    fn test_commit_proposing_external_senders_is_refused() {
4168        let (alice, mallory, gid) = create_test_group_with_bob();
4169
4170        let mut group = mallory.group_manager.load_group(&gid).unwrap().unwrap();
4171        let signer = mallory.get_signer().unwrap();
4172
4173        // An ExternalSenders extension naming a party that is in no leaf.
4174        let outsider = SignatureKeyPair::new(DEFAULT_CIPHERSUITE.signature_algorithm()).unwrap();
4175        let external = ExternalSender::new(
4176            outsider.public().into(),
4177            Credential::new(CredentialType::Basic, b"off1outsider".to_vec()),
4178        );
4179        let extensions = Extensions::single(Extension::ExternalSenders(vec![external]));
4180
4181        let bundle = group
4182            .commit_builder()
4183            .propose_group_context_extensions(extensions)
4184            .load_psks(mallory.provider.storage())
4185            .unwrap()
4186            .build(
4187                mallory.provider.rand(),
4188                mallory.provider.crypto(),
4189                &signer,
4190                |_| true,
4191            )
4192            .unwrap()
4193            .stage_commit(&mallory.provider)
4194            .unwrap();
4195        let (commit, _, _) = bundle.into_contents();
4196
4197        let err = alice
4198            .decrypt_from_group(&commit_message(&gid, &commit, &addr("bob")), &addr("bob"))
4199            .unwrap_err();
4200        assert!(
4201            matches!(err, MlsError::UnsupportedSender { ref detail, .. }
4202                if detail.contains("ExternalSenders")),
4203            "expected an ExternalSenders refusal, got {err:?}"
4204        );
4205    }
4206
4207    /// Why `remove_group_member`'s loop can never find a second leaf — and why
4208    /// it is a loop anyway.
4209    ///
4210    /// The duplicate it would guard against is refused a layer down: RFC 9420
4211    /// requires unique signature keys across a group, so seating one identity
4212    /// twice is rejected by OpenMLS before the roster ever sees it. Pinned as a
4213    /// test because the *removal* code's correctness rests on it, and it is an
4214    /// invariant enforced in another crate — exactly the kind that changes
4215    /// without anyone here noticing.
4216    #[test]
4217    fn test_one_identity_cannot_hold_two_leaves_in_a_group() {
4218        let alice = create_test_manager("alice");
4219        let bob = create_test_manager("bob");
4220
4221        let gid = alice.create_group("Team").unwrap().group_id;
4222        let first = bob.generate_key_package().unwrap();
4223        alice
4224            .add_group_member(&gid, &addr("bob"), &first.key_package_data)
4225            .unwrap();
4226
4227        // A second, distinct key package for the same identity: different init
4228        // key, same signature key, because the signature key *is* the identity.
4229        let second = bob.generate_key_package().unwrap();
4230        assert_ne!(first.package_id, second.package_id);
4231        let err = alice
4232            .add_group_member(&gid, &addr("bob"), &second.key_package_data)
4233            .unwrap_err();
4234        assert!(
4235            matches!(err, MlsError::AddMember(ref m) if m.contains("Duplicate signature key")),
4236            "expected MLS to refuse a duplicate signature key, got {err:?}"
4237        );
4238
4239        // One leaf, and removing it removes the member.
4240        alice.remove_group_member(&gid, &addr("bob")).unwrap();
4241        assert!(!alice
4242            .get_group_info(&gid)
4243            .unwrap()
4244            .unwrap()
4245            .members
4246            .contains(&addr("bob")));
4247    }
4248
4249    /// A non-`Member` sender is refused, not ignored.
4250    ///
4251    /// `verify_sender_leaf` covers four sender shapes and only one of them —
4252    /// `Sender::Member` — resolves to a leaf that can be bound. The other three
4253    /// take the `UnsupportedSender` arm, and this is the one of them that is
4254    /// cheap to build: an external join proposal, authenticated to
4255    /// `Sender::NewMemberProposal`, whose leaf is by definition in no tree yet.
4256    ///
4257    /// Worth its own test because the arm *changed disposition* in this change.
4258    /// It used to fall through to `ProcessedMessageContent::
4259    /// ExternalJoinProposalMessage` and be tolerated as `Ok(None)`; the same
4260    /// tolerance applied to a `NewMemberCommit` would have *merged* a commit
4261    /// from a party holding an unjudged leaf. Nothing else pins that the
4262    /// tolerance is gone — the surviving `ExternalJoinProposalMessage` arm still
4263    /// reads like the policy, and its comment says it is not.
4264    #[test]
4265    fn test_external_join_proposal_is_refused_as_an_unsupported_sender() {
4266        let (alice, _bob, gid) = create_test_group_with_bob();
4267
4268        // An outsider asks to join. The proposal is well-formed and correctly
4269        // signed by its own key — it is refused for its *sender role*, not for
4270        // anything malformed, which is the point.
4271        let outsider = create_test_manager("mallory");
4272        let kp_bytes = outsider.generate_key_package().unwrap().key_package_data;
4273        let key_package = KeyPackageIn::tls_deserialize_exact(&kp_bytes)
4274            .unwrap()
4275            .validate(alice.provider.crypto(), ProtocolVersion::Mls10)
4276            .unwrap();
4277        let signer = outsider.get_signer().unwrap();
4278
4279        let (mls_group_id, epoch) = {
4280            let group = alice.group_manager.load_group(&gid).unwrap().unwrap();
4281            (group.group_id().clone(), group.epoch())
4282        };
4283        let proposal =
4284            JoinProposal::new::<MlsStorageAdapter>(key_package, mls_group_id, epoch, &signer)
4285                .expect(
4286                    "an external join proposal is well-formed; the SDK just declines to honour it",
4287                );
4288
4289        let err = alice
4290            .decrypt_from_group(
4291                &app_message(
4292                    &gid,
4293                    proposal.tls_serialize_detached().unwrap(),
4294                    &addr("mallory"),
4295                ),
4296                &addr("mallory"),
4297            )
4298            .unwrap_err();
4299        assert!(
4300            matches!(err, MlsError::UnsupportedSender { ref detail, .. }
4301                if detail.contains("not a group member")),
4302            "expected an UnsupportedSender refusal, got {err:?}"
4303        );
4304    }
4305
4306    /// A leaf seated behind the gates is *counted*, not merely hidden.
4307    ///
4308    /// The roster filter is the only seam at which a leaf already in local
4309    /// group state surfaces, and a `warn!` reaches no app. The count is what
4310    /// the protocol layer turns into `GroupLeafIdentityUnproven`, so a filter
4311    /// that silently returned the same shortened roster would look identical
4312    /// here without it.
4313    ///
4314    /// Built with the in-module fixture rather than
4315    /// `seat_forged_leaf_for_testing`, which is behind the `test-utils` feature
4316    /// for the *protocol* crate's benefit: workspace feature unification turns
4317    /// it on for `cargo test --workspace`, but `cargo test -p
4318    /// offline-protocol-mls` would then not compile.
4319    #[test]
4320    fn test_roster_read_counts_the_leaves_it_skips() {
4321        let (alice, _bob, gid) = create_test_group_with_bob();
4322
4323        let before = alice.get_group_info(&gid).unwrap().unwrap();
4324        assert_eq!(
4325            before.unproven_members, 0,
4326            "an honest group must report no unproven members: {:?}",
4327            before.members
4328        );
4329        let honest_members = before.members.len();
4330
4331        // Seat a leaf claiming carol, bypassing the gates as a tampered store
4332        // would. Alice commits it into her own group, so it is in *her* tree.
4333        let impostor = Impostor::claiming(&addr("carol"), "an-attackers-key");
4334        add_member_bypassing_checks(&alice, &gid, &impostor.key_package);
4335
4336        let after = alice.get_group_info(&gid).unwrap().unwrap();
4337        assert_eq!(
4338            after.unproven_members, 1,
4339            "the skipped leaf must be counted, not only dropped"
4340        );
4341        assert_eq!(
4342            after.members.len(),
4343            honest_members,
4344            "the roster must be unchanged: {:?}",
4345            after.members
4346        );
4347        assert!(
4348            !after.members.contains(&addr("carol")),
4349            "roster surfaced the unproven member: {:?}",
4350            after.members
4351        );
4352    }
4353}