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 { service: service.into(), user: user.into() }
255 }
256
257 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
263 match mode.effective_mode() {
264 AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
265 Ok(()) => {
266 let _ = self.clear_file();
267 Ok(())
268 }
269 Err(err) => {
270 tracing::warn!(
271 "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
272 self.service,
273 self.user,
274 err
275 );
276 self.store_file(value).context("failed to store credential in encrypted file")
277 }
278 },
279 AuthCredentialsStoreMode::File => self.store_file(value),
280 _ => unreachable!(),
281 }
282 }
283
284 pub fn store(&self, value: &str) -> Result<()> {
286 self.store_keyring(value)
287 }
288
289 fn store_keyring(&self, value: &str) -> Result<()> {
291 let entry =
292 keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
293
294 entry.set_password(value).context("Failed to store credential in OS keyring")?;
295
296 tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
297 Ok(())
298 }
299
300 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
304 match mode.effective_mode() {
305 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
306 Ok(Some(value)) => Ok(Some(value)),
307 Ok(None) => self.load_file(),
308 Err(err) => {
309 tracing::warn!(
310 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
311 self.service,
312 self.user,
313 err
314 );
315 self.load_file()
316 }
317 },
318 AuthCredentialsStoreMode::File => self.load_file(),
319 _ => unreachable!(),
320 }
321 }
322
323 pub fn load(&self) -> Result<Option<String>> {
327 self.load_keyring()
328 }
329
330 fn load_keyring(&self) -> Result<Option<String>> {
332 let entry = match keyring_entry(&self.service, &self.user) {
333 Ok(e) => e,
334 Err(_) => return Ok(None),
335 };
336
337 match entry.get_password() {
338 Ok(value) => Ok(Some(value)),
339 Err(keyring_core::Error::NoEntry) => Ok(None),
340 Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
341 }
342 }
343
344 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
346 match mode.effective_mode() {
347 AuthCredentialsStoreMode::Keyring => {
348 let mut errors = Vec::new();
349
350 if let Err(err) = self.clear_keyring() {
351 errors.push(err.to_string());
352 }
353 if let Err(err) = self.clear_file() {
354 errors.push(err.to_string());
355 }
356
357 if errors.is_empty() {
358 Ok(())
359 } else {
360 Err(anyhow!(
361 "Failed to clear credential from secure storage: {}",
362 errors.join("; ")
363 ))
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!(
386 "Credential cleared from keyring for {}/{}",
387 self.service,
388 self.user
389 );
390 }
391 Err(keyring_core::Error::NoEntry) => {}
392 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
393 }
394
395 Ok(())
396 }
397
398 fn store_file(&self, value: &str) -> Result<()> {
399 let path = self.file_path()?;
400 let encrypted = encrypt_credential(value)?;
401 let payload = serde_json::to_vec_pretty(&encrypted)
402 .context("failed to serialize encrypted credential")?;
403 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
404
405 Ok(())
406 }
407
408 fn load_file(&self) -> Result<Option<String>> {
409 let path = self.file_path()?;
410 let data = match fs::read(&path) {
411 Ok(data) => data,
412 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
413 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
414 };
415
416 let encrypted: EncryptedCredential =
417 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
418 decrypt_credential(&encrypted).map(Some)
419 }
420
421 fn clear_file(&self) -> Result<()> {
422 let path = self.file_path()?;
423 match fs::remove_file(path) {
424 Ok(()) => Ok(()),
425 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
426 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
427 }
428 }
429
430 fn file_path(&self) -> Result<std::path::PathBuf> {
431 use sha2::Digest as _;
432
433 let mut hasher = sha2::Sha256::new();
434 hasher.update(self.service.as_bytes());
435 hasher.update([0]);
436 hasher.update(self.user.as_bytes());
437 let digest = hasher.finalize();
438 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
439
440 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
441 }
442}
443
444pub struct CustomApiKeyStorage {
449 provider: String,
450 storage: CredentialStorage,
451}
452
453impl CustomApiKeyStorage {
454 pub fn new(provider: &str) -> Self {
459 let normalized_provider = provider.to_lowercase();
460 Self {
461 provider: normalized_provider.clone(),
462 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
463 }
464 }
465
466 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
472 self.storage.store_with_mode(api_key, mode)?;
473 clear_legacy_auth_file_if_matches(&self.provider)?;
474 Ok(())
475 }
476
477 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
481 if let Some(key) = self.storage.load_with_mode(mode)? {
482 return Ok(Some(key));
483 }
484
485 self.load_legacy_auth_json(mode)
486 }
487
488 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
490 self.storage.clear_with_mode(mode)?;
491 clear_legacy_auth_file_if_matches(&self.provider)?;
492 Ok(())
493 }
494
495 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
496 let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
497 return Ok(None);
498 };
499
500 if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
501 tracing::warn!(
502 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
503 self.provider,
504 err
505 );
506 return Ok(Some(legacy.api_key));
507 }
508
509 clear_legacy_auth_file_if_matches(&self.provider)?;
510 tracing::warn!(
511 "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
512 self.provider
513 );
514 Ok(Some(legacy.api_key))
515 }
516}
517
518fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
519 let rng = SystemRandom::new();
521 let mut salt_bytes = [0_u8; 16];
522 rng.fill(&mut salt_bytes)
523 .map_err(|_| anyhow!("failed to generate credential salt"))?;
524 let salt = STANDARD.encode(salt_bytes);
525
526 let key = derive_file_encryption_key(Some(&salt))?;
527 let mut nonce_bytes = [0_u8; NONCE_LEN];
528 rng.fill(&mut nonce_bytes)
529 .map_err(|_| anyhow!("failed to generate credential nonce"))?;
530
531 let mut ciphertext = value.as_bytes().to_vec();
532 key.seal_in_place_append_tag(
533 Nonce::assume_unique_for_key(nonce_bytes),
534 Aad::empty(),
535 &mut ciphertext,
536 )
537 .map_err(|_| anyhow!("failed to encrypt credential"))?;
538
539 Ok(EncryptedCredential {
540 nonce: STANDARD.encode(nonce_bytes),
541 ciphertext: STANDARD.encode(ciphertext),
542 version: ENCRYPTED_CREDENTIAL_VERSION,
543 salt: Some(salt),
544 })
545}
546
547fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
548 if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
549 return Err(anyhow!("unsupported encrypted credential format"));
550 }
551
552 let nonce_bytes =
553 STANDARD.decode(&encrypted.nonce).context("failed to decode credential nonce")?;
554 let nonce_array: [u8; NONCE_LEN] =
555 nonce_bytes.try_into().map_err(|_| anyhow!("invalid credential nonce length"))?;
556 let mut ciphertext = STANDARD
557 .decode(&encrypted.ciphertext)
558 .context("failed to decode credential ciphertext")?;
559
560 let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
562 let plaintext = key
563 .open_in_place(Nonce::assume_unique_for_key(nonce_array), Aad::empty(), &mut ciphertext)
564 .map_err(|_| anyhow!("failed to decrypt credential"))?;
565
566 String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
567}
568
569fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
570 use ring::digest::SHA256;
571 use ring::digest::digest;
572
573 let mut key_material = Vec::new();
574 if let Ok(hostname) = hostname::get() {
575 key_material.extend_from_slice(hostname.as_encoded_bytes());
576 }
577
578 #[cfg(unix)]
579 {
580 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
581 }
582 #[cfg(not(unix))]
583 {
584 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
585 key_material.extend_from_slice(user.as_bytes());
586 }
587 }
588
589 key_material.extend_from_slice(b"vtcode-credentials-v1");
590
591 if let Some(salt) = salt {
595 key_material.extend_from_slice(salt.as_bytes());
596 }
597
598 let hash = digest(&SHA256, &key_material);
599 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
600 .try_into()
601 .context("credential encryption key was too short")?;
602 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
603 .map_err(|_| anyhow!("invalid credential encryption key"))?;
604 Ok(LessSafeKey::new(unbound))
605}
606
607fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
608 let path = legacy_auth_storage_path()?;
609 let data = match fs::read(&path) {
610 Ok(data) => data,
611 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
612 Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
613 };
614
615 let legacy: LegacyAuthFile =
616 serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
617 let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
618 let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
619 let has_key = !legacy.api_key.trim().is_empty();
620
621 if matches_provider && stores_api_key && has_key {
622 Ok(Some(legacy))
623 } else {
624 Ok(None)
625 }
626}
627
628fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
629 let path = legacy_auth_storage_path()?;
630 let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
631 return Ok(());
632 };
633
634 match fs::remove_file(path) {
635 Ok(()) => Ok(()),
636 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
637 Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
638 }
639}
640
641pub fn migrate_custom_api_keys_to_keyring(
654 custom_api_keys: &BTreeMap<String, String>,
655 mode: AuthCredentialsStoreMode,
656) -> Result<BTreeMap<String, bool>> {
657 let mut migration_results = BTreeMap::new();
658
659 for (provider, api_key) in custom_api_keys {
660 let storage = CustomApiKeyStorage::new(provider);
661 match storage.store(api_key, mode) {
662 Ok(()) => {
663 tracing::info!("Migrated API key for provider '{}' to secure storage", provider);
664 migration_results.insert(provider.clone(), true);
665 }
666 Err(e) => {
667 tracing::warn!("Failed to migrate API key for provider '{}': {}", provider, e);
668 migration_results.insert(provider.clone(), false);
669 }
670 }
671 }
672
673 Ok(migration_results)
674}
675
676pub fn load_custom_api_keys(
687 providers: &[String],
688 mode: AuthCredentialsStoreMode,
689) -> Result<BTreeMap<String, String>> {
690 let mut api_keys = BTreeMap::new();
691
692 for provider in providers {
693 let storage = CustomApiKeyStorage::new(provider);
694 if let Some(key) = storage.load(mode)? {
695 api_keys.insert(provider.clone(), key);
696 }
697 }
698
699 Ok(api_keys)
700}
701
702pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
708 for provider in providers {
709 let storage = CustomApiKeyStorage::new(provider);
710 if let Err(e) = storage.clear(mode) {
711 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
712 }
713 }
714 Ok(())
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use assert_fs::TempDir;
721 use serial_test::serial;
722
723 struct TestAuthDirGuard {
724 temp_dir: Option<TempDir>,
725 previous: Option<std::path::PathBuf>,
726 }
727
728 impl TestAuthDirGuard {
729 fn new() -> Self {
730 let temp_dir = TempDir::new().expect("create temp auth dir");
731 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
732 .expect("read auth dir override");
733 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
734 temp_dir.path().to_path_buf(),
735 ))
736 .expect("set auth dir override");
737
738 Self { temp_dir: Some(temp_dir), previous }
739 }
740 }
741
742 impl Drop for TestAuthDirGuard {
743 fn drop(&mut self) {
744 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
745 .expect("restore auth dir override");
746 if let Some(temp_dir) = self.temp_dir.take() {
747 temp_dir.close().expect("remove temp auth dir");
748 }
749 }
750 }
751
752 #[test]
753 fn test_storage_mode_default_is_keyring() {
754 assert_eq!(AuthCredentialsStoreMode::default(), AuthCredentialsStoreMode::Keyring);
755 }
756
757 #[test]
758 fn test_storage_mode_effective_mode() {
759 assert_eq!(
760 AuthCredentialsStoreMode::Keyring.effective_mode(),
761 AuthCredentialsStoreMode::Keyring
762 );
763 assert_eq!(AuthCredentialsStoreMode::File.effective_mode(), AuthCredentialsStoreMode::File);
764
765 let auto_permission = AuthCredentialsStoreMode::Auto.effective_mode();
767 assert!(
768 auto_permission == AuthCredentialsStoreMode::Keyring
769 || auto_permission == AuthCredentialsStoreMode::File
770 );
771 }
772
773 #[test]
774 fn test_storage_mode_serialization() {
775 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
776 assert_eq!(keyring_json, "\"keyring\"");
777
778 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
779 assert_eq!(file_json, "\"file\"");
780
781 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
782 assert_eq!(auto_json, "\"auto\"");
783
784 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
786 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
787
788 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
789 assert_eq!(parsed, AuthCredentialsStoreMode::File);
790
791 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
792 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
793 }
794
795 #[test]
796 fn test_credential_storage_new() {
797 let storage = CredentialStorage::new("vtcode", "test_key");
798 assert_eq!(storage.service, "vtcode");
799 assert_eq!(storage.user, "test_key");
800 }
801
802 #[test]
803 fn test_is_keyring_functional_check() {
804 let _functional = is_keyring_functional();
807 }
808
809 #[test]
810 #[serial]
811 fn credential_storage_file_mode_round_trips_without_plaintext() {
812 let _guard = TestAuthDirGuard::new();
813 let storage = CredentialStorage::new("vtcode", "test_key");
814
815 storage
816 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
817 .expect("store encrypted credential");
818
819 let loaded = storage
820 .load_with_mode(AuthCredentialsStoreMode::File)
821 .expect("load encrypted credential");
822 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
823
824 let stored = fs::read_to_string(storage.file_path().expect("credential path"))
825 .expect("read encrypted credential file");
826 assert!(!stored.contains("secret_api_key"));
827 }
828
829 #[test]
830 #[serial]
831 fn keyring_mode_load_falls_back_to_encrypted_file() {
832 let _guard = TestAuthDirGuard::new();
833 let storage = CredentialStorage::new("vtcode", "test_key");
834
835 storage
836 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
837 .expect("store encrypted credential");
838
839 let loaded = storage
840 .load_with_mode(AuthCredentialsStoreMode::Keyring)
841 .expect("load credential");
842 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
843 }
844
845 #[test]
846 #[serial]
847 #[cfg(unix)]
848 fn credential_storage_file_mode_uses_private_permissions() {
849 use std::os::unix::fs::PermissionsExt;
850
851 let _guard = TestAuthDirGuard::new();
852 let storage = CredentialStorage::new("vtcode", "test_key");
853
854 storage
855 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
856 .expect("store encrypted credential");
857
858 let metadata = fs::metadata(storage.file_path().expect("credential path"))
859 .expect("read credential metadata");
860 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
861 }
862
863 #[test]
864 #[serial]
865 #[cfg(unix)]
866 fn credential_storage_file_mode_restricts_existing_file_permissions() {
867 use std::os::unix::fs::PermissionsExt;
868
869 let _guard = TestAuthDirGuard::new();
870 let storage = CredentialStorage::new("vtcode", "test_key");
871
872 storage
873 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
874 .expect("store initial credential");
875
876 let path = storage.file_path().expect("credential path");
877 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
878 .expect("broaden existing credential permissions");
879
880 storage
881 .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
882 .expect("rewrite credential");
883
884 let metadata = fs::metadata(path).expect("read credential metadata");
885 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
886 }
887
888 #[test]
889 #[serial]
890 fn custom_api_key_load_migrates_legacy_auth_json() {
891 let _guard = TestAuthDirGuard::new();
892 let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
893 fs::write(
894 &legacy_path,
895 r#"{
896 "version": 1,
897 "mode": "api_key",
898 "provider": "openai",
899 "api_key": "legacy-secret",
900 "authenticated_at": 1768406185
901}"#,
902 )
903 .expect("write legacy auth file");
904
905 let storage = CustomApiKeyStorage::new("openai");
906 let loaded = storage.load(AuthCredentialsStoreMode::File).expect("load migrated api key");
907 assert_eq!(loaded.as_deref(), Some("legacy-secret"));
908 assert!(!legacy_path.exists());
909
910 let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
911 .expect("read migrated credential file");
912 assert!(!encrypted.contains("legacy-secret"));
913 }
914}