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}
49
50#[derive(Debug, Deserialize)]
51struct LegacyAuthFile {
52 mode: String,
53 provider: String,
54 api_key: String,
55}
56
57#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67pub enum AuthCredentialsStoreMode {
68 Keyring,
72 File,
75 Auto,
77}
78
79impl Default for AuthCredentialsStoreMode {
80 fn default() -> Self {
83 Self::Keyring
84 }
85}
86
87impl AuthCredentialsStoreMode {
88 pub fn effective_mode(self) -> Self {
90 match self {
91 Self::Auto => {
92 if is_keyring_functional() {
94 Self::Keyring
95 } else {
96 tracing::debug!("Keyring not available, falling back to file storage");
97 Self::File
98 }
99 }
100 mode => mode,
101 }
102 }
103}
104
105pub(crate) fn is_keyring_functional() -> bool {
110 let test_user = format!("test_{}", std::process::id());
112 let entry = match keyring_entry("vtcode", &test_user) {
113 Ok(e) => e,
114 Err(_) => return false,
115 };
116
117 if entry.set_password("test").is_err() {
119 return false;
120 }
121
122 let functional = entry.get_password().is_ok();
124
125 let _ = entry.delete_credential();
127
128 functional
129}
130
131fn ensure_native_keyring_store() -> keyring_core::Result<()> {
132 if keyring_core::get_default_store().is_some() {
133 return Ok(());
134 }
135
136 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
137 let store = dbus_secret_service_keyring_store::Store::new_with_configuration(
138 &std::collections::HashMap::new(),
139 )?;
140
141 #[cfg(target_os = "macos")]
142 let store = apple_native_keyring_store::keychain::Store::new_with_configuration(
143 &std::collections::HashMap::new(),
144 )?;
145
146 #[cfg(target_os = "windows")]
147 let store = windows_native_keyring_store::Store::new_with_configuration(
148 &std::collections::HashMap::new(),
149 )?;
150
151 #[cfg(not(any(
152 target_os = "linux",
153 target_os = "freebsd",
154 target_os = "macos",
155 target_os = "windows"
156 )))]
157 {
158 return Err(keyring_core::Error::NotSupportedByStore(
159 "VT Code does not have a native keyring store configured for this platform".to_string(),
160 ));
161 }
162
163 keyring_core::set_default_store(store);
164 Ok(())
165}
166
167pub(crate) fn keyring_entry(
168 service: &str,
169 user: &str,
170) -> keyring_core::Result<keyring_core::Entry> {
171 if keyring_core::get_default_store().is_none() {
172 ensure_native_keyring_store()?;
173 }
174
175 keyring_core::Entry::new(service, user)
176}
177
178pub struct CredentialStorage {
183 service: String,
184 user: String,
185}
186
187impl CredentialStorage {
188 pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
194 Self {
195 service: service.into(),
196 user: user.into(),
197 }
198 }
199
200 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
206 match mode.effective_mode() {
207 AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
208 Ok(()) => {
209 let _ = self.clear_file();
210 Ok(())
211 }
212 Err(err) => {
213 tracing::warn!(
214 "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
215 self.service,
216 self.user,
217 err
218 );
219 self.store_file(value)
220 .context("failed to store credential in encrypted file")
221 }
222 },
223 AuthCredentialsStoreMode::File => self.store_file(value),
224 _ => unreachable!(),
225 }
226 }
227
228 pub fn store(&self, value: &str) -> Result<()> {
230 self.store_keyring(value)
231 }
232
233 fn store_keyring(&self, value: &str) -> Result<()> {
235 let entry =
236 keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
237
238 entry
239 .set_password(value)
240 .context("Failed to store credential in OS keyring")?;
241
242 tracing::debug!(
243 "Credential stored in OS keyring for {}/{}",
244 self.service,
245 self.user
246 );
247 Ok(())
248 }
249
250 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
254 match mode.effective_mode() {
255 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
256 Ok(Some(value)) => Ok(Some(value)),
257 Ok(None) => self.load_file(),
258 Err(err) => {
259 tracing::warn!(
260 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
261 self.service,
262 self.user,
263 err
264 );
265 self.load_file()
266 }
267 },
268 AuthCredentialsStoreMode::File => self.load_file(),
269 _ => unreachable!(),
270 }
271 }
272
273 pub fn load(&self) -> Result<Option<String>> {
277 self.load_keyring()
278 }
279
280 fn load_keyring(&self) -> Result<Option<String>> {
282 let entry = match keyring_entry(&self.service, &self.user) {
283 Ok(e) => e,
284 Err(_) => return Ok(None),
285 };
286
287 match entry.get_password() {
288 Ok(value) => Ok(Some(value)),
289 Err(keyring_core::Error::NoEntry) => Ok(None),
290 Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
291 }
292 }
293
294 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
296 match mode.effective_mode() {
297 AuthCredentialsStoreMode::Keyring => {
298 let mut errors = Vec::new();
299
300 if let Err(err) = self.clear_keyring() {
301 errors.push(err.to_string());
302 }
303 if let Err(err) = self.clear_file() {
304 errors.push(err.to_string());
305 }
306
307 if errors.is_empty() {
308 Ok(())
309 } else {
310 Err(anyhow!(
311 "Failed to clear credential from secure storage: {}",
312 errors.join("; ")
313 ))
314 }
315 }
316 AuthCredentialsStoreMode::File => self.clear_file(),
317 _ => unreachable!(),
318 }
319 }
320
321 pub fn clear(&self) -> Result<()> {
323 self.clear_keyring()
324 }
325
326 fn clear_keyring(&self) -> Result<()> {
328 let entry = match keyring_entry(&self.service, &self.user) {
329 Ok(e) => e,
330 Err(_) => return Ok(()),
331 };
332
333 match entry.delete_credential() {
334 Ok(_) => {
335 tracing::debug!(
336 "Credential cleared from keyring for {}/{}",
337 self.service,
338 self.user
339 );
340 }
341 Err(keyring_core::Error::NoEntry) => {}
342 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
343 }
344
345 Ok(())
346 }
347
348 fn store_file(&self, value: &str) -> Result<()> {
349 let path = self.file_path()?;
350 let encrypted = encrypt_credential(value)?;
351 let payload = serde_json::to_vec_pretty(&encrypted)
352 .context("failed to serialize encrypted credential")?;
353 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
354
355 Ok(())
356 }
357
358 fn load_file(&self) -> Result<Option<String>> {
359 let path = self.file_path()?;
360 let data = match fs::read(&path) {
361 Ok(data) => data,
362 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
363 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
364 };
365
366 let encrypted: EncryptedCredential =
367 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
368 decrypt_credential(&encrypted).map(Some)
369 }
370
371 fn clear_file(&self) -> Result<()> {
372 let path = self.file_path()?;
373 match fs::remove_file(path) {
374 Ok(()) => Ok(()),
375 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
376 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
377 }
378 }
379
380 fn file_path(&self) -> Result<std::path::PathBuf> {
381 use sha2::Digest as _;
382
383 let mut hasher = sha2::Sha256::new();
384 hasher.update(self.service.as_bytes());
385 hasher.update([0]);
386 hasher.update(self.user.as_bytes());
387 let digest = hasher.finalize();
388 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
389
390 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
391 }
392}
393
394pub struct CustomApiKeyStorage {
399 provider: String,
400 storage: CredentialStorage,
401}
402
403impl CustomApiKeyStorage {
404 pub fn new(provider: &str) -> Self {
409 let normalized_provider = provider.to_lowercase();
410 Self {
411 provider: normalized_provider.clone(),
412 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
413 }
414 }
415
416 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
422 self.storage.store_with_mode(api_key, mode)?;
423 clear_legacy_auth_file_if_matches(&self.provider)?;
424 Ok(())
425 }
426
427 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
431 if let Some(key) = self.storage.load_with_mode(mode)? {
432 return Ok(Some(key));
433 }
434
435 self.load_legacy_auth_json(mode)
436 }
437
438 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
440 self.storage.clear_with_mode(mode)?;
441 clear_legacy_auth_file_if_matches(&self.provider)?;
442 Ok(())
443 }
444
445 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
446 let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
447 return Ok(None);
448 };
449
450 if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
451 tracing::warn!(
452 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
453 self.provider,
454 err
455 );
456 return Ok(Some(legacy.api_key));
457 }
458
459 clear_legacy_auth_file_if_matches(&self.provider)?;
460 tracing::warn!(
461 "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
462 self.provider
463 );
464 Ok(Some(legacy.api_key))
465 }
466}
467
468fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
469 let key = derive_file_encryption_key()?;
470 let rng = SystemRandom::new();
471 let mut nonce_bytes = [0_u8; NONCE_LEN];
472 rng.fill(&mut nonce_bytes)
473 .map_err(|_| anyhow!("failed to generate credential nonce"))?;
474
475 let mut ciphertext = value.as_bytes().to_vec();
476 key.seal_in_place_append_tag(
477 Nonce::assume_unique_for_key(nonce_bytes),
478 Aad::empty(),
479 &mut ciphertext,
480 )
481 .map_err(|_| anyhow!("failed to encrypt credential"))?;
482
483 Ok(EncryptedCredential {
484 nonce: STANDARD.encode(nonce_bytes),
485 ciphertext: STANDARD.encode(ciphertext),
486 version: ENCRYPTED_CREDENTIAL_VERSION,
487 })
488}
489
490fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
491 if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
492 return Err(anyhow!("unsupported encrypted credential format"));
493 }
494
495 let nonce_bytes = STANDARD
496 .decode(&encrypted.nonce)
497 .context("failed to decode credential nonce")?;
498 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
499 .try_into()
500 .map_err(|_| anyhow!("invalid credential nonce length"))?;
501 let mut ciphertext = STANDARD
502 .decode(&encrypted.ciphertext)
503 .context("failed to decode credential ciphertext")?;
504
505 let key = derive_file_encryption_key()?;
506 let plaintext = key
507 .open_in_place(
508 Nonce::assume_unique_for_key(nonce_array),
509 Aad::empty(),
510 &mut ciphertext,
511 )
512 .map_err(|_| anyhow!("failed to decrypt credential"))?;
513
514 String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
515}
516
517fn derive_file_encryption_key() -> Result<LessSafeKey> {
518 use ring::digest::SHA256;
519 use ring::digest::digest;
520
521 let mut key_material = Vec::new();
522 if let Ok(hostname) = hostname::get() {
523 key_material.extend_from_slice(hostname.as_encoded_bytes());
524 }
525
526 #[cfg(unix)]
527 {
528 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
529 }
530 #[cfg(not(unix))]
531 {
532 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
533 key_material.extend_from_slice(user.as_bytes());
534 }
535 }
536
537 key_material.extend_from_slice(b"vtcode-credentials-v1");
538
539 let hash = digest(&SHA256, &key_material);
540 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
541 .try_into()
542 .context("credential encryption key was too short")?;
543 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
544 .map_err(|_| anyhow!("invalid credential encryption key"))?;
545 Ok(LessSafeKey::new(unbound))
546}
547
548fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
549 let path = legacy_auth_storage_path()?;
550 let data = match fs::read(&path) {
551 Ok(data) => data,
552 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
553 Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
554 };
555
556 let legacy: LegacyAuthFile =
557 serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
558 let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
559 let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
560 let has_key = !legacy.api_key.trim().is_empty();
561
562 if matches_provider && stores_api_key && has_key {
563 Ok(Some(legacy))
564 } else {
565 Ok(None)
566 }
567}
568
569fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
570 let path = legacy_auth_storage_path()?;
571 let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
572 return Ok(());
573 };
574
575 match fs::remove_file(path) {
576 Ok(()) => Ok(()),
577 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
578 Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
579 }
580}
581
582pub fn migrate_custom_api_keys_to_keyring(
595 custom_api_keys: &BTreeMap<String, String>,
596 mode: AuthCredentialsStoreMode,
597) -> Result<BTreeMap<String, bool>> {
598 let mut migration_results = BTreeMap::new();
599
600 for (provider, api_key) in custom_api_keys {
601 let storage = CustomApiKeyStorage::new(provider);
602 match storage.store(api_key, mode) {
603 Ok(()) => {
604 tracing::info!(
605 "Migrated API key for provider '{}' to secure storage",
606 provider
607 );
608 migration_results.insert(provider.clone(), true);
609 }
610 Err(e) => {
611 tracing::warn!(
612 "Failed to migrate API key for provider '{}': {}",
613 provider,
614 e
615 );
616 migration_results.insert(provider.clone(), false);
617 }
618 }
619 }
620
621 Ok(migration_results)
622}
623
624pub fn load_custom_api_keys(
635 providers: &[String],
636 mode: AuthCredentialsStoreMode,
637) -> Result<BTreeMap<String, String>> {
638 let mut api_keys = BTreeMap::new();
639
640 for provider in providers {
641 let storage = CustomApiKeyStorage::new(provider);
642 if let Some(key) = storage.load(mode)? {
643 api_keys.insert(provider.clone(), key);
644 }
645 }
646
647 Ok(api_keys)
648}
649
650pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
656 for provider in providers {
657 let storage = CustomApiKeyStorage::new(provider);
658 if let Err(e) = storage.clear(mode) {
659 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
660 }
661 }
662 Ok(())
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use assert_fs::TempDir;
669 use serial_test::serial;
670
671 struct TestAuthDirGuard {
672 temp_dir: Option<TempDir>,
673 previous: Option<std::path::PathBuf>,
674 }
675
676 impl TestAuthDirGuard {
677 fn new() -> Self {
678 let temp_dir = TempDir::new().expect("create temp auth dir");
679 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
680 .expect("read auth dir override");
681 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
682 temp_dir.path().to_path_buf(),
683 ))
684 .expect("set auth dir override");
685
686 Self {
687 temp_dir: Some(temp_dir),
688 previous,
689 }
690 }
691 }
692
693 impl Drop for TestAuthDirGuard {
694 fn drop(&mut self) {
695 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
696 .expect("restore auth dir override");
697 if let Some(temp_dir) = self.temp_dir.take() {
698 temp_dir.close().expect("remove temp auth dir");
699 }
700 }
701 }
702
703 #[test]
704 fn test_storage_mode_default_is_keyring() {
705 assert_eq!(
706 AuthCredentialsStoreMode::default(),
707 AuthCredentialsStoreMode::Keyring
708 );
709 }
710
711 #[test]
712 fn test_storage_mode_effective_mode() {
713 assert_eq!(
714 AuthCredentialsStoreMode::Keyring.effective_mode(),
715 AuthCredentialsStoreMode::Keyring
716 );
717 assert_eq!(
718 AuthCredentialsStoreMode::File.effective_mode(),
719 AuthCredentialsStoreMode::File
720 );
721
722 let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
724 assert!(
725 auto_mode == AuthCredentialsStoreMode::Keyring
726 || auto_mode == AuthCredentialsStoreMode::File
727 );
728 }
729
730 #[test]
731 fn test_storage_mode_serialization() {
732 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
733 assert_eq!(keyring_json, "\"keyring\"");
734
735 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
736 assert_eq!(file_json, "\"file\"");
737
738 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
739 assert_eq!(auto_json, "\"auto\"");
740
741 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
743 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
744
745 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
746 assert_eq!(parsed, AuthCredentialsStoreMode::File);
747
748 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
749 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
750 }
751
752 #[test]
753 fn test_credential_storage_new() {
754 let storage = CredentialStorage::new("vtcode", "test_key");
755 assert_eq!(storage.service, "vtcode");
756 assert_eq!(storage.user, "test_key");
757 }
758
759 #[test]
760 fn test_is_keyring_functional_check() {
761 let _functional = is_keyring_functional();
764 }
765
766 #[test]
767 #[serial]
768 fn credential_storage_file_mode_round_trips_without_plaintext() {
769 let _guard = TestAuthDirGuard::new();
770 let storage = CredentialStorage::new("vtcode", "test_key");
771
772 storage
773 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
774 .expect("store encrypted credential");
775
776 let loaded = storage
777 .load_with_mode(AuthCredentialsStoreMode::File)
778 .expect("load encrypted credential");
779 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
780
781 let stored = fs::read_to_string(storage.file_path().expect("credential path"))
782 .expect("read encrypted credential file");
783 assert!(!stored.contains("secret_api_key"));
784 }
785
786 #[test]
787 #[serial]
788 fn keyring_mode_load_falls_back_to_encrypted_file() {
789 let _guard = TestAuthDirGuard::new();
790 let storage = CredentialStorage::new("vtcode", "test_key");
791
792 storage
793 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
794 .expect("store encrypted credential");
795
796 let loaded = storage
797 .load_with_mode(AuthCredentialsStoreMode::Keyring)
798 .expect("load credential");
799 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
800 }
801
802 #[test]
803 #[serial]
804 #[cfg(unix)]
805 fn credential_storage_file_mode_uses_private_permissions() {
806 use std::os::unix::fs::PermissionsExt;
807
808 let _guard = TestAuthDirGuard::new();
809 let storage = CredentialStorage::new("vtcode", "test_key");
810
811 storage
812 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
813 .expect("store encrypted credential");
814
815 let metadata = fs::metadata(storage.file_path().expect("credential path"))
816 .expect("read credential metadata");
817 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
818 }
819
820 #[test]
821 #[serial]
822 #[cfg(unix)]
823 fn credential_storage_file_mode_restricts_existing_file_permissions() {
824 use std::os::unix::fs::PermissionsExt;
825
826 let _guard = TestAuthDirGuard::new();
827 let storage = CredentialStorage::new("vtcode", "test_key");
828
829 storage
830 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
831 .expect("store initial credential");
832
833 let path = storage.file_path().expect("credential path");
834 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
835 .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
864 .load(AuthCredentialsStoreMode::File)
865 .expect("load migrated api key");
866 assert_eq!(loaded.as_deref(), Some("legacy-secret"));
867 assert!(!legacy_path.exists());
868
869 let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
870 .expect("read migrated credential file");
871 assert!(!encrypted.contains("legacy-secret"));
872 }
873}