use std::fs;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use tracing::warn;
use tracing::{debug, info};
use crate::sealed::{RecipientPrivateKey, RecipientPublicKey};
use crate::{EncryptionKey, Result, SecretsError};
const NODE_SECRETS_KEY_FILE: &str = "node_secrets.key";
#[must_use]
pub fn node_secrets_key_path(base_dir: &Path) -> PathBuf {
base_dir.join(NODE_SECRETS_KEY_FILE)
}
pub fn load_or_generate_node_keypair(
base_dir: &Path,
) -> std::result::Result<(RecipientPrivateKey, RecipientPublicKey), SecretsError> {
fs::create_dir_all(base_dir).map_err(|e| {
SecretsError::Storage(format!(
"Failed to create node key directory {}: {e}",
base_dir.display()
))
})?;
let path = node_secrets_key_path(base_dir);
if path.exists() {
debug!("Loading node X25519 keypair from {}", path.display());
let buf = fs::read(&path).map_err(|e| {
SecretsError::Storage(format!(
"Failed to read node key file {}: {e}",
path.display()
))
})?;
if buf.len() != 32 {
return Err(SecretsError::Storage(format!(
"node_secrets.key has wrong length: expected 32, got {}",
buf.len()
)));
}
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&buf);
let private = RecipientPrivateKey::from_bytes(bytes);
let public = private.public_key();
return Ok((private, public));
}
info!("Generating new node X25519 keypair at {}", path.display());
let (private, public) = RecipientPrivateKey::generate();
write_node_key_file(&path, &private)?;
Ok((private, public))
}
fn write_node_key_file(
path: &Path,
private: &RecipientPrivateKey,
) -> std::result::Result<(), SecretsError> {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
let raw = B64
.decode(private.to_base64())
.map_err(|e| SecretsError::Storage(format!("Failed to encode node private key: {e}")))?;
debug_assert_eq!(raw.len(), 32);
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| {
SecretsError::Storage(format!(
"Failed to create node key file {}: {e}",
path.display()
))
})?;
file.write_all(&raw).map_err(|e| {
SecretsError::Storage(format!(
"Failed to write node key file {}: {e}",
path.display()
))
})?;
let permissions = fs::Permissions::from_mode(0o600);
if let Err(e) = fs::set_permissions(path, permissions) {
warn!(
"Failed to set permissions on node key file {}: {e}",
path.display()
);
return Err(SecretsError::Storage(format!(
"Failed to set permissions on node key file {}: {e}",
path.display()
)));
}
}
#[cfg(not(unix))]
{
fs::write(path, &raw).map_err(|e| {
SecretsError::Storage(format!(
"Failed to write node key file {}: {e}",
path.display()
))
})?;
}
Ok(())
}
const ENV_KEY: &str = "ZLAYER_SECRETS_KEY";
const ENV_PASSWORD: &str = "ZLAYER_SECRETS_PASSWORD";
#[derive(Debug, Clone)]
pub struct KeyManager {
base_dir: PathBuf,
}
impl Default for KeyManager {
fn default() -> Self {
Self::new()
}
}
impl KeyManager {
#[must_use]
pub fn new() -> Self {
Self {
base_dir: zlayer_paths::ZLayerDirs::system_default().secrets(),
}
}
#[must_use]
pub fn with_base_dir(base_dir: impl AsRef<Path>) -> Self {
Self {
base_dir: base_dir.as_ref().to_path_buf(),
}
}
fn key_file_path(&self, deployment: &str) -> PathBuf {
self.base_dir.join(format!("secrets_{deployment}.key"))
}
pub fn get_or_create_key(&self, deployment: &str) -> Result<EncryptionKey> {
if let Ok(hex_key) = std::env::var(ENV_KEY) {
debug!("Using encryption key from {ENV_KEY} environment variable");
return Self::key_from_hex(&hex_key);
}
if let Ok(password) = std::env::var(ENV_PASSWORD) {
debug!("Deriving encryption key from {ENV_PASSWORD} environment variable");
return Self::key_from_password(&password, deployment);
}
let key_path = self.key_file_path(deployment);
if key_path.exists() {
debug!("Loading encryption key from file: {}", key_path.display());
return Self::load_key_from_file(&key_path);
}
info!(
"Generating new encryption key for deployment '{}' at {}",
deployment,
key_path.display()
);
Self::generate_and_save_key(&key_path)
}
fn key_from_hex(hex_key: &str) -> Result<EncryptionKey> {
let key_bytes = hex::decode(hex_key.trim()).map_err(|e| {
SecretsError::Encryption(format!("Invalid hex-encoded key in {ENV_KEY}: {e}"))
})?;
EncryptionKey::from_bytes(&key_bytes)
}
fn key_from_password(password: &str, deployment: &str) -> Result<EncryptionKey> {
EncryptionKey::derive_from_password(password, deployment.as_bytes())
}
fn load_key_from_file(path: &Path) -> Result<EncryptionKey> {
let key_bytes = fs::read(path).map_err(|e| {
SecretsError::Encryption(format!("Failed to read key file {}: {e}", path.display()))
})?;
EncryptionKey::from_bytes(&key_bytes)
}
fn generate_and_save_key(path: &Path) -> Result<EncryptionKey> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
SecretsError::Encryption(format!(
"Failed to create key directory {}: {e}",
parent.display()
))
})?;
}
let key = EncryptionKey::generate();
fs::write(path, key.as_bytes()).map_err(|e| {
SecretsError::Encryption(format!("Failed to write key file {}: {e}", path.display()))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = fs::Permissions::from_mode(0o600);
if let Err(e) = fs::set_permissions(path, permissions) {
warn!(
"Failed to set permissions on key file {}: {e}",
path.display()
);
}
}
info!("Created new encryption key at {}", path.display());
Ok(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
struct EnvGuard;
impl EnvGuard {
fn new() -> Self {
env::remove_var(ENV_KEY);
env::remove_var(ENV_PASSWORD);
Self
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
env::remove_var(ENV_KEY);
env::remove_var(ENV_PASSWORD);
}
}
fn setup_manager() -> (KeyManager, TempDir) {
let temp_dir = TempDir::new().unwrap();
let manager = KeyManager::with_base_dir(temp_dir.path());
(manager, temp_dir)
}
#[test]
fn test_new_uses_default_dir() {
let manager = KeyManager::new();
let expected = zlayer_paths::ZLayerDirs::system_default().secrets();
assert_eq!(manager.base_dir, expected);
}
#[test]
fn test_with_base_dir() {
let manager = KeyManager::with_base_dir("/custom/path");
assert_eq!(manager.base_dir, PathBuf::from("/custom/path"));
}
#[test]
fn test_key_file_path() {
let dirs = zlayer_paths::ZLayerDirs::system_default();
let manager = KeyManager::with_base_dir(dirs.secrets());
let path = manager.key_file_path("production");
assert_eq!(path, dirs.secrets().join("secrets_production.key"));
}
#[test]
#[serial]
fn test_auto_generate_key() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
let key = manager.get_or_create_key("test-deployment").unwrap();
assert_eq!(key.as_bytes().len(), 32);
let key_path = manager.key_file_path("test-deployment");
assert!(key_path.exists());
}
#[test]
#[serial]
fn test_load_existing_key() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
let key1 = manager.get_or_create_key("test-deployment").unwrap();
let key2 = manager.get_or_create_key("test-deployment").unwrap();
assert_eq!(key1.as_bytes(), key2.as_bytes());
}
#[test]
#[serial]
fn test_different_deployments_get_different_keys() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
let key1 = manager.get_or_create_key("deployment-a").unwrap();
let key2 = manager.get_or_create_key("deployment-b").unwrap();
assert_ne!(key1.as_bytes(), key2.as_bytes());
}
#[test]
#[serial]
fn test_env_key_takes_priority() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
let known_key = [42u8; 32];
let hex_key = hex::encode(known_key);
env::set_var(ENV_KEY, &hex_key);
let key = manager.get_or_create_key("any-deployment").unwrap();
assert_eq!(key.as_bytes(), &known_key);
}
#[test]
#[serial]
fn test_env_password_takes_priority_over_file() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
let file_key = manager.get_or_create_key("test-deployment").unwrap();
env::set_var(ENV_PASSWORD, "my-secret-password");
let password_key = manager.get_or_create_key("test-deployment").unwrap();
assert_ne!(file_key.as_bytes(), password_key.as_bytes());
}
#[test]
#[serial]
fn test_password_derivation_is_deterministic() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
env::set_var(ENV_PASSWORD, "test-password");
let key1 = manager.get_or_create_key("deployment").unwrap();
let key2 = manager.get_or_create_key("deployment").unwrap();
assert_eq!(key1.as_bytes(), key2.as_bytes());
}
#[test]
#[serial]
fn test_password_with_different_deployments() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
env::set_var(ENV_PASSWORD, "same-password");
let key1 = manager.get_or_create_key("deployment-a").unwrap();
let key2 = manager.get_or_create_key("deployment-b").unwrap();
assert_ne!(key1.as_bytes(), key2.as_bytes());
}
#[test]
#[serial]
fn test_invalid_hex_key_error() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
env::set_var(ENV_KEY, "not-valid-hex!!");
let result = manager.get_or_create_key("test");
assert!(result.is_err());
}
#[test]
#[serial]
fn test_hex_key_wrong_length_error() {
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
env::set_var(ENV_KEY, "0011223344556677889900112233445566778899");
let result = manager.get_or_create_key("test");
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
#[serial]
fn test_key_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let _guard = EnvGuard::new();
let (manager, _temp) = setup_manager();
manager.get_or_create_key("secure-deployment").unwrap();
let key_path = manager.key_file_path("secure-deployment");
let metadata = fs::metadata(&key_path).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o777, 0o600);
}
#[test]
fn node_keypair_round_trip_generate_then_load() {
let temp = TempDir::new().unwrap();
let (_priv1, pub1) = load_or_generate_node_keypair(temp.path()).unwrap();
let (_priv2, pub2) = load_or_generate_node_keypair(temp.path()).unwrap();
assert_eq!(pub1, pub2);
assert!(node_secrets_key_path(temp.path()).exists());
}
#[cfg(unix)]
#[test]
fn node_keypair_perms_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let _ = load_or_generate_node_keypair(temp.path()).unwrap();
let path = node_secrets_key_path(temp.path());
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "expected mode 0600, got {mode:o}");
}
#[test]
fn node_keypair_rejects_wrong_length() {
let temp = TempDir::new().unwrap();
let path = node_secrets_key_path(temp.path());
fs::create_dir_all(temp.path()).unwrap();
fs::write(&path, [0u8; 16]).unwrap();
let result = load_or_generate_node_keypair(temp.path());
match result {
Ok(_) => panic!("expected SecretsError::Storage, got Ok(_)"),
Err(SecretsError::Storage(msg)) => {
assert!(
msg.contains("length") || msg.contains("expected 32"),
"expected length error message, got: {msg}"
);
}
Err(other) => panic!("expected SecretsError::Storage, got {other:?}"),
}
}
#[test]
fn node_keypair_pubkey_matches_private() {
let temp = TempDir::new().unwrap();
let (private, public) = load_or_generate_node_keypair(temp.path()).unwrap();
let derived = private.public_key();
assert_eq!(derived, public);
}
}