use std::fs;
use std::path::{Path, PathBuf};
use rand::rngs::OsRng;
use rand::TryRngCore;
use secrecy::SecretString;
#[cfg(unix)]
use tracing::warn;
use tracing::{debug, info};
use crate::{Result, SecretsError};
pub const ENV_JWT_SECRET: &str = "ZLAYER_JWT_SECRET";
const GENERATED_SECRET_BYTES: usize = 64;
#[derive(Debug, Clone)]
pub struct JwtSecretManager {
base_dir: PathBuf,
}
impl JwtSecretManager {
#[must_use]
pub fn with_base_dir(base_dir: impl AsRef<Path>) -> Self {
Self {
base_dir: base_dir.as_ref().to_path_buf(),
}
}
fn secret_file_path(&self, deployment: &str) -> PathBuf {
self.base_dir.join(format!("jwt_secret_{deployment}.key"))
}
#[must_use]
pub fn cluster_secret_file_path(&self) -> PathBuf {
self.base_dir.join("cluster_jwt_secret")
}
pub fn load_cluster_secret(&self) -> Result<Option<SecretString>> {
let path = self.cluster_secret_file_path();
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to read cluster JWT secret file {}: {e}",
path.display()
))
})?;
let trimmed = raw.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
return Err(SecretsError::Encryption(format!(
"cluster JWT secret file {} is empty",
path.display()
)));
}
debug!("Loaded cluster JWT secret from {}", path.display());
Ok(Some(SecretString::from(trimmed.to_string())))
}
pub fn import_cluster_secret(&self, secret: &str) -> Result<()> {
if secret.is_empty() {
return Err(SecretsError::Encryption(
"refusing to persist empty cluster JWT secret".to_string(),
));
}
let path = self.cluster_secret_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to create cluster JWT secret directory {}: {e}",
parent.display()
))
})?;
}
fs::write(&path, secret.as_bytes()).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to write cluster JWT secret file {}: {e}",
path.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
if let Err(e) = fs::set_permissions(&path, perms) {
warn!(
"Failed to set permissions on cluster JWT secret file {}: {e}",
path.display()
);
}
}
info!("Persisted cluster JWT secret at {}", path.display());
Ok(())
}
pub fn get_or_create(&self, deployment: &str) -> Result<SecretString> {
if let Ok(secret) = std::env::var(ENV_JWT_SECRET) {
if secret.is_empty() {
return Err(SecretsError::Encryption(format!(
"{ENV_JWT_SECRET} is set but empty"
)));
}
debug!("Using JWT secret from {ENV_JWT_SECRET} environment variable");
return Ok(SecretString::from(secret));
}
let path = self.secret_file_path(deployment);
if path.exists() {
debug!("Loading JWT secret from file: {}", path.display());
return Self::load_from_file(&path);
}
info!(
"Generating new JWT secret for deployment '{}' at {}",
deployment,
path.display()
);
Self::generate_and_save(&path)
}
fn load_from_file(path: &Path) -> Result<SecretString> {
let bytes = fs::read(path).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to read JWT secret file {}: {e}",
path.display()
))
})?;
if bytes.is_empty() {
return Err(SecretsError::Encryption(format!(
"JWT secret file {} is empty",
path.display()
)));
}
Ok(SecretString::from(hex::encode(bytes)))
}
fn generate_and_save(path: &Path) -> Result<SecretString> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to create JWT secret directory {}: {e}",
parent.display()
))
})?;
}
let mut bytes = vec![0u8; GENERATED_SECRET_BYTES];
OsRng
.try_fill_bytes(&mut bytes)
.map_err(|e| SecretsError::Encryption(format!("OS RNG failed: {e}")))?;
fs::write(path, &bytes).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to write JWT secret file {}: {e}",
path.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
if let Err(e) = fs::set_permissions(path, perms) {
warn!(
"Failed to set permissions on JWT secret file {}: {e}",
path.display()
);
}
}
info!("Created new JWT secret at {}", path.display());
Ok(SecretString::from(hex::encode(&bytes)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
struct EnvGuard;
impl EnvGuard {
fn new() -> Self {
env::remove_var(ENV_JWT_SECRET);
Self
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
env::remove_var(ENV_JWT_SECRET);
}
}
fn setup() -> (JwtSecretManager, TempDir) {
let dir = TempDir::new().unwrap();
(JwtSecretManager::with_base_dir(dir.path()), dir)
}
#[test]
#[serial]
fn generates_and_persists() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
let s1 = mgr.get_or_create("dev").unwrap();
let path = mgr.secret_file_path("dev");
assert!(path.exists(), "secret file should be persisted");
assert_eq!(s1.expose_secret().len(), GENERATED_SECRET_BYTES * 2);
}
#[test]
#[serial]
fn stable_across_calls() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
let s1 = mgr.get_or_create("dev").unwrap();
let s2 = mgr.get_or_create("dev").unwrap();
assert_eq!(s1.expose_secret(), s2.expose_secret());
}
#[test]
#[serial]
fn different_deployments_get_different_secrets() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
let a = mgr.get_or_create("a").unwrap();
let b = mgr.get_or_create("b").unwrap();
assert_ne!(a.expose_secret(), b.expose_secret());
}
#[test]
#[serial]
fn env_overrides_file() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
let _file_secret = mgr.get_or_create("dev").unwrap();
env::set_var(ENV_JWT_SECRET, "operator-supplied");
let env_secret = mgr.get_or_create("dev").unwrap();
assert_eq!(env_secret.expose_secret(), "operator-supplied");
}
#[test]
#[serial]
fn empty_env_is_rejected() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
env::set_var(ENV_JWT_SECRET, "");
let result = mgr.get_or_create("dev");
assert!(result.is_err());
}
#[test]
#[serial]
fn cluster_secret_absent_returns_none() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
assert!(
mgr.load_cluster_secret().unwrap().is_none(),
"no cluster secret file means standalone -> None"
);
}
#[test]
#[serial]
fn cluster_secret_import_then_load_roundtrips() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
mgr.import_cluster_secret("cluster-shared-signing-key")
.unwrap();
let loaded = mgr.load_cluster_secret().unwrap().expect("present");
assert_eq!(loaded.expose_secret(), "cluster-shared-signing-key");
}
#[test]
#[serial]
fn cluster_secret_tolerates_trailing_newline() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
fs::write(mgr.cluster_secret_file_path(), b"key-with-newline\n").unwrap();
let loaded = mgr.load_cluster_secret().unwrap().expect("present");
assert_eq!(loaded.expose_secret(), "key-with-newline");
}
#[test]
#[serial]
fn cluster_secret_rejects_empty_import() {
let _g = EnvGuard::new();
let (mgr, _t) = setup();
assert!(mgr.import_cluster_secret("").is_err());
}
#[cfg(unix)]
#[test]
#[serial]
fn cluster_secret_file_has_restrictive_perms() {
use std::os::unix::fs::PermissionsExt;
let _g = EnvGuard::new();
let (mgr, _t) = setup();
mgr.import_cluster_secret("k").unwrap();
let mode = fs::metadata(mgr.cluster_secret_file_path())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[cfg(unix)]
#[test]
#[serial]
fn file_has_restrictive_perms() {
use std::os::unix::fs::PermissionsExt;
let _g = EnvGuard::new();
let (mgr, _t) = setup();
mgr.get_or_create("dev").unwrap();
let path = mgr.secret_file_path("dev");
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}