1#![allow(deprecated)] use 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 let _ = inst.resolve_master_key();
55 Ok(inst)
56 }
57
58 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 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 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 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 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 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 let mut key = vec![0u8; 32];
151 rand::rng().fill_bytes(&mut key);
152 let b64 = BASE64_STANDARD.encode(&key);
153
154 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 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 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 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 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 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 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 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 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 let _storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), vec![]);
474 let _storage2 = EncryptedFileSessionStorage::new().unwrap();
477 let _ = std::fs::remove_dir_all(&dir);
479 }
480}