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(
152 &std::collections::HashMap::new(),
153 )?;
154
155 #[cfg(target_os = "macos")]
156 let store = apple_native_keyring_store::keychain::Store::new_with_configuration(
157 &std::collections::HashMap::new(),
158 )?;
159
160 #[cfg(target_os = "windows")]
161 let store = windows_native_keyring_store::Store::new_with_configuration(
162 &std::collections::HashMap::new(),
163 )?;
164
165 #[cfg(not(any(
166 target_os = "linux",
167 target_os = "freebsd",
168 target_os = "macos",
169 target_os = "windows"
170 )))]
171 {
172 return Err(keyring_core::Error::NotSupportedByStore(
173 "VT Code does not have a native keyring store configured for this platform".to_string(),
174 ));
175 }
176
177 keyring_core::set_default_store(store);
178 Ok(())
179}
180
181pub(crate) fn keyring_disabled() -> bool {
190 if cfg!(debug_assertions) {
194 if let Ok(value) = std::env::var("VTCODE_DISABLE_KEYRING") {
195 if matches!(
197 value.trim().to_ascii_lowercase().as_str(),
198 "" | "0" | "false" | "no" | "off"
199 ) {
200 return false;
201 }
202 }
203 return true;
204 }
205
206 if cfg!(test) {
207 return true;
208 }
209
210 if let Ok(value) = std::env::var("VTCODE_DISABLE_KEYRING") {
211 return !matches!(
212 value.trim().to_ascii_lowercase().as_str(),
213 "" | "0" | "false" | "no" | "off"
214 );
215 }
216
217 std::env::var_os("CI").is_some()
218}
219
220pub(crate) fn keyring_entry(
221 service: &str,
222 user: &str,
223) -> keyring_core::Result<keyring_core::Entry> {
224 if keyring_disabled() {
225 return Err(keyring_core::Error::NotSupportedByStore(
226 "VT Code keyring access is disabled (test run or VTCODE_DISABLE_KEYRING/CI set)"
227 .to_string(),
228 ));
229 }
230
231 if keyring_core::get_default_store().is_none() {
232 ensure_native_keyring_store()?;
233 }
234
235 keyring_core::Entry::new(service, user)
236}
237
238pub struct CredentialStorage {
243 service: String,
244 user: String,
245}
246
247impl CredentialStorage {
248 pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
254 Self {
255 service: service.into(),
256 user: user.into(),
257 }
258 }
259
260 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
266 match mode.effective_mode() {
267 AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
268 Ok(()) => {
269 let _ = self.clear_file();
270 Ok(())
271 }
272 Err(err) => {
273 tracing::warn!(
274 "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
275 self.service,
276 self.user,
277 err
278 );
279 self.store_file(value)
280 .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 =
296 keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
297
298 entry
299 .set_password(value)
300 .context("Failed to store credential in OS keyring")?;
301
302 tracing::debug!(
303 "Credential stored in OS keyring for {}/{}",
304 self.service,
305 self.user
306 );
307 Ok(())
308 }
309
310 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
314 match mode.effective_mode() {
315 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
316 Ok(Some(value)) => Ok(Some(value)),
317 Ok(None) => self.load_file(),
318 Err(err) => {
319 tracing::warn!(
320 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
321 self.service,
322 self.user,
323 err
324 );
325 self.load_file()
326 }
327 },
328 AuthCredentialsStoreMode::File => self.load_file(),
329 _ => unreachable!(),
330 }
331 }
332
333 pub fn load(&self) -> Result<Option<String>> {
337 self.load_keyring()
338 }
339
340 fn load_keyring(&self) -> Result<Option<String>> {
342 let entry = match keyring_entry(&self.service, &self.user) {
343 Ok(e) => e,
344 Err(_) => return Ok(None),
345 };
346
347 match entry.get_password() {
348 Ok(value) => Ok(Some(value)),
349 Err(keyring_core::Error::NoEntry) => Ok(None),
350 Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
351 }
352 }
353
354 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
356 match mode.effective_mode() {
357 AuthCredentialsStoreMode::Keyring => {
358 let mut errors = Vec::new();
359
360 if let Err(err) = self.clear_keyring() {
361 errors.push(err.to_string());
362 }
363 if let Err(err) = self.clear_file() {
364 errors.push(err.to_string());
365 }
366
367 if errors.is_empty() {
368 Ok(())
369 } else {
370 Err(anyhow!(
371 "Failed to clear credential from secure storage: {}",
372 errors.join("; ")
373 ))
374 }
375 }
376 AuthCredentialsStoreMode::File => self.clear_file(),
377 _ => unreachable!(),
378 }
379 }
380
381 pub fn clear(&self) -> Result<()> {
383 self.clear_keyring()
384 }
385
386 fn clear_keyring(&self) -> Result<()> {
388 let entry = match keyring_entry(&self.service, &self.user) {
389 Ok(e) => e,
390 Err(_) => return Ok(()),
391 };
392
393 match entry.delete_credential() {
394 Ok(_) => {
395 tracing::debug!(
396 "Credential cleared from keyring for {}/{}",
397 self.service,
398 self.user
399 );
400 }
401 Err(keyring_core::Error::NoEntry) => {}
402 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
403 }
404
405 Ok(())
406 }
407
408 fn store_file(&self, value: &str) -> Result<()> {
409 let path = self.file_path()?;
410 let encrypted = encrypt_credential(value)?;
411 let payload = serde_json::to_vec_pretty(&encrypted)
412 .context("failed to serialize encrypted credential")?;
413 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
414
415 Ok(())
416 }
417
418 fn load_file(&self) -> Result<Option<String>> {
419 let path = self.file_path()?;
420 let data = match fs::read(&path) {
421 Ok(data) => data,
422 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
423 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
424 };
425
426 let encrypted: EncryptedCredential =
427 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
428 decrypt_credential(&encrypted).map(Some)
429 }
430
431 fn clear_file(&self) -> Result<()> {
432 let path = self.file_path()?;
433 match fs::remove_file(path) {
434 Ok(()) => Ok(()),
435 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
436 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
437 }
438 }
439
440 fn file_path(&self) -> Result<std::path::PathBuf> {
441 use sha2::Digest as _;
442
443 let mut hasher = sha2::Sha256::new();
444 hasher.update(self.service.as_bytes());
445 hasher.update([0]);
446 hasher.update(self.user.as_bytes());
447 let digest = hasher.finalize();
448 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
449
450 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
451 }
452}
453
454pub struct CustomApiKeyStorage {
459 provider: String,
460 storage: CredentialStorage,
461}
462
463impl CustomApiKeyStorage {
464 pub fn new(provider: &str) -> Self {
469 let normalized_provider = provider.to_lowercase();
470 Self {
471 provider: normalized_provider.clone(),
472 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
473 }
474 }
475
476 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
482 self.storage.store_with_mode(api_key, mode)?;
483 clear_legacy_auth_file_if_matches(&self.provider)?;
484 Ok(())
485 }
486
487 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
491 if let Some(key) = self.storage.load_with_mode(mode)? {
492 return Ok(Some(key));
493 }
494
495 self.load_legacy_auth_json(mode)
496 }
497
498 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
500 self.storage.clear_with_mode(mode)?;
501 clear_legacy_auth_file_if_matches(&self.provider)?;
502 Ok(())
503 }
504
505 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
506 let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
507 return Ok(None);
508 };
509
510 if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
511 tracing::warn!(
512 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
513 self.provider,
514 err
515 );
516 return Ok(Some(legacy.api_key));
517 }
518
519 clear_legacy_auth_file_if_matches(&self.provider)?;
520 tracing::warn!(
521 "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
522 self.provider
523 );
524 Ok(Some(legacy.api_key))
525 }
526}
527
528fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
529 let rng = SystemRandom::new();
531 let mut salt_bytes = [0_u8; 16];
532 rng.fill(&mut salt_bytes)
533 .map_err(|_| anyhow!("failed to generate credential salt"))?;
534 let salt = STANDARD.encode(salt_bytes);
535
536 let key = derive_file_encryption_key(Some(&salt))?;
537 let mut nonce_bytes = [0_u8; NONCE_LEN];
538 rng.fill(&mut nonce_bytes)
539 .map_err(|_| anyhow!("failed to generate credential nonce"))?;
540
541 let mut ciphertext = value.as_bytes().to_vec();
542 key.seal_in_place_append_tag(
543 Nonce::assume_unique_for_key(nonce_bytes),
544 Aad::empty(),
545 &mut ciphertext,
546 )
547 .map_err(|_| anyhow!("failed to encrypt credential"))?;
548
549 Ok(EncryptedCredential {
550 nonce: STANDARD.encode(nonce_bytes),
551 ciphertext: STANDARD.encode(ciphertext),
552 version: ENCRYPTED_CREDENTIAL_VERSION,
553 salt: Some(salt),
554 })
555}
556
557fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
558 if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
559 return Err(anyhow!("unsupported encrypted credential format"));
560 }
561
562 let nonce_bytes = STANDARD
563 .decode(&encrypted.nonce)
564 .context("failed to decode credential nonce")?;
565 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
566 .try_into()
567 .map_err(|_| anyhow!("invalid credential nonce length"))?;
568 let mut ciphertext = STANDARD
569 .decode(&encrypted.ciphertext)
570 .context("failed to decode credential ciphertext")?;
571
572 let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
574 let plaintext = key
575 .open_in_place(
576 Nonce::assume_unique_for_key(nonce_array),
577 Aad::empty(),
578 &mut ciphertext,
579 )
580 .map_err(|_| anyhow!("failed to decrypt credential"))?;
581
582 String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
583}
584
585fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
586 use ring::digest::SHA256;
587 use ring::digest::digest;
588
589 let mut key_material = Vec::new();
590 if let Ok(hostname) = hostname::get() {
591 key_material.extend_from_slice(hostname.as_encoded_bytes());
592 }
593
594 #[cfg(unix)]
595 {
596 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
597 }
598 #[cfg(not(unix))]
599 {
600 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
601 key_material.extend_from_slice(user.as_bytes());
602 }
603 }
604
605 key_material.extend_from_slice(b"vtcode-credentials-v1");
606
607 if let Some(salt) = salt {
611 key_material.extend_from_slice(salt.as_bytes());
612 }
613
614 let hash = digest(&SHA256, &key_material);
615 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
616 .try_into()
617 .context("credential encryption key was too short")?;
618 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
619 .map_err(|_| anyhow!("invalid credential encryption key"))?;
620 Ok(LessSafeKey::new(unbound))
621}
622
623fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
624 let path = legacy_auth_storage_path()?;
625 let data = match fs::read(&path) {
626 Ok(data) => data,
627 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
628 Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
629 };
630
631 let legacy: LegacyAuthFile =
632 serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
633 let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
634 let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
635 let has_key = !legacy.api_key.trim().is_empty();
636
637 if matches_provider && stores_api_key && has_key {
638 Ok(Some(legacy))
639 } else {
640 Ok(None)
641 }
642}
643
644fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
645 let path = legacy_auth_storage_path()?;
646 let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
647 return Ok(());
648 };
649
650 match fs::remove_file(path) {
651 Ok(()) => Ok(()),
652 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
653 Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
654 }
655}
656
657pub fn migrate_custom_api_keys_to_keyring(
670 custom_api_keys: &BTreeMap<String, String>,
671 mode: AuthCredentialsStoreMode,
672) -> Result<BTreeMap<String, bool>> {
673 let mut migration_results = BTreeMap::new();
674
675 for (provider, api_key) in custom_api_keys {
676 let storage = CustomApiKeyStorage::new(provider);
677 match storage.store(api_key, mode) {
678 Ok(()) => {
679 tracing::info!(
680 "Migrated API key for provider '{}' to secure storage",
681 provider
682 );
683 migration_results.insert(provider.clone(), true);
684 }
685 Err(e) => {
686 tracing::warn!(
687 "Failed to migrate API key for provider '{}': {}",
688 provider,
689 e
690 );
691 migration_results.insert(provider.clone(), false);
692 }
693 }
694 }
695
696 Ok(migration_results)
697}
698
699pub fn load_custom_api_keys(
710 providers: &[String],
711 mode: AuthCredentialsStoreMode,
712) -> Result<BTreeMap<String, String>> {
713 let mut api_keys = BTreeMap::new();
714
715 for provider in providers {
716 let storage = CustomApiKeyStorage::new(provider);
717 if let Some(key) = storage.load(mode)? {
718 api_keys.insert(provider.clone(), key);
719 }
720 }
721
722 Ok(api_keys)
723}
724
725pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
731 for provider in providers {
732 let storage = CustomApiKeyStorage::new(provider);
733 if let Err(e) = storage.clear(mode) {
734 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
735 }
736 }
737 Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use assert_fs::TempDir;
744 use serial_test::serial;
745
746 struct TestAuthDirGuard {
747 temp_dir: Option<TempDir>,
748 previous: Option<std::path::PathBuf>,
749 }
750
751 impl TestAuthDirGuard {
752 fn new() -> Self {
753 let temp_dir = TempDir::new().expect("create temp auth dir");
754 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
755 .expect("read auth dir override");
756 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
757 temp_dir.path().to_path_buf(),
758 ))
759 .expect("set auth dir override");
760
761 Self {
762 temp_dir: Some(temp_dir),
763 previous,
764 }
765 }
766 }
767
768 impl Drop for TestAuthDirGuard {
769 fn drop(&mut self) {
770 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
771 .expect("restore auth dir override");
772 if let Some(temp_dir) = self.temp_dir.take() {
773 temp_dir.close().expect("remove temp auth dir");
774 }
775 }
776 }
777
778 #[test]
779 fn test_storage_mode_default_is_keyring() {
780 assert_eq!(
781 AuthCredentialsStoreMode::default(),
782 AuthCredentialsStoreMode::Keyring
783 );
784 }
785
786 #[test]
787 fn test_storage_mode_effective_mode() {
788 assert_eq!(
789 AuthCredentialsStoreMode::Keyring.effective_mode(),
790 AuthCredentialsStoreMode::Keyring
791 );
792 assert_eq!(
793 AuthCredentialsStoreMode::File.effective_mode(),
794 AuthCredentialsStoreMode::File
795 );
796
797 let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
799 assert!(
800 auto_mode == AuthCredentialsStoreMode::Keyring
801 || auto_mode == AuthCredentialsStoreMode::File
802 );
803 }
804
805 #[test]
806 fn test_storage_mode_serialization() {
807 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
808 assert_eq!(keyring_json, "\"keyring\"");
809
810 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
811 assert_eq!(file_json, "\"file\"");
812
813 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
814 assert_eq!(auto_json, "\"auto\"");
815
816 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
818 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
819
820 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
821 assert_eq!(parsed, AuthCredentialsStoreMode::File);
822
823 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
824 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
825 }
826
827 #[test]
828 fn test_credential_storage_new() {
829 let storage = CredentialStorage::new("vtcode", "test_key");
830 assert_eq!(storage.service, "vtcode");
831 assert_eq!(storage.user, "test_key");
832 }
833
834 #[test]
835 fn test_is_keyring_functional_check() {
836 let _functional = is_keyring_functional();
839 }
840
841 #[test]
842 #[serial]
843 fn credential_storage_file_mode_round_trips_without_plaintext() {
844 let _guard = TestAuthDirGuard::new();
845 let storage = CredentialStorage::new("vtcode", "test_key");
846
847 storage
848 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
849 .expect("store encrypted credential");
850
851 let loaded = storage
852 .load_with_mode(AuthCredentialsStoreMode::File)
853 .expect("load encrypted credential");
854 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
855
856 let stored = fs::read_to_string(storage.file_path().expect("credential path"))
857 .expect("read encrypted credential file");
858 assert!(!stored.contains("secret_api_key"));
859 }
860
861 #[test]
862 #[serial]
863 fn keyring_mode_load_falls_back_to_encrypted_file() {
864 let _guard = TestAuthDirGuard::new();
865 let storage = CredentialStorage::new("vtcode", "test_key");
866
867 storage
868 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
869 .expect("store encrypted credential");
870
871 let loaded = storage
872 .load_with_mode(AuthCredentialsStoreMode::Keyring)
873 .expect("load credential");
874 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
875 }
876
877 #[test]
878 #[serial]
879 #[cfg(unix)]
880 fn credential_storage_file_mode_uses_private_permissions() {
881 use std::os::unix::fs::PermissionsExt;
882
883 let _guard = TestAuthDirGuard::new();
884 let storage = CredentialStorage::new("vtcode", "test_key");
885
886 storage
887 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
888 .expect("store encrypted credential");
889
890 let metadata = fs::metadata(storage.file_path().expect("credential path"))
891 .expect("read credential metadata");
892 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
893 }
894
895 #[test]
896 #[serial]
897 #[cfg(unix)]
898 fn credential_storage_file_mode_restricts_existing_file_permissions() {
899 use std::os::unix::fs::PermissionsExt;
900
901 let _guard = TestAuthDirGuard::new();
902 let storage = CredentialStorage::new("vtcode", "test_key");
903
904 storage
905 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
906 .expect("store initial credential");
907
908 let path = storage.file_path().expect("credential path");
909 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
910 .expect("broaden existing credential permissions");
911
912 storage
913 .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
914 .expect("rewrite credential");
915
916 let metadata = fs::metadata(path).expect("read credential metadata");
917 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
918 }
919
920 #[test]
921 #[serial]
922 fn custom_api_key_load_migrates_legacy_auth_json() {
923 let _guard = TestAuthDirGuard::new();
924 let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
925 fs::write(
926 &legacy_path,
927 r#"{
928 "version": 1,
929 "mode": "api_key",
930 "provider": "openai",
931 "api_key": "legacy-secret",
932 "authenticated_at": 1768406185
933}"#,
934 )
935 .expect("write legacy auth file");
936
937 let storage = CustomApiKeyStorage::new("openai");
938 let loaded = storage
939 .load(AuthCredentialsStoreMode::File)
940 .expect("load migrated api key");
941 assert_eq!(loaded.as_deref(), Some("legacy-secret"));
942 assert!(!legacy_path.exists());
943
944 let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
945 .expect("read migrated credential file");
946 assert!(!encrypted.contains("legacy-secret"));
947 }
948}