1use anyhow::{Context, Result, anyhow};
29use base64::Engine;
30use base64::engine::general_purpose::STANDARD;
31use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
32use ring::rand::{SecureRandom, SystemRandom};
33use serde::{Deserialize, Serialize};
34use std::collections::BTreeMap;
35use std::fs;
36
37use crate::storage_paths::auth_storage_dir;
38use crate::storage_paths::legacy_auth_storage_path;
39use crate::storage_paths::write_private_file;
40
41const ENCRYPTED_CREDENTIAL_VERSION: u8 = 1;
42
43#[derive(Debug, Serialize, Deserialize)]
44struct EncryptedCredential {
45 nonce: String,
46 ciphertext: String,
47 version: u8,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 salt: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
55struct LegacyAuthFile {
56 mode: String,
57 provider: String,
58 api_key: String,
59}
60
61#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70#[serde(rename_all = "lowercase")]
71pub enum AuthCredentialsStoreMode {
72 Keyring,
76 File,
79 Auto,
81}
82
83impl Default for AuthCredentialsStoreMode {
84 fn default() -> Self {
87 Self::Keyring
88 }
89}
90
91impl AuthCredentialsStoreMode {
92 pub fn effective_mode(self) -> Self {
94 match self {
95 Self::Auto => {
96 if is_keyring_functional() {
98 Self::Keyring
99 } else {
100 tracing::debug!("Keyring not available, falling back to file storage");
101 Self::File
102 }
103 }
104 mode => mode,
105 }
106 }
107}
108
109pub(crate) fn is_keyring_functional() -> bool {
117 use std::sync::OnceLock;
118
119 static FUNCTIONAL: OnceLock<bool> = OnceLock::new();
120
121 *FUNCTIONAL.get_or_init(|| {
122 let test_user = format!("test_{}", std::process::id());
124 let entry = match keyring_entry("vtcode", &test_user) {
125 Ok(e) => e,
126 Err(_) => return false,
127 };
128
129 if entry.set_password("test").is_err() {
131 return false;
132 }
133
134 let functional = entry.get_password().is_ok();
136
137 let _ = entry.delete_credential();
140
141 functional
142 })
143}
144
145fn ensure_native_keyring_store() -> keyring_core::Result<()> {
146 if keyring_core::get_default_store().is_some() {
147 return Ok(());
148 }
149
150 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
151 let store = dbus_secret_service_keyring_store::Store::new_with_configuration(&std::collections::HashMap::new())?;
152
153 #[cfg(target_os = "macos")]
154 let store = apple_native_keyring_store::keychain::Store::new_with_configuration(&std::collections::HashMap::new())?;
155
156 #[cfg(target_os = "windows")]
157 let store = windows_native_keyring_store::Store::new_with_configuration(&std::collections::HashMap::new())?;
158
159 #[cfg(not(any(
160 target_os = "linux",
161 target_os = "freebsd",
162 target_os = "macos",
163 target_os = "windows"
164 )))]
165 {
166 return Err(keyring_core::Error::NotSupportedByStore(
167 "VT Code does not have a native keyring store configured for this platform".to_string(),
168 ));
169 }
170
171 keyring_core::set_default_store(store);
172 Ok(())
173}
174
175pub(crate) fn keyring_disabled() -> bool {
184 if cfg!(debug_assertions) {
188 if let Ok(value) = std::env::var("VTCODE_DISABLE_KEYRING") {
189 if matches!(value.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off") {
191 return false;
192 }
193 }
194 return true;
195 }
196
197 if cfg!(test) {
198 return true;
199 }
200
201 if let Ok(value) = std::env::var("VTCODE_DISABLE_KEYRING") {
202 return !matches!(value.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off");
203 }
204
205 std::env::var_os("CI").is_some()
206}
207
208pub(crate) fn keyring_entry(service: &str, user: &str) -> keyring_core::Result<keyring_core::Entry> {
209 if keyring_disabled() {
210 return Err(keyring_core::Error::NotSupportedByStore(
211 "VT Code keyring access is disabled (test run or VTCODE_DISABLE_KEYRING/CI set)".to_string(),
212 ));
213 }
214
215 if keyring_core::get_default_store().is_none() {
216 ensure_native_keyring_store()?;
217 }
218
219 keyring_core::Entry::new(service, user)
220}
221
222pub struct CredentialStorage {
227 service: String,
228 user: String,
229}
230
231impl CredentialStorage {
232 pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
238 Self { service: service.into(), user: user.into() }
239 }
240
241 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
247 match mode.effective_mode() {
248 AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
249 Ok(()) => {
250 if let Err(err) = self.store_file(value) {
264 tracing::warn!(
265 "Failed to write encrypted file backup for {}/{}: {}",
266 self.service,
267 self.user,
268 err
269 );
270 }
271 Ok(())
272 }
273 Err(err) => {
274 tracing::warn!(
275 "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
276 self.service,
277 self.user,
278 err
279 );
280 self.store_file(value).context("failed to store credential in encrypted file")
281 }
282 },
283 AuthCredentialsStoreMode::File => self.store_file(value),
284 _ => unreachable!(),
285 }
286 }
287
288 pub fn store(&self, value: &str) -> Result<()> {
290 self.store_keyring(value)
291 }
292
293 fn store_keyring(&self, value: &str) -> Result<()> {
295 let entry = keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
296
297 entry.set_password(value).context("Failed to store credential in OS keyring")?;
298
299 tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
300 Ok(())
301 }
302
303 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
307 match mode.effective_mode() {
308 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
309 Ok(Some(value)) => Ok(Some(value)),
310 Ok(None) => self.load_file(),
311 Err(err) => {
312 tracing::warn!(
313 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
314 self.service,
315 self.user,
316 err
317 );
318 self.load_file()
319 }
320 },
321 AuthCredentialsStoreMode::File => self.load_file(),
322 _ => unreachable!(),
323 }
324 }
325
326 pub fn load(&self) -> Result<Option<String>> {
330 self.load_keyring()
331 }
332
333 fn load_keyring(&self) -> Result<Option<String>> {
335 let entry = match keyring_entry(&self.service, &self.user) {
336 Ok(e) => e,
337 Err(_) => return Ok(None),
338 };
339
340 match entry.get_password() {
341 Ok(value) => Ok(Some(value)),
342 Err(keyring_core::Error::NoEntry) => Ok(None),
343 Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
344 }
345 }
346
347 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
349 match mode.effective_mode() {
350 AuthCredentialsStoreMode::Keyring => {
351 let mut errors = Vec::new();
352
353 if let Err(err) = self.clear_keyring() {
354 errors.push(err.to_string());
355 }
356 if let Err(err) = self.clear_file() {
357 errors.push(err.to_string());
358 }
359
360 if errors.is_empty() {
361 Ok(())
362 } else {
363 Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
364 }
365 }
366 AuthCredentialsStoreMode::File => self.clear_file(),
367 _ => unreachable!(),
368 }
369 }
370
371 pub fn clear(&self) -> Result<()> {
373 self.clear_keyring()
374 }
375
376 fn clear_keyring(&self) -> Result<()> {
378 let entry = match keyring_entry(&self.service, &self.user) {
379 Ok(e) => e,
380 Err(_) => return Ok(()),
381 };
382
383 match entry.delete_credential() {
384 Ok(_) => {
385 tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
386 }
387 Err(keyring_core::Error::NoEntry) => {}
388 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
389 }
390
391 Ok(())
392 }
393
394 fn store_file(&self, value: &str) -> Result<()> {
395 let path = self.file_path()?;
396 let encrypted = encrypt_credential(value)?;
397 let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
398 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
399
400 Ok(())
401 }
402
403 fn load_file(&self) -> Result<Option<String>> {
404 let path = self.file_path()?;
405 let data = match fs::read(&path) {
406 Ok(data) => data,
407 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
408 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
409 };
410
411 let encrypted: EncryptedCredential =
412 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
413 decrypt_credential(&encrypted).map(Some)
414 }
415
416 fn clear_file(&self) -> Result<()> {
417 let path = self.file_path()?;
418 match fs::remove_file(path) {
419 Ok(()) => Ok(()),
420 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
421 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
422 }
423 }
424
425 fn file_path(&self) -> Result<std::path::PathBuf> {
426 use sha2::Digest as _;
427
428 let mut hasher = sha2::Sha256::new();
429 hasher.update(self.service.as_bytes());
430 hasher.update([0]);
431 hasher.update(self.user.as_bytes());
432 let digest = hasher.finalize();
433 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
434
435 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
436 }
437}
438
439pub struct CustomApiKeyStorage {
444 provider: String,
445 storage: CredentialStorage,
446}
447
448impl CustomApiKeyStorage {
449 pub fn new(provider: &str) -> Self {
454 let normalized_provider = provider.to_lowercase();
455 Self {
456 provider: normalized_provider.clone(),
457 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
458 }
459 }
460
461 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
467 self.storage.store_with_mode(api_key, mode)?;
468 clear_legacy_auth_file_if_matches(&self.provider)?;
469 Ok(())
470 }
471
472 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
476 if let Some(key) = self.storage.load_with_mode(mode)? {
477 return Ok(Some(key));
478 }
479
480 self.load_legacy_auth_json(mode)
481 }
482
483 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
485 self.storage.clear_with_mode(mode)?;
486 clear_legacy_auth_file_if_matches(&self.provider)?;
487 Ok(())
488 }
489
490 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
491 let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
492 return Ok(None);
493 };
494
495 if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
496 tracing::warn!(
497 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
498 self.provider,
499 err
500 );
501 return Ok(Some(legacy.api_key));
502 }
503
504 clear_legacy_auth_file_if_matches(&self.provider)?;
505 tracing::warn!(
506 "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
507 self.provider
508 );
509 Ok(Some(legacy.api_key))
510 }
511}
512
513fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
514 let rng = SystemRandom::new();
516 let mut salt_bytes = [0_u8; 16];
517 rng.fill(&mut salt_bytes)
518 .map_err(|_| anyhow!("failed to generate credential salt"))?;
519 let salt = STANDARD.encode(salt_bytes);
520
521 let key = derive_file_encryption_key(Some(&salt))?;
522 let mut nonce_bytes = [0_u8; NONCE_LEN];
523 rng.fill(&mut nonce_bytes)
524 .map_err(|_| anyhow!("failed to generate credential nonce"))?;
525
526 let mut ciphertext = value.as_bytes().to_vec();
527 key.seal_in_place_append_tag(Nonce::assume_unique_for_key(nonce_bytes), Aad::empty(), &mut ciphertext)
528 .map_err(|_| anyhow!("failed to encrypt credential"))?;
529
530 Ok(EncryptedCredential {
531 nonce: STANDARD.encode(nonce_bytes),
532 ciphertext: STANDARD.encode(ciphertext),
533 version: ENCRYPTED_CREDENTIAL_VERSION,
534 salt: Some(salt),
535 })
536}
537
538fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
539 if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
540 return Err(anyhow!("unsupported encrypted credential format"));
541 }
542
543 let nonce_bytes = STANDARD.decode(&encrypted.nonce).context("failed to decode credential nonce")?;
544 let nonce_array: [u8; NONCE_LEN] =
545 nonce_bytes.try_into().map_err(|_| anyhow!("invalid credential nonce length"))?;
546 let mut ciphertext = STANDARD
547 .decode(&encrypted.ciphertext)
548 .context("failed to decode credential ciphertext")?;
549
550 let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
552 let plaintext = key
553 .open_in_place(Nonce::assume_unique_for_key(nonce_array), Aad::empty(), &mut ciphertext)
554 .map_err(|_| anyhow!("failed to decrypt credential"))?;
555
556 String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
557}
558
559fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
560 use ring::digest::SHA256;
561 use ring::digest::digest;
562
563 let mut key_material = Vec::new();
564 if let Ok(hostname) = hostname::get() {
565 key_material.extend_from_slice(hostname.as_encoded_bytes());
566 }
567
568 #[cfg(unix)]
569 {
570 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
571 }
572 #[cfg(not(unix))]
573 {
574 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
575 key_material.extend_from_slice(user.as_bytes());
576 }
577 }
578
579 key_material.extend_from_slice(b"vtcode-credentials-v1");
580
581 if let Some(salt) = salt {
585 key_material.extend_from_slice(salt.as_bytes());
586 }
587
588 let hash = digest(&SHA256, &key_material);
589 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
590 .try_into()
591 .context("credential encryption key was too short")?;
592 let unbound =
593 UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("invalid credential encryption key"))?;
594 Ok(LessSafeKey::new(unbound))
595}
596
597fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
598 let path = legacy_auth_storage_path()?;
599 let data = match fs::read(&path) {
600 Ok(data) => data,
601 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
602 Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
603 };
604
605 let legacy: LegacyAuthFile = serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
606 let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
607 let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
608 let has_key = !legacy.api_key.trim().is_empty();
609
610 if matches_provider && stores_api_key && has_key {
611 Ok(Some(legacy))
612 } else {
613 Ok(None)
614 }
615}
616
617fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
618 let path = legacy_auth_storage_path()?;
619 let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
620 return Ok(());
621 };
622
623 match fs::remove_file(path) {
624 Ok(()) => Ok(()),
625 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
626 Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
627 }
628}
629
630pub fn migrate_custom_api_keys_to_keyring(
643 custom_api_keys: &BTreeMap<String, String>,
644 mode: AuthCredentialsStoreMode,
645) -> Result<BTreeMap<String, bool>> {
646 let mut migration_results = BTreeMap::new();
647
648 for (provider, api_key) in custom_api_keys {
649 let storage = CustomApiKeyStorage::new(provider);
650 match storage.store(api_key, mode) {
651 Ok(()) => {
652 tracing::info!("Migrated API key for provider '{}' to secure storage", provider);
653 migration_results.insert(provider.clone(), true);
654 }
655 Err(e) => {
656 tracing::warn!("Failed to migrate API key for provider '{}': {}", provider, e);
657 migration_results.insert(provider.clone(), false);
658 }
659 }
660 }
661
662 Ok(migration_results)
663}
664
665pub fn load_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<BTreeMap<String, String>> {
676 let mut api_keys = BTreeMap::new();
677
678 for provider in providers {
679 let storage = CustomApiKeyStorage::new(provider);
680 if let Some(key) = storage.load(mode)? {
681 api_keys.insert(provider.clone(), key);
682 }
683 }
684
685 Ok(api_keys)
686}
687
688pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
694 for provider in providers {
695 let storage = CustomApiKeyStorage::new(provider);
696 if let Err(e) = storage.clear(mode) {
697 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
698 }
699 }
700 Ok(())
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use assert_fs::TempDir;
707 use serial_test::serial;
708
709 struct TestAuthDirGuard {
710 temp_dir: Option<TempDir>,
711 previous: Option<std::path::PathBuf>,
712 }
713
714 impl TestAuthDirGuard {
715 fn new() -> Self {
716 let temp_dir = TempDir::new().expect("create temp auth dir");
717 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
718 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
719 .expect("set auth dir override");
720
721 Self { temp_dir: Some(temp_dir), previous }
722 }
723 }
724
725 impl Drop for TestAuthDirGuard {
726 fn drop(&mut self) {
727 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
728 .expect("restore auth dir override");
729 if let Some(temp_dir) = self.temp_dir.take() {
730 temp_dir.close().expect("remove temp auth dir");
731 }
732 }
733 }
734
735 #[test]
736 fn test_storage_mode_default_is_keyring() {
737 assert_eq!(AuthCredentialsStoreMode::default(), AuthCredentialsStoreMode::Keyring);
738 }
739
740 #[test]
741 fn test_storage_mode_effective_mode() {
742 assert_eq!(AuthCredentialsStoreMode::Keyring.effective_mode(), AuthCredentialsStoreMode::Keyring);
743 assert_eq!(AuthCredentialsStoreMode::File.effective_mode(), AuthCredentialsStoreMode::File);
744
745 let auto_permission = AuthCredentialsStoreMode::Auto.effective_mode();
747 assert!(
748 auto_permission == AuthCredentialsStoreMode::Keyring || auto_permission == AuthCredentialsStoreMode::File
749 );
750 }
751
752 #[test]
753 fn test_storage_mode_serialization() {
754 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
755 assert_eq!(keyring_json, "\"keyring\"");
756
757 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
758 assert_eq!(file_json, "\"file\"");
759
760 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
761 assert_eq!(auto_json, "\"auto\"");
762
763 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
765 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
766
767 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
768 assert_eq!(parsed, AuthCredentialsStoreMode::File);
769
770 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
771 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
772 }
773
774 #[test]
775 fn test_credential_storage_new() {
776 let storage = CredentialStorage::new("vtcode", "test_key");
777 assert_eq!(storage.service, "vtcode");
778 assert_eq!(storage.user, "test_key");
779 }
780
781 #[test]
782 fn test_is_keyring_functional_check() {
783 let _functional = is_keyring_functional();
786 }
787
788 #[test]
789 #[serial]
790 fn credential_storage_file_mode_round_trips_without_plaintext() {
791 let _guard = TestAuthDirGuard::new();
792 let storage = CredentialStorage::new("vtcode", "test_key");
793
794 storage
795 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
796 .expect("store encrypted credential");
797
798 let loaded = storage
799 .load_with_mode(AuthCredentialsStoreMode::File)
800 .expect("load encrypted credential");
801 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
802
803 let stored =
804 fs::read_to_string(storage.file_path().expect("credential path")).expect("read encrypted credential file");
805 assert!(!stored.contains("secret_api_key"));
806 }
807
808 #[test]
809 #[serial]
810 fn keyring_mode_load_falls_back_to_encrypted_file() {
811 let _guard = TestAuthDirGuard::new();
812 let storage = CredentialStorage::new("vtcode", "test_key");
813
814 storage
815 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
816 .expect("store encrypted credential");
817
818 let loaded = storage
819 .load_with_mode(AuthCredentialsStoreMode::Keyring)
820 .expect("load credential");
821 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
822 }
823
824 #[test]
825 #[serial]
826 #[cfg(unix)]
827 fn credential_storage_file_mode_uses_private_permissions() {
828 use std::os::unix::fs::PermissionsExt;
829
830 let _guard = TestAuthDirGuard::new();
831 let storage = CredentialStorage::new("vtcode", "test_key");
832
833 storage
834 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
835 .expect("store encrypted credential");
836
837 let metadata = fs::metadata(storage.file_path().expect("credential path")).expect("read credential metadata");
838 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
839 }
840
841 #[test]
842 #[serial]
843 #[cfg(unix)]
844 fn credential_storage_file_mode_restricts_existing_file_permissions() {
845 use std::os::unix::fs::PermissionsExt;
846
847 let _guard = TestAuthDirGuard::new();
848 let storage = CredentialStorage::new("vtcode", "test_key");
849
850 storage
851 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
852 .expect("store initial credential");
853
854 let path = storage.file_path().expect("credential path");
855 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("broaden existing credential permissions");
856
857 storage
858 .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
859 .expect("rewrite credential");
860
861 let metadata = fs::metadata(path).expect("read credential metadata");
862 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
863 }
864
865 #[test]
866 #[serial]
867 fn keyring_store_keeps_encrypted_file_as_fallback() {
868 let _guard = TestAuthDirGuard::new();
874 let storage = CredentialStorage::new("vtcode", "reliability_key");
875
876 storage
877 .store_with_mode("rk-secret", AuthCredentialsStoreMode::Keyring)
878 .expect("store credential");
879
880 let loaded_keyring = storage
883 .load_with_mode(AuthCredentialsStoreMode::Keyring)
884 .expect("load credential");
885 assert_eq!(loaded_keyring.as_deref(), Some("rk-secret"));
886
887 let loaded_file = storage
889 .load_with_mode(AuthCredentialsStoreMode::File)
890 .expect("load file credential");
891 assert_eq!(loaded_file.as_deref(), Some("rk-secret"));
892 }
893
894 #[test]
895 #[serial]
896 fn custom_api_key_storage_isolates_keys_per_provider() {
897 let _guard = TestAuthDirGuard::new();
900 let anthropic = CustomApiKeyStorage::new("anthropic");
901 let openrouter = CustomApiKeyStorage::new("openrouter");
902
903 anthropic
904 .store("anthropic-key", AuthCredentialsStoreMode::File)
905 .expect("store anthropic key");
906 openrouter
907 .store("openrouter-key", AuthCredentialsStoreMode::File)
908 .expect("store openrouter key");
909
910 assert_eq!(anthropic.load(AuthCredentialsStoreMode::File).unwrap().as_deref(), Some("anthropic-key"));
911 assert_eq!(openrouter.load(AuthCredentialsStoreMode::File).unwrap().as_deref(), Some("openrouter-key"));
912
913 anthropic.clear(AuthCredentialsStoreMode::File).expect("clear anthropic key");
915 assert_eq!(anthropic.load(AuthCredentialsStoreMode::File).unwrap(), None);
916 assert_eq!(openrouter.load(AuthCredentialsStoreMode::File).unwrap().as_deref(), Some("openrouter-key"));
917 }
918
919 #[test]
920 #[serial]
921 fn custom_api_key_load_migrates_legacy_auth_json() {
922 let _guard = TestAuthDirGuard::new();
923 let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
924 fs::write(
925 &legacy_path,
926 r#"{
927 "version": 1,
928 "mode": "api_key",
929 "provider": "openai",
930 "api_key": "legacy-secret",
931 "authenticated_at": 1768406185
932}"#,
933 )
934 .expect("write legacy auth file");
935
936 let storage = CustomApiKeyStorage::new("openai");
937 let loaded = storage.load(AuthCredentialsStoreMode::File).expect("load migrated api key");
938 assert_eq!(loaded.as_deref(), Some("legacy-secret"));
939 assert!(!legacy_path.exists());
940
941 let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
942 .expect("read migrated credential file");
943 assert!(!encrypted.contains("legacy-secret"));
944 }
945}