1use 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#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum KeyStoreTarget {
35 OsKeychain,
40 SystemStore,
47 File,
49 OsKeychainAndFile,
51}
52
53#[derive(Debug, Clone)]
55pub enum KeyLocation {
56 OsKeychain {
58 service: String,
60 account: String,
62 },
63 SystemKeychain {
65 service: String,
67 account: String,
69 },
70 SystemFile(PathBuf),
72 UserFile(PathBuf),
74}
75
76#[derive(Debug, Clone)]
78pub struct KeyGenOptions {
79 pub target: KeyStoreTarget,
81 pub key_name: Option<String>,
84 pub file_path: Option<PathBuf>,
87 pub force: bool,
89}
90
91pub struct KeyGenResult {
93 pub manager: SecretManager,
95 pub locations: Vec<KeyLocation>,
97 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#[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
326struct 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
353fn 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 #[cfg(target_os = "macos")]
392 if let Some(data) = load_from_macos_system_keychain(key_name)? {
393 return Ok(Some(data));
394 }
395
396 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
458fn 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 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 Err(e) if e.code() == -25300 => Ok(None),
509 Err(_) => Ok(None), }
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
538struct DotenvageVars {
541 age_key_name: Option<String>,
543 system_store_dir: Option<String>,
545}
546
547impl SecretManager {
548 pub fn new() -> SecretsResult<Self> {
580 Self::load_key()
581 }
582
583 pub fn generate() -> SecretsResult<Self> {
605 Ok(Self {
606 identity: x25519::Identity::generate(),
607 })
608 }
609
610 pub fn from_identity(identity: x25519::Identity) -> Self {
615 Self { identity }
616 }
617
618 pub fn from_identity_string(identity: &str) -> SecretsResult<Self> {
641 Self::load_from_string(identity)
642 }
643
644 pub fn public_key(&self) -> x25519::Recipient {
649 self.identity.to_public()
650 }
651
652 pub fn public_key_string(&self) -> String {
667 self.public_key().to_string()
668 }
669
670 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 pub fn decrypt_value(&self, value: &str) -> SecretsResult<String> {
749 let trimmed = value.trim();
750
751 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 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 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 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 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 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 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 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 pub fn generate_and_save(options: KeyGenOptions) -> SecretsResult<KeyGenResult> {
959 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 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 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 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 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 #[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 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 pub fn load_key() -> SecretsResult<Self> {
1141 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 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 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 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 pub fn discover_age_key_name_from_env_files() -> SecretsResult<()> {
1200 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 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 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 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 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 pub fn key_path_from_env_or_default() -> PathBuf {
1331 let key_name = Self::key_name_from_env_or_default();
1332
1333 Self::xdg_base_dir_for(&key_name)
1335 .unwrap_or_else(|| PathBuf::from(".").join(&key_name))
1336 .with_extension("key")
1337 }
1338
1339 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 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 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 unsafe {
1442 std::env::remove_var("XDG_CONFIG_HOME"); 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 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 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 unsafe {
1479 std::env::remove_var("AGE_KEY_NAME");
1480 std::env::remove_var("XDG_CONFIG_HOME"); 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 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 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 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 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 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}