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