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