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 load_from_os_keychain(service: &str, account: &str) -> SecretsResult<Option<String>> {
232 let entry = match keyring::Entry::new(service, account) {
233 Ok(e) => e,
234 Err(_) => return Ok(None),
235 };
236 match entry.get_password() {
237 Ok(password) => Ok(normalize_key_data(&password)),
238 Err(keyring::Error::NoEntry) => Ok(None),
239 Err(keyring::Error::PlatformFailure(_)) => Ok(None),
240 Err(e) => Err(SecretsError::KeyLoadFailed(format!(
241 "OS keychain read failed (service='{}', account='{}'): {}",
242 service, account, e
243 ))),
244 }
245}
246
247#[cfg(not(feature = "os-keychain"))]
248fn load_from_os_keychain(_service: &str, _account: &str) -> SecretsResult<Option<String>> {
249 Ok(None)
250}
251
252#[cfg(feature = "os-keychain")]
253fn save_to_os_keychain(service: &str, account: &str, identity: &str) -> SecretsResult<()> {
254 let entry = keyring::Entry::new(service, account).map_err(|e| {
255 SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
256 })?;
257 entry.set_password(identity).map_err(|e| {
258 SecretsError::KeySaveFailed(format!(
259 "failed to save to OS keychain (service='{}', account='{}'): {}",
260 service, account, e
261 ))
262 })
263}
264
265#[cfg(not(feature = "os-keychain"))]
266fn save_to_os_keychain(_service: &str, _account: &str, _identity: &str) -> SecretsResult<()> {
267 Err(SecretsError::KeySaveFailed(
268 "OS keychain support not compiled (enable 'os-keychain' feature)".to_string(),
269 ))
270}
271
272#[cfg(feature = "os-keychain")]
273fn delete_from_os_keychain(service: &str, account: &str) -> SecretsResult<()> {
274 let entry = keyring::Entry::new(service, account).map_err(|e| {
275 SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
276 })?;
277 match entry.delete_credential() {
278 Ok(()) => Ok(()),
279 Err(keyring::Error::NoEntry) => Ok(()),
280 Err(e) => Err(SecretsError::KeySaveFailed(format!(
281 "failed to delete from OS keychain (service='{}', account='{}'): {}",
282 service, account, e
283 ))),
284 }
285}
286
287struct SystemStoreBackend {
290 key_name: String,
291}
292
293impl SystemStoreBackend {
294 fn new(key_name: String) -> Self {
295 Self { key_name }
296 }
297
298 #[allow(dead_code)]
299 fn path(&self) -> PathBuf {
300 system_store_path_for(&self.key_name)
301 }
302}
303
304impl KeyBackend for SystemStoreBackend {
305 fn load_identity_string(&self) -> SecretsResult<Option<String>> {
306 load_from_system_store_impl(&self.key_name)
307 }
308
309 fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
310 save_to_system_store_impl(&self.key_name, identity)
311 }
312}
313
314fn system_store_path_for(_key_name: &str) -> PathBuf {
321 #[cfg(target_os = "linux")]
322 {
323 PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
324 }
325
326 #[cfg(target_os = "windows")]
327 {
328 let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string());
329 PathBuf::from(base)
330 .join("dotenvage")
331 .join(format!("{}.key", _key_name))
332 }
333
334 #[cfg(target_os = "macos")]
335 {
336 PathBuf::from("/Library/Keychains/System.keychain")
339 }
340
341 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
342 {
343 PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
344 }
345}
346
347fn load_from_system_store_impl(key_name: &str) -> SecretsResult<Option<String>> {
348 #[cfg(target_os = "macos")]
349 {
350 load_from_macos_system_keychain(key_name)
351 }
352
353 #[cfg(not(target_os = "macos"))]
354 {
355 let path = system_store_path_for(key_name);
356 if !path.exists() {
357 return Ok(None);
358 }
359 let data = std::fs::read_to_string(&path)
360 .map_err(|e| SecretsError::KeyLoadFailed(format!("read {}: {}", path.display(), e)))?;
361 Ok(normalize_key_data(&data))
362 }
363}
364
365fn save_to_system_store_impl(key_name: &str, identity: &str) -> SecretsResult<()> {
366 #[cfg(target_os = "macos")]
367 {
368 save_to_macos_system_keychain(key_name, identity)
369 }
370
371 #[cfg(not(target_os = "macos"))]
372 {
373 let path = system_store_path_for(key_name);
374 if let Some(parent) = path.parent() {
375 std::fs::create_dir_all(parent).map_err(|e| {
376 if e.kind() == std::io::ErrorKind::PermissionDenied {
377 return SecretsError::InsufficientPrivileges(format!(
378 "cannot create {}: {} (try with sudo/admin)",
379 parent.display(),
380 e
381 ));
382 }
383 SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
384 })?;
385 }
386 std::fs::write(&path, identity.as_bytes()).map_err(|e| {
387 if e.kind() == std::io::ErrorKind::PermissionDenied {
388 return SecretsError::InsufficientPrivileges(format!(
389 "cannot write {}: {} (try with sudo/admin)",
390 path.display(),
391 e
392 ));
393 }
394 SecretsError::KeySaveFailed(format!("write {}: {}", path.display(), e))
395 })?;
396
397 #[cfg(unix)]
398 {
399 use std::os::unix::fs::PermissionsExt;
400 let mut perms = std::fs::metadata(&path)
401 .map_err(|e| {
402 SecretsError::KeySaveFailed(format!("metadata {}: {}", path.display(), e))
403 })?
404 .permissions();
405 perms.set_mode(0o600);
406 std::fs::set_permissions(&path, perms).map_err(|e| {
407 SecretsError::KeySaveFailed(format!("chmod {}: {}", path.display(), e))
408 })?;
409 }
410
411 Ok(())
412 }
413}
414
415fn resolve_user_home(username: &str) -> SecretsResult<PathBuf> {
417 #[cfg(unix)]
418 {
419 use nix::unistd::User;
420
421 let user = User::from_name(username).map_err(|e| {
422 SecretsError::KeyLoadFailed(format!("failed to look up user '{}': {}", username, e))
423 })?;
424 match user {
425 Some(u) => Ok(u.dir),
426 None => Err(SecretsError::KeyLoadFailed(format!(
427 "user '{}' not found",
428 username
429 ))),
430 }
431 }
432
433 #[cfg(windows)]
434 {
435 let drive = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string());
437 Ok(PathBuf::from(drive).join("Users").join(username))
438 }
439
440 #[cfg(not(any(unix, windows)))]
441 {
442 let _ = username;
443 Err(SecretsError::KeyLoadFailed(
444 "resolve_user_home not supported on this platform".to_string(),
445 ))
446 }
447}
448
449#[cfg(target_os = "macos")]
450fn load_from_macos_system_keychain(key_name: &str) -> SecretsResult<Option<String>> {
451 use security_framework::os::macos::keychain::SecKeychain;
452
453 let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
454 .map_err(|e| SecretsError::KeyLoadFailed(format!("cannot open System Keychain: {}", e)))?;
455
456 let service = SecretManager::keychain_service_name();
457 match keychain.find_generic_password(&service, key_name) {
458 Ok((password, _item)) => {
459 let data = String::from_utf8(password.as_ref().to_vec()).map_err(|e| {
460 SecretsError::KeyLoadFailed(format!("invalid keychain data: {}", e))
461 })?;
462 Ok(normalize_key_data(&data))
463 }
464 Err(e) if e.code() == -25300 => Ok(None),
466 Err(_) => Ok(None), }
468}
469
470#[cfg(target_os = "macos")]
471fn save_to_macos_system_keychain(key_name: &str, identity: &str) -> SecretsResult<()> {
472 use security_framework::os::macos::keychain::SecKeychain;
473
474 let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
475 .map_err(|e| SecretsError::KeySaveFailed(format!("cannot open System Keychain: {}", e)))?;
476
477 let service = SecretManager::keychain_service_name();
478 keychain
479 .set_generic_password(&service, key_name, identity.as_bytes())
480 .map_err(|e| {
481 let msg = e.to_string();
482 if msg.contains("Authorization") || msg.contains("permission") || e.code() == -25293 {
483 return SecretsError::InsufficientPrivileges(format!(
485 "cannot write to System Keychain (try with sudo): {}",
486 msg
487 ));
488 }
489 SecretsError::KeySaveFailed(format!(
490 "failed to save to macOS System Keychain \
491 (service='{}', account='{}'): {}",
492 service, key_name, msg
493 ))
494 })
495}
496
497impl SecretManager {
498 pub fn new() -> SecretsResult<Self> {
530 Self::load_key()
531 }
532
533 pub fn generate() -> SecretsResult<Self> {
555 Ok(Self {
556 identity: x25519::Identity::generate(),
557 })
558 }
559
560 pub fn from_identity(identity: x25519::Identity) -> Self {
565 Self { identity }
566 }
567
568 pub fn public_key(&self) -> x25519::Recipient {
573 self.identity.to_public()
574 }
575
576 pub fn public_key_string(&self) -> String {
591 self.public_key().to_string()
592 }
593
594 pub fn encrypt_value(&self, plaintext: &str) -> SecretsResult<String> {
617 let recipient = self.public_key();
618 let recipients: Vec<&dyn age::Recipient> = vec![&recipient];
619 let encryptor = age::Encryptor::with_recipients(recipients.into_iter())
620 .map_err(|e: age::EncryptError| SecretsError::EncryptionFailed(e.to_string()))?;
621
622 let mut encrypted = Vec::new();
623 let mut writer = encryptor
624 .wrap_output(&mut encrypted)
625 .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
626 writer
627 .write_all(plaintext.as_bytes())
628 .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
629 writer
630 .finish()
631 .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
632
633 let b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
634 Ok(format!("ENC[AGE:b64:{}]", b64))
635 }
636
637 pub fn decrypt_value(&self, value: &str) -> SecretsResult<String> {
673 let trimmed = value.trim();
674
675 if let Some(inner) = trimmed
677 .strip_prefix("ENC[AGE:b64:")
678 .and_then(|s| s.strip_suffix(']'))
679 {
680 let encrypted = base64::engine::general_purpose::STANDARD
681 .decode(inner)
682 .map_err(|e| SecretsError::DecryptionFailed(format!("invalid base64: {}", e)))?;
683
684 let decryptor = age::Decryptor::new(&encrypted[..])
685 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
686 let identities: Vec<&dyn age::Identity> = vec![&self.identity];
687 let mut reader = decryptor
688 .decrypt(identities.into_iter())
689 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
690
691 let mut decrypted = Vec::new();
692 reader
693 .read_to_end(&mut decrypted)
694 .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
695 return String::from_utf8(decrypted)
696 .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
697 }
698
699 if trimmed.starts_with("-----BEGIN AGE ENCRYPTED FILE-----") {
701 let armor_reader = age::armor::ArmoredReader::new(trimmed.as_bytes());
702 let decryptor = age::Decryptor::new(armor_reader)
703 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
704 let identities: Vec<&dyn age::Identity> = vec![&self.identity];
705 let mut reader = decryptor
706 .decrypt(identities.into_iter())
707 .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
708
709 let mut decrypted = Vec::new();
710 reader
711 .read_to_end(&mut decrypted)
712 .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
713 return String::from_utf8(decrypted)
714 .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
715 }
716
717 Ok(value.to_string())
718 }
719
720 pub fn is_encrypted(value: &str) -> bool {
736 let t = value.trim();
737 t.starts_with("ENC[AGE:b64:") || t.starts_with("-----BEGIN AGE ENCRYPTED FILE-----")
738 }
739
740 pub fn save_key(&self, path: impl AsRef<Path>) -> SecretsResult<()> {
761 let backend = FileKeyBackend::new(path.as_ref().to_path_buf());
762 backend.save_identity_string(&self.identity_string())
763 }
764
765 pub fn save_key_to_default(&self) -> SecretsResult<PathBuf> {
787 let p = Self::default_key_path();
788 self.save_key(&p)?;
789 Ok(p)
790 }
791
792 pub fn save_key_to_os_keychain(&self) -> SecretsResult<(String, String)> {
804 let service = Self::keychain_service_name();
805 let account = Self::key_name_from_env_or_default();
806 let backend = OsKeychainBackend::new(service.clone(), account.clone());
807 backend.save_identity_string(&self.identity_string())?;
808 Ok((service, account))
809 }
810
811 pub fn save_key_to_system_store(&self) -> SecretsResult<KeyLocation> {
824 let key_name = Self::key_name_from_env_or_default();
825 self.save_key_to_system_store_as(&key_name)
826 }
827
828 pub fn save_key_to_system_store_as(&self, key_name: &str) -> SecretsResult<KeyLocation> {
836 let backend = SystemStoreBackend::new(key_name.to_string());
837 backend.save_identity_string(&self.identity_string())?;
838
839 #[cfg(target_os = "macos")]
840 {
841 let service = Self::keychain_service_name();
842 Ok(KeyLocation::SystemKeychain {
843 service,
844 account: key_name.to_string(),
845 })
846 }
847
848 #[cfg(not(target_os = "macos"))]
849 {
850 Ok(KeyLocation::SystemFile(backend.path()))
851 }
852 }
853
854 pub fn generate_and_save(options: KeyGenOptions) -> SecretsResult<KeyGenResult> {
883 if let Some(ref name) = options.key_name {
886 unsafe {
887 std::env::set_var("AGE_KEY_NAME", name);
888 }
889 } else {
890 Self::discover_age_key_name_from_env_files()?;
891 }
892
893 let manager = Self::generate()?;
894 let mut locations = Vec::new();
895
896 match options.target {
897 KeyStoreTarget::File => {
898 let path = options
899 .file_path
900 .unwrap_or_else(Self::key_path_from_env_or_default);
901 if path.exists() && !options.force {
902 return Err(SecretsError::KeyAlreadyExists(format!(
903 "key file at {}",
904 path.display()
905 )));
906 }
907 manager.save_key(&path)?;
908 locations.push(KeyLocation::UserFile(path));
909 }
910 KeyStoreTarget::OsKeychain => {
911 let (service, account) = manager.save_key_to_os_keychain()?;
912 locations.push(KeyLocation::OsKeychain { service, account });
913 }
914 KeyStoreTarget::SystemStore => {
915 let key_name = Self::key_name_from_env_or_default();
916 let loc = manager.save_key_to_system_store_as(&key_name)?;
917 locations.push(loc);
918 }
919 KeyStoreTarget::OsKeychainAndFile => {
920 let (service, account) = manager.save_key_to_os_keychain()?;
921 locations.push(KeyLocation::OsKeychain { service, account });
922 let path = options
923 .file_path
924 .unwrap_or_else(Self::key_path_from_env_or_default);
925 if path.exists() && !options.force {
926 return Err(SecretsError::KeyAlreadyExists(format!(
927 "key file at {}",
928 path.display()
929 )));
930 }
931 manager.save_key(&path)?;
932 locations.push(KeyLocation::UserFile(path));
933 }
934 }
935
936 let public_key = manager.public_key_string();
937 Ok(KeyGenResult {
938 manager,
939 locations,
940 public_key,
941 })
942 }
943
944 pub fn load_from_system_store() -> SecretsResult<Self> {
953 Self::discover_age_key_name_from_env_files()?;
954 let key_name = Self::key_name_from_env_or_default();
955 let backend = SystemStoreBackend::new(key_name.clone());
956 match backend.load_identity_string()? {
957 Some(data) => Self::load_from_string(&data),
958 None => Err(SecretsError::KeyLoadFailed(format!(
959 "no key found in system store for '{}'",
960 key_name
961 ))),
962 }
963 }
964
965 pub fn load_from_user(username: &str) -> SecretsResult<Self> {
977 Self::discover_age_key_name_from_env_files()?;
978 let key_name = Self::key_name_from_env_or_default();
979
980 let home = resolve_user_home(username)?;
981 let key_path = home
982 .join(".local/state")
983 .join(&key_name)
984 .with_extension("key");
985
986 let backend = FileKeyBackend::new(key_path.clone());
987 match backend.load_identity_string()? {
988 Some(data) => Self::load_from_string(&data),
989 None => Err(SecretsError::KeyLoadFailed(format!(
990 "no key file for user '{}' at {}",
991 username,
992 key_path.display()
993 ))),
994 }
995 }
996
997 pub fn key_exists_in_os_keychain() -> bool {
999 let _ = Self::discover_age_key_name_from_env_files();
1000 let key_name = Self::key_name_from_env_or_default();
1001 let service = Self::keychain_service_name();
1002 let backend = OsKeychainBackend::new(service, key_name);
1003 matches!(backend.load_identity_string(), Ok(Some(_)))
1004 }
1005
1006 pub fn key_exists_in_system_store() -> bool {
1008 let _ = Self::discover_age_key_name_from_env_files();
1009 let key_name = Self::key_name_from_env_or_default();
1010 let backend = SystemStoreBackend::new(key_name);
1011 matches!(backend.load_identity_string(), Ok(Some(_)))
1012 }
1013
1014 #[cfg(feature = "os-keychain")]
1021 pub fn delete_from_os_keychain() -> SecretsResult<()> {
1022 let _ = Self::discover_age_key_name_from_env_files();
1023 let key_name = Self::key_name_from_env_or_default();
1024 let service = Self::keychain_service_name();
1025 delete_from_os_keychain(&service, &key_name)
1026 }
1027
1028 pub fn system_store_path() -> PathBuf {
1035 let _ = Self::discover_age_key_name_from_env_files();
1036 let key_name = Self::key_name_from_env_or_default();
1037 system_store_path_for(&key_name)
1038 }
1039
1040 pub fn load_key() -> SecretsResult<Self> {
1065 Self::discover_age_key_name_from_env_files()?;
1069
1070 if let Ok(data) = std::env::var("DOTENVAGE_AGE_KEY") {
1071 return Self::load_from_string(&data);
1072 }
1073 if let Ok(data) = std::env::var("AGE_KEY") {
1074 return Self::load_from_string(&data);
1075 }
1076 if let Ok(data) = std::env::var("EKG_AGE_KEY") {
1077 return Self::load_from_string(&data);
1078 }
1079
1080 let key_name = Self::key_name_from_env_or_default();
1082 let keychain_service = Self::keychain_service_name();
1083 let os_keychain_backend = OsKeychainBackend::new(keychain_service, key_name.clone());
1084 if let Some(data) = os_keychain_backend.load_identity_string()? {
1085 return Self::load_from_string(&data);
1086 }
1087
1088 let system_backend = SystemStoreBackend::new(key_name);
1090 if let Some(data) = system_backend.load_identity_string()? {
1091 return Self::load_from_string(&data);
1092 }
1093
1094 let key_path = Self::key_path_from_env_or_default();
1096 let file_backend = FileKeyBackend::new(key_path.clone());
1097 if let Some(data) = file_backend.load_identity_string()? {
1098 return Self::load_from_string(&data);
1099 }
1100 Err(SecretsError::KeyLoadFailed(format!(
1101 "no key found (env vars, OS keychain, system store, \
1102 or key file at {})",
1103 key_path.display()
1104 )))
1105 }
1106
1107 pub fn discover_age_key_name_from_env_files() -> SecretsResult<()> {
1124 if std::env::var("AGE_KEY_NAME").is_ok() {
1126 return Ok(());
1127 }
1128
1129 let env_files = [".env.local", ".env"];
1131
1132 for env_file in &env_files {
1133 match Self::find_age_key_name_in_file(env_file)? {
1134 Some(key_name) => {
1135 unsafe {
1136 std::env::set_var("AGE_KEY_NAME", key_name);
1137 }
1138 return Ok(());
1139 }
1140 None => continue,
1141 }
1142 }
1143
1144 Ok(())
1145 }
1146
1147 fn find_age_key_name_in_file(file_path: &str) -> SecretsResult<Option<String>> {
1165 let content = std::fs::read_to_string(file_path).ok();
1166
1167 let Some(content) = content else {
1168 return Ok(None);
1169 };
1170
1171 for line in content.lines() {
1172 let line = line.trim();
1173
1174 if line.is_empty() || line.starts_with('#') {
1176 continue;
1177 }
1178
1179 let Some((key, value)) = line.split_once('=') else {
1181 continue;
1182 };
1183 let key = key.trim();
1184 let value = value.trim().trim_matches('"').trim_matches('\'');
1185
1186 if (key == "AGE_KEY_NAME" || key.ends_with("_AGE_KEY_NAME")) && !value.is_empty() {
1188 if Self::is_encrypted(value) {
1190 return Err(SecretsError::KeyLoadFailed(format!(
1191 "found encrypted AGE key name variable '{}' in {}: \
1192 AGE key name variables (e.g., EKG_AGE_KEY_NAME, AGE_KEY_NAME) must be \
1193 plaintext because they are used to discover the encryption key. \
1194 Please decrypt this variable or remove it from your .env file.",
1195 key, file_path
1196 )));
1197 }
1198 return Ok(Some(value.to_string()));
1199 }
1200 }
1201
1202 Ok(None)
1203 }
1204
1205 fn load_from_string(data: &str) -> SecretsResult<Self> {
1206 let identity = data
1207 .parse::<x25519::Identity>()
1208 .map_err(|e| SecretsError::KeyLoadFailed(format!("parse key: {}", e)))?;
1209 Ok(Self { identity })
1210 }
1211
1212 pub fn identity_string(&self) -> String {
1219 self.identity.to_string().expose_secret().to_string()
1220 }
1221
1222 fn key_name_from_env_or_default() -> String {
1223 std::env::var("AGE_KEY_NAME")
1224 .ok()
1225 .filter(|s| !s.trim().is_empty())
1226 .unwrap_or_else(|| {
1227 format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1229 })
1230 }
1231
1232 fn keychain_service_name() -> String {
1233 std::env::var("DOTENVAGE_KEYCHAIN_SERVICE")
1234 .ok()
1235 .filter(|s| !s.trim().is_empty())
1236 .unwrap_or_else(|| "dotenvage".to_string())
1237 }
1238
1239 pub fn key_path_from_env_or_default() -> PathBuf {
1257 let key_name = Self::key_name_from_env_or_default();
1258
1259 Self::xdg_base_dir_for(&key_name)
1261 .unwrap_or_else(|| PathBuf::from(".").join(&key_name))
1262 .with_extension("key")
1263 }
1264
1265 pub fn default_key_path() -> PathBuf {
1279 Self::xdg_base_dir_for("dotenvage")
1280 .unwrap_or_else(|| PathBuf::from(".").join("dotenvage"))
1281 .join("dotenvage.key")
1282 }
1283
1284 fn xdg_base_dir_for(name: &str) -> Option<PathBuf> {
1285 if let Ok(p) = std::env::var("XDG_STATE_HOME")
1286 && !p.is_empty()
1287 {
1288 return Some(PathBuf::from(p).join(name));
1289 }
1290 if let Ok(p) = std::env::var("XDG_CONFIG_HOME")
1291 && !p.is_empty()
1292 {
1293 return Some(PathBuf::from(p).join(name));
1294 }
1295 if let Ok(home) = std::env::var("HOME") {
1296 let home_path = PathBuf::from(home);
1297 let state_dir = home_path.join(".local/state").join(name);
1298 if state_dir.exists() || !home_path.join(".config").join(name).exists() {
1300 return Some(state_dir);
1301 }
1302 return Some(home_path.join(".config").join(name));
1303 }
1304 None
1305 }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use serial_test::serial;
1311
1312 use super::*;
1313
1314 #[test]
1315 fn test_encrypt_decrypt_roundtrip() {
1316 let manager = SecretManager::generate().expect("failed to generate manager");
1317 let plaintext = "sk_live_abc123";
1318 let encrypted = manager.encrypt_value(plaintext).expect("encryption failed");
1319 assert!(SecretManager::is_encrypted(&encrypted));
1320 let decrypted = manager
1321 .decrypt_value(&encrypted)
1322 .expect("decryption failed");
1323 assert_eq!(plaintext, decrypted);
1324 }
1325
1326 #[test]
1327 fn test_decrypt_unencrypted_value() {
1328 let manager = SecretManager::generate().expect("failed to generate manager");
1329 let plaintext = "not_encrypted";
1330 let result = manager
1331 .decrypt_value(plaintext)
1332 .expect("decrypt should pass through");
1333 assert_eq!(plaintext, result);
1334 }
1335
1336 #[test]
1337 #[serial]
1338 fn test_key_path_from_env_or_default_with_age_key_name() {
1339 let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1341 let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1342 let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1343
1344 unsafe {
1346 std::env::remove_var("XDG_CONFIG_HOME"); std::env::set_var("AGE_KEY_NAME", "myproject/myapp");
1348 std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1349 }
1350
1351 let path = SecretManager::key_path_from_env_or_default();
1352 assert_eq!(
1353 path,
1354 std::path::PathBuf::from("/tmp/xdg-state/myproject/myapp.key")
1355 );
1356
1357 unsafe {
1359 std::env::remove_var("AGE_KEY_NAME");
1360 std::env::remove_var("XDG_STATE_HOME");
1361 if let Some(val) = orig_age_key_name {
1362 std::env::set_var("AGE_KEY_NAME", val);
1363 }
1364 if let Some(val) = orig_xdg_state {
1365 std::env::set_var("XDG_STATE_HOME", val);
1366 }
1367 if let Some(val) = orig_xdg_config {
1368 std::env::set_var("XDG_CONFIG_HOME", val);
1369 }
1370 }
1371 }
1372
1373 #[test]
1374 #[serial]
1375 fn test_key_path_from_env_or_default_without_age_key_name() {
1376 let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1378 let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1379 let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1380
1381 unsafe {
1383 std::env::remove_var("AGE_KEY_NAME");
1384 std::env::remove_var("XDG_CONFIG_HOME"); std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1386 }
1387
1388 let path = SecretManager::key_path_from_env_or_default();
1389 let expected = format!("/tmp/xdg-state/{}/dotenvage.key", env!("CARGO_PKG_NAME"));
1390 assert_eq!(path, std::path::PathBuf::from(expected));
1391
1392 unsafe {
1394 std::env::remove_var("XDG_STATE_HOME");
1395 if let Some(val) = orig_age_key_name {
1396 std::env::set_var("AGE_KEY_NAME", val);
1397 }
1398 if let Some(val) = orig_xdg_state {
1399 std::env::set_var("XDG_STATE_HOME", val);
1400 }
1401 if let Some(val) = orig_xdg_config {
1402 std::env::set_var("XDG_CONFIG_HOME", val);
1403 }
1404 }
1405 }
1406
1407 #[test]
1408 #[serial]
1409 fn test_key_name_from_env_or_default() {
1410 let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1411
1412 unsafe {
1413 std::env::set_var("AGE_KEY_NAME", "myproject/prod");
1414 }
1415 assert_eq!(
1416 SecretManager::key_name_from_env_or_default(),
1417 "myproject/prod"
1418 );
1419
1420 unsafe {
1421 std::env::set_var("AGE_KEY_NAME", " ");
1422 }
1423 assert_eq!(
1424 SecretManager::key_name_from_env_or_default(),
1425 format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1426 );
1427
1428 unsafe {
1429 if let Some(val) = orig_age_key_name {
1430 std::env::set_var("AGE_KEY_NAME", val);
1431 } else {
1432 std::env::remove_var("AGE_KEY_NAME");
1433 }
1434 }
1435 }
1436
1437 #[test]
1438 #[serial]
1439 fn test_keychain_service_name() {
1440 let orig = std::env::var("DOTENVAGE_KEYCHAIN_SERVICE").ok();
1441
1442 unsafe {
1443 std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "team-secrets");
1444 }
1445 assert_eq!(SecretManager::keychain_service_name(), "team-secrets");
1446
1447 unsafe {
1448 std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", " ");
1449 }
1450 assert_eq!(SecretManager::keychain_service_name(), "dotenvage");
1451
1452 unsafe {
1453 if let Some(val) = orig {
1454 std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", val);
1455 } else {
1456 std::env::remove_var("DOTENVAGE_KEYCHAIN_SERVICE");
1457 }
1458 }
1459 }
1460
1461 #[test]
1462 #[serial]
1463 fn test_xdg_base_dir_for() {
1464 let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1466 let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1467 let orig_home = std::env::var("HOME").ok();
1468
1469 unsafe {
1471 std::env::set_var("XDG_STATE_HOME", "/custom/state");
1472 }
1473 let path = SecretManager::xdg_base_dir_for("test");
1474 assert_eq!(path, Some(std::path::PathBuf::from("/custom/state/test")));
1475
1476 unsafe {
1478 std::env::remove_var("XDG_STATE_HOME");
1479 std::env::remove_var("XDG_CONFIG_HOME");
1480 std::env::set_var("HOME", "/home/user");
1481 }
1482 let path = SecretManager::xdg_base_dir_for("test");
1483 assert_eq!(
1484 path,
1485 Some(std::path::PathBuf::from("/home/user/.local/state/test"))
1486 );
1487
1488 unsafe {
1490 if let Some(val) = orig_xdg_state {
1491 std::env::set_var("XDG_STATE_HOME", val);
1492 } else {
1493 std::env::remove_var("XDG_STATE_HOME");
1494 }
1495 if let Some(val) = orig_xdg_config {
1496 std::env::set_var("XDG_CONFIG_HOME", val);
1497 } else {
1498 std::env::remove_var("XDG_CONFIG_HOME");
1499 }
1500 if let Some(val) = orig_home {
1501 std::env::set_var("HOME", val);
1502 } else {
1503 std::env::remove_var("HOME");
1504 }
1505 }
1506 }
1507}