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