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    /// This session's epoch, and a tag only a member of the same group at the
160    /// same epoch computes for `nonce`. `None` without a session.
161    ///
162    /// Two groups for one slot sit at the same epoch, since a session never
163    /// commits after it is created, so the epoch alone cannot tell them
164    /// apart and the tag is what does. It is an MLS exporter value: it shows
165    /// nothing about the group, and tags over different nonces cannot be
166    /// linked to each other.
167    pub fn corroboration(
168        &self,
169        other_user_id: &str,
170        nonce: &[u8],
171    ) -> Result<Option<(u64, Vec<u8>)>> {
172        let session_id = self.get_session_id(other_user_id)?;
173        let Some(group) = self.group_manager.load_group(&session_id)? else {
174            return Ok(None);
175        };
176        let tag = group
177            .export_secret(
178                self.group_manager.provider().crypto(),
179                offline_protocol_sealed::SESSION_CORROBORATION_LABEL,
180                nonce,
181                offline_protocol_sealed::SESSION_CORROBORATION_TAG_LEN,
182            )
183            .map_err(|e| {
184                MlsError::Encryption(format!("session corroboration export failed: {e}"))
185            })?;
186        Ok(Some((group.epoch().as_u64(), tag)))
187    }
188
189    /// Creates a new session with another user using their key package.
190    pub fn create_session(
191        &self,
192        other_user_id: &str,
193        their_key_package: KeyPackage,
194        credential_with_key: &CredentialWithKey,
195        signer: &impl Signer,
196    ) -> Result<WelcomeMessage> {
197        let session_id = self.get_session_id(other_user_id)?;
198
199        if self.group_manager.load_group(&session_id)?.is_some() {
200            return Err(MlsError::GroupCreation(format!(
201                "Session already exists with user: {}",
202                other_user_id
203            )));
204        }
205
206        let mut group =
207            self.group_manager
208                .create_group(&session_id, credential_with_key, signer)?;
209
210        let (_commit, welcome) =
211            self.group_manager
212                .add_member(&mut group, their_key_package, signer)?;
213
214        self.group_manager.save_group(&session_id, &group)?;
215
216        let welcome_bytes = welcome
217            .tls_serialize_detached()
218            .map_err(|e| MlsError::Serialization(e.to_string()))?;
219
220        info!(
221            session_id = %session_id,
222            other_user = %other_user_id,
223            "Created new 1:1 session"
224        );
225
226        self.restart_seal_progress(&session_id);
227        Ok(WelcomeMessage {
228            group_id: session_id,
229            welcome_data: welcome_bytes,
230            inviter_id: self.user_id.clone(),
231            group_name: None,
232            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
233        })
234    }
235
236    /// Verifies a Welcome's `group_id` names the `(self, inviter)` session slot
237    /// — the only 1:1 slot an authenticated inviter may install (SEC-M6) — and
238    /// returns the mismatch error **without touching any state**, so callers
239    /// can reject a forged/tampered slot before performing any mutation.
240    ///
241    /// `welcome_msg.group_id` is an attacker-controllable wire field that
242    /// becomes the raw storage key for the adopted group, and the inviter is
243    /// authenticated one layer up (`handle_welcome_message` drops any Welcome
244    /// whose `inviter_id` != the transport-authenticated sender). The only 1:1
245    /// slot this Welcome may legitimately install is therefore
246    /// `session:<self>:<inviter>`. Without this check an authenticated peer
247    /// could name a *third* party's slot (e.g. "session:alice:bob") and, via
248    /// the stage-then-replace adopt in `join_group_replacing`, overwrite or
249    /// seed the victim's session with that party — hijacking it so the victim's
250    /// `encrypt_for_user` output goes to the attacker's group. This mirrors the
251    /// invite-side (SEC-M5) and decrypt-side (SEC-M1) identity bindings on the
252    /// previously-unguarded join side. The sibling group-Welcome path is
253    /// guarded separately by the reserved-namespace check in
254    /// [`crate::MlsManager::join_group`], which refuses any `session:`-prefixed
255    /// `group_id`. Checked before deserialization so a tampered slot is
256    /// rejected regardless of the Welcome blob.
257    pub(crate) fn verify_welcome_slot(&self, welcome_msg: &WelcomeMessage) -> Result<()> {
258        let expected_id = GroupId::for_session(&self.user_id, &welcome_msg.inviter_id)?;
259        if welcome_msg.group_id != expected_id {
260            return Err(MlsError::WelcomeIdentityMismatch {
261                expected: expected_id.to_string(),
262                found: welcome_msg.group_id.to_string(),
263            });
264        }
265        Ok(())
266    }
267
268    /// Joins a session using a Welcome message.
269    ///
270    /// Implements a **non-destructive** "welcome-wins" strategy: if we already
271    /// created a session for this peer (both-create race), the incoming Welcome
272    /// is staged first and only replaces our group once staging succeeds (see
273    /// [`crate::group::GroupManager::join_group_replacing`]). A retransmitted
274    /// Welcome we already adopted, or a first-contact key-package race, fails to
275    /// stage and leaves the existing converged group intact — so adoption is
276    /// idempotent and never bricks a working session.
277    pub fn join_session(&self, welcome_msg: &WelcomeMessage) -> Result<GroupInfo> {
278        // SEC-M6: bind the Welcome's session slot to the (self, inviter) pair
279        // before touching any group state (see `verify_welcome_slot`).
280        self.verify_welcome_slot(welcome_msg)?;
281
282        // Deserialize the Welcome from the MlsMessageOut bytes
283        let mls_msg = MlsMessageIn::tls_deserialize_exact(&welcome_msg.welcome_data)
284            .map_err(|e| MlsError::Deserialization(e.to_string()))?;
285
286        // Extract the Welcome from the message
287        let welcome = match mls_msg.extract() {
288            MlsMessageBodyIn::Welcome(w) => w,
289            _ => {
290                return Err(MlsError::WelcomeProcessing(
291                    "Not a Welcome message".to_string(),
292                ))
293            }
294        };
295
296        // Stage-then-swap: replaces any existing group only after the incoming
297        // Welcome stages successfully, so a failed stage is a safe no-op.
298        let group = self
299            .group_manager
300            .join_group_replacing(welcome, &welcome_msg.group_id)?;
301        let info = self
302            .group_manager
303            .get_group_info(&group, &welcome_msg.group_id);
304
305        info!(
306            session_id = %welcome_msg.group_id,
307            inviter = %welcome_msg.inviter_id,
308            "Joined/adopted 1:1 session (non-destructive)"
309        );
310
311        self.restart_seal_progress(&welcome_msg.group_id);
312        Ok(info)
313    }
314
315    /// Encrypts a message for the session partner.
316    pub fn encrypt_message(
317        &self,
318        other_user_id: &str,
319        plaintext: &[u8],
320        signer: &impl Signer,
321    ) -> Result<EncryptedMessage> {
322        let session_id = self.get_session_id(other_user_id)?;
323
324        let mut group = self
325            .group_manager
326            .load_group(&session_id)?
327            .ok_or_else(|| MlsError::SessionNotFound(other_user_id.to_string()))?;
328
329        let mls_message = self
330            .group_manager
331            .encrypt_message(&mut group, plaintext, signer)?;
332
333        self.group_manager.save_group(&session_id, &group)?;
334        {
335            let mut progress = self.progress();
336            let entry = progress
337                .entry(session_id.to_string())
338                .or_insert_with(|| self.fresh_progress());
339            entry.sealed = entry.sealed.saturating_add(1);
340        }
341
342        let ciphertext = mls_message
343            .tls_serialize_detached()
344            .map_err(|e| MlsError::Serialization(e.to_string()))?;
345
346        Ok(EncryptedMessage {
347            group_id: session_id,
348            message_type: MlsMessageType::Application,
349            epoch: group.epoch().as_u64(),
350            ciphertext,
351            sender_id: self.user_id.clone(),
352            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
353        })
354    }
355
356    /// Decrypts a message from a session.
357    ///
358    /// `claimed_sender` is the transport-level sender this message will be
359    /// attributed to; it must match the MLS-authenticated credential.
360    ///
361    /// The envelope's `group_id` is bound to `claimed_sender` before the group
362    /// is loaded, because every failure OpenMLS raises below happens *before*
363    /// it authenticates anything — see [`MlsError::SessionIdentityMismatch`].
364    /// This is the single chokepoint for the binding: `decrypt_from_user` is
365    /// reachable both from the receive path and from the public
366    /// `manual_mls_decrypt_from_user` API, so checking it in either caller
367    /// alone leaves the other as a bypass.
368    pub fn decrypt_message(
369        &self,
370        encrypted: &EncryptedMessage,
371        claimed_sender: &str,
372    ) -> Result<Option<Vec<u8>>> {
373        let expected_id = GroupId::for_session(&self.user_id, claimed_sender)?;
374        if encrypted.group_id != expected_id {
375            return Err(MlsError::SessionIdentityMismatch {
376                expected: expected_id.to_string(),
377                found: encrypted.group_id.to_string(),
378            });
379        }
380
381        let mut group = self
382            .group_manager
383            .load_group(&encrypted.group_id)?
384            .ok_or_else(|| MlsError::GroupNotFound(encrypted.group_id.to_string()))?;
385
386        let mls_message = MlsMessageIn::tls_deserialize_exact(&encrypted.ciphertext)
387            .map_err(|e| MlsError::Deserialization(e.to_string()))?;
388
389        let result = self.group_manager.decrypt_message(
390            &mut group,
391            &encrypted.group_id,
392            mls_message,
393            claimed_sender,
394        )?;
395
396        self.group_manager.save_group(&encrypted.group_id, &group)?;
397
398        Ok(result)
399    }
400
401    /// Lists the peers this device has a 1:1 session with.
402    ///
403    /// A session id is `session:<a>:<b>` where exactly one of the two halves
404    /// is us — ids cannot contain `:` (`GroupId::validate` runs the storage-key
405    /// charset check per segment), so the split is unambiguous. Anything that
406    /// does not have that exact shape is skipped rather than guessed at: a slot
407    /// naming two other parties has no "other peer" to report, and returning
408    /// one half of it anyway would surface a peer this device has no session
409    /// with.
410    pub fn list_sessions(&self) -> Result<Vec<String>> {
411        let sessions: Vec<String> = self
412            .group_manager
413            .list_groups()?
414            .into_iter()
415            .filter_map(|g| {
416                let parts: Vec<&str> = g.as_str().split(':').collect();
417                match parts.as_slice() {
418                    ["session", a, b] if *a == self.user_id => Some(b.to_string()),
419                    ["session", a, b] if *b == self.user_id => Some(a.to_string()),
420                    _ => None,
421                }
422            })
423            .collect();
424
425        Ok(sessions)
426    }
427
428    /// Deletes a session.
429    pub fn delete_session(&self, other_user_id: &str) -> Result<()> {
430        let session_id = self.get_session_id(other_user_id)?;
431        self.group_manager.delete_group(&session_id)?;
432        self.restart_seal_progress(&session_id);
433        debug!(session_id = %session_id, "Deleted session");
434        Ok(())
435    }
436
437    /// Gets information about a session.
438    pub fn get_session_info(&self, other_user_id: &str) -> Result<Option<GroupInfo>> {
439        let session_id = self.get_session_id(other_user_id)?;
440        let group = self.group_manager.load_group(&session_id)?;
441        Ok(group.map(|g| self.group_manager.get_group_info(&g, &session_id)))
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::storage::InMemoryStorage;
449    use crate::storage_adapter::MlsStorageAdapter;
450
451    fn create_test_session_manager(user_id: &str) -> SessionManager {
452        let storage = Arc::new(InMemoryStorage::new());
453        let adapter = MlsStorageAdapter::new(storage.clone());
454        let provider = MlsProvider::new(adapter);
455        SessionManager::new(user_id.to_string(), storage, provider)
456    }
457
458    #[test]
459    fn test_session_id_generation() {
460        let manager = create_test_session_manager("alice");
461        let id1 = manager.get_session_id("bob").unwrap();
462        let id2 = GroupId::for_session("bob", "alice").unwrap();
463        assert_eq!(id1, id2);
464        assert!(id1.as_str().starts_with("session:"));
465    }
466
467    #[test]
468    fn test_no_session_initially() {
469        let manager = create_test_session_manager("alice");
470        assert!(!manager.has_session("bob").unwrap());
471        assert!(manager.list_sessions().unwrap().is_empty());
472    }
473
474    #[test]
475    fn test_session_id_is_deterministic() {
476        // Session IDs should be the same regardless of which user creates them
477        let alice_manager = create_test_session_manager("alice");
478        let bob_manager = create_test_session_manager("bob");
479
480        let alice_to_bob = alice_manager.get_session_id("bob").unwrap();
481        let bob_to_alice = bob_manager.get_session_id("alice").unwrap();
482
483        // Both should produce the same session ID
484        assert_eq!(alice_to_bob, bob_to_alice);
485
486        // The session ID should have a consistent format with sorted user IDs
487        assert_eq!(alice_to_bob.as_str(), "session:alice:bob");
488    }
489
490    #[test]
491    fn test_delete_session() {
492        let manager = create_test_session_manager("alice");
493
494        // Initially no session
495        assert!(!manager.has_session("bob").unwrap());
496
497        // Delete non-existent session should not panic (delete_group handles this)
498        // This is important for welcome-wins where we delete before knowing if exists
499        let result = manager.delete_session("bob");
500        // Should succeed even if nothing to delete
501        assert!(result.is_ok());
502    }
503}