Skip to main content

offline_protocol_mls/
session.rs

1//! 1:1 session management using MLS groups.
2//!
3//! This module provides a higher-level abstraction for 1:1 encrypted messaging
4//! by using 2-person MLS groups under the hood.
5
6use crate::error::{MlsError, Result};
7use crate::group::GroupManager;
8use crate::provider::MlsProvider;
9use crate::storage::MlsStorage;
10use crate::types::{EncryptedMessage, GroupId, GroupInfo, MlsMessageType, WelcomeMessage};
11
12use openmls::prelude::tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize};
13use openmls::prelude::*;
14use openmls_traits::signatures::Signer;
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18use tracing::{debug, info};
19
20/// Where one sealed frame sits in its session's sending order: the
21/// `index`-th frame this device sealed in that session, and which session
22/// (`incarnation`) that was.
23///
24/// The incarnation is what keeps an acknowledgement for a frame of a session
25/// that has since been rebuilt from vouching for the new one.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct SealMark {
28    /// Which session the frame was sealed in. New on every create, join and
29    /// delete.
30    pub incarnation: u64,
31    /// The frame's position among this session's seals, from 1.
32    pub index: u64,
33}
34
35/// How far this device's sealing has run ahead of what the peer has shown
36/// it received, per session.
37#[derive(Debug, Clone, Copy)]
38struct SealProgress {
39    incarnation: u64,
40    sealed: u64,
41    acknowledged: u64,
42}
43
44/// Manages 1:1 encrypted sessions.
45pub struct SessionManager {
46    /// The local user's ID.
47    user_id: String,
48
49    /// Group manager for underlying MLS operations.
50    group_manager: GroupManager,
51
52    /// Seals per session against the highest seal the peer has acknowledged.
53    /// In memory only: after a restart the count starts again from nothing,
54    /// which reads as "no gap", the permissive direction, until the first
55    /// acknowledgement.
56    seal_progress: Mutex<HashMap<String, SealProgress>>,
57
58    /// Source of session incarnations.
59    next_incarnation: AtomicU64,
60}
61
62impl SessionManager {
63    /// Creates a new session manager.
64    pub fn new(user_id: String, storage: Arc<dyn MlsStorage>, provider: MlsProvider) -> Self {
65        let group_manager = GroupManager::new(storage, provider);
66        Self {
67            user_id,
68            group_manager,
69            seal_progress: Mutex::new(HashMap::new()),
70            next_incarnation: AtomicU64::new(1),
71        }
72    }
73
74    fn progress(&self) -> std::sync::MutexGuard<'_, HashMap<String, SealProgress>> {
75        self.seal_progress
76            .lock()
77            .unwrap_or_else(std::sync::PoisonError::into_inner)
78    }
79
80    fn fresh_progress(&self) -> SealProgress {
81        SealProgress {
82            incarnation: self.next_incarnation.fetch_add(1, Ordering::Relaxed),
83            sealed: 0,
84            acknowledged: 0,
85        }
86    }
87
88    /// Starts a session's seal count from nothing: it was created, joined or
89    /// deleted, so nothing sealed before can vouch for it.
90    fn restart_seal_progress(&self, session_id: &GroupId) {
91        let fresh = self.fresh_progress();
92        self.progress().insert(session_id.to_string(), fresh);
93    }
94
95    /// The position of the most recent frame sealed for `other_user_id`, or
96    /// `None` when nothing has been sealed in this session since it started
97    /// (or since this process did).
98    pub fn last_seal_mark(&self, other_user_id: &str) -> Option<SealMark> {
99        let session_id = self.get_session_id(other_user_id).ok()?;
100        let progress = self.progress();
101        let entry = progress.get(session_id.as_str())?;
102        (entry.sealed > 0).then_some(SealMark {
103            incarnation: entry.incarnation,
104            index: entry.sealed,
105        })
106    }
107
108    /// Records that the peer received the frame at `mark`, so it has
109    /// decrypted at least that far into the session. Ignored for a frame of
110    /// an earlier incarnation of the session.
111    pub fn note_acknowledged_seal(&self, other_user_id: &str, mark: SealMark) {
112        let Ok(session_id) = self.get_session_id(other_user_id) else {
113            return;
114        };
115        if let Some(entry) = self.progress().get_mut(session_id.as_str()) {
116            if entry.incarnation == mark.incarnation {
117                entry.acknowledged = entry.acknowledged.max(mark.index);
118            }
119        }
120    }
121
122    /// How many frames this device has sealed for `other_user_id` past the
123    /// last one the peer acknowledged.
124    ///
125    /// A receiver cannot decrypt a frame more than the forward window past
126    /// the newest it has decrypted, and an acknowledged frame is one it has,
127    /// so keeping this below the window keeps every frame decryptable. The
128    /// sender is what controls it: each re-sealed resend adds one.
129    pub fn unacknowledged_seals(&self, other_user_id: &str) -> u64 {
130        let Ok(session_id) = self.get_session_id(other_user_id) else {
131            return 0;
132        };
133        self.progress()
134            .get(session_id.as_str())
135            .map_or(0, |entry| entry.sealed.saturating_sub(entry.acknowledged))
136    }
137
138    /// Returns a reference to the underlying group manager.
139    pub fn group_manager(&self) -> &GroupManager {
140        &self.group_manager
141    }
142
143    /// Gets the session ID for a 1:1 conversation with another user.
144    ///
145    /// Fails with [`crate::MlsError::InvalidGroupId`] if `other_user_id`
146    /// contains storage-hostile characters (the session id is a raw storage
147    /// key).
148    pub fn get_session_id(&self, other_user_id: &str) -> Result<GroupId> {
149        GroupId::for_session(&self.user_id, other_user_id).map_err(MlsError::from)
150    }
151
152    /// Checks if a session exists with another user.
153    pub fn has_session(&self, other_user_id: &str) -> Result<bool> {
154        let session_id = self.get_session_id(other_user_id)?;
155        let group = self.group_manager.load_group(&session_id)?;
156        Ok(group.is_some())
157    }
158
159    /// Creates a new session with another user using their key package.
160    pub fn create_session(
161        &self,
162        other_user_id: &str,
163        their_key_package: KeyPackage,
164        credential_with_key: &CredentialWithKey,
165        signer: &impl Signer,
166    ) -> Result<WelcomeMessage> {
167        let session_id = self.get_session_id(other_user_id)?;
168
169        if self.group_manager.load_group(&session_id)?.is_some() {
170            return Err(MlsError::GroupCreation(format!(
171                "Session already exists with user: {}",
172                other_user_id
173            )));
174        }
175
176        let mut group =
177            self.group_manager
178                .create_group(&session_id, credential_with_key, signer)?;
179
180        let (_commit, welcome) =
181            self.group_manager
182                .add_member(&mut group, their_key_package, signer)?;
183
184        self.group_manager.save_group(&session_id, &group)?;
185
186        let welcome_bytes = welcome
187            .tls_serialize_detached()
188            .map_err(|e| MlsError::Serialization(e.to_string()))?;
189
190        info!(
191            session_id = %session_id,
192            other_user = %other_user_id,
193            "Created new 1:1 session"
194        );
195
196        self.restart_seal_progress(&session_id);
197        Ok(WelcomeMessage {
198            group_id: session_id,
199            welcome_data: welcome_bytes,
200            inviter_id: self.user_id.clone(),
201            group_name: None,
202            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
203        })
204    }
205
206    /// Verifies a Welcome's `group_id` names the `(self, inviter)` session slot
207    /// — the only 1:1 slot an authenticated inviter may install (SEC-M6) — and
208    /// returns the mismatch error **without touching any state**, so callers
209    /// can reject a forged/tampered slot before performing any mutation.
210    ///
211    /// `welcome_msg.group_id` is an attacker-controllable wire field that
212    /// becomes the raw storage key for the adopted group, and the inviter is
213    /// authenticated one layer up (`handle_welcome_message` drops any Welcome
214    /// whose `inviter_id` != the transport-authenticated sender). The only 1:1
215    /// slot this Welcome may legitimately install is therefore
216    /// `session:<self>:<inviter>`. Without this check an authenticated peer
217    /// could name a *third* party's slot (e.g. "session:alice:bob") and, via
218    /// the stage-then-replace adopt in `join_group_replacing`, overwrite or
219    /// seed the victim's session with that party — hijacking it so the victim's
220    /// `encrypt_for_user` output goes to the attacker's group. This mirrors the
221    /// invite-side (SEC-M5) and decrypt-side (SEC-M1) identity bindings on the
222    /// previously-unguarded join side. The sibling group-Welcome path is
223    /// guarded separately by the reserved-namespace check in
224    /// [`crate::MlsManager::join_group`], which refuses any `session:`-prefixed
225    /// `group_id`. Checked before deserialization so a tampered slot is
226    /// rejected regardless of the Welcome blob.
227    pub(crate) fn verify_welcome_slot(&self, welcome_msg: &WelcomeMessage) -> Result<()> {
228        let expected_id = GroupId::for_session(&self.user_id, &welcome_msg.inviter_id)?;
229        if welcome_msg.group_id != expected_id {
230            return Err(MlsError::WelcomeIdentityMismatch {
231                expected: expected_id.to_string(),
232                found: welcome_msg.group_id.to_string(),
233            });
234        }
235        Ok(())
236    }
237
238    /// Joins a session using a Welcome message.
239    ///
240    /// Implements a **non-destructive** "welcome-wins" strategy: if we already
241    /// created a session for this peer (both-create race), the incoming Welcome
242    /// is staged first and only replaces our group once staging succeeds (see
243    /// [`crate::group::GroupManager::join_group_replacing`]). A retransmitted
244    /// Welcome we already adopted, or a first-contact key-package race, fails to
245    /// stage and leaves the existing converged group intact — so adoption is
246    /// idempotent and never bricks a working session.
247    pub fn join_session(&self, welcome_msg: &WelcomeMessage) -> Result<GroupInfo> {
248        // SEC-M6: bind the Welcome's session slot to the (self, inviter) pair
249        // before touching any group state (see `verify_welcome_slot`).
250        self.verify_welcome_slot(welcome_msg)?;
251
252        // Deserialize the Welcome from the MlsMessageOut bytes
253        let mls_msg = MlsMessageIn::tls_deserialize_exact(&welcome_msg.welcome_data)
254            .map_err(|e| MlsError::Deserialization(e.to_string()))?;
255
256        // Extract the Welcome from the message
257        let welcome = match mls_msg.extract() {
258            MlsMessageBodyIn::Welcome(w) => w,
259            _ => {
260                return Err(MlsError::WelcomeProcessing(
261                    "Not a Welcome message".to_string(),
262                ))
263            }
264        };
265
266        // Stage-then-swap: replaces any existing group only after the incoming
267        // Welcome stages successfully, so a failed stage is a safe no-op.
268        let group = self
269            .group_manager
270            .join_group_replacing(welcome, &welcome_msg.group_id)?;
271        let info = self
272            .group_manager
273            .get_group_info(&group, &welcome_msg.group_id);
274
275        info!(
276            session_id = %welcome_msg.group_id,
277            inviter = %welcome_msg.inviter_id,
278            "Joined/adopted 1:1 session (non-destructive)"
279        );
280
281        self.restart_seal_progress(&welcome_msg.group_id);
282        Ok(info)
283    }
284
285    /// Encrypts a message for the session partner.
286    pub fn encrypt_message(
287        &self,
288        other_user_id: &str,
289        plaintext: &[u8],
290        signer: &impl Signer,
291    ) -> Result<EncryptedMessage> {
292        let session_id = self.get_session_id(other_user_id)?;
293
294        let mut group = self
295            .group_manager
296            .load_group(&session_id)?
297            .ok_or_else(|| MlsError::SessionNotFound(other_user_id.to_string()))?;
298
299        let mls_message = self
300            .group_manager
301            .encrypt_message(&mut group, plaintext, signer)?;
302
303        self.group_manager.save_group(&session_id, &group)?;
304        {
305            let mut progress = self.progress();
306            let entry = progress
307                .entry(session_id.to_string())
308                .or_insert_with(|| self.fresh_progress());
309            entry.sealed = entry.sealed.saturating_add(1);
310        }
311
312        let ciphertext = mls_message
313            .tls_serialize_detached()
314            .map_err(|e| MlsError::Serialization(e.to_string()))?;
315
316        Ok(EncryptedMessage {
317            group_id: session_id,
318            message_type: MlsMessageType::Application,
319            epoch: group.epoch().as_u64(),
320            ciphertext,
321            sender_id: self.user_id.clone(),
322            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
323        })
324    }
325
326    /// Decrypts a message from a session.
327    ///
328    /// `claimed_sender` is the transport-level sender this message will be
329    /// attributed to; it must match the MLS-authenticated credential.
330    ///
331    /// The envelope's `group_id` is bound to `claimed_sender` before the group
332    /// is loaded, because every failure OpenMLS raises below happens *before*
333    /// it authenticates anything — see [`MlsError::SessionIdentityMismatch`].
334    /// This is the single chokepoint for the binding: `decrypt_from_user` is
335    /// reachable both from the receive path and from the public
336    /// `manual_mls_decrypt_from_user` API, so checking it in either caller
337    /// alone leaves the other as a bypass.
338    pub fn decrypt_message(
339        &self,
340        encrypted: &EncryptedMessage,
341        claimed_sender: &str,
342    ) -> Result<Option<Vec<u8>>> {
343        let expected_id = GroupId::for_session(&self.user_id, claimed_sender)?;
344        if encrypted.group_id != expected_id {
345            return Err(MlsError::SessionIdentityMismatch {
346                expected: expected_id.to_string(),
347                found: encrypted.group_id.to_string(),
348            });
349        }
350
351        let mut group = self
352            .group_manager
353            .load_group(&encrypted.group_id)?
354            .ok_or_else(|| MlsError::GroupNotFound(encrypted.group_id.to_string()))?;
355
356        let mls_message = MlsMessageIn::tls_deserialize_exact(&encrypted.ciphertext)
357            .map_err(|e| MlsError::Deserialization(e.to_string()))?;
358
359        let result = self.group_manager.decrypt_message(
360            &mut group,
361            &encrypted.group_id,
362            mls_message,
363            claimed_sender,
364        )?;
365
366        self.group_manager.save_group(&encrypted.group_id, &group)?;
367
368        Ok(result)
369    }
370
371    /// Lists the peers this device has a 1:1 session with.
372    ///
373    /// A session id is `session:<a>:<b>` where exactly one of the two halves
374    /// is us — ids cannot contain `:` (`GroupId::validate` runs the storage-key
375    /// charset check per segment), so the split is unambiguous. Anything that
376    /// does not have that exact shape is skipped rather than guessed at: a slot
377    /// naming two other parties has no "other peer" to report, and returning
378    /// one half of it anyway would surface a peer this device has no session
379    /// with.
380    pub fn list_sessions(&self) -> Result<Vec<String>> {
381        let sessions: Vec<String> = self
382            .group_manager
383            .list_groups()?
384            .into_iter()
385            .filter_map(|g| {
386                let parts: Vec<&str> = g.as_str().split(':').collect();
387                match parts.as_slice() {
388                    ["session", a, b] if *a == self.user_id => Some(b.to_string()),
389                    ["session", a, b] if *b == self.user_id => Some(a.to_string()),
390                    _ => None,
391                }
392            })
393            .collect();
394
395        Ok(sessions)
396    }
397
398    /// Deletes a session.
399    pub fn delete_session(&self, other_user_id: &str) -> Result<()> {
400        let session_id = self.get_session_id(other_user_id)?;
401        self.group_manager.delete_group(&session_id)?;
402        self.restart_seal_progress(&session_id);
403        debug!(session_id = %session_id, "Deleted session");
404        Ok(())
405    }
406
407    /// Gets information about a session.
408    pub fn get_session_info(&self, other_user_id: &str) -> Result<Option<GroupInfo>> {
409        let session_id = self.get_session_id(other_user_id)?;
410        let group = self.group_manager.load_group(&session_id)?;
411        Ok(group.map(|g| self.group_manager.get_group_info(&g, &session_id)))
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::storage::InMemoryStorage;
419    use crate::storage_adapter::MlsStorageAdapter;
420
421    fn create_test_session_manager(user_id: &str) -> SessionManager {
422        let storage = Arc::new(InMemoryStorage::new());
423        let adapter = MlsStorageAdapter::new(storage.clone());
424        let provider = MlsProvider::new(adapter);
425        SessionManager::new(user_id.to_string(), storage, provider)
426    }
427
428    #[test]
429    fn test_session_id_generation() {
430        let manager = create_test_session_manager("alice");
431        let id1 = manager.get_session_id("bob").unwrap();
432        let id2 = GroupId::for_session("bob", "alice").unwrap();
433        assert_eq!(id1, id2);
434        assert!(id1.as_str().starts_with("session:"));
435    }
436
437    #[test]
438    fn test_no_session_initially() {
439        let manager = create_test_session_manager("alice");
440        assert!(!manager.has_session("bob").unwrap());
441        assert!(manager.list_sessions().unwrap().is_empty());
442    }
443
444    #[test]
445    fn test_session_id_is_deterministic() {
446        // Session IDs should be the same regardless of which user creates them
447        let alice_manager = create_test_session_manager("alice");
448        let bob_manager = create_test_session_manager("bob");
449
450        let alice_to_bob = alice_manager.get_session_id("bob").unwrap();
451        let bob_to_alice = bob_manager.get_session_id("alice").unwrap();
452
453        // Both should produce the same session ID
454        assert_eq!(alice_to_bob, bob_to_alice);
455
456        // The session ID should have a consistent format with sorted user IDs
457        assert_eq!(alice_to_bob.as_str(), "session:alice:bob");
458    }
459
460    #[test]
461    fn test_delete_session() {
462        let manager = create_test_session_manager("alice");
463
464        // Initially no session
465        assert!(!manager.has_session("bob").unwrap());
466
467        // Delete non-existent session should not panic (delete_group handles this)
468        // This is important for welcome-wins where we delete before knowing if exists
469        let result = manager.delete_session("bob");
470        // Should succeed even if nothing to delete
471        assert!(result.is_ok());
472    }
473}