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 public_key(&self) -> x25519::Recipient {
623 self.identity.to_public()
624 }
625
626 pub fn public_key_string(&self) -> String {
641 self.public_key().to_string()
642 }
643
644 pub fn encrypt_value(&self, plaintext: &str) -> SecretsResult<String> {
667 let recipient = self.public_key();
668 let recipients: Vec<&dyn age::Recipient> = vec![&recipient];
669 let encryptor = age::Encryptor::with_recipients(recipients.into_iter())
670 .map_err(|e: age::EncryptError| SecretsError::EncryptionFailed(e.to_string()))?;
671
672 let mut encrypted = Vec::new();
673 let mut writer = encryptor
674 .wrap_output(&mut encrypted)
675 .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
676 writer
677 .write_all(plaintext.as_bytes())
678 .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
679 writer
680 .finish()
681 .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
682
683 let b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
684 Ok(format!("ENC[AGE:b64:{}]", b64))
685 }
686
687 pub fn decrypt_value(&self, value: &str) -> SecretsResult<String> {
723 let trimmed = value.trim();
724
725 if let Some(inner) = trimmed
727 .strip_prefix("ENC[AGE:b64:")
728 .and_then(|s| s.strip_suffix(']'))
729 {
730 let encrypted = base64::engine::general_purpose::STANDARD
731 .decode(inner)
732 .map_err(|e| SecretsError::DecryptionFailed(format!("invalid base64: {}", e)))?;
733
734 let decryptor = age::Decryptor::new(&encrypted[..])
735 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
736 let identities: Vec<&dyn age::Identity> = vec![&self.identity];
737 let mut reader = decryptor
738 .decrypt(identities.into_iter())
739 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
740
741 let mut decrypted = Vec::new();
742 reader
743 .read_to_end(&mut decrypted)
744 .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
745 return String::from_utf8(decrypted)
746 .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
747 }
748
749 if trimmed.starts_with("-----BEGIN AGE ENCRYPTED FILE-----") {
751 let armor_reader = age::armor::ArmoredReader::new(trimmed.as_bytes());
752 let decryptor = age::Decryptor::new(armor_reader)
753 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
754 let identities: Vec<&dyn age::Identity> = vec![&self.identity];
755 let mut reader = decryptor
756 .decrypt(identities.into_iter())
757 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
758
759 let mut decrypted = Vec::new();
760 reader
761 .read_to_end(&mut decrypted)
762 .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
763 return String::from_utf8(decrypted)
764 .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
765 }
766
767 Ok(value.to_string())
768 }
769
770 pub fn is_encrypted(value: &str) -> bool {
786 let t = value.trim();
787 t.starts_with("ENC[AGE:b64:") || t.starts_with("-----BEGIN AGE ENCRYPTED FILE-----")
788 }
789
790 pub fn save_key(&self, path: impl AsRef<Path>) -> SecretsResult<()> {
811 let backend = FileKeyBackend::new(path.as_ref().to_path_buf());
812 backend.save_identity_string(&self.identity_string())
813 }
814
815 pub fn save_key_to_default(&self) -> SecretsResult<PathBuf> {
837 let p = Self::default_key_path();
838 self.save_key(&p)?;
839 Ok(p)
840 }
841
842 pub fn save_key_to_os_keychain(&self) -> SecretsResult<(String, String)> {
854 let service = Self::keychain_service_name();
855 let account = Self::key_name_from_env_or_default();
856 let backend = OsKeychainBackend::new(service.clone(), account.clone());
857 backend.save_identity_string(&self.identity_string())?;
858 Ok((service, account))
859 }
860
861 pub fn save_key_to_system_store(&self) -> SecretsResult<KeyLocation> {
874 let key_name = Self::key_name_from_env_or_default();
875 self.save_key_to_system_store_as(&key_name)
876 }
877
878 pub fn save_key_to_system_store_as(&self, key_name: &str) -> SecretsResult<KeyLocation> {
886 let backend = SystemStoreBackend::new(key_name.to_string());
887 backend.save_identity_string(&self.identity_string())?;
888
889 #[cfg(target_os = "macos")]
890 {
891 let service = Self::keychain_service_name();
892 Ok(KeyLocation::SystemKeychain {
893 service,
894 account: key_name.to_string(),
895 })
896 }
897
898 #[cfg(not(target_os = "macos"))]
899 {
900 Ok(KeyLocation::SystemFile(backend.path()))
901 }
902 }
903
904 pub fn generate_and_save(options: KeyGenOptions) -> SecretsResult<KeyGenResult> {
933 if let Some(ref name) = options.key_name {
936 unsafe {
937 std::env::set_var("AGE_KEY_NAME", name);
938 }
939 } else {
940 Self::discover_age_key_name_from_env_files()?;
941 }
942
943 let manager = Self::generate()?;
944 let mut locations = Vec::new();
945
946 match options.target {
947 KeyStoreTarget::File => {
948 let path = options
949 .file_path
950 .unwrap_or_else(Self::key_path_from_env_or_default);
951 if path.exists() && !options.force {
952 return Err(SecretsError::KeyAlreadyExists(format!(
953 "key file at {}",
954 path.display()
955 )));
956 }
957 manager.save_key(&path)?;
958 locations.push(KeyLocation::UserFile(path));
959 }
960 KeyStoreTarget::OsKeychain => {
961 let (service, account) = manager.save_key_to_os_keychain()?;
962 locations.push(KeyLocation::OsKeychain { service, account });
963 }
964 KeyStoreTarget::SystemStore => {
965 let key_name = Self::key_name_from_env_or_default();
966 let loc = manager.save_key_to_system_store_as(&key_name)?;
967 locations.push(loc);
968 }
969 KeyStoreTarget::OsKeychainAndFile => {
970 let (service, account) = manager.save_key_to_os_keychain()?;
971 locations.push(KeyLocation::OsKeychain { service, account });
972 let path = options
973 .file_path
974 .unwrap_or_else(Self::key_path_from_env_or_default);
975 if path.exists() && !options.force {
976 return Err(SecretsError::KeyAlreadyExists(format!(
977 "key file at {}",
978 path.display()
979 )));
980 }
981 manager.save_key(&path)?;
982 locations.push(KeyLocation::UserFile(path));
983 }
984 }
985
986 let public_key = manager.public_key_string();
987 Ok(KeyGenResult {
988 manager,
989 locations,
990 public_key,
991 })
992 }
993
994 pub fn load_from_system_store() -> SecretsResult<Self> {
1003 Self::discover_age_key_name_from_env_files()?;
1004 let key_name = Self::key_name_from_env_or_default();
1005 let backend = SystemStoreBackend::new(key_name.clone());
1006 match backend.load_identity_string()? {
1007 Some(data) => Self::load_from_string(&data),
1008 None => Err(SecretsError::KeyLoadFailed(format!(
1009 "no key found in system store for '{}'",
1010 key_name
1011 ))),
1012 }
1013 }
1014
1015 pub fn load_from_user(username: &str) -> SecretsResult<Self> {
1027 Self::discover_age_key_name_from_env_files()?;
1028 let key_name = Self::key_name_from_env_or_default();
1029
1030 let home = resolve_user_home(username)?;
1031 let key_path = home
1032 .join(".local/state")
1033 .join(&key_name)
1034 .with_extension("key");
1035
1036 let backend = FileKeyBackend::new(key_path.clone());
1037 match backend.load_identity_string()? {
1038 Some(data) => Self::load_from_string(&data),
1039 None => Err(SecretsError::KeyLoadFailed(format!(
1040 "no key file for user '{}' at {}",
1041 username,
1042 key_path.display()
1043 ))),
1044 }
1045 }
1046
1047 pub fn key_exists_in_os_keychain() -> bool {
1049 let _ = Self::discover_age_key_name_from_env_files();
1050 let key_name = Self::key_name_from_env_or_default();
1051 let service = Self::keychain_service_name();
1052 let backend = OsKeychainBackend::new(service, key_name);
1053 matches!(backend.load_identity_string(), Ok(Some(_)))
1054 }
1055
1056 pub fn key_exists_in_system_store() -> bool {
1058 let _ = Self::discover_age_key_name_from_env_files();
1059 let key_name = Self::key_name_from_env_or_default();
1060 let backend = SystemStoreBackend::new(key_name);
1061 matches!(backend.load_identity_string(), Ok(Some(_)))
1062 }
1063
1064 #[cfg(feature = "os-keychain")]
1071 pub fn delete_from_os_keychain() -> SecretsResult<()> {
1072 let _ = Self::discover_age_key_name_from_env_files();
1073 let key_name = Self::key_name_from_env_or_default();
1074 let service = Self::keychain_service_name();
1075 delete_from_os_keychain(&service, &key_name)
1076 }
1077
1078 pub fn system_store_path() -> PathBuf {
1085 let _ = Self::discover_age_key_name_from_env_files();
1086 let key_name = Self::key_name_from_env_or_default();
1087 system_store_path_for(&key_name)
1088 }
1089
1090 pub fn load_key() -> SecretsResult<Self> {
1115 Self::discover_age_key_name_from_env_files()?;
1119
1120 if let Ok(data) = std::env::var("DOTENVAGE_AGE_KEY") {
1121 return Self::load_from_string(&data);
1122 }
1123 if let Ok(data) = std::env::var("AGE_KEY") {
1124 return Self::load_from_string(&data);
1125 }
1126 if let Ok(data) = std::env::var("EKG_AGE_KEY") {
1127 return Self::load_from_string(&data);
1128 }
1129
1130 let key_name = Self::key_name_from_env_or_default();
1132 let keychain_service = Self::keychain_service_name();
1133 let os_keychain_backend = OsKeychainBackend::new(keychain_service, key_name.clone());
1134 if let Some(data) = os_keychain_backend.load_identity_string()? {
1135 return Self::load_from_string(&data);
1136 }
1137
1138 let system_backend = SystemStoreBackend::new(key_name);
1140 if let Some(data) = system_backend.load_identity_string()? {
1141 return Self::load_from_string(&data);
1142 }
1143
1144 let key_path = Self::key_path_from_env_or_default();
1146 let file_backend = FileKeyBackend::new(key_path.clone());
1147 if let Some(data) = file_backend.load_identity_string()? {
1148 return Self::load_from_string(&data);
1149 }
1150 Err(SecretsError::KeyLoadFailed(format!(
1151 "no key found (env vars, OS keychain, system store, \
1152 or key file at {})",
1153 key_path.display()
1154 )))
1155 }
1156
1157 pub fn discover_age_key_name_from_env_files() -> SecretsResult<()> {
1174 let env_files = [".env.local", ".env"];
1176
1177 for env_file in &env_files {
1178 let vars = Self::find_dotenvage_vars_in_file(env_file)?;
1179 if let Some(key_name) = vars.age_key_name
1180 && std::env::var("AGE_KEY_NAME").is_err()
1181 {
1182 unsafe {
1184 std::env::set_var("AGE_KEY_NAME", key_name);
1185 }
1186 }
1187 if let Some(dir) = vars.system_store_dir
1188 && std::env::var("DOTENVAGE_SYSTEM_STORE_DIR").is_err()
1189 {
1190 unsafe {
1191 std::env::set_var("DOTENVAGE_SYSTEM_STORE_DIR", dir);
1192 }
1193 }
1194 }
1195
1196 Ok(())
1197 }
1198
1199 fn find_dotenvage_vars_in_file(file_path: &str) -> SecretsResult<DotenvageVars> {
1213 let mut vars = DotenvageVars {
1214 age_key_name: None,
1215 system_store_dir: None,
1216 };
1217
1218 let Ok(content) = std::fs::read_to_string(file_path) else {
1219 return Ok(vars);
1220 };
1221
1222 for line in content.lines() {
1223 let line = line.trim();
1224 if line.is_empty() || line.starts_with('#') {
1225 continue;
1226 }
1227 let Some((key, value)) = line.split_once('=') else {
1228 continue;
1229 };
1230 let key = key.trim();
1231 let value = value.trim().trim_matches('"').trim_matches('\'');
1232
1233 if (key == "AGE_KEY_NAME" || key.ends_with("_AGE_KEY_NAME")) && !value.is_empty() {
1234 if Self::is_encrypted(value) {
1235 return Err(SecretsError::KeyLoadFailed(format!(
1236 "found encrypted AGE key name variable \
1237 '{key}' in {file_path}: AGE key name \
1238 variables must be plaintext because they \
1239 are used to discover the encryption key."
1240 )));
1241 }
1242 vars.age_key_name = Some(value.to_string());
1243 }
1244
1245 if key == "DOTENVAGE_SYSTEM_STORE_DIR" && !value.is_empty() {
1246 vars.system_store_dir = Some(value.to_string());
1247 }
1248 }
1249
1250 Ok(vars)
1251 }
1252
1253 fn load_from_string(data: &str) -> SecretsResult<Self> {
1254 let identity = data
1255 .parse::<x25519::Identity>()
1256 .map_err(|e| SecretsError::KeyLoadFailed(format!("parse key: {}", e)))?;
1257 Ok(Self { identity })
1258 }
1259
1260 pub fn identity_string(&self) -> String {
1267 self.identity.to_string().expose_secret().to_string()
1268 }
1269
1270 fn key_name_from_env_or_default() -> String {
1271 std::env::var("AGE_KEY_NAME")
1272 .ok()
1273 .filter(|s| !s.trim().is_empty())
1274 .unwrap_or_else(|| {
1275 format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1277 })
1278 }
1279
1280 fn keychain_service_name() -> String {
1281 std::env::var("DOTENVAGE_KEYCHAIN_SERVICE")
1282 .ok()
1283 .filter(|s| !s.trim().is_empty())
1284 .unwrap_or_else(|| "dotenvage".to_string())
1285 }
1286
1287 pub fn key_path_from_env_or_default() -> PathBuf {
1305 let key_name = Self::key_name_from_env_or_default();
1306
1307 Self::xdg_base_dir_for(&key_name)
1309 .unwrap_or_else(|| PathBuf::from(".").join(&key_name))
1310 .with_extension("key")
1311 }
1312
1313 pub fn default_key_path() -> PathBuf {
1327 Self::xdg_base_dir_for("dotenvage")
1328 .unwrap_or_else(|| PathBuf::from(".").join("dotenvage"))
1329 .join("dotenvage.key")
1330 }
1331
1332 fn xdg_base_dir_for(name: &str) -> Option<PathBuf> {
1333 if let Ok(p) = std::env::var("XDG_STATE_HOME")
1334 && !p.is_empty()
1335 {
1336 return Some(PathBuf::from(p).join(name));
1337 }
1338 if let Ok(p) = std::env::var("XDG_CONFIG_HOME")
1339 && !p.is_empty()
1340 {
1341 return Some(PathBuf::from(p).join(name));
1342 }
1343 if let Ok(home) = std::env::var("HOME") {
1344 let home_path = PathBuf::from(home);
1345 let state_dir = home_path.join(".local/state").join(name);
1346 if state_dir.exists() || !home_path.join(".config").join(name).exists() {
1348 return Some(state_dir);
1349 }
1350 return Some(home_path.join(".config").join(name));
1351 }
1352 None
1353 }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358 use serial_test::serial;
1359
1360 use super::*;
1361
1362 #[test]
1363 fn test_encrypt_decrypt_roundtrip() {
1364 let manager = SecretManager::generate().expect("failed to generate manager");
1365 let plaintext = "sk_live_abc123";
1366 let encrypted = manager.encrypt_value(plaintext).expect("encryption failed");
1367 assert!(SecretManager::is_encrypted(&encrypted));
1368 let decrypted = manager
1369 .decrypt_value(&encrypted)
1370 .expect("decryption failed");
1371 assert_eq!(plaintext, decrypted);
1372 }
1373
1374 #[test]
1375 fn test_decrypt_unencrypted_value() {
1376 let manager = SecretManager::generate().expect("failed to generate manager");
1377 let plaintext = "not_encrypted";
1378 let result = manager
1379 .decrypt_value(plaintext)
1380 .expect("decrypt should pass through");
1381 assert_eq!(plaintext, result);
1382 }
1383
1384 #[test]
1385 #[serial]
1386 fn test_key_path_from_env_or_default_with_age_key_name() {
1387 let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1389 let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1390 let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1391
1392 unsafe {
1394 std::env::remove_var("XDG_CONFIG_HOME"); std::env::set_var("AGE_KEY_NAME", "myproject/myapp");
1396 std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1397 }
1398
1399 let path = SecretManager::key_path_from_env_or_default();
1400 assert_eq!(
1401 path,
1402 std::path::PathBuf::from("/tmp/xdg-state/myproject/myapp.key")
1403 );
1404
1405 unsafe {
1407 std::env::remove_var("AGE_KEY_NAME");
1408 std::env::remove_var("XDG_STATE_HOME");
1409 if let Some(val) = orig_age_key_name {
1410 std::env::set_var("AGE_KEY_NAME", val);
1411 }
1412 if let Some(val) = orig_xdg_state {
1413 std::env::set_var("XDG_STATE_HOME", val);
1414 }
1415 if let Some(val) = orig_xdg_config {
1416 std::env::set_var("XDG_CONFIG_HOME", val);
1417 }
1418 }
1419 }
1420
1421 #[test]
1422 #[serial]
1423 fn test_key_path_from_env_or_default_without_age_key_name() {
1424 let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1426 let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1427 let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1428
1429 unsafe {
1431 std::env::remove_var("AGE_KEY_NAME");
1432 std::env::remove_var("XDG_CONFIG_HOME"); std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1434 }
1435
1436 let path = SecretManager::key_path_from_env_or_default();
1437 let expected = format!("/tmp/xdg-state/{}/dotenvage.key", env!("CARGO_PKG_NAME"));
1438 assert_eq!(path, std::path::PathBuf::from(expected));
1439
1440 unsafe {
1442 std::env::remove_var("XDG_STATE_HOME");
1443 if let Some(val) = orig_age_key_name {
1444 std::env::set_var("AGE_KEY_NAME", val);
1445 }
1446 if let Some(val) = orig_xdg_state {
1447 std::env::set_var("XDG_STATE_HOME", val);
1448 }
1449 if let Some(val) = orig_xdg_config {
1450 std::env::set_var("XDG_CONFIG_HOME", val);
1451 }
1452 }
1453 }
1454
1455 #[test]
1456 #[serial]
1457 fn test_key_name_from_env_or_default() {
1458 let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1459
1460 unsafe {
1461 std::env::set_var("AGE_KEY_NAME", "myproject/prod");
1462 }
1463 assert_eq!(
1464 SecretManager::key_name_from_env_or_default(),
1465 "myproject/prod"
1466 );
1467
1468 unsafe {
1469 std::env::set_var("AGE_KEY_NAME", " ");
1470 }
1471 assert_eq!(
1472 SecretManager::key_name_from_env_or_default(),
1473 format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1474 );
1475
1476 unsafe {
1477 if let Some(val) = orig_age_key_name {
1478 std::env::set_var("AGE_KEY_NAME", val);
1479 } else {
1480 std::env::remove_var("AGE_KEY_NAME");
1481 }
1482 }
1483 }
1484
1485 #[test]
1486 #[serial]
1487 fn test_keychain_service_name() {
1488 let orig = std::env::var("DOTENVAGE_KEYCHAIN_SERVICE").ok();
1489
1490 unsafe {
1491 std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "team-secrets");
1492 }
1493 assert_eq!(SecretManager::keychain_service_name(), "team-secrets");
1494
1495 unsafe {
1496 std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", " ");
1497 }
1498 assert_eq!(SecretManager::keychain_service_name(), "dotenvage");
1499
1500 unsafe {
1501 if let Some(val) = orig {
1502 std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", val);
1503 } else {
1504 std::env::remove_var("DOTENVAGE_KEYCHAIN_SERVICE");
1505 }
1506 }
1507 }
1508
1509 #[test]
1510 #[serial]
1511 fn test_xdg_base_dir_for() {
1512 let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1514 let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1515 let orig_home = std::env::var("HOME").ok();
1516
1517 unsafe {
1519 std::env::set_var("XDG_STATE_HOME", "/custom/state");
1520 }
1521 let path = SecretManager::xdg_base_dir_for("test");
1522 assert_eq!(path, Some(std::path::PathBuf::from("/custom/state/test")));
1523
1524 unsafe {
1526 std::env::remove_var("XDG_STATE_HOME");
1527 std::env::remove_var("XDG_CONFIG_HOME");
1528 std::env::set_var("HOME", "/home/user");
1529 }
1530 let path = SecretManager::xdg_base_dir_for("test");
1531 assert_eq!(
1532 path,
1533 Some(std::path::PathBuf::from("/home/user/.local/state/test"))
1534 );
1535
1536 unsafe {
1538 if let Some(val) = orig_xdg_state {
1539 std::env::set_var("XDG_STATE_HOME", val);
1540 } else {
1541 std::env::remove_var("XDG_STATE_HOME");
1542 }
1543 if let Some(val) = orig_xdg_config {
1544 std::env::set_var("XDG_CONFIG_HOME", val);
1545 } else {
1546 std::env::remove_var("XDG_CONFIG_HOME");
1547 }
1548 if let Some(val) = orig_home {
1549 std::env::set_var("HOME", val);
1550 } else {
1551 std::env::remove_var("HOME");
1552 }
1553 }
1554 }
1555
1556 #[test]
1557 fn test_encrypt_value_no_newline() {
1558 let manager = SecretManager::generate().expect("failed to generate manager");
1559 let secret = "a1b2c3d4e5f6789012345678901234567890abcdef0123456789012345678901";
1560 let encrypted = manager.encrypt_value(secret).expect("encryption failed");
1561 assert!(
1562 !encrypted.contains('\n'),
1563 "encrypted value should not contain newlines"
1564 );
1565 }
1566}