1use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37use zeroize::{Zeroize, ZeroizeOnDrop};
38
39use crate::error::AppError;
40
41#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
53pub struct Secret {
54 #[serde(skip_serializing)]
55 pub access_key_id: String,
56 #[serde(skip_serializing)]
57 pub secret_access_key: String,
58 #[serde(skip_serializing)]
59 pub session_token: Option<String>,
60}
61
62#[derive(Debug, Serialize, Deserialize)]
65struct StoredSecret {
66 access_key_id: String,
67 secret_access_key: String,
68 session_token: Option<String>,
69}
70
71impl From<&Secret> for StoredSecret {
72 fn from(s: &Secret) -> Self {
73 Self {
74 access_key_id: s.access_key_id.clone(),
75 secret_access_key: s.secret_access_key.clone(),
76 session_token: s.session_token.clone(),
77 }
78 }
79}
80
81impl From<StoredSecret> for Secret {
82 fn from(s: StoredSecret) -> Self {
83 Self {
84 access_key_id: s.access_key_id,
85 secret_access_key: s.secret_access_key,
86 session_token: s.session_token,
87 }
88 }
89}
90
91pub trait KeychainBackend: Send + Sync {
98 fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError>;
100
101 fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError>;
103
104 fn delete(&mut self, profile_id: &str) -> Result<(), AppError>;
106
107 fn unlock(&mut self, _passphrase: &str) -> Result<(), AppError> {
118 Ok(())
119 }
120}
121
122pub struct KeyringBackend;
135
136impl KeyringBackend {
137 pub fn new() -> Self {
140 Self
141 }
142
143 fn entry_name(profile_id: &str) -> String {
144 format!("profile:{profile_id}")
145 }
146}
147
148impl Default for KeyringBackend {
149 fn default() -> Self {
150 Self::new()
151 }
152}
153
154impl KeychainBackend for KeyringBackend {
155 fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
156 let stored = StoredSecret::from(secret);
157 let json = serde_json::to_string(&stored).map_err(|e| AppError::Internal {
158 trace_id: format!("keyring::set::serialize:{e}"),
159 })?;
160 keyring::Entry::new("brows3r", &Self::entry_name(profile_id))
161 .map_err(|e| AppError::Internal {
162 trace_id: format!("keyring::set::entry:{e}"),
163 })?
164 .set_password(&json)
165 .map_err(|e| AppError::Internal {
166 trace_id: format!("keyring::set::write:{e}"),
167 })
168 }
169
170 fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
171 let entry = keyring::Entry::new("brows3r", &Self::entry_name(profile_id)).map_err(|e| {
172 AppError::Internal {
173 trace_id: format!("keyring::get::entry:{e}"),
174 }
175 })?;
176 match entry.get_password() {
177 Ok(json) => {
178 let stored: StoredSecret =
179 serde_json::from_str(&json).map_err(|e| AppError::Internal {
180 trace_id: format!("keyring::get::deserialize:{e}"),
181 })?;
182 Ok(Some(Secret::from(stored)))
183 }
184 Err(keyring::Error::NoEntry) => Ok(None),
185 Err(e) => Err(AppError::Internal {
186 trace_id: format!("keyring::get::read:{e}"),
187 }),
188 }
189 }
190
191 fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
192 let entry = keyring::Entry::new("brows3r", &Self::entry_name(profile_id)).map_err(|e| {
193 AppError::Internal {
194 trace_id: format!("keyring::delete::entry:{e}"),
195 }
196 })?;
197 match entry.delete_credential() {
198 Ok(()) => Ok(()),
199 Err(keyring::Error::NoEntry) => Ok(()),
201 Err(e) => Err(AppError::Internal {
202 trace_id: format!("keyring::delete::remove:{e}"),
203 }),
204 }
205 }
206}
207
208use aes_gcm::{
223 aead::{Aead, AeadCore, KeyInit, OsRng},
224 Aes256Gcm, Key, Nonce,
225};
226use argon2::{Argon2, Params};
227
228struct FileBackend {
239 path: std::path::PathBuf,
241 key_bytes: [u8; 32],
244 map: BTreeMap<String, StoredSecret>,
246 loaded: bool,
248}
249
250impl FileBackend {
251 fn new(dir: impl Into<std::path::PathBuf>, passphrase: &str) -> Self {
252 let key_bytes = derive_key(passphrase, &[0u8; 32]);
255 Self {
256 path: dir.into(),
257 key_bytes,
258 map: BTreeMap::new(),
259 loaded: false,
260 }
261 }
262
263 fn file_path(&self) -> std::path::PathBuf {
264 self.path.join("secrets.enc")
265 }
266
267 fn ensure_loaded(&mut self, passphrase: &str) -> Result<(), AppError> {
269 if self.loaded {
270 return Ok(());
271 }
272 self.loaded = true;
273
274 let file_path = self.file_path();
275 if !file_path.exists() {
276 return Ok(());
277 }
278
279 let blob = std::fs::read(&file_path).map_err(|e| AppError::Internal {
280 trace_id: format!("file_backend::read:{e}"),
281 })?;
282
283 if blob.len() < 44 {
285 return Err(AppError::Internal {
286 trace_id: "file_backend::truncated_blob".to_string(),
287 });
288 }
289
290 let salt: [u8; 32] = blob[..32].try_into().unwrap();
291 let nonce_bytes: [u8; 12] = blob[32..44].try_into().unwrap();
292 let ciphertext = &blob[44..];
293
294 let key_bytes = derive_key(passphrase, &salt);
295 self.key_bytes = key_bytes;
297
298 let key: Key<Aes256Gcm> = key_bytes.into();
299 let cipher = Aes256Gcm::new(&key);
300 let nonce = Nonce::from_slice(&nonce_bytes);
301
302 let plaintext = cipher
303 .decrypt(nonce, ciphertext)
304 .map_err(|_| AppError::Auth {
305 reason: "invalid passphrase or corrupted secrets file".to_string(),
306 })?;
307
308 self.map = serde_json::from_slice(&plaintext).map_err(|e| AppError::Internal {
309 trace_id: format!("file_backend::deserialize:{e}"),
310 })?;
311
312 Ok(())
313 }
314
315 fn flush(&self, passphrase: &str) -> Result<(), AppError> {
317 let json = serde_json::to_vec(&self.map).map_err(|e| AppError::Internal {
318 trace_id: format!("file_backend::serialize:{e}"),
319 })?;
320
321 let salt: [u8; 32] = {
323 let mut s = [0u8; 32];
324 use aes_gcm::aead::rand_core::RngCore;
325 OsRng.fill_bytes(&mut s);
326 s
327 };
328
329 let key_bytes = derive_key(passphrase, &salt);
330 let key: Key<Aes256Gcm> = key_bytes.into();
331 let cipher = Aes256Gcm::new(&key);
332 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
333
334 let ciphertext = cipher
335 .encrypt(&nonce, json.as_ref())
336 .map_err(|e| AppError::Internal {
337 trace_id: format!("file_backend::encrypt:{e}"),
338 })?;
339
340 let mut blob = Vec::with_capacity(44 + ciphertext.len());
342 blob.extend_from_slice(&salt);
343 blob.extend_from_slice(&nonce);
344 blob.extend_from_slice(&ciphertext);
345
346 if let Some(parent) = self.file_path().parent() {
347 std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
348 trace_id: format!("file_backend::mkdir:{e}"),
349 })?;
350 }
351 std::fs::write(self.file_path(), &blob).map_err(|e| AppError::Internal {
352 trace_id: format!("file_backend::write:{e}"),
353 })
354 }
355}
356
357impl Drop for FileBackend {
358 fn drop(&mut self) {
359 self.key_bytes.zeroize();
360 }
361}
362
363pub struct FileBackendWithPassphrase {
372 inner: FileBackend,
373 passphrase: zeroize::Zeroizing<String>,
374}
375
376impl FileBackendWithPassphrase {
377 pub fn new(dir: impl Into<std::path::PathBuf>, passphrase: impl Into<String>) -> Self {
383 let passphrase: String = passphrase.into();
384 let inner = FileBackend::new(dir, &passphrase);
385 Self {
386 inner,
387 passphrase: zeroize::Zeroizing::new(passphrase),
388 }
389 }
390}
391
392impl KeychainBackend for FileBackendWithPassphrase {
393 fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
394 self.inner.ensure_loaded(self.passphrase.as_str())?;
395 self.inner
396 .map
397 .insert(profile_id.to_string(), StoredSecret::from(secret));
398 self.inner.flush(self.passphrase.as_str())
399 }
400
401 fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
402 if self.inner.loaded {
406 return Ok(self.inner.map.get(profile_id).map(|s| {
407 Secret::from(StoredSecret {
408 access_key_id: s.access_key_id.clone(),
409 secret_access_key: s.secret_access_key.clone(),
410 session_token: s.session_token.clone(),
411 })
412 }));
413 }
414
415 let file_path = self.inner.file_path();
416 if !file_path.exists() {
417 return Ok(None);
418 }
419
420 let map = decrypt_file(&file_path, self.passphrase.as_str())?;
422 Ok(map.get(profile_id).map(|s| {
423 Secret::from(StoredSecret {
424 access_key_id: s.access_key_id.clone(),
425 secret_access_key: s.secret_access_key.clone(),
426 session_token: s.session_token.clone(),
427 })
428 }))
429 }
430
431 fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
432 self.inner.ensure_loaded(self.passphrase.as_str())?;
433 self.inner.map.remove(profile_id);
434 self.inner.flush(self.passphrase.as_str())
435 }
436}
437
438fn decrypt_file(
440 path: &std::path::Path,
441 passphrase: &str,
442) -> Result<BTreeMap<String, StoredSecret>, AppError> {
443 let blob = std::fs::read(path).map_err(|e| AppError::Internal {
444 trace_id: format!("file_backend::get::read:{e}"),
445 })?;
446
447 if blob.len() < 44 {
448 return Err(AppError::Internal {
449 trace_id: "file_backend::get::truncated_blob".to_string(),
450 });
451 }
452
453 let salt: [u8; 32] = blob[..32].try_into().unwrap();
454 let nonce_bytes: [u8; 12] = blob[32..44].try_into().unwrap();
455 let ciphertext = &blob[44..];
456
457 let key_bytes = derive_key(passphrase, &salt);
458 let key: Key<Aes256Gcm> = key_bytes.into();
459 let cipher = Aes256Gcm::new(&key);
460 let nonce = Nonce::from_slice(&nonce_bytes);
461
462 let plaintext = cipher
463 .decrypt(nonce, ciphertext)
464 .map_err(|_| AppError::Auth {
465 reason: "invalid passphrase or corrupted secrets file".to_string(),
466 })?;
467
468 serde_json::from_slice(&plaintext).map_err(|e| AppError::Internal {
469 trace_id: format!("file_backend::get::deserialize:{e}"),
470 })
471}
472
473fn derive_key(passphrase: &str, salt: &[u8; 32]) -> [u8; 32] {
479 let mut key = [0u8; 32];
480 let params = Params::new(
485 65536, 3, 1, Some(32),
489 )
490 .expect("argon2 params are valid constants");
491 Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
492 .hash_password_into(passphrase.as_bytes(), salt, &mut key)
493 .expect("argon2 hash_password_into must not fail with valid params");
494 key
495}
496
497#[cfg(feature = "test-keyring-stub")]
506pub struct StubBackend {
507 map: std::collections::HashMap<String, Secret>,
508}
509
510#[cfg(feature = "test-keyring-stub")]
511impl StubBackend {
512 pub fn new() -> Self {
513 Self {
514 map: std::collections::HashMap::new(),
515 }
516 }
517}
518
519#[cfg(feature = "test-keyring-stub")]
520impl Default for StubBackend {
521 fn default() -> Self {
522 Self::new()
523 }
524}
525
526#[cfg(feature = "test-keyring-stub")]
527impl KeychainBackend for StubBackend {
528 fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
529 self.map.insert(profile_id.to_string(), secret.clone());
530 Ok(())
531 }
532
533 fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
534 Ok(self.map.get(profile_id).cloned())
535 }
536
537 fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
538 self.map.remove(profile_id);
539 Ok(())
540 }
541}
542
543pub fn select_backend(
561 fallback_dir: impl Into<std::path::PathBuf>,
562 fallback_passphrase: &str,
563) -> (Box<dyn KeychainBackend + Send + Sync>, bool) {
564 if std::env::var("BROWS3R_FORCE_OS_KEYCHAIN").is_ok() {
569 return (Box::new(KeyringBackend::new()), false);
570 }
571
572 let probe_err: Option<String> = match keyring::Entry::new("brows3r", "__probe__") {
584 Ok(entry) => match entry.get_password() {
585 Ok(_) => None,
586 Err(keyring::Error::NoEntry) => None,
587 Err(e) => Some(format!("get: {e}")),
588 },
589 Err(e) => Some(format!("new: {e}")),
590 };
591
592 if probe_err.is_none() {
593 (Box::new(KeyringBackend::new()), false)
594 } else {
595 eprintln!(
603 "[brows3r] OS keychain unavailable — falling back to encrypted file backend. \
604 Probe error: {}. Until the user supplies a passphrase via the Credential \
605 Manager prompt, secrets.enc is encrypted with the empty placeholder passphrase. \
606 In dev mode this is often caused by macOS prompting per unsigned binary; \
607 set BROWS3R_FORCE_OS_KEYCHAIN=1 to bypass the probe and trust the OS keychain.",
608 probe_err.as_deref().unwrap_or("<unknown>"),
609 );
610 (
611 Box::new(FileBackendWithPassphrase::new(
612 fallback_dir,
613 fallback_passphrase,
614 )),
615 true,
616 )
617 }
618}
619
620#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[cfg(feature = "test-keyring-stub")]
633 mod stub_tests {
634 use super::*;
635
636 fn stub() -> StubBackend {
637 StubBackend::new()
638 }
639
640 fn secret(id: &str) -> Secret {
641 Secret {
642 access_key_id: format!("AKIA{id}"),
643 secret_access_key: format!("secret{id}"),
644 session_token: None,
645 }
646 }
647
648 #[test]
649 fn stub_set_and_retrieve() {
650 let mut b = stub();
651 let s = secret("01");
652 b.set("p1", &s).unwrap();
653 let got = b.get("p1").unwrap().expect("should be Some");
654 assert_eq!(got.access_key_id, s.access_key_id);
655 assert_eq!(got.secret_access_key, s.secret_access_key);
656 assert!(got.session_token.is_none());
657 }
658
659 #[test]
660 fn stub_get_missing_returns_none() {
661 let b = stub();
662 assert!(b.get("nonexistent").unwrap().is_none());
663 }
664
665 #[test]
666 fn stub_delete_removes_entry() {
667 let mut b = stub();
668 let s = secret("02");
669 b.set("p2", &s).unwrap();
670 b.delete("p2").unwrap();
671 assert!(b.get("p2").unwrap().is_none());
672 }
673
674 #[test]
675 fn stub_delete_nonexistent_is_noop() {
676 let mut b = stub();
677 b.delete("ghost").unwrap(); }
679
680 #[test]
681 fn stub_set_overwrites_existing() {
682 let mut b = stub();
683 let s1 = secret("03a");
684 let s2 = secret("03b");
685 b.set("p3", &s1).unwrap();
686 b.set("p3", &s2).unwrap();
687 let got = b.get("p3").unwrap().expect("should be Some");
688 assert_eq!(got.access_key_id, s2.access_key_id);
689 }
690
691 #[test]
692 fn stub_session_token_round_trips() {
693 let mut b = stub();
694 let s = Secret {
695 access_key_id: "AKIATOKEN".to_string(),
696 secret_access_key: "secret".to_string(),
697 session_token: Some("sess-tok-123".to_string()),
698 };
699 b.set("p4", &s).unwrap();
700 let got = b.get("p4").unwrap().unwrap();
701 assert_eq!(got.session_token.as_deref(), Some("sess-tok-123"));
702 }
703 }
704
705 mod file_tests {
710 use super::*;
711
712 fn secret(id: &str) -> Secret {
713 Secret {
714 access_key_id: format!("AKIA{id}"),
715 secret_access_key: format!("secret{id}"),
716 session_token: None,
717 }
718 }
719
720 #[test]
721 fn file_round_trip_same_passphrase() {
722 let dir = tempfile::tempdir().expect("tempdir");
723 let s = secret("rt");
724
725 {
726 let mut b = FileBackendWithPassphrase::new(dir.path(), "correcthorsebatterystaple");
727 b.set("profile-rt", &s).unwrap();
728 }
729
730 let b = FileBackendWithPassphrase::new(dir.path(), "correcthorsebatterystaple");
732 let got = b.get("profile-rt").unwrap().expect("should be Some");
733 assert_eq!(got.access_key_id, s.access_key_id);
734 assert_eq!(got.secret_access_key, s.secret_access_key);
735 }
736
737 #[test]
738 fn file_wrong_passphrase_returns_auth_error() {
739 let dir = tempfile::tempdir().expect("tempdir");
740 let s = secret("wp");
741
742 {
743 let mut b = FileBackendWithPassphrase::new(dir.path(), "correct");
744 b.set("profile-wp", &s).unwrap();
745 }
746
747 let b = FileBackendWithPassphrase::new(dir.path(), "wrong");
748 match b.get("profile-wp") {
749 Err(AppError::Auth { .. }) => {} other => panic!("expected Auth error, got {other:?}"),
751 }
752 }
753
754 #[test]
755 fn file_delete_persists_across_instances() {
756 let dir = tempfile::tempdir().expect("tempdir");
757 let s = secret("del");
758
759 {
760 let mut b = FileBackendWithPassphrase::new(dir.path(), "pass");
761 b.set("profile-del", &s).unwrap();
762 }
763
764 {
765 let mut b = FileBackendWithPassphrase::new(dir.path(), "pass");
766 b.delete("profile-del").unwrap();
767 }
768
769 let b = FileBackendWithPassphrase::new(dir.path(), "pass");
770 assert!(b.get("profile-del").unwrap().is_none());
771 }
772
773 #[test]
774 fn file_multiple_profiles_coexist() {
775 let dir = tempfile::tempdir().expect("tempdir");
776
777 {
778 let mut b = FileBackendWithPassphrase::new(dir.path(), "multi-pass");
779 b.set("p-a", &secret("A")).unwrap();
780 b.set("p-b", &secret("B")).unwrap();
781 }
782
783 let b = FileBackendWithPassphrase::new(dir.path(), "multi-pass");
784 assert_eq!(b.get("p-a").unwrap().unwrap().access_key_id, "AKIAA");
785 assert_eq!(b.get("p-b").unwrap().unwrap().access_key_id, "AKIAB");
786 }
787
788 #[test]
789 fn file_session_token_round_trips() {
790 let dir = tempfile::tempdir().expect("tempdir");
791
792 let s = Secret {
793 access_key_id: "AKIATOKEN".to_string(),
794 secret_access_key: "secret".to_string(),
795 session_token: Some("sess-tok-abc".to_string()),
796 };
797
798 {
799 let mut b = FileBackendWithPassphrase::new(dir.path(), "tok-pass");
800 b.set("p-tok", &s).unwrap();
801 }
802
803 let b = FileBackendWithPassphrase::new(dir.path(), "tok-pass");
804 let got = b.get("p-tok").unwrap().unwrap();
805 assert_eq!(got.session_token.as_deref(), Some("sess-tok-abc"));
806 }
807 }
808
809 #[test]
814 fn secret_fields_are_not_serialized_to_json() {
815 let s = Secret {
816 access_key_id: "AKIATEST".to_string(),
817 secret_access_key: "supersecret".to_string(),
818 session_token: Some("tok".to_string()),
819 };
820 let json = serde_json::to_string(&s).expect("serialize");
821 assert!(
822 !json.contains("AKIATEST"),
823 "access_key_id must not appear in serialized output: {json}"
824 );
825 assert!(
826 !json.contains("supersecret"),
827 "secret_access_key must not appear in serialized output: {json}"
828 );
829 assert!(
830 !json.contains("tok"),
831 "session_token must not appear in serialized output: {json}"
832 );
833 }
834
835 #[test]
844 fn secret_implements_zeroize_on_drop() {
845 fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
846 assert_zeroize_on_drop::<Secret>();
847 }
848}