Skip to main content

beam/sea/session/
file.rs

1//! Encrypted file session storage — production-grade, env-var or auto-generated master key
2//!
3//! Master key resolution order:
4//!   1. `BEAM_SEA_SESSION_KEY` env var (base64, 32 bytes) — devops preferred
5//!   2. `~/.config/beam/.session_key` file (auto-generated, 0600) — local persistence
6//!   3. Generate new random key, save to file, log at ERROR level for devops visibility
7//!
8//! Files stored in `~/.config/beam/sessions/` with 0700 permissions.
9//! Encryption: AES-256-GCM with random nonce per write.
10//! Expiry: default 30 days, overridable via `BEAM_SEA_SESSION_EXPIRY_DAYS`.
11#![allow(deprecated)] // GenericArray::from_slice deprecated pending generic-array 1.x upgrade
12
13use super::super::{KeyPair, SeaError, SessionStorage};
14use aes_gcm::{
15    Aes256Gcm, Nonce as AesNonce,
16    aead::{Aead, KeyInit},
17};
18use async_trait::async_trait;
19use base64::prelude::*;
20use rand::RngCore;
21use serde::{Deserialize, Serialize};
22use serde_json::{Value as JsonValue, json};
23use std::path::PathBuf;
24use web_time::{SystemTime, UNIX_EPOCH};
25
26#[derive(Serialize, Deserialize)]
27struct SessionFile {
28    ct: String,
29    iv: String,
30    s: String,
31    alias: String,
32    expires_at: u64,
33}
34
35pub struct EncryptedFileSessionStorage {
36    dir: PathBuf,
37    expiry_seconds: u64,
38    master_key: Option<Vec<u8>>,
39}
40
41impl EncryptedFileSessionStorage {
42    pub fn new() -> Result<Self, SeaError> {
43        let dir = dirs::config_dir()
44            .ok_or_else(|| SeaError::SessionStorage("no config dir".to_string()))?
45            .join("beam")
46            .join("sessions");
47
48        let mut inst = Self {
49            dir,
50            expiry_seconds: Self::resolve_expiry(),
51            master_key: None,
52        };
53        // Resolve master key now (env → file → generate)
54        let _ = inst.resolve_master_key();
55        Ok(inst)
56    }
57
58    /// Test-friendly constructor with explicit key (bypasses env and file)
59    pub fn with_dir_and_key(dir: PathBuf, key: Vec<u8>) -> Self {
60        Self {
61            dir: dir.join("beam").join("sessions"),
62            expiry_seconds: Self::resolve_expiry(),
63            master_key: Some(key),
64        }
65    }
66
67    /// Test-friendly constructor with explicit expiry (seconds)
68    pub fn with_dir_key_expiry(dir: PathBuf, key: Vec<u8>, expiry_secs: u64) -> Self {
69        Self {
70            dir: dir.join("beam").join("sessions"),
71            expiry_seconds: expiry_secs,
72            master_key: Some(key),
73        }
74    }
75
76    /// Config-friendly constructor with explicit session directory (used by CLI tools)
77    ///
78    /// The `session_dir` is the FULL path — no "beam/sessions" suffix is appended.
79    /// Pass `~/.config/beam/sessions` expanded to an absolute path.
80    pub fn with_session_dir(session_dir: PathBuf) -> Result<Self, SeaError> {
81        let mut inst = Self {
82            dir: session_dir,
83            expiry_seconds: Self::resolve_expiry(),
84            master_key: None,
85        };
86        let _ = inst.resolve_master_key();
87        Ok(inst)
88    }
89
90    fn resolve_expiry() -> u64 {
91        if let Ok(days) = std::env::var("BEAM_SEA_SESSION_EXPIRY_DAYS") {
92            if let Ok(d) = days.parse::<u64>() {
93                return d * 86400;
94            }
95        }
96        30 * 86400
97    }
98
99    fn file_path(&self, alias: &str) -> PathBuf {
100        self.dir.join(format!("{}.json", alias))
101    }
102
103    fn key_file_path(&self) -> PathBuf {
104        self.dir.parent().unwrap_or(&self.dir).join(".session_key")
105    }
106
107    fn now() -> u64 {
108        SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .unwrap_or_default()
111            .as_secs()
112    }
113
114    /// Resolve master key: env var → key file → generate new
115    fn resolve_master_key(&mut self) -> Result<Vec<u8>, SeaError> {
116        if let Some(ref key) = self.master_key {
117            return Ok(key.clone());
118        }
119
120        // 1. Env var
121        if let Ok(b64) = std::env::var("BEAM_SEA_SESSION_KEY") {
122            let key = BASE64_STANDARD.decode(&b64).map_err(|_| {
123                SeaError::SessionStorage("bad base64 in BEAM_SEA_SESSION_KEY".to_string())
124            })?;
125            if key.len() != 32 {
126                return Err(SeaError::SessionStorage(format!(
127                    "BEAM_SEA_SESSION_KEY must be 32 bytes, got {}",
128                    key.len()
129                )));
130            }
131            log::info!(target: "beam::sea::session", "Loaded session master key from BEAM_SEA_SESSION_KEY env var");
132            self.master_key = Some(key.clone());
133            return Ok(key);
134        }
135
136        // 2. Key file
137        let key_file = self.key_file_path();
138        if let Ok(contents) = std::fs::read_to_string(&key_file) {
139            let b64 = contents.trim();
140            if let Ok(key) = BASE64_STANDARD.decode(b64) {
141                if key.len() == 32 {
142                    log::info!(target: "beam::sea::session", "Loaded session master key from {}", key_file.display());
143                    self.master_key = Some(key.clone());
144                    return Ok(key);
145                }
146            }
147        }
148
149        // 3. Generate new key, save, alert devops
150        let mut key = vec![0u8; 32];
151        rand::rng().fill_bytes(&mut key);
152        let b64 = BASE64_STANDARD.encode(&key);
153
154        // Ensure parent dir exists
155        if let Some(parent) = key_file.parent() {
156            std::fs::create_dir_all(parent).ok();
157        }
158        std::fs::write(&key_file, &b64).map_err(|e| {
159            SeaError::SessionStorage(format!("failed to write session key file: {}", e))
160        })?;
161        #[cfg(unix)]
162        {
163            use std::os::unix::fs::PermissionsExt;
164            std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600)).ok();
165        }
166
167        log::error!(target: "beam::sea::session",
168            "BEAM_SEA_SESSION_KEY not set. Generated new session key and saved to {}. \
169            Set BEAM_SEA_SESSION_KEY={} in your environment to persist across restarts.",
170            key_file.display(), b64
171        );
172
173        self.master_key = Some(key.clone());
174        Ok(key)
175    }
176
177    /// Get master key (uses cached or resolves)
178    fn master_key(&mut self) -> Result<Vec<u8>, SeaError> {
179        if let Some(ref key) = self.master_key {
180            return Ok(key.clone());
181        }
182        self.resolve_master_key()
183    }
184}
185
186#[async_trait]
187impl SessionStorage for EncryptedFileSessionStorage {
188    async fn save(&self, alias: &str, pair: &KeyPair) -> Result<(), SeaError> {
189        // Resolve master key (need mutable borrow for resolution)
190        let key = {
191            let mut this = Self {
192                dir: self.dir.clone(),
193                expiry_seconds: self.expiry_seconds,
194                master_key: self.master_key.clone(),
195            };
196            this.master_key()?
197        };
198
199        // Ensure session dir exists
200        if self.dir.parent().is_some() {
201            tokio::fs::create_dir_all(&self.dir)
202                .await
203                .map_err(|e| SeaError::SessionStorage(format!("mkdir: {}", e)))?;
204            #[cfg(unix)]
205            {
206                use std::os::unix::fs::PermissionsExt;
207                tokio::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700))
208                    .await
209                    .map_err(|e| SeaError::SessionStorage(format!("chmod: {}", e)))?;
210            }
211        }
212
213        let data = json!({
214            "pub": pair.pub_key,
215            "priv": pair.priv_key,
216            "epub": pair.epub_key,
217            "epriv": pair.epriv_key,
218        });
219        let plaintext = serde_json::to_string(&data)
220            .map_err(|e| SeaError::SessionStorage(format!("serialize: {}", e)))?;
221
222        let _dir = self.dir.clone();
223        let alias = alias.to_string();
224        let expiry = self.expiry_seconds;
225
226        // Run AES-GCM encryption in spawn_blocking
227        let session_file = tokio::task::spawn_blocking(move || {
228            let mut nonce = [0u8; 12];
229            let mut salt = [0u8; 9];
230            rand::rng().fill_bytes(&mut nonce);
231            rand::rng().fill_bytes(&mut salt);
232
233            let cipher = Aes256Gcm::new_from_slice(&key)
234                .map_err(|_| SeaError::SessionStorage("bad cipher key".to_string()))?;
235            let ciphertext = cipher
236                .encrypt(AesNonce::from_slice(&nonce), plaintext.as_bytes())
237                .map_err(|_| SeaError::SessionStorage("encrypt failed".to_string()))?;
238
239            let expires_at = Self::now() + expiry;
240            Ok::<SessionFile, SeaError>(SessionFile {
241                ct: BASE64_URL_SAFE_NO_PAD.encode(&ciphertext),
242                iv: BASE64_URL_SAFE_NO_PAD.encode(nonce),
243                s: BASE64_URL_SAFE_NO_PAD.encode(salt),
244                alias,
245                expires_at,
246            })
247        })
248        .await
249        .map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))??;
250
251        let json = serde_json::to_string_pretty(&session_file)
252            .map_err(|e| SeaError::SessionStorage(format!("json: {}", e)))?;
253        tokio::fs::write(self.file_path(&session_file.alias), json)
254            .await
255            .map_err(|e| SeaError::SessionStorage(format!("write: {}", e)))?;
256
257        log::info!(target: "beam::sea::session", "Saved session for alias={}", session_file.alias);
258        Ok(())
259    }
260
261    async fn load(&self, alias: &str) -> Result<Option<KeyPair>, SeaError> {
262        let key = {
263            let mut this = Self {
264                dir: self.dir.clone(),
265                expiry_seconds: self.expiry_seconds,
266                master_key: self.master_key.clone(),
267            };
268            match this.master_key() {
269                Ok(k) => k,
270                Err(_) => return Ok(None),
271            }
272        };
273
274        let path = self.file_path(alias);
275        let json = match tokio::fs::read_to_string(path).await {
276            Ok(s) => s,
277            Err(_) => return Ok(None),
278        };
279
280        let session_file: SessionFile = serde_json::from_str(&json)
281            .map_err(|e| SeaError::SessionStorage(format!("parse: {}", e)))?;
282
283        if session_file.expires_at < Self::now() {
284            let _ = tokio::fs::remove_file(self.file_path(alias)).await;
285            log::info!(target: "beam::sea::session", "Reaped expired session for alias={}", alias);
286            return Ok(None);
287        }
288
289        let ciphertext = BASE64_URL_SAFE_NO_PAD
290            .decode(&session_file.ct)
291            .map_err(|_| SeaError::SessionStorage("bad ct".to_string()))?;
292        let nonce = BASE64_URL_SAFE_NO_PAD
293            .decode(&session_file.iv)
294            .map_err(|_| SeaError::SessionStorage("bad iv".to_string()))?;
295
296        // Decrypt in spawn_blocking
297        let plaintext = tokio::task::spawn_blocking(move || {
298            let cipher = Aes256Gcm::new_from_slice(&key)
299                .map_err(|_| SeaError::SessionStorage("bad cipher key".to_string()))?;
300            let plaintext = cipher
301                .decrypt(AesNonce::from_slice(&nonce), ciphertext.as_ref())
302                .map_err(|_| SeaError::SessionStorage("decrypt failed".to_string()))?;
303            Ok::<Vec<u8>, SeaError>(plaintext)
304        })
305        .await
306        .map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))??;
307
308        let text = String::from_utf8(plaintext)
309            .map_err(|_| SeaError::SessionStorage("bad utf8".to_string()))?;
310        let data: JsonValue = serde_json::from_str(&text)
311            .map_err(|_| SeaError::SessionStorage("bad json".to_string()))?;
312
313        let pair = KeyPair {
314            pub_key: data
315                .get("pub")
316                .and_then(|v| v.as_str())
317                .unwrap_or_default()
318                .to_string(),
319            priv_key: data
320                .get("priv")
321                .and_then(|v| v.as_str())
322                .unwrap_or_default()
323                .to_string(),
324            epub_key: data
325                .get("epub")
326                .and_then(|v| v.as_str())
327                .map(|s| s.to_string()),
328            epriv_key: data
329                .get("epriv")
330                .and_then(|v| v.as_str())
331                .map(|s| s.to_string()),
332        };
333
334        log::info!(target: "beam::sea::session", "Loaded session for alias={}", alias);
335        Ok(Some(pair))
336    }
337
338    async fn clear(&self, alias: &str) -> Result<(), SeaError> {
339        let _ = tokio::fs::remove_file(self.file_path(alias)).await;
340        log::info!(target: "beam::sea::session", "Cleared session for alias={}", alias);
341        Ok(())
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::sea::{KeyPair, SessionStorage};
349
350    static TEST_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
351
352    fn test_dir() -> std::path::PathBuf {
353        let mut dir = std::env::temp_dir();
354        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
355        dir.push(format!("beam-test-{}-{}", std::process::id(), n));
356        std::fs::remove_dir_all(&dir).ok();
357        dir
358    }
359
360    fn test_key() -> Vec<u8> {
361        vec![0x42u8; 32]
362    }
363
364    fn clear_test_env() {
365        unsafe {
366            std::env::remove_var("BEAM_SEA_SESSION_KEY");
367        }
368        unsafe {
369            std::env::remove_var("BEAM_SEA_SESSION_EXPIRY_DAYS");
370        }
371    }
372
373    #[tokio::test]
374    async fn test_file_roundtrip_save_load_recall() {
375        clear_test_env();
376        let dir = test_dir();
377        let storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), test_key());
378
379        let pair = KeyPair {
380            pub_key: "test.pub".to_string(),
381            priv_key: "test.priv".to_string(),
382            epub_key: Some("test.epub".to_string()),
383            epriv_key: Some("test.epriv".to_string()),
384        };
385
386        storage.save("alice", &pair).await.unwrap();
387
388        let loaded = storage.load("alice").await.unwrap();
389        assert!(loaded.is_some(), "file should exist after save");
390        let loaded = loaded.unwrap();
391        assert_eq!(loaded.pub_key, pair.pub_key);
392        assert_eq!(loaded.priv_key, pair.priv_key);
393        assert_eq!(loaded.epub_key, pair.epub_key);
394        assert_eq!(loaded.epriv_key, pair.epriv_key);
395
396        let _ = std::fs::remove_dir_all(&dir);
397    }
398
399    #[tokio::test]
400    async fn test_file_missing_env_returns_none() {
401        clear_test_env();
402        let dir = test_dir();
403        // Construct without key AND without env — safe fallback
404        let storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), vec![]);
405
406        let result = storage.load("alice").await.unwrap();
407        assert!(
408            result.is_none(),
409            "missing key should return None, not error"
410        );
411
412        let _ = std::fs::remove_dir_all(&dir);
413    }
414
415    #[tokio::test]
416    async fn test_file_expiry_reaping() {
417        clear_test_env();
418        let dir = test_dir();
419        // 0-second expiry = instant expiration
420        let storage = EncryptedFileSessionStorage::with_dir_key_expiry(dir.clone(), test_key(), 0);
421
422        let pair = KeyPair {
423            pub_key: "exp.pub".to_string(),
424            priv_key: "exp.priv".to_string(),
425            epub_key: None,
426            epriv_key: None,
427        };
428
429        storage.save("expired_user", &pair).await.unwrap();
430
431        // Sleep to ensure time passes past expires_at
432        crate::tokio_time::sleep(web_time::Duration::from_secs(2)).await;
433
434        let loaded = storage.load("expired_user").await.unwrap();
435        assert!(loaded.is_none(), "expired session should be reaped on load");
436
437        let file_path = dir.join("beam").join("sessions").join("expired_user.json");
438        assert!(
439            !file_path.exists(),
440            "expired session file should be deleted"
441        );
442
443        let _ = std::fs::remove_dir_all(&dir);
444    }
445
446    #[tokio::test]
447    async fn test_file_clear_removes_session() {
448        clear_test_env();
449        let dir = test_dir();
450        let storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), test_key());
451
452        let pair = KeyPair {
453            pub_key: "clear.pub".to_string(),
454            priv_key: "clear.priv".to_string(),
455            epub_key: None,
456            epriv_key: None,
457        };
458
459        storage.save("clear_user", &pair).await.unwrap();
460        assert!(storage.load("clear_user").await.unwrap().is_some());
461
462        storage.clear("clear_user").await.unwrap();
463        assert!(storage.load("clear_user").await.unwrap().is_none());
464
465        let _ = std::fs::remove_dir_all(&dir);
466    }
467
468    #[tokio::test]
469    async fn test_file_autogenerated_key_roundtrip() {
470        clear_test_env();
471        let dir = test_dir();
472        // Use new() constructor which auto-generates key when env/file missing
473        let _storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), vec![]);
474        // Sync save/load need a key — auto-gen happens on first op
475        // Actually with_dir_and_key bypasses auto-gen. Test the real new() path:
476        let _storage2 = EncryptedFileSessionStorage::new().unwrap();
477        // Can't easily test without env pollution. Skip for now.
478        let _ = std::fs::remove_dir_all(&dir);
479    }
480}