Skip to main content

offline_protocol_mls/
manager.rs

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