Skip to main content

zlayer_secrets/
key_manager.rs

1//! Encryption key management for `ZLayer` secrets.
2//!
3//! Provides automatic key discovery, generation, and persistence with
4//! support for environment-based configuration, password derivation,
5//! and file-based key storage.
6//!
7//! ## Key Resolution Priority
8//!
9//! Keys are resolved in the following order:
10//! 1. `ZLAYER_SECRETS_KEY` environment variable (hex-encoded 32-byte key)
11//! 2. `ZLAYER_SECRETS_PASSWORD` environment variable (derived with Argon2id)
12//! 3. Key file at `{base_dir}/secrets_{deployment}.key`
13//! 4. Auto-generated key (saved to key file for future use)
14
15use std::fs;
16use std::path::{Path, PathBuf};
17
18#[cfg(unix)]
19use tracing::warn;
20use tracing::{debug, info};
21
22use crate::{EncryptionKey, Result, SecretsError};
23
24/// Environment variable name for hex-encoded encryption key.
25const ENV_KEY: &str = "ZLAYER_SECRETS_KEY";
26
27/// Environment variable name for password-based key derivation.
28const ENV_PASSWORD: &str = "ZLAYER_SECRETS_PASSWORD";
29
30/// Manages encryption keys for secret storage.
31///
32/// The `KeyManager` handles key discovery, generation, and persistence
33/// with automatic fallback through multiple sources.
34///
35/// # Example
36///
37/// ```no_run
38/// use zlayer_secrets::KeyManager;
39///
40/// let manager = KeyManager::new();
41/// let key = manager.get_or_create_key("production").unwrap();
42/// ```
43#[derive(Debug, Clone)]
44pub struct KeyManager {
45    base_dir: PathBuf,
46}
47
48impl Default for KeyManager {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl KeyManager {
55    /// Creates a new `KeyManager` with the default secrets directory.
56    ///
57    /// The default directory is determined by [`zlayer_paths::ZLayerDirs::system_default()`].
58    #[must_use]
59    pub fn new() -> Self {
60        Self {
61            base_dir: zlayer_paths::ZLayerDirs::system_default().secrets(),
62        }
63    }
64
65    /// Creates a new `KeyManager` with a custom base directory.
66    ///
67    /// # Arguments
68    /// * `base_dir` - Path to the directory for storing key files
69    #[must_use]
70    pub fn with_base_dir(base_dir: impl AsRef<Path>) -> Self {
71        Self {
72            base_dir: base_dir.as_ref().to_path_buf(),
73        }
74    }
75
76    /// Returns the path to the key file for a given deployment.
77    fn key_file_path(&self, deployment: &str) -> PathBuf {
78        self.base_dir.join(format!("secrets_{deployment}.key"))
79    }
80
81    /// Gets or creates an encryption key for the specified deployment.
82    ///
83    /// Attempts to obtain a key through the following priority chain:
84    ///
85    /// 1. **Environment key**: If `ZLAYER_SECRETS_KEY` is set, decodes the
86    ///    hex-encoded 32-byte key directly.
87    ///
88    /// 2. **Password derivation**: If `ZLAYER_SECRETS_PASSWORD` is set,
89    ///    derives a key using Argon2id with the deployment name as salt.
90    ///
91    /// 3. **File-based key**: Loads the key from the deployment's key file
92    ///    if it exists at `{base_dir}/secrets_{deployment}.key`.
93    ///
94    /// 4. **Auto-generation**: Generates a new random key and saves it to
95    ///    the key file with restricted permissions (0600 on Unix).
96    ///
97    /// # Arguments
98    /// * `deployment` - The deployment name used for key file naming and
99    ///   password salt derivation
100    ///
101    /// # Errors
102    ///
103    /// Returns `SecretsError::Encryption` if:
104    /// - The hex-encoded key in `ZLAYER_SECRETS_KEY` is invalid
105    /// - Key file I/O operations fail
106    /// - Password derivation fails
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// use zlayer_secrets::KeyManager;
112    ///
113    /// let manager = KeyManager::new();
114    ///
115    /// // First call: generates and saves key
116    /// let key = manager.get_or_create_key("production").unwrap();
117    ///
118    /// // Subsequent calls: loads from file
119    /// let same_key = manager.get_or_create_key("production").unwrap();
120    /// assert_eq!(key.as_bytes(), same_key.as_bytes());
121    /// ```
122    pub fn get_or_create_key(&self, deployment: &str) -> Result<EncryptionKey> {
123        // Priority 1: Check ZLAYER_SECRETS_KEY env var (hex-encoded)
124        if let Ok(hex_key) = std::env::var(ENV_KEY) {
125            debug!("Using encryption key from {ENV_KEY} environment variable");
126            return Self::key_from_hex(&hex_key);
127        }
128
129        // Priority 2: Check ZLAYER_SECRETS_PASSWORD env var, derive with Argon2
130        if let Ok(password) = std::env::var(ENV_PASSWORD) {
131            debug!("Deriving encryption key from {ENV_PASSWORD} environment variable");
132            return Self::key_from_password(&password, deployment);
133        }
134
135        // Priority 3: Load from file if exists
136        let key_path = self.key_file_path(deployment);
137        if key_path.exists() {
138            debug!("Loading encryption key from file: {}", key_path.display());
139            return Self::load_key_from_file(&key_path);
140        }
141
142        // Priority 4: Auto-generate and save to file
143        info!(
144            "Generating new encryption key for deployment '{}' at {}",
145            deployment,
146            key_path.display()
147        );
148        Self::generate_and_save_key(&key_path)
149    }
150
151    /// Decodes a hex-encoded key string into an [`EncryptionKey`].
152    fn key_from_hex(hex_key: &str) -> Result<EncryptionKey> {
153        let key_bytes = hex::decode(hex_key.trim()).map_err(|e| {
154            SecretsError::Encryption(format!("Invalid hex-encoded key in {ENV_KEY}: {e}"))
155        })?;
156
157        EncryptionKey::from_bytes(&key_bytes)
158    }
159
160    /// Derives an encryption key from a password using the deployment as salt.
161    fn key_from_password(password: &str, deployment: &str) -> Result<EncryptionKey> {
162        // Use deployment name as salt - this ensures different deployments
163        // with the same password get different keys
164        EncryptionKey::derive_from_password(password, deployment.as_bytes())
165    }
166
167    /// Loads an encryption key from a file containing raw key bytes.
168    fn load_key_from_file(path: &Path) -> Result<EncryptionKey> {
169        let key_bytes = fs::read(path).map_err(|e| {
170            SecretsError::Encryption(format!("Failed to read key file {}: {e}", path.display()))
171        })?;
172
173        EncryptionKey::from_bytes(&key_bytes)
174    }
175
176    /// Generates a new encryption key and saves it to a file.
177    ///
178    /// On Unix systems, sets file permissions to 0600 (owner read/write only).
179    fn generate_and_save_key(path: &Path) -> Result<EncryptionKey> {
180        // Ensure parent directory exists
181        if let Some(parent) = path.parent() {
182            fs::create_dir_all(parent).map_err(|e| {
183                SecretsError::Encryption(format!(
184                    "Failed to create key directory {}: {e}",
185                    parent.display()
186                ))
187            })?;
188        }
189
190        // Generate new key
191        let key = EncryptionKey::generate();
192
193        // Write key bytes to file
194        fs::write(path, key.as_bytes()).map_err(|e| {
195            SecretsError::Encryption(format!("Failed to write key file {}: {e}", path.display()))
196        })?;
197
198        // Set restrictive permissions on Unix
199        #[cfg(unix)]
200        {
201            use std::os::unix::fs::PermissionsExt;
202            let permissions = fs::Permissions::from_mode(0o600);
203            if let Err(e) = fs::set_permissions(path, permissions) {
204                warn!(
205                    "Failed to set permissions on key file {}: {e}",
206                    path.display()
207                );
208            }
209        }
210
211        info!("Created new encryption key at {}", path.display());
212        Ok(key)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serial_test::serial;
220    use std::env;
221    use tempfile::TempDir;
222
223    /// Guard that clears environment variables on drop to ensure test isolation.
224    struct EnvGuard;
225
226    impl EnvGuard {
227        fn new() -> Self {
228            // Clear any existing env vars at test start
229            env::remove_var(ENV_KEY);
230            env::remove_var(ENV_PASSWORD);
231            Self
232        }
233    }
234
235    impl Drop for EnvGuard {
236        fn drop(&mut self) {
237            env::remove_var(ENV_KEY);
238            env::remove_var(ENV_PASSWORD);
239        }
240    }
241
242    fn setup_manager() -> (KeyManager, TempDir) {
243        let temp_dir = TempDir::new().unwrap();
244        let manager = KeyManager::with_base_dir(temp_dir.path());
245        (manager, temp_dir)
246    }
247
248    #[test]
249    fn test_new_uses_default_dir() {
250        let manager = KeyManager::new();
251        let expected = zlayer_paths::ZLayerDirs::system_default().secrets();
252        assert_eq!(manager.base_dir, expected);
253    }
254
255    #[test]
256    fn test_with_base_dir() {
257        let manager = KeyManager::with_base_dir("/custom/path");
258        assert_eq!(manager.base_dir, PathBuf::from("/custom/path"));
259    }
260
261    #[test]
262    fn test_key_file_path() {
263        let dirs = zlayer_paths::ZLayerDirs::system_default();
264        let manager = KeyManager::with_base_dir(dirs.secrets());
265        let path = manager.key_file_path("production");
266        assert_eq!(path, dirs.secrets().join("secrets_production.key"));
267    }
268
269    // All tests that call get_or_create_key must be serial because that method
270    // reads environment variables which are process-global state.
271    #[test]
272    #[serial]
273    fn test_auto_generate_key() {
274        let _guard = EnvGuard::new();
275        let (manager, _temp) = setup_manager();
276
277        let key = manager.get_or_create_key("test-deployment").unwrap();
278        assert_eq!(key.as_bytes().len(), 32);
279
280        // Key file should exist
281        let key_path = manager.key_file_path("test-deployment");
282        assert!(key_path.exists());
283    }
284
285    #[test]
286    #[serial]
287    fn test_load_existing_key() {
288        let _guard = EnvGuard::new();
289        let (manager, _temp) = setup_manager();
290
291        // Generate key first
292        let key1 = manager.get_or_create_key("test-deployment").unwrap();
293
294        // Should load the same key
295        let key2 = manager.get_or_create_key("test-deployment").unwrap();
296
297        assert_eq!(key1.as_bytes(), key2.as_bytes());
298    }
299
300    #[test]
301    #[serial]
302    fn test_different_deployments_get_different_keys() {
303        let _guard = EnvGuard::new();
304        let (manager, _temp) = setup_manager();
305
306        let key1 = manager.get_or_create_key("deployment-a").unwrap();
307        let key2 = manager.get_or_create_key("deployment-b").unwrap();
308
309        assert_ne!(key1.as_bytes(), key2.as_bytes());
310    }
311
312    #[test]
313    #[serial]
314    fn test_env_key_takes_priority() {
315        let _guard = EnvGuard::new();
316        let (manager, _temp) = setup_manager();
317
318        // Create a known key
319        let known_key = [42u8; 32];
320        let hex_key = hex::encode(known_key);
321
322        // Set env var
323        env::set_var(ENV_KEY, &hex_key);
324
325        let key = manager.get_or_create_key("any-deployment").unwrap();
326        assert_eq!(key.as_bytes(), &known_key);
327    }
328
329    #[test]
330    #[serial]
331    fn test_env_password_takes_priority_over_file() {
332        let _guard = EnvGuard::new();
333        let (manager, _temp) = setup_manager();
334
335        // First generate a file-based key
336        let file_key = manager.get_or_create_key("test-deployment").unwrap();
337
338        // Set password env var
339        env::set_var(ENV_PASSWORD, "my-secret-password");
340
341        // Should now derive from password, not load from file
342        let password_key = manager.get_or_create_key("test-deployment").unwrap();
343        assert_ne!(file_key.as_bytes(), password_key.as_bytes());
344    }
345
346    #[test]
347    #[serial]
348    fn test_password_derivation_is_deterministic() {
349        let _guard = EnvGuard::new();
350        let (manager, _temp) = setup_manager();
351
352        env::set_var(ENV_PASSWORD, "test-password");
353
354        let key1 = manager.get_or_create_key("deployment").unwrap();
355        let key2 = manager.get_or_create_key("deployment").unwrap();
356
357        assert_eq!(key1.as_bytes(), key2.as_bytes());
358    }
359
360    #[test]
361    #[serial]
362    fn test_password_with_different_deployments() {
363        let _guard = EnvGuard::new();
364        let (manager, _temp) = setup_manager();
365
366        env::set_var(ENV_PASSWORD, "same-password");
367
368        // Same password but different deployments should produce different keys
369        let key1 = manager.get_or_create_key("deployment-a").unwrap();
370        let key2 = manager.get_or_create_key("deployment-b").unwrap();
371
372        assert_ne!(key1.as_bytes(), key2.as_bytes());
373    }
374
375    #[test]
376    #[serial]
377    fn test_invalid_hex_key_error() {
378        let _guard = EnvGuard::new();
379        let (manager, _temp) = setup_manager();
380
381        env::set_var(ENV_KEY, "not-valid-hex!!");
382
383        let result = manager.get_or_create_key("test");
384        assert!(result.is_err());
385    }
386
387    #[test]
388    #[serial]
389    fn test_hex_key_wrong_length_error() {
390        let _guard = EnvGuard::new();
391        let (manager, _temp) = setup_manager();
392
393        // Only 16 bytes (32 hex chars needed for 32 bytes)
394        env::set_var(ENV_KEY, "0011223344556677889900112233445566778899");
395
396        let result = manager.get_or_create_key("test");
397        assert!(result.is_err());
398    }
399
400    #[cfg(unix)]
401    #[test]
402    #[serial]
403    fn test_key_file_permissions() {
404        use std::os::unix::fs::PermissionsExt;
405
406        let _guard = EnvGuard::new();
407        let (manager, _temp) = setup_manager();
408
409        manager.get_or_create_key("secure-deployment").unwrap();
410
411        let key_path = manager.key_file_path("secure-deployment");
412        let metadata = fs::metadata(&key_path).unwrap();
413        let permissions = metadata.permissions();
414
415        // Should be 0600 (owner read/write only)
416        assert_eq!(permissions.mode() & 0o777, 0o600);
417    }
418}