#![allow(deprecated)]
use super::super::{KeyPair, SeaError, SessionStorage};
use aes_gcm::{
Aes256Gcm, Nonce as AesNonce,
aead::{Aead, KeyInit},
};
use async_trait::async_trait;
use base64::prelude::*;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use std::path::PathBuf;
use web_time::{SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize)]
struct SessionFile {
ct: String,
iv: String,
s: String,
alias: String,
expires_at: u64,
}
pub struct EncryptedFileSessionStorage {
dir: PathBuf,
expiry_seconds: u64,
master_key: Option<Vec<u8>>,
}
impl EncryptedFileSessionStorage {
pub fn new() -> Result<Self, SeaError> {
let dir = dirs::config_dir()
.ok_or_else(|| SeaError::SessionStorage("no config dir".to_string()))?
.join("beam")
.join("sessions");
let mut inst = Self {
dir,
expiry_seconds: Self::resolve_expiry(),
master_key: None,
};
let _ = inst.resolve_master_key();
Ok(inst)
}
pub fn with_dir_and_key(dir: PathBuf, key: Vec<u8>) -> Self {
Self {
dir: dir.join("beam").join("sessions"),
expiry_seconds: Self::resolve_expiry(),
master_key: Some(key),
}
}
pub fn with_dir_key_expiry(dir: PathBuf, key: Vec<u8>, expiry_secs: u64) -> Self {
Self {
dir: dir.join("beam").join("sessions"),
expiry_seconds: expiry_secs,
master_key: Some(key),
}
}
pub fn with_session_dir(session_dir: PathBuf) -> Result<Self, SeaError> {
let mut inst = Self {
dir: session_dir,
expiry_seconds: Self::resolve_expiry(),
master_key: None,
};
let _ = inst.resolve_master_key();
Ok(inst)
}
fn resolve_expiry() -> u64 {
if let Ok(days) = std::env::var("BEAM_SEA_SESSION_EXPIRY_DAYS") {
if let Ok(d) = days.parse::<u64>() {
return d * 86400;
}
}
30 * 86400
}
fn file_path(&self, alias: &str) -> PathBuf {
self.dir.join(format!("{}.json", alias))
}
fn key_file_path(&self) -> PathBuf {
self.dir.parent().unwrap_or(&self.dir).join(".session_key")
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn resolve_master_key(&mut self) -> Result<Vec<u8>, SeaError> {
if let Some(ref key) = self.master_key {
return Ok(key.clone());
}
if let Ok(b64) = std::env::var("BEAM_SEA_SESSION_KEY") {
let key = BASE64_STANDARD.decode(&b64).map_err(|_| {
SeaError::SessionStorage("bad base64 in BEAM_SEA_SESSION_KEY".to_string())
})?;
if key.len() != 32 {
return Err(SeaError::SessionStorage(format!(
"BEAM_SEA_SESSION_KEY must be 32 bytes, got {}",
key.len()
)));
}
log::info!(target: "beam::sea::session", "Loaded session master key from BEAM_SEA_SESSION_KEY env var");
self.master_key = Some(key.clone());
return Ok(key);
}
let key_file = self.key_file_path();
if let Ok(contents) = std::fs::read_to_string(&key_file) {
let b64 = contents.trim();
if let Ok(key) = BASE64_STANDARD.decode(b64) {
if key.len() == 32 {
log::info!(target: "beam::sea::session", "Loaded session master key from {}", key_file.display());
self.master_key = Some(key.clone());
return Ok(key);
}
}
}
let mut key = vec![0u8; 32];
rand::rng().fill_bytes(&mut key);
let b64 = BASE64_STANDARD.encode(&key);
if let Some(parent) = key_file.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&key_file, &b64).map_err(|e| {
SeaError::SessionStorage(format!("failed to write session key file: {}", e))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600)).ok();
}
log::error!(target: "beam::sea::session",
"BEAM_SEA_SESSION_KEY not set. Generated new session key and saved to {}. \
Set BEAM_SEA_SESSION_KEY={} in your environment to persist across restarts.",
key_file.display(), b64
);
self.master_key = Some(key.clone());
Ok(key)
}
fn master_key(&mut self) -> Result<Vec<u8>, SeaError> {
if let Some(ref key) = self.master_key {
return Ok(key.clone());
}
self.resolve_master_key()
}
}
#[async_trait]
impl SessionStorage for EncryptedFileSessionStorage {
async fn save(&self, alias: &str, pair: &KeyPair) -> Result<(), SeaError> {
let key = {
let mut this = Self {
dir: self.dir.clone(),
expiry_seconds: self.expiry_seconds,
master_key: self.master_key.clone(),
};
this.master_key()?
};
if self.dir.parent().is_some() {
tokio::fs::create_dir_all(&self.dir)
.await
.map_err(|e| SeaError::SessionStorage(format!("mkdir: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700))
.await
.map_err(|e| SeaError::SessionStorage(format!("chmod: {}", e)))?;
}
}
let data = json!({
"pub": pair.pub_key,
"priv": pair.priv_key,
"epub": pair.epub_key,
"epriv": pair.epriv_key,
});
let plaintext = serde_json::to_string(&data)
.map_err(|e| SeaError::SessionStorage(format!("serialize: {}", e)))?;
let _dir = self.dir.clone();
let alias = alias.to_string();
let expiry = self.expiry_seconds;
let session_file = tokio::task::spawn_blocking(move || {
let mut nonce = [0u8; 12];
let mut salt = [0u8; 9];
rand::rng().fill_bytes(&mut nonce);
rand::rng().fill_bytes(&mut salt);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| SeaError::SessionStorage("bad cipher key".to_string()))?;
let ciphertext = cipher
.encrypt(AesNonce::from_slice(&nonce), plaintext.as_bytes())
.map_err(|_| SeaError::SessionStorage("encrypt failed".to_string()))?;
let expires_at = Self::now() + expiry;
Ok::<SessionFile, SeaError>(SessionFile {
ct: BASE64_URL_SAFE_NO_PAD.encode(&ciphertext),
iv: BASE64_URL_SAFE_NO_PAD.encode(nonce),
s: BASE64_URL_SAFE_NO_PAD.encode(salt),
alias,
expires_at,
})
})
.await
.map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))??;
let json = serde_json::to_string_pretty(&session_file)
.map_err(|e| SeaError::SessionStorage(format!("json: {}", e)))?;
tokio::fs::write(self.file_path(&session_file.alias), json)
.await
.map_err(|e| SeaError::SessionStorage(format!("write: {}", e)))?;
log::info!(target: "beam::sea::session", "Saved session for alias={}", session_file.alias);
Ok(())
}
async fn load(&self, alias: &str) -> Result<Option<KeyPair>, SeaError> {
let key = {
let mut this = Self {
dir: self.dir.clone(),
expiry_seconds: self.expiry_seconds,
master_key: self.master_key.clone(),
};
match this.master_key() {
Ok(k) => k,
Err(_) => return Ok(None),
}
};
let path = self.file_path(alias);
let json = match tokio::fs::read_to_string(path).await {
Ok(s) => s,
Err(_) => return Ok(None),
};
let session_file: SessionFile = serde_json::from_str(&json)
.map_err(|e| SeaError::SessionStorage(format!("parse: {}", e)))?;
if session_file.expires_at < Self::now() {
let _ = tokio::fs::remove_file(self.file_path(alias)).await;
log::info!(target: "beam::sea::session", "Reaped expired session for alias={}", alias);
return Ok(None);
}
let ciphertext = BASE64_URL_SAFE_NO_PAD
.decode(&session_file.ct)
.map_err(|_| SeaError::SessionStorage("bad ct".to_string()))?;
let nonce = BASE64_URL_SAFE_NO_PAD
.decode(&session_file.iv)
.map_err(|_| SeaError::SessionStorage("bad iv".to_string()))?;
let plaintext = tokio::task::spawn_blocking(move || {
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| SeaError::SessionStorage("bad cipher key".to_string()))?;
let plaintext = cipher
.decrypt(AesNonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| SeaError::SessionStorage("decrypt failed".to_string()))?;
Ok::<Vec<u8>, SeaError>(plaintext)
})
.await
.map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))??;
let text = String::from_utf8(plaintext)
.map_err(|_| SeaError::SessionStorage("bad utf8".to_string()))?;
let data: JsonValue = serde_json::from_str(&text)
.map_err(|_| SeaError::SessionStorage("bad json".to_string()))?;
let pair = KeyPair {
pub_key: data
.get("pub")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
priv_key: data
.get("priv")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
epub_key: data
.get("epub")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
epriv_key: data
.get("epriv")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
log::info!(target: "beam::sea::session", "Loaded session for alias={}", alias);
Ok(Some(pair))
}
async fn clear(&self, alias: &str) -> Result<(), SeaError> {
let _ = tokio::fs::remove_file(self.file_path(alias)).await;
log::info!(target: "beam::sea::session", "Cleared session for alias={}", alias);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sea::{KeyPair, SessionStorage};
static TEST_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn test_dir() -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
dir.push(format!("beam-test-{}-{}", std::process::id(), n));
std::fs::remove_dir_all(&dir).ok();
dir
}
fn test_key() -> Vec<u8> {
vec![0x42u8; 32]
}
fn clear_test_env() {
unsafe {
std::env::remove_var("BEAM_SEA_SESSION_KEY");
}
unsafe {
std::env::remove_var("BEAM_SEA_SESSION_EXPIRY_DAYS");
}
}
#[tokio::test]
async fn test_file_roundtrip_save_load_recall() {
clear_test_env();
let dir = test_dir();
let storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), test_key());
let pair = KeyPair {
pub_key: "test.pub".to_string(),
priv_key: "test.priv".to_string(),
epub_key: Some("test.epub".to_string()),
epriv_key: Some("test.epriv".to_string()),
};
storage.save("alice", &pair).await.unwrap();
let loaded = storage.load("alice").await.unwrap();
assert!(loaded.is_some(), "file should exist after save");
let loaded = loaded.unwrap();
assert_eq!(loaded.pub_key, pair.pub_key);
assert_eq!(loaded.priv_key, pair.priv_key);
assert_eq!(loaded.epub_key, pair.epub_key);
assert_eq!(loaded.epriv_key, pair.epriv_key);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_file_missing_env_returns_none() {
clear_test_env();
let dir = test_dir();
let storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), vec![]);
let result = storage.load("alice").await.unwrap();
assert!(
result.is_none(),
"missing key should return None, not error"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_file_expiry_reaping() {
clear_test_env();
let dir = test_dir();
let storage = EncryptedFileSessionStorage::with_dir_key_expiry(dir.clone(), test_key(), 0);
let pair = KeyPair {
pub_key: "exp.pub".to_string(),
priv_key: "exp.priv".to_string(),
epub_key: None,
epriv_key: None,
};
storage.save("expired_user", &pair).await.unwrap();
crate::tokio_time::sleep(web_time::Duration::from_secs(2)).await;
let loaded = storage.load("expired_user").await.unwrap();
assert!(loaded.is_none(), "expired session should be reaped on load");
let file_path = dir.join("beam").join("sessions").join("expired_user.json");
assert!(
!file_path.exists(),
"expired session file should be deleted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_file_clear_removes_session() {
clear_test_env();
let dir = test_dir();
let storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), test_key());
let pair = KeyPair {
pub_key: "clear.pub".to_string(),
priv_key: "clear.priv".to_string(),
epub_key: None,
epriv_key: None,
};
storage.save("clear_user", &pair).await.unwrap();
assert!(storage.load("clear_user").await.unwrap().is_some());
storage.clear("clear_user").await.unwrap();
assert!(storage.load("clear_user").await.unwrap().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_file_autogenerated_key_roundtrip() {
clear_test_env();
let dir = test_dir();
let _storage = EncryptedFileSessionStorage::with_dir_and_key(dir.clone(), vec![]);
let _storage2 = EncryptedFileSessionStorage::new().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}