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