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::sealed::{RecipientPrivateKey, RecipientPublicKey};
23use crate::{EncryptionKey, Result, SecretsError};
24
25/// File name used for the on-disk node X25519 keypair (raw 32-byte private key).
26const NODE_SECRETS_KEY_FILE: &str = "node_secrets.key";
27
28/// Path of the on-disk node X25519 keypair (raw 32-byte private key bytes,
29/// Unix mode 0600).
30#[must_use]
31pub fn node_secrets_key_path(base_dir: &Path) -> PathBuf {
32    base_dir.join(NODE_SECRETS_KEY_FILE)
33}
34
35/// Load the existing node keypair from `{base_dir}/node_secrets.key`, or
36/// generate a new one and persist it (Unix mode 0600) if the file does
37/// not exist yet.
38///
39/// Returns `(private, public)`. The private key is held in [`RecipientPrivateKey`]
40/// which zeroes itself on drop.
41///
42/// # Errors
43/// - [`SecretsError::Storage`] if the directory cannot be created, the file
44///   cannot be read/written, or the on-disk content is the wrong length
45///   (must be exactly 32 bytes).
46/// - [`SecretsError::Storage`] if file permissions cannot be set on Unix.
47pub fn load_or_generate_node_keypair(
48    base_dir: &Path,
49) -> std::result::Result<(RecipientPrivateKey, RecipientPublicKey), SecretsError> {
50    // 1. Ensure base_dir exists (mkdir -p semantics).
51    fs::create_dir_all(base_dir).map_err(|e| {
52        SecretsError::Storage(format!(
53            "Failed to create node key directory {}: {e}",
54            base_dir.display()
55        ))
56    })?;
57
58    let path = node_secrets_key_path(base_dir);
59
60    // 2. If the file exists, load it.
61    if path.exists() {
62        debug!("Loading node X25519 keypair from {}", path.display());
63        let buf = fs::read(&path).map_err(|e| {
64            SecretsError::Storage(format!(
65                "Failed to read node key file {}: {e}",
66                path.display()
67            ))
68        })?;
69
70        if buf.len() != 32 {
71            return Err(SecretsError::Storage(format!(
72                "node_secrets.key has wrong length: expected 32, got {}",
73                buf.len()
74            )));
75        }
76
77        let mut bytes = [0u8; 32];
78        bytes.copy_from_slice(&buf);
79        let private = RecipientPrivateKey::from_bytes(bytes);
80        let public = private.public_key();
81        return Ok((private, public));
82    }
83
84    // 3. Otherwise, generate a fresh keypair and persist it.
85    info!("Generating new node X25519 keypair at {}", path.display());
86    let (private, public) = RecipientPrivateKey::generate();
87
88    write_node_key_file(&path, &private)?;
89
90    Ok((private, public))
91}
92
93/// Persist the raw 32-byte private key to `path`, setting Unix mode 0600
94/// before any data is written so the key is never world-readable on disk.
95fn write_node_key_file(
96    path: &Path,
97    private: &RecipientPrivateKey,
98) -> std::result::Result<(), SecretsError> {
99    // RecipientPrivateKey holds raw 32 bytes internally but does not expose
100    // a direct byte accessor (intentional: the type is zeroize-on-drop and
101    // we want any disclosure to be explicit). Round-trip through its
102    // `to_base64` representation to recover the raw bytes for on-disk storage.
103    use base64::engine::general_purpose::STANDARD as B64;
104    use base64::Engine as _;
105    let raw = B64
106        .decode(private.to_base64())
107        .map_err(|e| SecretsError::Storage(format!("Failed to encode node private key: {e}")))?;
108    debug_assert_eq!(raw.len(), 32);
109
110    // On Unix, create the file with mode 0600 from the start so the key
111    // bytes are never written under a more permissive mode.
112    #[cfg(unix)]
113    {
114        use std::fs::OpenOptions;
115        use std::io::Write;
116        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
117
118        let mut file = OpenOptions::new()
119            .write(true)
120            .create(true)
121            .truncate(true)
122            .mode(0o600)
123            .open(path)
124            .map_err(|e| {
125                SecretsError::Storage(format!(
126                    "Failed to create node key file {}: {e}",
127                    path.display()
128                ))
129            })?;
130
131        file.write_all(&raw).map_err(|e| {
132            SecretsError::Storage(format!(
133                "Failed to write node key file {}: {e}",
134                path.display()
135            ))
136        })?;
137
138        // Belt-and-suspenders: explicitly set perms in case the umask or
139        // a pre-existing file overrode the open mode.
140        let permissions = fs::Permissions::from_mode(0o600);
141        if let Err(e) = fs::set_permissions(path, permissions) {
142            warn!(
143                "Failed to set permissions on node key file {}: {e}",
144                path.display()
145            );
146            return Err(SecretsError::Storage(format!(
147                "Failed to set permissions on node key file {}: {e}",
148                path.display()
149            )));
150        }
151    }
152
153    #[cfg(not(unix))]
154    {
155        fs::write(path, &raw).map_err(|e| {
156            SecretsError::Storage(format!(
157                "Failed to write node key file {}: {e}",
158                path.display()
159            ))
160        })?;
161    }
162
163    Ok(())
164}
165
166/// Environment variable name for hex-encoded encryption key.
167const ENV_KEY: &str = "ZLAYER_SECRETS_KEY";
168
169/// Environment variable name for password-based key derivation.
170const ENV_PASSWORD: &str = "ZLAYER_SECRETS_PASSWORD";
171
172/// Manages encryption keys for secret storage.
173///
174/// The `KeyManager` handles key discovery, generation, and persistence
175/// with automatic fallback through multiple sources.
176///
177/// # Example
178///
179/// ```no_run
180/// use zlayer_secrets::KeyManager;
181///
182/// let manager = KeyManager::new();
183/// let key = manager.get_or_create_key("production").unwrap();
184/// ```
185#[derive(Debug, Clone)]
186pub struct KeyManager {
187    base_dir: PathBuf,
188}
189
190impl Default for KeyManager {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl KeyManager {
197    /// Creates a new `KeyManager` with the default secrets directory.
198    ///
199    /// The default directory is determined by [`zlayer_paths::ZLayerDirs::system_default()`].
200    #[must_use]
201    pub fn new() -> Self {
202        Self {
203            base_dir: zlayer_paths::ZLayerDirs::system_default().secrets(),
204        }
205    }
206
207    /// Creates a new `KeyManager` with a custom base directory.
208    ///
209    /// # Arguments
210    /// * `base_dir` - Path to the directory for storing key files
211    #[must_use]
212    pub fn with_base_dir(base_dir: impl AsRef<Path>) -> Self {
213        Self {
214            base_dir: base_dir.as_ref().to_path_buf(),
215        }
216    }
217
218    /// Returns the path to the key file for a given deployment.
219    fn key_file_path(&self, deployment: &str) -> PathBuf {
220        self.base_dir.join(format!("secrets_{deployment}.key"))
221    }
222
223    /// Gets or creates an encryption key for the specified deployment.
224    ///
225    /// Attempts to obtain a key through the following priority chain:
226    ///
227    /// 1. **Environment key**: If `ZLAYER_SECRETS_KEY` is set, decodes the
228    ///    hex-encoded 32-byte key directly.
229    ///
230    /// 2. **Password derivation**: If `ZLAYER_SECRETS_PASSWORD` is set,
231    ///    derives a key using Argon2id with the deployment name as salt.
232    ///
233    /// 3. **File-based key**: Loads the key from the deployment's key file
234    ///    if it exists at `{base_dir}/secrets_{deployment}.key`.
235    ///
236    /// 4. **Auto-generation**: Generates a new random key and saves it to
237    ///    the key file with restricted permissions (0600 on Unix).
238    ///
239    /// # Arguments
240    /// * `deployment` - The deployment name used for key file naming and
241    ///   password salt derivation
242    ///
243    /// # Errors
244    ///
245    /// Returns `SecretsError::Encryption` if:
246    /// - The hex-encoded key in `ZLAYER_SECRETS_KEY` is invalid
247    /// - Key file I/O operations fail
248    /// - Password derivation fails
249    ///
250    /// # Example
251    ///
252    /// ```no_run
253    /// use zlayer_secrets::KeyManager;
254    ///
255    /// let manager = KeyManager::new();
256    ///
257    /// // First call: generates and saves key
258    /// let key = manager.get_or_create_key("production").unwrap();
259    ///
260    /// // Subsequent calls: loads from file
261    /// let same_key = manager.get_or_create_key("production").unwrap();
262    /// assert_eq!(key.as_bytes(), same_key.as_bytes());
263    /// ```
264    pub fn get_or_create_key(&self, deployment: &str) -> Result<EncryptionKey> {
265        // Priority 1: Check ZLAYER_SECRETS_KEY env var (hex-encoded)
266        if let Ok(hex_key) = std::env::var(ENV_KEY) {
267            debug!("Using encryption key from {ENV_KEY} environment variable");
268            return Self::key_from_hex(&hex_key);
269        }
270
271        // Priority 2: Check ZLAYER_SECRETS_PASSWORD env var, derive with Argon2
272        if let Ok(password) = std::env::var(ENV_PASSWORD) {
273            debug!("Deriving encryption key from {ENV_PASSWORD} environment variable");
274            return Self::key_from_password(&password, deployment);
275        }
276
277        // Priority 3: Load from file if exists
278        let key_path = self.key_file_path(deployment);
279        if key_path.exists() {
280            debug!("Loading encryption key from file: {}", key_path.display());
281            return Self::load_key_from_file(&key_path);
282        }
283
284        // Priority 4: Auto-generate and save to file
285        info!(
286            "Generating new encryption key for deployment '{}' at {}",
287            deployment,
288            key_path.display()
289        );
290        Self::generate_and_save_key(&key_path)
291    }
292
293    /// Decodes a hex-encoded key string into an [`EncryptionKey`].
294    fn key_from_hex(hex_key: &str) -> Result<EncryptionKey> {
295        let key_bytes = hex::decode(hex_key.trim()).map_err(|e| {
296            SecretsError::Encryption(format!("Invalid hex-encoded key in {ENV_KEY}: {e}"))
297        })?;
298
299        EncryptionKey::from_bytes(&key_bytes)
300    }
301
302    /// Derives an encryption key from a password using the deployment as salt.
303    fn key_from_password(password: &str, deployment: &str) -> Result<EncryptionKey> {
304        // Use deployment name as salt - this ensures different deployments
305        // with the same password get different keys
306        EncryptionKey::derive_from_password(password, deployment.as_bytes())
307    }
308
309    /// Loads an encryption key from a file containing raw key bytes.
310    fn load_key_from_file(path: &Path) -> Result<EncryptionKey> {
311        let key_bytes = fs::read(path).map_err(|e| {
312            SecretsError::Encryption(format!("Failed to read key file {}: {e}", path.display()))
313        })?;
314
315        EncryptionKey::from_bytes(&key_bytes)
316    }
317
318    /// Generates a new encryption key and saves it to a file.
319    ///
320    /// On Unix systems, sets file permissions to 0600 (owner read/write only).
321    fn generate_and_save_key(path: &Path) -> Result<EncryptionKey> {
322        // Ensure parent directory exists
323        if let Some(parent) = path.parent() {
324            fs::create_dir_all(parent).map_err(|e| {
325                SecretsError::Encryption(format!(
326                    "Failed to create key directory {}: {e}",
327                    parent.display()
328                ))
329            })?;
330        }
331
332        // Generate new key
333        let key = EncryptionKey::generate();
334
335        // Write key bytes to file
336        fs::write(path, key.as_bytes()).map_err(|e| {
337            SecretsError::Encryption(format!("Failed to write key file {}: {e}", path.display()))
338        })?;
339
340        // Set restrictive permissions on Unix
341        #[cfg(unix)]
342        {
343            use std::os::unix::fs::PermissionsExt;
344            let permissions = fs::Permissions::from_mode(0o600);
345            if let Err(e) = fs::set_permissions(path, permissions) {
346                warn!(
347                    "Failed to set permissions on key file {}: {e}",
348                    path.display()
349                );
350            }
351        }
352
353        info!("Created new encryption key at {}", path.display());
354        Ok(key)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use serial_test::serial;
362    use std::env;
363    use tempfile::TempDir;
364
365    /// Guard that clears environment variables on drop to ensure test isolation.
366    struct EnvGuard;
367
368    impl EnvGuard {
369        fn new() -> Self {
370            // Clear any existing env vars at test start
371            env::remove_var(ENV_KEY);
372            env::remove_var(ENV_PASSWORD);
373            Self
374        }
375    }
376
377    impl Drop for EnvGuard {
378        fn drop(&mut self) {
379            env::remove_var(ENV_KEY);
380            env::remove_var(ENV_PASSWORD);
381        }
382    }
383
384    fn setup_manager() -> (KeyManager, TempDir) {
385        let temp_dir = TempDir::new().unwrap();
386        let manager = KeyManager::with_base_dir(temp_dir.path());
387        (manager, temp_dir)
388    }
389
390    #[test]
391    fn test_new_uses_default_dir() {
392        let manager = KeyManager::new();
393        let expected = zlayer_paths::ZLayerDirs::system_default().secrets();
394        assert_eq!(manager.base_dir, expected);
395    }
396
397    #[test]
398    fn test_with_base_dir() {
399        let manager = KeyManager::with_base_dir("/custom/path");
400        assert_eq!(manager.base_dir, PathBuf::from("/custom/path"));
401    }
402
403    #[test]
404    fn test_key_file_path() {
405        let dirs = zlayer_paths::ZLayerDirs::system_default();
406        let manager = KeyManager::with_base_dir(dirs.secrets());
407        let path = manager.key_file_path("production");
408        assert_eq!(path, dirs.secrets().join("secrets_production.key"));
409    }
410
411    // All tests that call get_or_create_key must be serial because that method
412    // reads environment variables which are process-global state.
413    #[test]
414    #[serial]
415    fn test_auto_generate_key() {
416        let _guard = EnvGuard::new();
417        let (manager, _temp) = setup_manager();
418
419        let key = manager.get_or_create_key("test-deployment").unwrap();
420        assert_eq!(key.as_bytes().len(), 32);
421
422        // Key file should exist
423        let key_path = manager.key_file_path("test-deployment");
424        assert!(key_path.exists());
425    }
426
427    #[test]
428    #[serial]
429    fn test_load_existing_key() {
430        let _guard = EnvGuard::new();
431        let (manager, _temp) = setup_manager();
432
433        // Generate key first
434        let key1 = manager.get_or_create_key("test-deployment").unwrap();
435
436        // Should load the same key
437        let key2 = manager.get_or_create_key("test-deployment").unwrap();
438
439        assert_eq!(key1.as_bytes(), key2.as_bytes());
440    }
441
442    #[test]
443    #[serial]
444    fn test_different_deployments_get_different_keys() {
445        let _guard = EnvGuard::new();
446        let (manager, _temp) = setup_manager();
447
448        let key1 = manager.get_or_create_key("deployment-a").unwrap();
449        let key2 = manager.get_or_create_key("deployment-b").unwrap();
450
451        assert_ne!(key1.as_bytes(), key2.as_bytes());
452    }
453
454    #[test]
455    #[serial]
456    fn test_env_key_takes_priority() {
457        let _guard = EnvGuard::new();
458        let (manager, _temp) = setup_manager();
459
460        // Create a known key
461        let known_key = [42u8; 32];
462        let hex_key = hex::encode(known_key);
463
464        // Set env var
465        env::set_var(ENV_KEY, &hex_key);
466
467        let key = manager.get_or_create_key("any-deployment").unwrap();
468        assert_eq!(key.as_bytes(), &known_key);
469    }
470
471    #[test]
472    #[serial]
473    fn test_env_password_takes_priority_over_file() {
474        let _guard = EnvGuard::new();
475        let (manager, _temp) = setup_manager();
476
477        // First generate a file-based key
478        let file_key = manager.get_or_create_key("test-deployment").unwrap();
479
480        // Set password env var
481        env::set_var(ENV_PASSWORD, "my-secret-password");
482
483        // Should now derive from password, not load from file
484        let password_key = manager.get_or_create_key("test-deployment").unwrap();
485        assert_ne!(file_key.as_bytes(), password_key.as_bytes());
486    }
487
488    #[test]
489    #[serial]
490    fn test_password_derivation_is_deterministic() {
491        let _guard = EnvGuard::new();
492        let (manager, _temp) = setup_manager();
493
494        env::set_var(ENV_PASSWORD, "test-password");
495
496        let key1 = manager.get_or_create_key("deployment").unwrap();
497        let key2 = manager.get_or_create_key("deployment").unwrap();
498
499        assert_eq!(key1.as_bytes(), key2.as_bytes());
500    }
501
502    #[test]
503    #[serial]
504    fn test_password_with_different_deployments() {
505        let _guard = EnvGuard::new();
506        let (manager, _temp) = setup_manager();
507
508        env::set_var(ENV_PASSWORD, "same-password");
509
510        // Same password but different deployments should produce different keys
511        let key1 = manager.get_or_create_key("deployment-a").unwrap();
512        let key2 = manager.get_or_create_key("deployment-b").unwrap();
513
514        assert_ne!(key1.as_bytes(), key2.as_bytes());
515    }
516
517    #[test]
518    #[serial]
519    fn test_invalid_hex_key_error() {
520        let _guard = EnvGuard::new();
521        let (manager, _temp) = setup_manager();
522
523        env::set_var(ENV_KEY, "not-valid-hex!!");
524
525        let result = manager.get_or_create_key("test");
526        assert!(result.is_err());
527    }
528
529    #[test]
530    #[serial]
531    fn test_hex_key_wrong_length_error() {
532        let _guard = EnvGuard::new();
533        let (manager, _temp) = setup_manager();
534
535        // Only 16 bytes (32 hex chars needed for 32 bytes)
536        env::set_var(ENV_KEY, "0011223344556677889900112233445566778899");
537
538        let result = manager.get_or_create_key("test");
539        assert!(result.is_err());
540    }
541
542    #[cfg(unix)]
543    #[test]
544    #[serial]
545    fn test_key_file_permissions() {
546        use std::os::unix::fs::PermissionsExt;
547
548        let _guard = EnvGuard::new();
549        let (manager, _temp) = setup_manager();
550
551        manager.get_or_create_key("secure-deployment").unwrap();
552
553        let key_path = manager.key_file_path("secure-deployment");
554        let metadata = fs::metadata(&key_path).unwrap();
555        let permissions = metadata.permissions();
556
557        // Should be 0600 (owner read/write only)
558        assert_eq!(permissions.mode() & 0o777, 0o600);
559    }
560
561    // -------------------------------------------------------------------
562    // Node X25519 keypair persistence tests.
563    // -------------------------------------------------------------------
564
565    #[test]
566    fn node_keypair_round_trip_generate_then_load() {
567        let temp = TempDir::new().unwrap();
568
569        let (_priv1, pub1) = load_or_generate_node_keypair(temp.path()).unwrap();
570        let (_priv2, pub2) = load_or_generate_node_keypair(temp.path()).unwrap();
571
572        // Second call must load the same key from disk, not generate a new one.
573        assert_eq!(pub1, pub2);
574
575        // And the file must exist at the documented path.
576        assert!(node_secrets_key_path(temp.path()).exists());
577    }
578
579    #[cfg(unix)]
580    #[test]
581    fn node_keypair_perms_0600_on_unix() {
582        use std::os::unix::fs::PermissionsExt;
583
584        let temp = TempDir::new().unwrap();
585        let _ = load_or_generate_node_keypair(temp.path()).unwrap();
586
587        let path = node_secrets_key_path(temp.path());
588        let mode = fs::metadata(&path).unwrap().permissions().mode();
589        assert_eq!(mode & 0o777, 0o600, "expected mode 0600, got {mode:o}");
590    }
591
592    #[test]
593    fn node_keypair_rejects_wrong_length() {
594        let temp = TempDir::new().unwrap();
595        let path = node_secrets_key_path(temp.path());
596
597        // Pre-create a 16-byte file at the keypair path.
598        fs::create_dir_all(temp.path()).unwrap();
599        fs::write(&path, [0u8; 16]).unwrap();
600
601        // `RecipientPrivateKey` does not implement `Debug` (intentional —
602        // see `sealed.rs`), so we can't `.unwrap_err()` on the tuple result.
603        // Match it manually instead.
604        let result = load_or_generate_node_keypair(temp.path());
605        match result {
606            Ok(_) => panic!("expected SecretsError::Storage, got Ok(_)"),
607            Err(SecretsError::Storage(msg)) => {
608                assert!(
609                    msg.contains("length") || msg.contains("expected 32"),
610                    "expected length error message, got: {msg}"
611                );
612            }
613            Err(other) => panic!("expected SecretsError::Storage, got {other:?}"),
614        }
615    }
616
617    #[test]
618    fn node_keypair_pubkey_matches_private() {
619        let temp = TempDir::new().unwrap();
620        let (private, public) = load_or_generate_node_keypair(temp.path()).unwrap();
621
622        // Re-derive the public key from the private key and confirm it
623        // matches the public key returned by `load_or_generate_node_keypair`.
624        let derived = private.public_key();
625        assert_eq!(derived, public);
626    }
627}