Skip to main content

communitas_core/encrypted_storage/
session.rs

1//! Session Management for Multi-Account Support
2//!
3//! Handles authenticated sessions with automatic expiration,
4//! allowing seamless switching between multiple accounts.
5
6use serde::{Deserialize, Serialize};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9/// Represents an authenticated session
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Session {
12    pub id: String,
13    pub four_words: String,
14    pub display_name: String,
15    pub created_at: u64,
16    pub last_activity: u64,
17    pub expires_at: u64,
18    pub auth_method: AuthMethod,
19}
20
21/// Authentication method used for the session
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub enum AuthMethod {
24    Password,
25    PasswordOnly, // Familiar device login
26    Passkey,
27    Combined, // Password + Passkey
28}
29
30impl Session {
31    /// Create a new session
32    pub fn new(four_words: String, display_name: String, timeout_seconds: u64) -> Self {
33        let now = current_timestamp();
34        Self {
35            id: generate_session_id(),
36            four_words,
37            display_name,
38            created_at: now,
39            last_activity: now,
40            expires_at: now + timeout_seconds,
41            auth_method: AuthMethod::Password,
42        }
43    }
44
45    /// Create a password-only session (familiar device)
46    pub fn new_password_only(
47        four_words: String,
48        display_name: String,
49        timeout_seconds: u64,
50    ) -> Self {
51        let mut session = Self::new(four_words, display_name, timeout_seconds);
52        session.auth_method = AuthMethod::PasswordOnly;
53        session
54    }
55
56    /// Check if the session has expired
57    pub fn is_expired(&self) -> bool {
58        current_timestamp() > self.expires_at
59    }
60
61    /// Update the last activity time
62    pub fn touch(&mut self) {
63        self.last_activity = current_timestamp();
64    }
65
66    /// Extend the session expiration
67    pub fn extend(&mut self, additional_seconds: u64) {
68        self.expires_at = current_timestamp() + additional_seconds;
69        self.touch();
70    }
71
72    /// Get remaining time until expiration
73    pub fn time_remaining(&self) -> Duration {
74        let now = current_timestamp();
75        if now >= self.expires_at {
76            Duration::from_secs(0)
77        } else {
78            Duration::from_secs(self.expires_at - now)
79        }
80    }
81}
82
83/// Session manager for handling multiple active sessions
84pub struct SessionManager {
85    sessions: std::sync::Arc<tokio::sync::RwLock<Vec<Session>>>,
86    max_sessions: usize,
87}
88
89impl SessionManager {
90    /// Create a new session manager
91    pub fn new(max_sessions: usize) -> Self {
92        Self {
93            sessions: std::sync::Arc::new(tokio::sync::RwLock::new(Vec::new())),
94            max_sessions,
95        }
96    }
97
98    /// Add a new session
99    pub async fn add_session(&self, session: Session) -> Result<String, anyhow::Error> {
100        let mut sessions = self.sessions.write().await;
101
102        // Check for existing session with same four_words
103        if let Some(existing) = sessions
104            .iter_mut()
105            .find(|s| s.four_words == session.four_words)
106        {
107            // Update existing session
108            existing.extend(session.expires_at - session.created_at);
109            return Ok(existing.id.clone());
110        }
111
112        // Enforce maximum sessions limit
113        if sessions.len() >= self.max_sessions {
114            // Remove oldest expired session or oldest by creation time
115            sessions.sort_by_key(|s| s.created_at);
116            sessions.remove(0);
117        }
118
119        let session_id = session.id.clone();
120        sessions.push(session);
121
122        Ok(session_id)
123    }
124
125    /// Get a session by ID
126    pub async fn get_session(&self, session_id: &str) -> Option<Session> {
127        let sessions = self.sessions.read().await;
128        sessions
129            .iter()
130            .find(|s| s.id == session_id && !s.is_expired())
131            .cloned()
132    }
133
134    /// Get all active sessions
135    pub async fn get_active_sessions(&self) -> Vec<Session> {
136        let sessions = self.sessions.read().await;
137        sessions
138            .iter()
139            .filter(|s| !s.is_expired())
140            .cloned()
141            .collect()
142    }
143
144    /// Remove a session
145    pub async fn remove_session(&self, session_id: &str) -> bool {
146        let mut sessions = self.sessions.write().await;
147        if let Some(pos) = sessions.iter().position(|s| s.id == session_id) {
148            sessions.remove(pos);
149            true
150        } else {
151            false
152        }
153    }
154
155    /// Clean up expired sessions
156    pub async fn cleanup_expired(&self) -> usize {
157        let mut sessions = self.sessions.write().await;
158        let original_len = sessions.len();
159        sessions.retain(|s| !s.is_expired());
160        original_len - sessions.len()
161    }
162
163    /// Switch to a different account session
164    pub async fn switch_session(&self, session_id: &str) -> Option<Session> {
165        let mut sessions = self.sessions.write().await;
166        if let Some(session) = sessions.iter_mut().find(|s| s.id == session_id)
167            && !session.is_expired()
168        {
169            session.touch();
170            return Some(session.clone());
171        }
172        None
173    }
174}
175
176/// Session storage for persistence across app restarts
177pub struct SessionStorage {
178    storage_path: std::path::PathBuf,
179}
180
181impl SessionStorage {
182    pub fn new(base_path: &std::path::Path) -> Self {
183        let storage_path = base_path.join("sessions.enc");
184        Self { storage_path }
185    }
186
187    /// Save sessions to encrypted storage
188    pub async fn save_sessions(&self, sessions: &[Session]) -> anyhow::Result<()> {
189        // Filter out expired sessions
190        let active_sessions: Vec<_> = sessions
191            .iter()
192            .filter(|s| !s.is_expired())
193            .cloned()
194            .collect();
195
196        let json = serde_json::to_vec(&active_sessions)?;
197
198        // In production, this should be encrypted with a device key
199        tokio::fs::write(&self.storage_path, json).await?;
200
201        Ok(())
202    }
203
204    /// Load sessions from encrypted storage
205    pub async fn load_sessions(&self) -> anyhow::Result<Vec<Session>> {
206        if !self.storage_path.exists() {
207            return Ok(Vec::new());
208        }
209
210        let data = tokio::fs::read(&self.storage_path).await?;
211
212        // In production, decrypt with device key
213        let sessions: Vec<Session> = serde_json::from_slice(&data)?;
214
215        // Filter out expired sessions
216        Ok(sessions.into_iter().filter(|s| !s.is_expired()).collect())
217    }
218
219    /// Clear all stored sessions
220    pub async fn clear(&self) -> anyhow::Result<()> {
221        if self.storage_path.exists() {
222            tokio::fs::remove_file(&self.storage_path).await?;
223        }
224        Ok(())
225    }
226}
227
228fn current_timestamp() -> u64 {
229    SystemTime::now()
230        .duration_since(UNIX_EPOCH)
231        .map(|d| d.as_secs())
232        .unwrap_or(0) // Fallback to epoch if clock is before 1970 (extremely rare)
233}
234
235fn generate_session_id() -> String {
236    use rand::{Rng, SeedableRng};
237    let mut rng = rand::rngs::StdRng::from_entropy();
238    let random_bytes: Vec<u8> = (0..16).map(|_| rng.r#gen::<u8>()).collect();
239    hex::encode(random_bytes)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_session_creation() {
248        let session = Session::new(
249            "test-four-words".to_string(),
250            "Test User".to_string(),
251            300, // 5 minutes
252        );
253
254        assert!(!session.is_expired());
255        assert_eq!(session.four_words, "test-four-words");
256        assert_eq!(session.display_name, "Test User");
257    }
258
259    #[test]
260    fn test_session_expiration() {
261        let mut session = Session::new(
262            "test-four-words".to_string(),
263            "Test User".to_string(),
264            1, // 1 second
265        );
266
267        assert!(!session.is_expired());
268
269        // Manually set expiration to past
270        session.expires_at = current_timestamp() - 10;
271        assert!(session.is_expired());
272    }
273
274    #[tokio::test]
275    async fn test_session_manager() {
276        let manager = SessionManager::new(3);
277
278        // Add sessions
279        let session1 = Session::new("user1".to_string(), "User 1".to_string(), 300);
280        let session2 = Session::new("user2".to_string(), "User 2".to_string(), 300);
281        let session3 = Session::new("user3".to_string(), "User 3".to_string(), 300);
282
283        let id1 = manager.add_session(session1).await.unwrap();
284        let id2 = manager.add_session(session2).await.unwrap();
285        let _id3 = manager.add_session(session3).await.unwrap();
286
287        // Get active sessions
288        let active = manager.get_active_sessions().await;
289        assert_eq!(active.len(), 3);
290
291        // Switch session
292        let switched = manager.switch_session(&id2).await;
293        assert!(switched.is_some());
294        assert_eq!(switched.unwrap().four_words, "user2");
295
296        // Remove session
297        assert!(manager.remove_session(&id1).await);
298        let active_after = manager.get_active_sessions().await;
299        assert_eq!(active_after.len(), 2);
300    }
301
302    #[tokio::test]
303    async fn test_session_storage() {
304        use tempfile::TempDir;
305
306        let temp_dir = TempDir::new().unwrap();
307        let storage = SessionStorage::new(temp_dir.path());
308
309        let sessions = vec![
310            Session::new("user1".to_string(), "User 1".to_string(), 300),
311            Session::new("user2".to_string(), "User 2".to_string(), 300),
312        ];
313
314        // Save sessions
315        storage.save_sessions(&sessions).await.unwrap();
316
317        // Load sessions
318        let loaded = storage.load_sessions().await.unwrap();
319        assert_eq!(loaded.len(), 2);
320
321        // Clear sessions
322        storage.clear().await.unwrap();
323        let after_clear = storage.load_sessions().await.unwrap();
324        assert_eq!(after_clear.len(), 0);
325    }
326}