1use crate::cli::error::{CliError, CliResult};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs::{self, File};
10use std::io::{Read, Write};
11use std::os::unix::fs::PermissionsExt;
12use std::path::PathBuf;
13
14pub struct SecretManager {
16 secrets_dir: PathBuf,
18 cache: HashMap<String, SecretValue>,
20 key: Option<Vec<u8>>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SecretValue {
27 pub value: String,
29 pub description: Option<String>,
31 pub created_at: chrono::DateTime<chrono::Utc>,
33 pub updated_at: chrono::DateTime<chrono::Utc>,
35 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
37}
38
39#[derive(Debug, Clone, Copy)]
41pub enum SecretBackend {
42 File,
44 SystemKeyring,
46 Environment,
48 Vault,
50}
51
52impl SecretManager {
53 pub fn new(_backend: SecretBackend) -> CliResult<Self> {
55 let secrets_dir = Self::get_secrets_dir()?;
56 Self::with_dir(secrets_dir)
57 }
58
59 pub fn with_dir(secrets_dir: PathBuf) -> CliResult<Self> {
64 if !secrets_dir.exists() {
66 fs::create_dir_all(&secrets_dir).map_err(|e| {
67 CliError::config_error(format!("Cannot create secrets directory: {e}"))
68 })?;
69
70 #[cfg(unix)]
72 {
73 let metadata = fs::metadata(&secrets_dir)?;
74 let mut permissions = metadata.permissions();
75 permissions.set_mode(0o700);
76 fs::set_permissions(&secrets_dir, permissions)?;
77 }
78 }
79
80 Ok(Self {
81 secrets_dir,
82 cache: HashMap::new(),
83 key: None,
84 })
85 }
86
87 fn get_secrets_dir() -> CliResult<PathBuf> {
89 if let Ok(dir) = std::env::var("OXIRS_SECRETS_DIR") {
91 return Ok(PathBuf::from(dir));
92 }
93
94 #[cfg(target_os = "macos")]
96 let base_dir = dirs::home_dir().map(|h| h.join("Library/Application Support"));
97
98 #[cfg(target_os = "linux")]
99 let base_dir = dirs::config_dir();
100
101 #[cfg(target_os = "windows")]
102 let base_dir = dirs::data_local_dir();
103
104 base_dir
105 .map(|p| p.join("oxirs/secrets"))
106 .ok_or_else(|| CliError::config_error("Cannot determine secrets directory"))
107 }
108
109 pub fn unlock(&mut self, password: &str) -> CliResult<()> {
111 self.key = Some(self.derive_key(password)?);
113
114 if self.secrets_dir.join(".test").exists() {
116 self.get_secret(".test")?;
117 }
118
119 Ok(())
120 }
121
122 pub fn is_unlocked(&self) -> bool {
124 self.key.is_some()
125 }
126
127 pub fn set_secret(
129 &mut self,
130 name: &str,
131 value: &str,
132 description: Option<String>,
133 ) -> CliResult<()> {
134 if !self.is_unlocked() {
135 return Err(CliError::config_error("Secret manager is locked"));
136 }
137
138 let secret = SecretValue {
139 value: value.to_string(),
140 description,
141 created_at: chrono::Utc::now(),
142 updated_at: chrono::Utc::now(),
143 expires_at: None,
144 };
145
146 self.store_encrypted_secret(name, &secret)?;
148
149 self.cache.insert(name.to_string(), secret);
151
152 Ok(())
153 }
154
155 pub fn get_secret(&mut self, name: &str) -> CliResult<String> {
157 if let Some(secret) = self.cache.get(name) {
159 if let Some(expires) = secret.expires_at {
161 if expires < chrono::Utc::now() {
162 self.cache.remove(name);
163 return Err(CliError::config_error("Secret has expired"));
164 }
165 }
166 return Ok(secret.value.clone());
167 }
168
169 let env_name = format!("OXIRS_SECRET_{}", name.to_uppercase().replace('-', "_"));
171 if let Ok(value) = std::env::var(&env_name) {
172 return Ok(value);
173 }
174
175 if !self.is_unlocked() {
177 return Err(CliError::config_error("Secret manager is locked"));
178 }
179
180 let secret = self.load_encrypted_secret(name)?;
181
182 if let Some(expires) = secret.expires_at {
184 if expires < chrono::Utc::now() {
185 return Err(CliError::config_error("Secret has expired"));
186 }
187 }
188
189 let value = secret.value.clone();
190 self.cache.insert(name.to_string(), secret);
191
192 Ok(value)
193 }
194
195 pub fn delete_secret(&mut self, name: &str) -> CliResult<()> {
197 self.cache.remove(name);
198
199 let path = self.secrets_dir.join(format!("{name}.secret"));
200 if path.exists() {
201 fs::remove_file(path)
202 .map_err(|e| CliError::config_error(format!("Cannot delete secret: {e}")))?;
203 }
204
205 Ok(())
206 }
207
208 pub fn list_secrets(&self) -> CliResult<Vec<SecretInfo>> {
210 let mut secrets = Vec::new();
211
212 if self.secrets_dir.exists() {
213 for entry in fs::read_dir(&self.secrets_dir)? {
214 let entry = entry?;
215 let path = entry.path();
216
217 if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
218 if path.extension().and_then(|e| e.to_str()) == Some("secret")
219 && name != ".test"
220 {
221 if let Ok(metadata) = self.load_secret_metadata(name) {
223 secrets.push(metadata);
224 }
225 }
226 }
227 }
228 }
229
230 for (key, _) in std::env::vars() {
232 if key.starts_with("OXIRS_SECRET_") {
233 let name = key
234 .strip_prefix("OXIRS_SECRET_")
235 .expect("prefix should match after starts_with check")
236 .to_lowercase()
237 .replace('_', "-");
238
239 secrets.push(SecretInfo {
240 name,
241 description: Some("Environment variable".to_string()),
242 created_at: None,
243 expires_at: None,
244 source: SecretSource::Environment,
245 });
246 }
247 }
248
249 secrets.sort_by(|a, b| a.name.cmp(&b.name));
250 Ok(secrets)
251 }
252
253 fn derive_key(&self, password: &str) -> CliResult<Vec<u8>> {
261 let salt = b"oxirs-secret-salt"; let mut key = vec![0u8; 32];
263
264 oxicrypto_kdf::pbkdf2_sha256(password.as_bytes(), salt, 100_000, &mut key)
265 .map_err(|e| CliError::config_error(format!("Key derivation failed: {e}")))?;
266
267 Ok(key)
268 }
269
270 fn store_encrypted_secret(&self, name: &str, secret: &SecretValue) -> CliResult<()> {
272 use oxicrypto_core::Aead;
273
274 let key = self
275 .key
276 .as_ref()
277 .ok_or_else(|| CliError::config_error("No encryption key available"))?;
278
279 let plaintext = serde_json::to_vec(secret)
281 .map_err(|e| CliError::config_error(format!("Cannot serialize secret: {e}")))?;
282
283 let nonce = [0u8; 12]; let ciphertext = oxicrypto_aead::Aes256Gcm
290 .seal_to_vec(key, &nonce, &[], &plaintext)
291 .map_err(|_| CliError::config_error("Encryption failed"))?;
292
293 let path = self.secrets_dir.join(format!("{name}.secret"));
295 let mut file = File::create(&path)
296 .map_err(|e| CliError::config_error(format!("Cannot create secret file: {e}")))?;
297
298 #[cfg(unix)]
300 {
301 let metadata = file.metadata()?;
302 let mut permissions = metadata.permissions();
303 permissions.set_mode(0o600);
304 file.set_permissions(permissions)?;
305 }
306
307 file.write_all(&ciphertext)
308 .map_err(|e| CliError::config_error(format!("Cannot write secret: {e}")))?;
309
310 Ok(())
311 }
312
313 fn load_encrypted_secret(&self, name: &str) -> CliResult<SecretValue> {
315 use oxicrypto_core::Aead;
316
317 let key = self
318 .key
319 .as_ref()
320 .ok_or_else(|| CliError::config_error("No decryption key available"))?;
321
322 let path = self.secrets_dir.join(format!("{name}.secret"));
324 let mut file = File::open(&path)
325 .map_err(|_| CliError::config_error(format!("Secret '{name}' not found")))?;
326
327 let mut ciphertext = Vec::new();
328 file.read_to_end(&mut ciphertext)
329 .map_err(|e| CliError::config_error(format!("Cannot read secret: {e}")))?;
330
331 let nonce = [0u8; 12];
337 let plaintext = oxicrypto_aead::Aes256Gcm
338 .open_to_vec(key, &nonce, &[], &ciphertext)
339 .map_err(|_| CliError::config_error("Decryption failed - wrong password?"))?;
340
341 serde_json::from_slice(&plaintext)
343 .map_err(|e| CliError::config_error(format!("Cannot deserialize secret: {e}")))
344 }
345
346 fn load_secret_metadata(&self, name: &str) -> CliResult<SecretInfo> {
348 Ok(SecretInfo {
351 name: name.to_string(),
352 description: None,
353 created_at: None,
354 expires_at: None,
355 source: SecretSource::File,
356 })
357 }
358}
359
360#[derive(Debug, Clone)]
362pub struct SecretInfo {
363 pub name: String,
364 pub description: Option<String>,
365 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
366 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
367 pub source: SecretSource,
368}
369
370#[derive(Debug, Clone, Copy)]
372pub enum SecretSource {
373 File,
374 Environment,
375 SystemKeyring,
376}
377
378pub mod keyring {
391 use super::*;
392
393 #[cfg(feature = "system-keyring")]
403 pub fn store_in_keyring(service: &str, name: &str, value: &str) -> CliResult<()> {
404 init_default_store()?;
405 let entry = keyring_core::Entry::new(service, name).map_err(|e| {
406 CliError::config_error(format!(
407 "System keyring error creating entry '{name}' for service '{service}': {e}"
408 ))
409 })?;
410 entry.set_password(value).map_err(|e| {
411 CliError::config_error(format!(
412 "System keyring error storing secret '{name}' for service '{service}': {e}"
413 ))
414 })
415 }
416
417 #[cfg(not(feature = "system-keyring"))]
418 pub fn store_in_keyring(_service: &str, _name: &str, _value: &str) -> CliResult<()> {
419 Err(CliError::config_error(
420 "System keyring requires the 'system-keyring' feature flag. \
421 Rebuild with: cargo build --features system-keyring",
422 ))
423 }
424
425 #[cfg(feature = "system-keyring")]
432 pub fn get_from_keyring(service: &str, name: &str) -> CliResult<String> {
433 init_default_store()?;
434 let entry = keyring_core::Entry::new(service, name).map_err(|e| {
435 CliError::config_error(format!(
436 "System keyring error creating entry '{name}' for service '{service}': {e}"
437 ))
438 })?;
439 entry.get_password().map_err(|e| {
440 CliError::config_error(format!(
441 "System keyring error retrieving secret '{name}' for service '{service}': {e}"
442 ))
443 })
444 }
445
446 #[cfg(not(feature = "system-keyring"))]
447 pub fn get_from_keyring(_service: &str, _name: &str) -> CliResult<String> {
448 Err(CliError::config_error(
449 "System keyring requires the 'system-keyring' feature flag. \
450 Rebuild with: cargo build --features system-keyring",
451 ))
452 }
453
454 #[cfg(feature = "system-keyring")]
461 fn init_default_store() -> CliResult<()> {
462 use std::sync::{Mutex, Once};
463 static INIT: Once = Once::new();
464 static INIT_ERR: Mutex<Option<String>> = Mutex::new(None);
465
466 INIT.call_once(|| {
467 let result: CliResult<()> = (|| {
468 #[cfg(target_os = "macos")]
469 {
470 use apple_native_keyring_store::keychain::Store;
471 let store = Store::new_with_configuration(&std::collections::HashMap::new())
472 .map_err(|e| {
473 CliError::config_error(format!(
474 "Failed to initialise macOS Keychain store: {e}"
475 ))
476 })?;
477 keyring_core::set_default_store(store);
478 }
479 #[cfg(not(target_os = "macos"))]
480 {
481 use keyring_core::sample::Store;
484 let store =
485 Store::new_with_configuration(&std::collections::HashMap::from([(
486 "persist", "true",
487 )]))
488 .map_err(|e| {
489 CliError::config_error(format!(
490 "Failed to initialise keyring sample store: {e}"
491 ))
492 })?;
493 keyring_core::set_default_store(store);
494 }
495 Ok(())
496 })();
497 if let Err(e) = result {
498 if let Ok(mut guard) = INIT_ERR.lock() {
499 *guard = Some(e.to_string());
500 }
501 }
502 });
503
504 match INIT_ERR.lock() {
506 Ok(guard) => match guard.as_ref() {
507 Some(msg) => Err(CliError::config_error(format!(
508 "System keyring initialisation failed: {msg}"
509 ))),
510 None => Ok(()),
511 },
512 Err(_) => Err(CliError::config_error(
513 "Internal error: keyring init mutex poisoned",
514 )),
515 }
516 }
517}
518
519pub mod credentials {
521 use super::*;
522
523 #[derive(Debug, Clone, Serialize, Deserialize)]
525 pub struct EndpointCredentials {
526 pub url: String,
527 pub username: Option<String>,
528 pub password: Option<String>,
529 pub auth_type: AuthType,
530 }
531
532 #[derive(Debug, Clone, Serialize, Deserialize)]
533 pub enum AuthType {
534 None,
535 Basic,
536 Bearer,
537 OAuth2,
538 }
539
540 pub fn get_endpoint_credentials(
542 manager: &mut SecretManager,
543 url: &str,
544 ) -> CliResult<EndpointCredentials> {
545 let secret_name = format!("endpoint_{}", url.replace(['/', ':'], "_"));
546
547 if let Ok(creds_json) = manager.get_secret(&secret_name) {
548 serde_json::from_str(&creds_json)
549 .map_err(|e| CliError::config_error(format!("Invalid credentials format: {e}")))
550 } else {
551 Ok(EndpointCredentials {
552 url: url.to_string(),
553 username: None,
554 password: None,
555 auth_type: AuthType::None,
556 })
557 }
558 }
559
560 pub fn store_endpoint_credentials(
562 manager: &mut SecretManager,
563 creds: &EndpointCredentials,
564 ) -> CliResult<()> {
565 let secret_name = format!("endpoint_{}", creds.url.replace(['/', ':'], "_"));
566 let creds_json = serde_json::to_string(creds)
567 .map_err(|e| CliError::config_error(format!("Cannot serialize credentials: {e}")))?;
568
569 manager.set_secret(
570 &secret_name,
571 &creds_json,
572 Some(format!("Credentials for {}", creds.url)),
573 )
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use tempfile::tempdir;
581
582 #[test]
583 fn test_secret_manager_creation() {
584 let dir = tempdir().expect("failed to create temp dir");
585 let manager = SecretManager::with_dir(dir.path().to_path_buf())
586 .expect("failed to create SecretManager");
587 assert!(!manager.is_unlocked());
588 }
589
590 #[test]
591 fn test_secret_storage_and_retrieval() {
592 let dir = tempdir().expect("failed to create temp dir");
593 let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
594 .expect("failed to create SecretManager");
595 manager.unlock("test-password").expect("failed to unlock");
596
597 manager
599 .set_secret("test-key", "test-value", Some("Test secret".to_string()))
600 .expect("failed to set secret");
601
602 let value = manager
604 .get_secret("test-key")
605 .expect("failed to get secret");
606 assert_eq!(value, "test-value");
607
608 let secrets = manager.list_secrets().expect("failed to list secrets");
610 assert!(secrets.iter().any(|s| s.name == "test-key"));
611 }
612
613 #[test]
614 fn test_pbkdf2_derive_is_deterministic_and_correct_length() {
615 let dir = tempdir().expect("failed to create temp dir");
619 let manager = SecretManager::with_dir(dir.path().to_path_buf())
620 .expect("failed to create SecretManager");
621
622 let key_a = manager
623 .derive_key("correct horse battery staple")
624 .expect("derive_key failed");
625 let key_b = manager
626 .derive_key("correct horse battery staple")
627 .expect("derive_key failed");
628 let key_other = manager
629 .derive_key("a different password")
630 .expect("derive_key failed");
631
632 assert_eq!(key_a.len(), 32, "derived key must be 32 bytes for AES-256");
633 assert_eq!(key_a, key_b, "PBKDF2 derivation must be deterministic");
634 assert_ne!(
635 key_a, key_other,
636 "different passwords must derive different keys"
637 );
638
639 let mut expected = vec![0u8; 32];
641 oxicrypto_kdf::pbkdf2_sha256(
642 b"correct horse battery staple",
643 b"oxirs-secret-salt",
644 100_000,
645 &mut expected,
646 )
647 .expect("pbkdf2_sha256 failed");
648 assert_eq!(
649 key_a, expected,
650 "derive_key must match raw pbkdf2_sha256 output"
651 );
652 }
653
654 #[test]
655 fn test_encrypt_decrypt_round_trip_on_disk() {
656 let dir = tempdir().expect("failed to create temp dir");
659 let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
660 .expect("failed to create SecretManager");
661 manager
662 .unlock("round-trip-password")
663 .expect("unlock failed");
664
665 manager
666 .set_secret(
667 "db-password",
668 "s3cr3t-value-with-unicode-\u{1F600}",
669 Some("integration secret".to_string()),
670 )
671 .expect("set_secret failed");
672
673 let secret_path = dir.path().join("db-password.secret");
676 let raw = std::fs::read(&secret_path).expect("failed to read secret file");
677 assert!(
678 raw.len() >= 16,
679 "ciphertext must include the 16-byte GCM tag"
680 );
681 assert!(
682 !raw.windows(7).any(|w| w == b"s3cr3t-"),
683 "plaintext must not appear in the encrypted file"
684 );
685
686 manager.cache.clear();
688 let value = manager
689 .get_secret("db-password")
690 .expect("get_secret failed");
691 assert_eq!(value, "s3cr3t-value-with-unicode-\u{1F600}");
692 }
693
694 #[test]
695 fn test_decrypt_with_wrong_password_fails() {
696 let dir = tempdir().expect("failed to create temp dir");
699
700 {
701 let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
702 .expect("failed to create SecretManager");
703 manager.unlock("first-password").expect("unlock failed");
704 manager
705 .set_secret("token", "abc123", None)
706 .expect("set_secret failed");
707 }
708
709 let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
710 .expect("failed to create SecretManager");
711 manager.unlock("wrong-password").expect("unlock failed");
712 let result = manager.get_secret("token");
713 assert!(
714 result.is_err(),
715 "decryption with the wrong password must fail"
716 );
717 }
718
719 #[test]
720 fn test_environment_secret() {
721 unsafe { std::env::set_var("OXIRS_SECRET_API_KEY", "secret-api-key") };
726
727 let dir = tempdir().expect("failed to create temp dir");
728 let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
729 .expect("failed to create SecretManager");
730 let value = manager.get_secret("api-key").expect("failed to get secret");
731 assert_eq!(value, "secret-api-key");
732
733 unsafe { std::env::remove_var("OXIRS_SECRET_API_KEY") };
735 }
736
737 #[cfg(not(feature = "system-keyring"))]
744 #[test]
745 fn test_keyring_store_requires_feature_flag() {
746 let result = keyring::store_in_keyring("oxirs-test", "test-key", "test-value");
747 assert!(result.is_err());
748 let msg = result.unwrap_err().to_string();
749 assert!(
750 msg.contains("system-keyring"),
751 "error should mention the feature flag, got: {msg}"
752 );
753 }
754
755 #[cfg(not(feature = "system-keyring"))]
756 #[test]
757 fn test_keyring_get_requires_feature_flag() {
758 let result = keyring::get_from_keyring("oxirs-test", "test-key");
759 assert!(result.is_err());
760 let msg = result.unwrap_err().to_string();
761 assert!(
762 msg.contains("system-keyring"),
763 "error should mention the feature flag, got: {msg}"
764 );
765 }
766
767 #[cfg(feature = "system-keyring")]
771 #[test]
772 fn test_keyring_round_trip() {
773 let service = "oxirs-test-service";
774 let key = "oxirs-test-round-trip-key";
775 let value = "super-secret-value-42";
776
777 keyring::store_in_keyring(service, key, value).expect("keyring store failed");
778
779 let retrieved = keyring::get_from_keyring(service, key).expect("keyring retrieve failed");
780 assert_eq!(retrieved, value, "retrieved value must equal stored value");
781
782 if let Ok(entry) = keyring_core::Entry::new(service, key) {
784 let _ = entry.delete_credential();
785 }
786 }
787}