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