Skip to main content

dotenvage/
manager.rs

1//! Secret manager implementation for encryption and decryption using age.
2//!
3//! This module provides the core [`SecretManager`] type for encrypting and
4//! decrypting sensitive values using the
5//! [age encryption tool](https://age-encryption.org/).
6//!
7//! It also provides types for managing key storage across user-level and
8//! system-level credential stores, enabling daemon processes to access
9//! encryption keys without embedded secrets.
10
11use std::io::{
12    Read,
13    Write,
14};
15use std::path::{
16    Path,
17    PathBuf,
18};
19
20use age::secrecy::ExposeSecret;
21use age::x25519;
22use base64::Engine as _;
23
24use crate::error::{
25    SecretsError,
26    SecretsResult,
27};
28
29/// Target credential store for key operations.
30///
31/// Controls where keys are saved and loaded from. Used with
32/// [`SecretManager::generate_and_save`] and related methods.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum KeyStoreTarget {
35    /// User-level OS credential store:
36    /// - macOS: Login Keychain
37    /// - Linux: kernel keyutils
38    /// - Windows: Credential Manager
39    OsKeychain,
40    /// System-level store for daemon processes:
41    /// - macOS: System Keychain (`/Library/Keychains/System.keychain`)
42    /// - Linux: `/etc/dotenvage/<key-name>.key`
43    /// - Windows: `%ProgramData%\dotenvage\<key-name>.key`
44    ///
45    /// Requires elevated privileges (sudo/admin) to write.
46    SystemStore,
47    /// Key file on disk at the XDG-compliant path.
48    File,
49    /// Both user-level OS keychain and file.
50    OsKeychainAndFile,
51}
52
53/// Describes where a key was saved.
54#[derive(Debug, Clone)]
55pub enum KeyLocation {
56    /// Saved to the user-level OS keychain.
57    OsKeychain {
58        /// The service name used for the keychain entry.
59        service: String,
60        /// The account name used for the keychain entry.
61        account: String,
62    },
63    /// Saved to the macOS System Keychain.
64    SystemKeychain {
65        /// The service name used for the keychain entry.
66        service: String,
67        /// The account name used for the keychain entry.
68        account: String,
69    },
70    /// Saved to a system-level protected file (Linux/Windows).
71    SystemFile(PathBuf),
72    /// Saved to a user-level key file.
73    UserFile(PathBuf),
74}
75
76/// Options for key generation via [`SecretManager::generate_and_save`].
77#[derive(Debug, Clone)]
78pub struct KeyGenOptions {
79    /// Where to save the generated key.
80    pub target: KeyStoreTarget,
81    /// Explicit key name (overrides `AGE_KEY_NAME` and `.env`
82    /// file discovery). Example: `"ekg/wwkg"`.
83    pub key_name: Option<String>,
84    /// Explicit file path (overrides XDG path derivation).
85    /// Only used when target includes [`KeyStoreTarget::File`].
86    pub file_path: Option<PathBuf>,
87    /// Overwrite existing key if present.
88    pub force: bool,
89}
90
91/// Result of a key generation operation.
92pub struct KeyGenResult {
93    /// The manager holding the generated key.
94    pub manager: SecretManager,
95    /// Where the key was persisted.
96    pub locations: Vec<KeyLocation>,
97    /// Public key string (`age1...`).
98    pub public_key: String,
99}
100
101impl std::fmt::Debug for KeyGenResult {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("KeyGenResult")
104            .field("locations", &self.locations)
105            .field("public_key", &self.public_key)
106            .finish_non_exhaustive()
107    }
108}
109
110/// Manages encryption and decryption of secrets using age/X25519.
111///
112/// `SecretManager` provides a simple interface for encrypting and decrypting
113/// sensitive values. It uses the age encryption format with X25519 keys.
114///
115/// Encrypted values are stored in the compact format: `ENC[AGE:b64:...]`
116///
117/// # Examples
118///
119/// ```rust
120/// use dotenvage::SecretManager;
121///
122/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
123/// // Generate a new key
124/// let manager = SecretManager::generate()?;
125///
126/// // Encrypt a value
127/// let encrypted = manager.encrypt_value("my-secret-token")?;
128/// assert!(SecretManager::is_encrypted(&encrypted));
129///
130/// // Decrypt it back
131/// let decrypted = manager.decrypt_value(&encrypted)?;
132/// assert_eq!(decrypted, "my-secret-token");
133/// # Ok(())
134/// # }
135/// ```
136#[derive(Clone)]
137pub struct SecretManager {
138    identity: x25519::Identity,
139}
140
141trait KeyBackend {
142    fn load_identity_string(&self) -> SecretsResult<Option<String>>;
143    fn save_identity_string(&self, _identity: &str) -> SecretsResult<()> {
144        Err(SecretsError::KeySaveFailed(
145            "save operation not implemented for this backend".to_string(),
146        ))
147    }
148}
149
150struct FileKeyBackend {
151    path: PathBuf,
152}
153
154impl FileKeyBackend {
155    fn new(path: PathBuf) -> Self {
156        Self { path }
157    }
158}
159
160impl KeyBackend for FileKeyBackend {
161    fn load_identity_string(&self) -> SecretsResult<Option<String>> {
162        if !self.path.exists() {
163            return Ok(None);
164        }
165
166        let key_data = std::fs::read_to_string(&self.path).map_err(|e| {
167            SecretsError::KeyLoadFailed(format!("read {}: {}", self.path.display(), e))
168        })?;
169        Ok(Some(key_data))
170    }
171
172    fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
173        if let Some(parent) = self.path.parent() {
174            std::fs::create_dir_all(parent).map_err(|e| {
175                SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
176            })?;
177        }
178
179        std::fs::write(&self.path, identity.as_bytes()).map_err(|e| {
180            SecretsError::KeySaveFailed(format!("write {}: {}", self.path.display(), e))
181        })?;
182
183        #[cfg(unix)]
184        {
185            use std::os::unix::fs::PermissionsExt;
186            let mut perms = std::fs::metadata(&self.path)
187                .map_err(|e| {
188                    SecretsError::KeySaveFailed(format!("metadata {}: {}", self.path.display(), e))
189                })?
190                .permissions();
191            perms.set_mode(0o600);
192            std::fs::set_permissions(&self.path, perms).map_err(|e| {
193                SecretsError::KeySaveFailed(format!("chmod {}: {}", self.path.display(), e))
194            })?;
195        }
196
197        Ok(())
198    }
199}
200
201struct OsKeychainBackend {
202    service: String,
203    account: String,
204}
205
206impl OsKeychainBackend {
207    fn new(service: String, account: String) -> Self {
208        Self { service, account }
209    }
210}
211
212impl KeyBackend for OsKeychainBackend {
213    fn load_identity_string(&self) -> SecretsResult<Option<String>> {
214        load_from_os_keychain(&self.service, &self.account)
215    }
216
217    fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
218        save_to_os_keychain(&self.service, &self.account, identity)
219    }
220}
221
222fn normalize_key_data(data: &str) -> Option<String> {
223    let trimmed = data.trim();
224    if trimmed.is_empty() {
225        return None;
226    }
227    Some(trimmed.to_string())
228}
229
230#[cfg(feature = "os-keychain")]
231fn ensure_default_store() -> Result<(), String> {
232    use std::sync::OnceLock;
233    static INIT: OnceLock<Result<(), String>> = OnceLock::new();
234    INIT.get_or_init(|| {
235        #[cfg(target_os = "macos")]
236        {
237            let store = apple_native_keyring_store::keychain::Store::new()
238                .map_err(|e| format!("failed to init macOS keychain store: {e}"))?;
239            keyring_core::set_default_store(store);
240            Ok(())
241        }
242        #[cfg(target_os = "linux")]
243        {
244            let store = linux_keyutils_keyring_store::Store::new()
245                .map_err(|e| format!("failed to init linux keyutils store: {e}"))?;
246            keyring_core::set_default_store(store);
247            Ok(())
248        }
249        #[cfg(target_os = "windows")]
250        {
251            let store = windows_native_keyring_store::Store::new()
252                .map_err(|e| format!("failed to init windows credential store: {e}"))?;
253            keyring_core::set_default_store(store);
254            Ok(())
255        }
256        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
257        {
258            Err("no OS keychain backend available for this platform".to_string())
259        }
260    })
261    .clone()
262}
263
264#[cfg(feature = "os-keychain")]
265fn load_from_os_keychain(service: &str, account: &str) -> SecretsResult<Option<String>> {
266    if ensure_default_store().is_err() {
267        return Ok(None);
268    }
269    let entry = match keyring_core::Entry::new(service, account) {
270        Ok(e) => e,
271        Err(_) => return Ok(None),
272    };
273    match entry.get_password() {
274        Ok(password) => Ok(normalize_key_data(&password)),
275        Err(keyring_core::Error::NoEntry) => Ok(None),
276        Err(keyring_core::Error::PlatformFailure(_)) => Ok(None),
277        Err(e) => Err(SecretsError::KeyLoadFailed(format!(
278            "OS keychain read failed (service='{}', account='{}'): {}",
279            service, account, e
280        ))),
281    }
282}
283
284#[cfg(not(feature = "os-keychain"))]
285fn load_from_os_keychain(_service: &str, _account: &str) -> SecretsResult<Option<String>> {
286    Ok(None)
287}
288
289#[cfg(feature = "os-keychain")]
290fn save_to_os_keychain(service: &str, account: &str, identity: &str) -> SecretsResult<()> {
291    ensure_default_store().map_err(SecretsError::KeySaveFailed)?;
292    let entry = keyring_core::Entry::new(service, account).map_err(|e| {
293        SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
294    })?;
295    entry.set_password(identity).map_err(|e| {
296        SecretsError::KeySaveFailed(format!(
297            "failed to save to OS keychain (service='{}', account='{}'): {}",
298            service, account, e
299        ))
300    })
301}
302
303#[cfg(not(feature = "os-keychain"))]
304fn save_to_os_keychain(_service: &str, _account: &str, _identity: &str) -> SecretsResult<()> {
305    Err(SecretsError::KeySaveFailed(
306        "OS keychain support not compiled (enable 'os-keychain' feature)".to_string(),
307    ))
308}
309
310#[cfg(feature = "os-keychain")]
311fn delete_from_os_keychain(service: &str, account: &str) -> SecretsResult<()> {
312    ensure_default_store().map_err(SecretsError::KeySaveFailed)?;
313    let entry = keyring_core::Entry::new(service, account).map_err(|e| {
314        SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
315    })?;
316    match entry.delete_credential() {
317        Ok(()) => Ok(()),
318        Err(keyring_core::Error::NoEntry) => Ok(()),
319        Err(e) => Err(SecretsError::KeySaveFailed(format!(
320            "failed to delete from OS keychain (service='{}', account='{}'): {}",
321            service, account, e
322        ))),
323    }
324}
325
326// ── System store backend ─────────────────────────────────────
327
328struct SystemStoreBackend {
329    key_name: String,
330}
331
332impl SystemStoreBackend {
333    fn new(key_name: String) -> Self {
334        Self { key_name }
335    }
336
337    #[allow(dead_code)]
338    fn path(&self) -> PathBuf {
339        system_store_path_for(&self.key_name)
340    }
341}
342
343impl KeyBackend for SystemStoreBackend {
344    fn load_identity_string(&self) -> SecretsResult<Option<String>> {
345        load_from_system_store_impl(&self.key_name)
346    }
347
348    fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
349        save_to_system_store_impl(&self.key_name, identity)
350    }
351}
352
353/// Returns the system store path for a given key name.
354///
355/// When `DOTENVAGE_SYSTEM_STORE_DIR` is set, uses that directory
356/// instead of the platform default. This lets daemon processes
357/// store keys alongside their other configuration (e.g.
358/// `/etc/myapp/` instead of `/etc/dotenvage/`).
359///
360/// Default directories:
361/// - Unix (macOS/Linux): `/etc/dotenvage/<key-name>.key`
362/// - Windows: `%ProgramData%\dotenvage\<key-name>.key`
363fn system_store_path_for(_key_name: &str) -> PathBuf {
364    if let Ok(dir) = std::env::var("DOTENVAGE_SYSTEM_STORE_DIR")
365        && !dir.is_empty()
366    {
367        return PathBuf::from(dir).join(format!("{}.key", _key_name));
368    }
369
370    #[cfg(unix)]
371    {
372        PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
373    }
374
375    #[cfg(target_os = "windows")]
376    {
377        let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string());
378        PathBuf::from(base)
379            .join("dotenvage")
380            .join(format!("{}.key", _key_name))
381    }
382
383    #[cfg(not(any(unix, target_os = "windows")))]
384    {
385        PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
386    }
387}
388
389fn load_from_system_store_impl(key_name: &str) -> SecretsResult<Option<String>> {
390    // Try the System Keychain first on macOS (interactive users).
391    #[cfg(target_os = "macos")]
392    if let Some(data) = load_from_macos_system_keychain(key_name)? {
393        return Ok(Some(data));
394    }
395
396    // Fall back to the file-based system store on all platforms.
397    // On macOS this serves daemon processes that cannot access the
398    // System Keychain due to ACL restrictions.
399    let path = system_store_path_for(key_name);
400    if !path.exists() {
401        return Ok(None);
402    }
403    let data = std::fs::read_to_string(&path)
404        .map_err(|e| SecretsError::KeyLoadFailed(format!("read {}: {}", path.display(), e)))?;
405    Ok(normalize_key_data(&data))
406}
407
408fn save_to_system_store_impl(key_name: &str, identity: &str) -> SecretsResult<()> {
409    #[cfg(target_os = "macos")]
410    {
411        save_to_macos_system_keychain(key_name, identity)
412    }
413
414    #[cfg(not(target_os = "macos"))]
415    {
416        let path = system_store_path_for(key_name);
417        if let Some(parent) = path.parent() {
418            std::fs::create_dir_all(parent).map_err(|e| {
419                if e.kind() == std::io::ErrorKind::PermissionDenied {
420                    return SecretsError::InsufficientPrivileges(format!(
421                        "cannot create {}: {} (try with sudo/admin)",
422                        parent.display(),
423                        e
424                    ));
425                }
426                SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
427            })?;
428        }
429        std::fs::write(&path, identity.as_bytes()).map_err(|e| {
430            if e.kind() == std::io::ErrorKind::PermissionDenied {
431                return SecretsError::InsufficientPrivileges(format!(
432                    "cannot write {}: {} (try with sudo/admin)",
433                    path.display(),
434                    e
435                ));
436            }
437            SecretsError::KeySaveFailed(format!("write {}: {}", path.display(), e))
438        })?;
439
440        #[cfg(unix)]
441        {
442            use std::os::unix::fs::PermissionsExt;
443            let mut perms = std::fs::metadata(&path)
444                .map_err(|e| {
445                    SecretsError::KeySaveFailed(format!("metadata {}: {}", path.display(), e))
446                })?
447                .permissions();
448            perms.set_mode(0o600);
449            std::fs::set_permissions(&path, perms).map_err(|e| {
450                SecretsError::KeySaveFailed(format!("chmod {}: {}", path.display(), e))
451            })?;
452        }
453
454        Ok(())
455    }
456}
457
458/// Resolve the home directory for a given username.
459fn resolve_user_home(username: &str) -> SecretsResult<PathBuf> {
460    #[cfg(unix)]
461    {
462        use nix::unistd::User;
463
464        let user = User::from_name(username).map_err(|e| {
465            SecretsError::KeyLoadFailed(format!("failed to look up user '{}': {}", username, e))
466        })?;
467        match user {
468            Some(u) => Ok(u.dir),
469            None => Err(SecretsError::KeyLoadFailed(format!(
470                "user '{}' not found",
471                username
472            ))),
473        }
474    }
475
476    #[cfg(windows)]
477    {
478        // On Windows, user profiles are at C:\Users\<username>.
479        let drive = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string());
480        Ok(PathBuf::from(drive).join("Users").join(username))
481    }
482
483    #[cfg(not(any(unix, windows)))]
484    {
485        let _ = username;
486        Err(SecretsError::KeyLoadFailed(
487            "resolve_user_home not supported on this platform".to_string(),
488        ))
489    }
490}
491
492#[cfg(target_os = "macos")]
493fn load_from_macos_system_keychain(key_name: &str) -> SecretsResult<Option<String>> {
494    use security_framework::os::macos::keychain::SecKeychain;
495
496    let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
497        .map_err(|e| SecretsError::KeyLoadFailed(format!("cannot open System Keychain: {}", e)))?;
498
499    let service = SecretManager::keychain_service_name();
500    match keychain.find_generic_password(&service, key_name) {
501        Ok((password, _item)) => {
502            let data = String::from_utf8(password.as_ref().to_vec()).map_err(|e| {
503                SecretsError::KeyLoadFailed(format!("invalid keychain data: {}", e))
504            })?;
505            Ok(normalize_key_data(&data))
506        }
507        // errSecItemNotFound = -25300
508        Err(e) if e.code() == -25300 => Ok(None),
509        Err(_) => Ok(None), // Keychain inaccessible (locked, permissions)
510    }
511}
512
513#[cfg(target_os = "macos")]
514fn save_to_macos_system_keychain(key_name: &str, identity: &str) -> SecretsResult<()> {
515    use security_framework::os::macos::keychain::SecKeychain;
516
517    let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
518        .map_err(|e| SecretsError::KeySaveFailed(format!("cannot open System Keychain: {e}")))?;
519
520    let service = SecretManager::keychain_service_name();
521    keychain
522        .set_generic_password(&service, key_name, identity.as_bytes())
523        .map_err(|e| {
524            let msg = e.to_string();
525            if msg.contains("Authorization") || msg.contains("permission") || e.code() == -25293 {
526                return SecretsError::InsufficientPrivileges(format!(
527                    "cannot write to System Keychain \
528                     (try with sudo): {msg}"
529                ));
530            }
531            SecretsError::KeySaveFailed(format!(
532                "failed to save to macOS System Keychain \
533                 (service='{service}', account='{key_name}'): {msg}"
534            ))
535        })
536}
537
538/// Dotenvage configuration variables discovered from a `.env`
539/// file before key loading.
540struct DotenvageVars {
541    /// `AGE_KEY_NAME` or `*_AGE_KEY_NAME` value.
542    age_key_name: Option<String>,
543    /// `DOTENVAGE_SYSTEM_STORE_DIR` value.
544    system_store_dir: Option<String>,
545}
546
547impl SecretManager {
548    /// Creates a new `SecretManager` by loading the key from standard
549    /// locations.
550    ///
551    /// # Key Loading Order
552    ///
553    /// 0. **Auto-discover** `AGE_KEY_NAME` from `.env` or `.env.local` files
554    ///    (looks for `AGE_KEY_NAME` or `*_AGE_KEY_NAME`)
555    /// 1. `DOTENVAGE_AGE_KEY` environment variable (full identity string)
556    /// 2. `AGE_KEY` environment variable (for compatibility)
557    /// 3. `EKG_AGE_KEY` environment variable (for EKG project compatibility)
558    /// 4. OS keychain entry using:
559    ///    - Service: `DOTENVAGE_KEYCHAIN_SERVICE` or `dotenvage`
560    ///    - Account: `AGE_KEY_NAME` or `{CARGO_PKG_NAME}/dotenvage`
561    /// 5. Key file at path determined by `AGE_KEY_NAME` (e.g.,
562    ///    `~/.local/state/ekg/myproject.key` if `AGE_KEY_NAME=ekg/myproject`)
563    /// 6. Default key file: `~/.local/state/{CARGO_PKG_NAME}/dotenvage.key`
564    ///
565    /// # Errors
566    ///
567    /// Returns an error if no key can be found or if the key is invalid.
568    ///
569    /// # Examples
570    ///
571    /// ```rust,no_run
572    /// use dotenvage::SecretManager;
573    ///
574    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
575    /// let manager = SecretManager::new()?;
576    /// # Ok(())
577    /// # }
578    /// ```
579    pub fn new() -> SecretsResult<Self> {
580        Self::load_key()
581    }
582
583    /// Generates a new random identity.
584    ///
585    /// Use this when creating a new encryption key. You'll typically want to
586    /// save this key using [`save_key`](Self::save_key) or
587    /// [`save_key_to_default`](Self::save_key_to_default).
588    ///
589    /// # Errors
590    ///
591    /// This function always succeeds and returns `Ok`.
592    ///
593    /// # Examples
594    ///
595    /// ```rust
596    /// use dotenvage::SecretManager;
597    ///
598    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
599    /// let manager = SecretManager::generate()?;
600    /// println!("Public key: {}", manager.public_key_string());
601    /// # Ok(())
602    /// # }
603    /// ```
604    pub fn generate() -> SecretsResult<Self> {
605        Ok(Self {
606            identity: x25519::Identity::generate(),
607        })
608    }
609
610    /// Creates a `SecretManager` from an existing identity.
611    ///
612    /// Use this when you have an age X25519 identity that you want to use
613    /// directly.
614    pub fn from_identity(identity: x25519::Identity) -> Self {
615        Self { identity }
616    }
617
618    /// Creates a `SecretManager` from an age X25519 identity string.
619    ///
620    /// This constructor keeps the concrete `age` identity type inside
621    /// dotenvage, so callers that persist or receive an identity as text do
622    /// not need to depend on the same `age` crate version.
623    ///
624    /// # Errors
625    ///
626    /// Returns an error when `identity` is not a valid age X25519 identity.
627    ///
628    /// # Examples
629    ///
630    /// ```rust
631    /// use dotenvage::SecretManager;
632    ///
633    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
634    /// let generated = SecretManager::generate()?;
635    /// let loaded = SecretManager::from_identity_string(&generated.identity_string())?;
636    /// assert_eq!(loaded.public_key_string(), generated.public_key_string());
637    /// # Ok(())
638    /// # }
639    /// ```
640    pub fn from_identity_string(identity: &str) -> SecretsResult<Self> {
641        Self::load_from_string(identity)
642    }
643
644    /// Gets the public key (recipient) corresponding to this identity.
645    ///
646    /// The public key can be shared with others who want to encrypt values
647    /// that only you can decrypt.
648    pub fn public_key(&self) -> x25519::Recipient {
649        self.identity.to_public()
650    }
651
652    /// Gets the public key as a string in age format (starts with `age1`).
653    ///
654    /// # Examples
655    ///
656    /// ```rust
657    /// use dotenvage::SecretManager;
658    ///
659    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
660    /// let manager = SecretManager::generate()?;
661    /// let public_key = manager.public_key_string();
662    /// assert!(public_key.starts_with("age1"));
663    /// # Ok(())
664    /// # }
665    /// ```
666    pub fn public_key_string(&self) -> String {
667        self.public_key().to_string()
668    }
669
670    /// Encrypts a plaintext value and wraps it in the format
671    /// `ENC[AGE:b64:...]`.
672    ///
673    /// The encrypted value can be safely stored in `.env` files and version
674    /// control.
675    ///
676    /// # Errors
677    ///
678    /// Returns an error if encryption fails.
679    ///
680    /// # Examples
681    ///
682    /// ```rust
683    /// use dotenvage::SecretManager;
684    ///
685    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
686    /// let manager = SecretManager::generate()?;
687    /// let encrypted = manager.encrypt_value("sk_live_abc123")?;
688    /// assert!(encrypted.starts_with("ENC[AGE:b64:"));
689    /// # Ok(())
690    /// # }
691    /// ```
692    pub fn encrypt_value(&self, plaintext: &str) -> SecretsResult<String> {
693        let recipient = self.public_key();
694        let recipients: Vec<&dyn age::Recipient> = vec![&recipient];
695        let encryptor = age::Encryptor::with_recipients(recipients.into_iter())
696            .map_err(|e: age::EncryptError| SecretsError::EncryptionFailed(e.to_string()))?;
697
698        let mut encrypted = Vec::new();
699        let mut writer = encryptor
700            .wrap_output(&mut encrypted)
701            .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
702        writer
703            .write_all(plaintext.as_bytes())
704            .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
705        writer
706            .finish()
707            .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
708
709        let b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
710        Ok(format!("ENC[AGE:b64:{}]", b64))
711    }
712
713    /// Decrypts a value if it's encrypted; otherwise returns it unchanged.
714    ///
715    /// This method automatically detects whether a value is encrypted by
716    /// checking for the `ENC[AGE:b64:...]` prefix or the legacy armor
717    /// format. If the value is not encrypted, it's returned as-is.
718    ///
719    /// # Supported Formats
720    ///
721    /// - Compact: `ENC[AGE:b64:...]` (recommended)
722    /// - Legacy: `-----BEGIN AGE ENCRYPTED FILE-----`
723    ///
724    /// # Errors
725    ///
726    /// Returns an error if the value is encrypted but decryption fails
727    /// (e.g., wrong key, corrupted data).
728    ///
729    /// # Examples
730    ///
731    /// ```rust
732    /// use dotenvage::SecretManager;
733    ///
734    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
735    /// let manager = SecretManager::generate()?;
736    ///
737    /// // Decrypt an encrypted value
738    /// let encrypted = manager.encrypt_value("secret")?;
739    /// let decrypted = manager.decrypt_value(&encrypted)?;
740    /// assert_eq!(decrypted, "secret");
741    ///
742    /// // Pass through unencrypted values
743    /// let plain = manager.decrypt_value("not-encrypted")?;
744    /// assert_eq!(plain, "not-encrypted");
745    /// # Ok(())
746    /// # }
747    /// ```
748    pub fn decrypt_value(&self, value: &str) -> SecretsResult<String> {
749        let trimmed = value.trim();
750
751        // Compact format: ENC[AGE:b64:...]
752        if let Some(inner) = trimmed
753            .strip_prefix("ENC[AGE:b64:")
754            .and_then(|s| s.strip_suffix(']'))
755        {
756            let encrypted = base64::engine::general_purpose::STANDARD
757                .decode(inner)
758                .map_err(|e| SecretsError::DecryptionFailed(format!("invalid base64: {}", e)))?;
759
760            let decryptor = age::Decryptor::new(&encrypted[..])
761                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
762            let identities: Vec<&dyn age::Identity> = vec![&self.identity];
763            let mut reader = decryptor
764                .decrypt(identities.into_iter())
765                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
766
767            let mut decrypted = Vec::new();
768            reader
769                .read_to_end(&mut decrypted)
770                .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
771            return String::from_utf8(decrypted)
772                .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
773        }
774
775        // Legacy armor format
776        if trimmed.starts_with("-----BEGIN AGE ENCRYPTED FILE-----") {
777            let armor_reader = age::armor::ArmoredReader::new(trimmed.as_bytes());
778            let decryptor = age::Decryptor::new(armor_reader)
779                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
780            let identities: Vec<&dyn age::Identity> = vec![&self.identity];
781            let mut reader = decryptor
782                .decrypt(identities.into_iter())
783                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
784
785            let mut decrypted = Vec::new();
786            reader
787                .read_to_end(&mut decrypted)
788                .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
789            return String::from_utf8(decrypted)
790                .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
791        }
792
793        Ok(value.to_string())
794    }
795
796    /// Checks if a value is in a recognized encrypted format.
797    ///
798    /// Returns `true` if the value starts with `ENC[AGE:b64:` or the legacy
799    /// age armor format.
800    ///
801    /// # Examples
802    ///
803    /// ```rust
804    /// use dotenvage::SecretManager;
805    ///
806    /// assert!(SecretManager::is_encrypted(
807    ///     "ENC[AGE:b64:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+...]"
808    /// ));
809    /// assert!(!SecretManager::is_encrypted("plaintext"));
810    /// ```
811    pub fn is_encrypted(value: &str) -> bool {
812        let t = value.trim();
813        t.starts_with("ENC[AGE:b64:") || t.starts_with("-----BEGIN AGE ENCRYPTED FILE-----")
814    }
815
816    /// Saves the private identity to a file with restricted permissions.
817    ///
818    /// On Unix systems, the file permissions are set to `0o600` (readable and
819    /// writable only by the owner).
820    ///
821    /// # Errors
822    ///
823    /// Returns an error if the file cannot be created or written.
824    ///
825    /// # Examples
826    ///
827    /// ```rust,no_run
828    /// use dotenvage::SecretManager;
829    ///
830    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
831    /// let manager = SecretManager::generate()?;
832    /// manager.save_key("my-key.txt")?;
833    /// # Ok(())
834    /// # }
835    /// ```
836    pub fn save_key(&self, path: impl AsRef<Path>) -> SecretsResult<()> {
837        let backend = FileKeyBackend::new(path.as_ref().to_path_buf());
838        backend.save_identity_string(&self.identity_string())
839    }
840
841    /// Saves the key to the default path and returns that path.
842    ///
843    /// The default path is typically `~/.local/state/dotenvage/dotenvage.key`
844    /// on Unix systems.
845    ///
846    /// # Errors
847    ///
848    /// Returns an error if the file cannot be created or written.
849    ///
850    /// # Examples
851    ///
852    /// ```rust,no_run
853    /// use dotenvage::SecretManager;
854    ///
855    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
856    /// let manager = SecretManager::generate()?;
857    /// let path = manager.save_key_to_default()?;
858    /// println!("Key saved to: {}", path.display());
859    /// # Ok(())
860    /// # }
861    /// ```
862    pub fn save_key_to_default(&self) -> SecretsResult<PathBuf> {
863        let p = Self::default_key_path();
864        self.save_key(&p)?;
865        Ok(p)
866    }
867
868    /// Saves the private key to the OS keychain.
869    ///
870    /// Uses:
871    /// - Service: `DOTENVAGE_KEYCHAIN_SERVICE` or `dotenvage`
872    /// - Account: `AGE_KEY_NAME` or `{CARGO_PKG_NAME}/dotenvage`
873    ///
874    /// Returns the `(service, account)` pair used.
875    ///
876    /// # Errors
877    ///
878    /// Returns an error if the key cannot be saved to the OS keychain.
879    pub fn save_key_to_os_keychain(&self) -> SecretsResult<(String, String)> {
880        let service = Self::keychain_service_name();
881        let account = Self::key_name_from_env_or_default();
882        let backend = OsKeychainBackend::new(service.clone(), account.clone());
883        backend.save_identity_string(&self.identity_string())?;
884        Ok((service, account))
885    }
886
887    /// Saves this key to the system-level credential store.
888    ///
889    /// - **macOS**: System Keychain (`/Library/Keychains/System.keychain`)
890    /// - **Linux**: `/etc/dotenvage/<key-name>.key`
891    /// - **Windows**: `%ProgramData%\dotenvage\<key-name>.key`
892    ///
893    /// Requires elevated privileges (sudo/admin).
894    ///
895    /// # Errors
896    ///
897    /// Returns [`SecretsError::InsufficientPrivileges`] if the process
898    /// lacks write access to the system store.
899    pub fn save_key_to_system_store(&self) -> SecretsResult<KeyLocation> {
900        let key_name = Self::key_name_from_env_or_default();
901        self.save_key_to_system_store_as(&key_name)
902    }
903
904    /// Saves this key to the system-level store with an explicit
905    /// key name.
906    ///
907    /// # Errors
908    ///
909    /// Returns [`SecretsError::InsufficientPrivileges`] if the process
910    /// lacks write access to the system store.
911    pub fn save_key_to_system_store_as(&self, key_name: &str) -> SecretsResult<KeyLocation> {
912        let backend = SystemStoreBackend::new(key_name.to_string());
913        backend.save_identity_string(&self.identity_string())?;
914
915        #[cfg(target_os = "macos")]
916        {
917            let service = Self::keychain_service_name();
918            Ok(KeyLocation::SystemKeychain {
919                service,
920                account: key_name.to_string(),
921            })
922        }
923
924        #[cfg(not(target_os = "macos"))]
925        {
926            Ok(KeyLocation::SystemFile(backend.path()))
927        }
928    }
929
930    /// Generates a new key and saves it to the specified store(s).
931    ///
932    /// This is the programmatic equivalent of
933    /// `dotenvage keygen --store <target>`.
934    ///
935    /// # Errors
936    ///
937    /// Returns an error if key generation or saving fails, or if
938    /// a key already exists and `force` is not set.
939    ///
940    /// # Examples
941    ///
942    /// ```rust,no_run
943    /// use dotenvage::{
944    ///     KeyGenOptions,
945    ///     KeyStoreTarget,
946    ///     SecretManager,
947    /// };
948    ///
949    /// let result = SecretManager::generate_and_save(KeyGenOptions {
950    ///     target: KeyStoreTarget::OsKeychain,
951    ///     key_name: Some("ekg/wwkg".into()),
952    ///     file_path: None,
953    ///     force: false,
954    /// })?;
955    /// println!("Public key: {}", result.public_key);
956    /// # Ok::<(), Box<dyn std::error::Error>>(())
957    /// ```
958    pub fn generate_and_save(options: KeyGenOptions) -> SecretsResult<KeyGenResult> {
959        // If key_name is provided, set it in the environment so all
960        // downstream path resolution uses it.
961        if let Some(ref name) = options.key_name {
962            unsafe {
963                std::env::set_var("AGE_KEY_NAME", name);
964            }
965        } else {
966            Self::discover_age_key_name_from_env_files()?;
967        }
968
969        let manager = Self::generate()?;
970        let mut locations = Vec::new();
971
972        match options.target {
973            KeyStoreTarget::File => {
974                let path = options
975                    .file_path
976                    .unwrap_or_else(Self::key_path_from_env_or_default);
977                if path.exists() && !options.force {
978                    return Err(SecretsError::KeyAlreadyExists(format!(
979                        "key file at {}",
980                        path.display()
981                    )));
982                }
983                manager.save_key(&path)?;
984                locations.push(KeyLocation::UserFile(path));
985            }
986            KeyStoreTarget::OsKeychain => {
987                let (service, account) = manager.save_key_to_os_keychain()?;
988                locations.push(KeyLocation::OsKeychain { service, account });
989            }
990            KeyStoreTarget::SystemStore => {
991                let key_name = Self::key_name_from_env_or_default();
992                let loc = manager.save_key_to_system_store_as(&key_name)?;
993                locations.push(loc);
994            }
995            KeyStoreTarget::OsKeychainAndFile => {
996                let (service, account) = manager.save_key_to_os_keychain()?;
997                locations.push(KeyLocation::OsKeychain { service, account });
998                let path = options
999                    .file_path
1000                    .unwrap_or_else(Self::key_path_from_env_or_default);
1001                if path.exists() && !options.force {
1002                    return Err(SecretsError::KeyAlreadyExists(format!(
1003                        "key file at {}",
1004                        path.display()
1005                    )));
1006                }
1007                manager.save_key(&path)?;
1008                locations.push(KeyLocation::UserFile(path));
1009            }
1010        }
1011
1012        let public_key = manager.public_key_string();
1013        Ok(KeyGenResult {
1014            manager,
1015            locations,
1016            public_key,
1017        })
1018    }
1019
1020    /// Loads the key specifically from the system-level store.
1021    ///
1022    /// Unlike [`new`](Self::new) which tries the full discovery
1023    /// chain, this only checks the system store.
1024    ///
1025    /// # Errors
1026    ///
1027    /// Returns an error if no key is found in the system store.
1028    pub fn load_from_system_store() -> SecretsResult<Self> {
1029        Self::discover_age_key_name_from_env_files()?;
1030        let key_name = Self::key_name_from_env_or_default();
1031        let backend = SystemStoreBackend::new(key_name.clone());
1032        match backend.load_identity_string()? {
1033            Some(data) => Self::load_from_string(&data),
1034            None => Err(SecretsError::KeyLoadFailed(format!(
1035                "no key found in system store for '{}'",
1036                key_name
1037            ))),
1038        }
1039    }
1040
1041    /// Loads a key from another user's file store.
1042    ///
1043    /// Resolves the key file path for `~<username>/.local/state/...`
1044    /// based on the current `AGE_KEY_NAME`. This is intended for use
1045    /// during `sudo` operations where the invoking user's key needs
1046    /// to be read by the elevated process.
1047    ///
1048    /// # Errors
1049    ///
1050    /// Returns an error if the user's home directory cannot be
1051    /// resolved or no key file is found.
1052    pub fn load_from_user(username: &str) -> SecretsResult<Self> {
1053        Self::discover_age_key_name_from_env_files()?;
1054        let key_name = Self::key_name_from_env_or_default();
1055
1056        let home = resolve_user_home(username)?;
1057        let key_path = home
1058            .join(".local/state")
1059            .join(&key_name)
1060            .with_extension("key");
1061
1062        let backend = FileKeyBackend::new(key_path.clone());
1063        match backend.load_identity_string()? {
1064            Some(data) => Self::load_from_string(&data),
1065            None => Err(SecretsError::KeyLoadFailed(format!(
1066                "no key file for user '{}' at {}",
1067                username,
1068                key_path.display()
1069            ))),
1070        }
1071    }
1072
1073    /// Checks whether a key exists in the OS user keychain.
1074    pub fn key_exists_in_os_keychain() -> bool {
1075        let _ = Self::discover_age_key_name_from_env_files();
1076        let key_name = Self::key_name_from_env_or_default();
1077        let service = Self::keychain_service_name();
1078        let backend = OsKeychainBackend::new(service, key_name);
1079        matches!(backend.load_identity_string(), Ok(Some(_)))
1080    }
1081
1082    /// Checks whether a key exists in the system-level store.
1083    pub fn key_exists_in_system_store() -> bool {
1084        let _ = Self::discover_age_key_name_from_env_files();
1085        let key_name = Self::key_name_from_env_or_default();
1086        let backend = SystemStoreBackend::new(key_name);
1087        matches!(backend.load_identity_string(), Ok(Some(_)))
1088    }
1089
1090    /// Deletes the key from the OS user keychain.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns an error if the deletion fails. Does not error if
1095    /// no key exists.
1096    #[cfg(feature = "os-keychain")]
1097    pub fn delete_from_os_keychain() -> SecretsResult<()> {
1098        let _ = Self::discover_age_key_name_from_env_files();
1099        let key_name = Self::key_name_from_env_or_default();
1100        let service = Self::keychain_service_name();
1101        delete_from_os_keychain(&service, &key_name)
1102    }
1103
1104    /// Returns the system store path for the current platform
1105    /// and key name.
1106    ///
1107    /// - **macOS**: `/Library/Keychains/System.keychain`
1108    /// - **Linux**: `/etc/dotenvage/<key-name>.key`
1109    /// - **Windows**: `%ProgramData%\dotenvage\<key-name>.key`
1110    pub fn system_store_path() -> PathBuf {
1111        let _ = Self::discover_age_key_name_from_env_files();
1112        let key_name = Self::key_name_from_env_or_default();
1113        system_store_path_for(&key_name)
1114    }
1115
1116    /// Loads the identity from standard locations.
1117    ///
1118    /// This is called internally by [`new`](Self::new).
1119    ///
1120    /// ## Key Loading Priority
1121    ///
1122    /// 0. Read .env files to discover `AGE_KEY_NAME` (or `*_AGE_KEY_NAME`) for
1123    ///    project-specific keys
1124    /// 1. `DOTENVAGE_AGE_KEY` env var (full identity string)
1125    /// 2. `AGE_KEY` env var (full identity string)
1126    /// 3. `EKG_AGE_KEY` env var (for EKG project compatibility)
1127    /// 4. OS user keychain (via `keyring` crate)
1128    /// 5. System-level store (macOS System Keychain, or
1129    ///    `/etc/dotenvage/<key>.key` on Linux,
1130    ///    `%ProgramData%\dotenvage\<key>.key` on Windows)
1131    /// 6. Key file at path determined by `AGE_KEY_NAME` from .env or
1132    ///    environment
1133    /// 7. Default key file: `~/.local/state/{CARGO_PKG_NAME or
1134    ///    "dotenvage"}/dotenvage.key`
1135    ///
1136    /// # Errors
1137    ///
1138    /// Returns an error if no key can be found in any of the standard
1139    /// locations or if the key file/string is invalid.
1140    pub fn load_key() -> SecretsResult<Self> {
1141        // FIRST: Try to discover AGE_KEY_NAME from .env files before
1142        // doing anything else. This allows project-specific key
1143        // discovery from .env configuration.
1144        Self::discover_age_key_name_from_env_files()?;
1145
1146        if let Ok(data) = std::env::var("DOTENVAGE_AGE_KEY") {
1147            return Self::load_from_string(&data);
1148        }
1149        if let Ok(data) = std::env::var("AGE_KEY") {
1150            return Self::load_from_string(&data);
1151        }
1152        if let Ok(data) = std::env::var("EKG_AGE_KEY") {
1153            return Self::load_from_string(&data);
1154        }
1155
1156        // Step 4: OS user keychain
1157        let key_name = Self::key_name_from_env_or_default();
1158        let keychain_service = Self::keychain_service_name();
1159        let os_keychain_backend = OsKeychainBackend::new(keychain_service, key_name.clone());
1160        if let Some(data) = os_keychain_backend.load_identity_string()? {
1161            return Self::load_from_string(&data);
1162        }
1163
1164        // Step 5: System-level store
1165        let system_backend = SystemStoreBackend::new(key_name);
1166        if let Some(data) = system_backend.load_identity_string()? {
1167            return Self::load_from_string(&data);
1168        }
1169
1170        // Step 6-7: File-based key
1171        let key_path = Self::key_path_from_env_or_default();
1172        let file_backend = FileKeyBackend::new(key_path.clone());
1173        if let Some(data) = file_backend.load_identity_string()? {
1174            return Self::load_from_string(&data);
1175        }
1176        Err(SecretsError::KeyLoadFailed(format!(
1177            "no key found (env vars, OS keychain, system store, \
1178             or key file at {})",
1179            key_path.display()
1180        )))
1181    }
1182
1183    /// Attempts to discover AGE_KEY_NAME from .env files in the current
1184    /// directory.
1185    ///
1186    /// This reads .env files (without decryption) to find AGE_KEY_NAME or
1187    /// *_AGE_KEY_NAME variables and sets them in the environment so they
1188    /// can be used for key path resolution.
1189    ///
1190    /// Priority order for .env files:
1191    /// 1. .env.local
1192    /// 2. .env
1193    ///
1194    /// # Errors
1195    ///
1196    /// Returns an error if an AGE key name variable (e.g., `EKG_AGE_KEY_NAME`)
1197    /// is found but encrypted. AGE key name variables must be plaintext because
1198    /// they are needed for key discovery, which happens before decryption.
1199    pub fn discover_age_key_name_from_env_files() -> SecretsResult<()> {
1200        // Try to read .env.local first, then .env
1201        let env_files = [".env.local", ".env"];
1202
1203        for env_file in &env_files {
1204            let vars = Self::find_dotenvage_vars_in_file(env_file)?;
1205            if let Some(key_name) = vars.age_key_name
1206                && std::env::var("AGE_KEY_NAME").is_err()
1207            {
1208                // SAFETY: called during single-threaded startup.
1209                unsafe {
1210                    std::env::set_var("AGE_KEY_NAME", key_name);
1211                }
1212            }
1213            if let Some(dir) = vars.system_store_dir
1214                && std::env::var("DOTENVAGE_SYSTEM_STORE_DIR").is_err()
1215            {
1216                unsafe {
1217                    std::env::set_var("DOTENVAGE_SYSTEM_STORE_DIR", dir);
1218                }
1219            }
1220        }
1221
1222        Ok(())
1223    }
1224
1225    /// Searches a single `.env` file for dotenvage configuration
1226    /// variables that must be resolved before key loading.
1227    ///
1228    /// Discovered variables:
1229    /// - `AGE_KEY_NAME` or `*_AGE_KEY_NAME` — determines which key file or
1230    ///   keychain account to load.
1231    /// - `DOTENVAGE_SYSTEM_STORE_DIR` — overrides the directory for the
1232    ///   file-based system store (default `/etc/dotenvage/`).
1233    ///
1234    /// # Errors
1235    ///
1236    /// Returns an error if an AGE key name variable is found but
1237    /// encrypted.
1238    fn find_dotenvage_vars_in_file(file_path: &str) -> SecretsResult<DotenvageVars> {
1239        let mut vars = DotenvageVars {
1240            age_key_name: None,
1241            system_store_dir: None,
1242        };
1243
1244        let Ok(content) = std::fs::read_to_string(file_path) else {
1245            return Ok(vars);
1246        };
1247
1248        for line in content.lines() {
1249            let line = line.trim();
1250            if line.is_empty() || line.starts_with('#') {
1251                continue;
1252            }
1253            let Some((key, value)) = line.split_once('=') else {
1254                continue;
1255            };
1256            let key = key.trim();
1257            let value = value.trim().trim_matches('"').trim_matches('\'');
1258
1259            if (key == "AGE_KEY_NAME" || key.ends_with("_AGE_KEY_NAME")) && !value.is_empty() {
1260                if Self::is_encrypted(value) {
1261                    return Err(SecretsError::KeyLoadFailed(format!(
1262                        "found encrypted AGE key name variable \
1263                         '{key}' in {file_path}: AGE key name \
1264                         variables must be plaintext because they \
1265                         are used to discover the encryption key."
1266                    )));
1267                }
1268                vars.age_key_name = Some(value.to_string());
1269            }
1270
1271            if key == "DOTENVAGE_SYSTEM_STORE_DIR" && !value.is_empty() {
1272                vars.system_store_dir = Some(value.to_string());
1273            }
1274        }
1275
1276        Ok(vars)
1277    }
1278
1279    fn load_from_string(data: &str) -> SecretsResult<Self> {
1280        let identity = data
1281            .parse::<x25519::Identity>()
1282            .map_err(|e| SecretsError::KeyLoadFailed(format!("parse key: {}", e)))?;
1283        Ok(Self { identity })
1284    }
1285
1286    /// Returns the raw identity string (`AGE-SECRET-KEY-1...`).
1287    ///
1288    /// Use this when you need to embed the key in a service definition
1289    /// for environments where keychain access is unavailable (e.g.,
1290    /// containers). Handle the returned string carefully — it is the
1291    /// private key in plaintext.
1292    pub fn identity_string(&self) -> String {
1293        self.identity.to_string().expose_secret().to_string()
1294    }
1295
1296    fn key_name_from_env_or_default() -> String {
1297        std::env::var("AGE_KEY_NAME")
1298            .ok()
1299            .filter(|s| !s.trim().is_empty())
1300            .unwrap_or_else(|| {
1301                // Default to CARGO_PKG_NAME/dotenvage for project-specific keys
1302                format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1303            })
1304    }
1305
1306    fn keychain_service_name() -> String {
1307        std::env::var("DOTENVAGE_KEYCHAIN_SERVICE")
1308            .ok()
1309            .filter(|s| !s.trim().is_empty())
1310            .unwrap_or_else(|| "dotenvage".to_string())
1311    }
1312
1313    /// Returns the key path based on AGE_KEY_NAME or project default.
1314    ///
1315    /// ## Priority:
1316    /// 1. If `AGE_KEY_NAME` is set in environment (e.g., from .env), use it
1317    /// 2. Otherwise default to `{CARGO_PKG_NAME}/dotenvage`
1318    ///
1319    /// ## Path Construction:
1320    /// - XDG-compliant: `$XDG_STATE_HOME/{name}.key`
1321    /// - Fallback: `~/.local/state/{name}.key`
1322    ///
1323    /// ## Examples
1324    ///
1325    /// With `AGE_KEY_NAME=myapp/production` in .env:
1326    /// - Returns: `~/.local/state/myapp/production.key`
1327    ///
1328    /// Without AGE_KEY_NAME (default for "ekg-backend" crate):
1329    /// - Returns: `~/.local/state/ekg-backend/dotenvage.key`
1330    pub fn key_path_from_env_or_default() -> PathBuf {
1331        let key_name = Self::key_name_from_env_or_default();
1332
1333        // Construct XDG-compliant path
1334        Self::xdg_base_dir_for(&key_name)
1335            .unwrap_or_else(|| PathBuf::from(".").join(&key_name))
1336            .with_extension("key")
1337    }
1338
1339    /// Returns the default key path (for backward compatibility).
1340    ///
1341    /// Prefer using `key_path_from_env_or_default()` which respects
1342    /// AGE_KEY_NAME.
1343    ///
1344    /// # Examples
1345    ///
1346    /// ```rust
1347    /// use dotenvage::SecretManager;
1348    ///
1349    /// let path = SecretManager::default_key_path();
1350    /// println!("Default key path: {}", path.display());
1351    /// ```
1352    pub fn default_key_path() -> PathBuf {
1353        Self::xdg_base_dir_for("dotenvage")
1354            .unwrap_or_else(|| PathBuf::from(".").join("dotenvage"))
1355            .join("dotenvage.key")
1356    }
1357
1358    fn xdg_base_dir_for(name: &str) -> Option<PathBuf> {
1359        if let Ok(p) = std::env::var("XDG_STATE_HOME")
1360            && !p.is_empty()
1361        {
1362            return Some(PathBuf::from(p).join(name));
1363        }
1364        if let Ok(p) = std::env::var("XDG_CONFIG_HOME")
1365            && !p.is_empty()
1366        {
1367            return Some(PathBuf::from(p).join(name));
1368        }
1369        if let Ok(home) = std::env::var("HOME") {
1370            let home_path = PathBuf::from(home);
1371            let state_dir = home_path.join(".local/state").join(name);
1372            // Prefer state dir unless config dir already exists
1373            if state_dir.exists() || !home_path.join(".config").join(name).exists() {
1374                return Some(state_dir);
1375            }
1376            return Some(home_path.join(".config").join(name));
1377        }
1378        None
1379    }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    use serial_test::serial;
1385
1386    use super::*;
1387
1388    #[test]
1389    fn identity_string_constructor_roundtrips_generated_identity() {
1390        let generated = SecretManager::generate().expect("failed to generate manager");
1391        let identity = generated.identity_string();
1392
1393        let parsed = SecretManager::from_identity_string(&identity)
1394            .expect("generated identity should parse");
1395
1396        assert_eq!(parsed.identity_string(), identity);
1397        assert_eq!(parsed.public_key_string(), generated.public_key_string());
1398    }
1399
1400    #[test]
1401    fn identity_string_constructor_rejects_invalid_identity() {
1402        let error = match SecretManager::from_identity_string("not-an-age-identity") {
1403            Ok(_) => panic!("invalid identity should fail"),
1404            Err(error) => error,
1405        };
1406
1407        assert!(error.to_string().contains("parse key"));
1408    }
1409
1410    #[test]
1411    fn test_encrypt_decrypt_roundtrip() {
1412        let manager = SecretManager::generate().expect("failed to generate manager");
1413        let plaintext = "sk_live_abc123";
1414        let encrypted = manager.encrypt_value(plaintext).expect("encryption failed");
1415        assert!(SecretManager::is_encrypted(&encrypted));
1416        let decrypted = manager
1417            .decrypt_value(&encrypted)
1418            .expect("decryption failed");
1419        assert_eq!(plaintext, decrypted);
1420    }
1421
1422    #[test]
1423    fn test_decrypt_unencrypted_value() {
1424        let manager = SecretManager::generate().expect("failed to generate manager");
1425        let plaintext = "not_encrypted";
1426        let result = manager
1427            .decrypt_value(plaintext)
1428            .expect("decrypt should pass through");
1429        assert_eq!(plaintext, result);
1430    }
1431
1432    #[test]
1433    #[serial]
1434    fn test_key_path_from_env_or_default_with_age_key_name() {
1435        // This test must clear ALL env vars that affect key path discovery
1436        let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1437        let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1438        let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1439
1440        // Test with AGE_KEY_NAME set
1441        unsafe {
1442            std::env::remove_var("XDG_CONFIG_HOME"); // Clear any XDG_CONFIG_HOME
1443            std::env::set_var("AGE_KEY_NAME", "myproject/myapp");
1444            std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1445        }
1446
1447        let path = SecretManager::key_path_from_env_or_default();
1448        assert_eq!(
1449            path,
1450            std::path::PathBuf::from("/tmp/xdg-state/myproject/myapp.key")
1451        );
1452
1453        // Restore env
1454        unsafe {
1455            std::env::remove_var("AGE_KEY_NAME");
1456            std::env::remove_var("XDG_STATE_HOME");
1457            if let Some(val) = orig_age_key_name {
1458                std::env::set_var("AGE_KEY_NAME", val);
1459            }
1460            if let Some(val) = orig_xdg_state {
1461                std::env::set_var("XDG_STATE_HOME", val);
1462            }
1463            if let Some(val) = orig_xdg_config {
1464                std::env::set_var("XDG_CONFIG_HOME", val);
1465            }
1466        }
1467    }
1468
1469    #[test]
1470    #[serial]
1471    fn test_key_path_from_env_or_default_without_age_key_name() {
1472        // Save original env
1473        let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1474        let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1475        let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1476
1477        // Test without AGE_KEY_NAME - should default to CARGO_PKG_NAME/dotenvage
1478        unsafe {
1479            std::env::remove_var("AGE_KEY_NAME");
1480            std::env::remove_var("XDG_CONFIG_HOME"); // Clear any XDG_CONFIG_HOME
1481            std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1482        }
1483
1484        let path = SecretManager::key_path_from_env_or_default();
1485        let expected = format!("/tmp/xdg-state/{}/dotenvage.key", env!("CARGO_PKG_NAME"));
1486        assert_eq!(path, std::path::PathBuf::from(expected));
1487
1488        // Restore env
1489        unsafe {
1490            std::env::remove_var("XDG_STATE_HOME");
1491            if let Some(val) = orig_age_key_name {
1492                std::env::set_var("AGE_KEY_NAME", val);
1493            }
1494            if let Some(val) = orig_xdg_state {
1495                std::env::set_var("XDG_STATE_HOME", val);
1496            }
1497            if let Some(val) = orig_xdg_config {
1498                std::env::set_var("XDG_CONFIG_HOME", val);
1499            }
1500        }
1501    }
1502
1503    #[test]
1504    #[serial]
1505    fn test_key_name_from_env_or_default() {
1506        let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1507
1508        unsafe {
1509            std::env::set_var("AGE_KEY_NAME", "myproject/prod");
1510        }
1511        assert_eq!(
1512            SecretManager::key_name_from_env_or_default(),
1513            "myproject/prod"
1514        );
1515
1516        unsafe {
1517            std::env::set_var("AGE_KEY_NAME", "   ");
1518        }
1519        assert_eq!(
1520            SecretManager::key_name_from_env_or_default(),
1521            format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1522        );
1523
1524        unsafe {
1525            if let Some(val) = orig_age_key_name {
1526                std::env::set_var("AGE_KEY_NAME", val);
1527            } else {
1528                std::env::remove_var("AGE_KEY_NAME");
1529            }
1530        }
1531    }
1532
1533    #[test]
1534    #[serial]
1535    fn test_keychain_service_name() {
1536        let orig = std::env::var("DOTENVAGE_KEYCHAIN_SERVICE").ok();
1537
1538        unsafe {
1539            std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "team-secrets");
1540        }
1541        assert_eq!(SecretManager::keychain_service_name(), "team-secrets");
1542
1543        unsafe {
1544            std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "   ");
1545        }
1546        assert_eq!(SecretManager::keychain_service_name(), "dotenvage");
1547
1548        unsafe {
1549            if let Some(val) = orig {
1550                std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", val);
1551            } else {
1552                std::env::remove_var("DOTENVAGE_KEYCHAIN_SERVICE");
1553            }
1554        }
1555    }
1556
1557    #[test]
1558    #[serial]
1559    fn test_xdg_base_dir_for() {
1560        // Save original env
1561        let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1562        let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1563        let orig_home = std::env::var("HOME").ok();
1564
1565        // Test with XDG_STATE_HOME
1566        unsafe {
1567            std::env::set_var("XDG_STATE_HOME", "/custom/state");
1568        }
1569        let path = SecretManager::xdg_base_dir_for("test");
1570        assert_eq!(path, Some(std::path::PathBuf::from("/custom/state/test")));
1571
1572        // Test with HOME fallback
1573        unsafe {
1574            std::env::remove_var("XDG_STATE_HOME");
1575            std::env::remove_var("XDG_CONFIG_HOME");
1576            std::env::set_var("HOME", "/home/user");
1577        }
1578        let path = SecretManager::xdg_base_dir_for("test");
1579        assert_eq!(
1580            path,
1581            Some(std::path::PathBuf::from("/home/user/.local/state/test"))
1582        );
1583
1584        // Restore env
1585        unsafe {
1586            if let Some(val) = orig_xdg_state {
1587                std::env::set_var("XDG_STATE_HOME", val);
1588            } else {
1589                std::env::remove_var("XDG_STATE_HOME");
1590            }
1591            if let Some(val) = orig_xdg_config {
1592                std::env::set_var("XDG_CONFIG_HOME", val);
1593            } else {
1594                std::env::remove_var("XDG_CONFIG_HOME");
1595            }
1596            if let Some(val) = orig_home {
1597                std::env::set_var("HOME", val);
1598            } else {
1599                std::env::remove_var("HOME");
1600            }
1601        }
1602    }
1603
1604    #[test]
1605    fn test_encrypt_value_no_newline() {
1606        let manager = SecretManager::generate().expect("failed to generate manager");
1607        let secret = "a1b2c3d4e5f6789012345678901234567890abcdef0123456789012345678901";
1608        let encrypted = manager.encrypt_value(secret).expect("encryption failed");
1609        assert!(
1610            !encrypted.contains('\n'),
1611            "encrypted value should not contain newlines"
1612        );
1613    }
1614}