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 {
114 let test_user = format!("test_{}", std::process::id());
116 let entry = match keyring_entry("vtcode", &test_user) {
117 Ok(e) => e,
118 Err(_) => return false,
119 };
120
121 if entry.set_password("test").is_err() {
123 return false;
124 }
125
126 let functional = entry.get_password().is_ok();
128
129 let _ = entry.delete_credential();
132
133 functional
134}
135
136fn ensure_native_keyring_store() -> keyring_core::Result<()> {
137 if keyring_core::get_default_store().is_some() {
138 return Ok(());
139 }
140
141 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
142 let store = dbus_secret_service_keyring_store::Store::new_with_configuration(
143 &std::collections::HashMap::new(),
144 )?;
145
146 #[cfg(target_os = "macos")]
147 let store = apple_native_keyring_store::keychain::Store::new_with_configuration(
148 &std::collections::HashMap::new(),
149 )?;
150
151 #[cfg(target_os = "windows")]
152 let store = windows_native_keyring_store::Store::new_with_configuration(
153 &std::collections::HashMap::new(),
154 )?;
155
156 #[cfg(not(any(
157 target_os = "linux",
158 target_os = "freebsd",
159 target_os = "macos",
160 target_os = "windows"
161 )))]
162 {
163 return Err(keyring_core::Error::NotSupportedByStore(
164 "VT Code does not have a native keyring store configured for this platform".to_string(),
165 ));
166 }
167
168 keyring_core::set_default_store(store);
169 Ok(())
170}
171
172pub(crate) fn keyring_entry(
173 service: &str,
174 user: &str,
175) -> keyring_core::Result<keyring_core::Entry> {
176 if keyring_core::get_default_store().is_none() {
177 ensure_native_keyring_store()?;
178 }
179
180 keyring_core::Entry::new(service, user)
181}
182
183pub struct CredentialStorage {
188 service: String,
189 user: String,
190}
191
192impl CredentialStorage {
193 pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
199 Self {
200 service: service.into(),
201 user: user.into(),
202 }
203 }
204
205 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
211 match mode.effective_mode() {
212 AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
213 Ok(()) => {
214 let _ = self.clear_file();
215 Ok(())
216 }
217 Err(err) => {
218 tracing::warn!(
219 "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
220 self.service,
221 self.user,
222 err
223 );
224 self.store_file(value)
225 .context("failed to store credential in encrypted file")
226 }
227 },
228 AuthCredentialsStoreMode::File => self.store_file(value),
229 _ => unreachable!(),
230 }
231 }
232
233 pub fn store(&self, value: &str) -> Result<()> {
235 self.store_keyring(value)
236 }
237
238 fn store_keyring(&self, value: &str) -> Result<()> {
240 let entry =
241 keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
242
243 entry
244 .set_password(value)
245 .context("Failed to store credential in OS keyring")?;
246
247 tracing::debug!(
248 "Credential stored in OS keyring for {}/{}",
249 self.service,
250 self.user
251 );
252 Ok(())
253 }
254
255 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
259 match mode.effective_mode() {
260 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
261 Ok(Some(value)) => Ok(Some(value)),
262 Ok(None) => self.load_file(),
263 Err(err) => {
264 tracing::warn!(
265 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
266 self.service,
267 self.user,
268 err
269 );
270 self.load_file()
271 }
272 },
273 AuthCredentialsStoreMode::File => self.load_file(),
274 _ => unreachable!(),
275 }
276 }
277
278 pub fn load(&self) -> Result<Option<String>> {
282 self.load_keyring()
283 }
284
285 fn load_keyring(&self) -> Result<Option<String>> {
287 let entry = match keyring_entry(&self.service, &self.user) {
288 Ok(e) => e,
289 Err(_) => return Ok(None),
290 };
291
292 match entry.get_password() {
293 Ok(value) => Ok(Some(value)),
294 Err(keyring_core::Error::NoEntry) => Ok(None),
295 Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
296 }
297 }
298
299 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
301 match mode.effective_mode() {
302 AuthCredentialsStoreMode::Keyring => {
303 let mut errors = Vec::new();
304
305 if let Err(err) = self.clear_keyring() {
306 errors.push(err.to_string());
307 }
308 if let Err(err) = self.clear_file() {
309 errors.push(err.to_string());
310 }
311
312 if errors.is_empty() {
313 Ok(())
314 } else {
315 Err(anyhow!(
316 "Failed to clear credential from secure storage: {}",
317 errors.join("; ")
318 ))
319 }
320 }
321 AuthCredentialsStoreMode::File => self.clear_file(),
322 _ => unreachable!(),
323 }
324 }
325
326 pub fn clear(&self) -> Result<()> {
328 self.clear_keyring()
329 }
330
331 fn clear_keyring(&self) -> Result<()> {
333 let entry = match keyring_entry(&self.service, &self.user) {
334 Ok(e) => e,
335 Err(_) => return Ok(()),
336 };
337
338 match entry.delete_credential() {
339 Ok(_) => {
340 tracing::debug!(
341 "Credential cleared from keyring for {}/{}",
342 self.service,
343 self.user
344 );
345 }
346 Err(keyring_core::Error::NoEntry) => {}
347 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
348 }
349
350 Ok(())
351 }
352
353 fn store_file(&self, value: &str) -> Result<()> {
354 let path = self.file_path()?;
355 let encrypted = encrypt_credential(value)?;
356 let payload = serde_json::to_vec_pretty(&encrypted)
357 .context("failed to serialize encrypted credential")?;
358 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
359
360 Ok(())
361 }
362
363 fn load_file(&self) -> Result<Option<String>> {
364 let path = self.file_path()?;
365 let data = match fs::read(&path) {
366 Ok(data) => data,
367 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
368 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
369 };
370
371 let encrypted: EncryptedCredential =
372 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
373 decrypt_credential(&encrypted).map(Some)
374 }
375
376 fn clear_file(&self) -> Result<()> {
377 let path = self.file_path()?;
378 match fs::remove_file(path) {
379 Ok(()) => Ok(()),
380 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
381 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
382 }
383 }
384
385 fn file_path(&self) -> Result<std::path::PathBuf> {
386 use sha2::Digest as _;
387
388 let mut hasher = sha2::Sha256::new();
389 hasher.update(self.service.as_bytes());
390 hasher.update([0]);
391 hasher.update(self.user.as_bytes());
392 let digest = hasher.finalize();
393 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
394
395 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
396 }
397}
398
399pub struct CustomApiKeyStorage {
404 provider: String,
405 storage: CredentialStorage,
406}
407
408impl CustomApiKeyStorage {
409 pub fn new(provider: &str) -> Self {
414 let normalized_provider = provider.to_lowercase();
415 Self {
416 provider: normalized_provider.clone(),
417 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
418 }
419 }
420
421 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
427 self.storage.store_with_mode(api_key, mode)?;
428 clear_legacy_auth_file_if_matches(&self.provider)?;
429 Ok(())
430 }
431
432 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
436 if let Some(key) = self.storage.load_with_mode(mode)? {
437 return Ok(Some(key));
438 }
439
440 self.load_legacy_auth_json(mode)
441 }
442
443 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
445 self.storage.clear_with_mode(mode)?;
446 clear_legacy_auth_file_if_matches(&self.provider)?;
447 Ok(())
448 }
449
450 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
451 let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
452 return Ok(None);
453 };
454
455 if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
456 tracing::warn!(
457 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
458 self.provider,
459 err
460 );
461 return Ok(Some(legacy.api_key));
462 }
463
464 clear_legacy_auth_file_if_matches(&self.provider)?;
465 tracing::warn!(
466 "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
467 self.provider
468 );
469 Ok(Some(legacy.api_key))
470 }
471}
472
473fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
474 let rng = SystemRandom::new();
476 let mut salt_bytes = [0_u8; 16];
477 rng.fill(&mut salt_bytes)
478 .map_err(|_| anyhow!("failed to generate credential salt"))?;
479 let salt = STANDARD.encode(salt_bytes);
480
481 let key = derive_file_encryption_key(Some(&salt))?;
482 let mut nonce_bytes = [0_u8; NONCE_LEN];
483 rng.fill(&mut nonce_bytes)
484 .map_err(|_| anyhow!("failed to generate credential nonce"))?;
485
486 let mut ciphertext = value.as_bytes().to_vec();
487 key.seal_in_place_append_tag(
488 Nonce::assume_unique_for_key(nonce_bytes),
489 Aad::empty(),
490 &mut ciphertext,
491 )
492 .map_err(|_| anyhow!("failed to encrypt credential"))?;
493
494 Ok(EncryptedCredential {
495 nonce: STANDARD.encode(nonce_bytes),
496 ciphertext: STANDARD.encode(ciphertext),
497 version: ENCRYPTED_CREDENTIAL_VERSION,
498 salt: Some(salt),
499 })
500}
501
502fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
503 if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
504 return Err(anyhow!("unsupported encrypted credential format"));
505 }
506
507 let nonce_bytes = STANDARD
508 .decode(&encrypted.nonce)
509 .context("failed to decode credential nonce")?;
510 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
511 .try_into()
512 .map_err(|_| anyhow!("invalid credential nonce length"))?;
513 let mut ciphertext = STANDARD
514 .decode(&encrypted.ciphertext)
515 .context("failed to decode credential ciphertext")?;
516
517 let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
519 let plaintext = key
520 .open_in_place(
521 Nonce::assume_unique_for_key(nonce_array),
522 Aad::empty(),
523 &mut ciphertext,
524 )
525 .map_err(|_| anyhow!("failed to decrypt credential"))?;
526
527 String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
528}
529
530fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
531 use ring::digest::SHA256;
532 use ring::digest::digest;
533
534 let mut key_material = Vec::new();
535 if let Ok(hostname) = hostname::get() {
536 key_material.extend_from_slice(hostname.as_encoded_bytes());
537 }
538
539 #[cfg(unix)]
540 {
541 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
542 }
543 #[cfg(not(unix))]
544 {
545 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
546 key_material.extend_from_slice(user.as_bytes());
547 }
548 }
549
550 key_material.extend_from_slice(b"vtcode-credentials-v1");
551
552 if let Some(salt) = salt {
556 key_material.extend_from_slice(salt.as_bytes());
557 }
558
559 let hash = digest(&SHA256, &key_material);
560 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
561 .try_into()
562 .context("credential encryption key was too short")?;
563 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
564 .map_err(|_| anyhow!("invalid credential encryption key"))?;
565 Ok(LessSafeKey::new(unbound))
566}
567
568fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
569 let path = legacy_auth_storage_path()?;
570 let data = match fs::read(&path) {
571 Ok(data) => data,
572 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
573 Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
574 };
575
576 let legacy: LegacyAuthFile =
577 serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
578 let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
579 let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
580 let has_key = !legacy.api_key.trim().is_empty();
581
582 if matches_provider && stores_api_key && has_key {
583 Ok(Some(legacy))
584 } else {
585 Ok(None)
586 }
587}
588
589fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
590 let path = legacy_auth_storage_path()?;
591 let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
592 return Ok(());
593 };
594
595 match fs::remove_file(path) {
596 Ok(()) => Ok(()),
597 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
598 Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
599 }
600}
601
602pub fn migrate_custom_api_keys_to_keyring(
615 custom_api_keys: &BTreeMap<String, String>,
616 mode: AuthCredentialsStoreMode,
617) -> Result<BTreeMap<String, bool>> {
618 let mut migration_results = BTreeMap::new();
619
620 for (provider, api_key) in custom_api_keys {
621 let storage = CustomApiKeyStorage::new(provider);
622 match storage.store(api_key, mode) {
623 Ok(()) => {
624 tracing::info!(
625 "Migrated API key for provider '{}' to secure storage",
626 provider
627 );
628 migration_results.insert(provider.clone(), true);
629 }
630 Err(e) => {
631 tracing::warn!(
632 "Failed to migrate API key for provider '{}': {}",
633 provider,
634 e
635 );
636 migration_results.insert(provider.clone(), false);
637 }
638 }
639 }
640
641 Ok(migration_results)
642}
643
644pub fn load_custom_api_keys(
655 providers: &[String],
656 mode: AuthCredentialsStoreMode,
657) -> Result<BTreeMap<String, String>> {
658 let mut api_keys = BTreeMap::new();
659
660 for provider in providers {
661 let storage = CustomApiKeyStorage::new(provider);
662 if let Some(key) = storage.load(mode)? {
663 api_keys.insert(provider.clone(), key);
664 }
665 }
666
667 Ok(api_keys)
668}
669
670pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
676 for provider in providers {
677 let storage = CustomApiKeyStorage::new(provider);
678 if let Err(e) = storage.clear(mode) {
679 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
680 }
681 }
682 Ok(())
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use assert_fs::TempDir;
689 use serial_test::serial;
690
691 struct TestAuthDirGuard {
692 temp_dir: Option<TempDir>,
693 previous: Option<std::path::PathBuf>,
694 }
695
696 impl TestAuthDirGuard {
697 fn new() -> Self {
698 let temp_dir = TempDir::new().expect("create temp auth dir");
699 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
700 .expect("read auth dir override");
701 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
702 temp_dir.path().to_path_buf(),
703 ))
704 .expect("set auth dir override");
705
706 Self {
707 temp_dir: Some(temp_dir),
708 previous,
709 }
710 }
711 }
712
713 impl Drop for TestAuthDirGuard {
714 fn drop(&mut self) {
715 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
716 .expect("restore auth dir override");
717 if let Some(temp_dir) = self.temp_dir.take() {
718 temp_dir.close().expect("remove temp auth dir");
719 }
720 }
721 }
722
723 #[test]
724 fn test_storage_mode_default_is_keyring() {
725 assert_eq!(
726 AuthCredentialsStoreMode::default(),
727 AuthCredentialsStoreMode::Keyring
728 );
729 }
730
731 #[test]
732 fn test_storage_mode_effective_mode() {
733 assert_eq!(
734 AuthCredentialsStoreMode::Keyring.effective_mode(),
735 AuthCredentialsStoreMode::Keyring
736 );
737 assert_eq!(
738 AuthCredentialsStoreMode::File.effective_mode(),
739 AuthCredentialsStoreMode::File
740 );
741
742 let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
744 assert!(
745 auto_mode == AuthCredentialsStoreMode::Keyring
746 || auto_mode == AuthCredentialsStoreMode::File
747 );
748 }
749
750 #[test]
751 fn test_storage_mode_serialization() {
752 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
753 assert_eq!(keyring_json, "\"keyring\"");
754
755 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
756 assert_eq!(file_json, "\"file\"");
757
758 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
759 assert_eq!(auto_json, "\"auto\"");
760
761 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
763 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
764
765 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
766 assert_eq!(parsed, AuthCredentialsStoreMode::File);
767
768 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
769 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
770 }
771
772 #[test]
773 fn test_credential_storage_new() {
774 let storage = CredentialStorage::new("vtcode", "test_key");
775 assert_eq!(storage.service, "vtcode");
776 assert_eq!(storage.user, "test_key");
777 }
778
779 #[test]
780 fn test_is_keyring_functional_check() {
781 let _functional = is_keyring_functional();
784 }
785
786 #[test]
787 #[serial]
788 fn credential_storage_file_mode_round_trips_without_plaintext() {
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::File)
798 .expect("load encrypted credential");
799 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
800
801 let stored = fs::read_to_string(storage.file_path().expect("credential path"))
802 .expect("read encrypted credential file");
803 assert!(!stored.contains("secret_api_key"));
804 }
805
806 #[test]
807 #[serial]
808 fn keyring_mode_load_falls_back_to_encrypted_file() {
809 let _guard = TestAuthDirGuard::new();
810 let storage = CredentialStorage::new("vtcode", "test_key");
811
812 storage
813 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
814 .expect("store encrypted credential");
815
816 let loaded = storage
817 .load_with_mode(AuthCredentialsStoreMode::Keyring)
818 .expect("load credential");
819 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
820 }
821
822 #[test]
823 #[serial]
824 #[cfg(unix)]
825 fn credential_storage_file_mode_uses_private_permissions() {
826 use std::os::unix::fs::PermissionsExt;
827
828 let _guard = TestAuthDirGuard::new();
829 let storage = CredentialStorage::new("vtcode", "test_key");
830
831 storage
832 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
833 .expect("store encrypted credential");
834
835 let metadata = fs::metadata(storage.file_path().expect("credential path"))
836 .expect("read credential metadata");
837 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
838 }
839
840 #[test]
841 #[serial]
842 #[cfg(unix)]
843 fn credential_storage_file_mode_restricts_existing_file_permissions() {
844 use std::os::unix::fs::PermissionsExt;
845
846 let _guard = TestAuthDirGuard::new();
847 let storage = CredentialStorage::new("vtcode", "test_key");
848
849 storage
850 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
851 .expect("store initial credential");
852
853 let path = storage.file_path().expect("credential path");
854 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
855 .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 custom_api_key_load_migrates_legacy_auth_json() {
868 let _guard = TestAuthDirGuard::new();
869 let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
870 fs::write(
871 &legacy_path,
872 r#"{
873 "version": 1,
874 "mode": "api_key",
875 "provider": "openai",
876 "api_key": "legacy-secret",
877 "authenticated_at": 1768406185
878}"#,
879 )
880 .expect("write legacy auth file");
881
882 let storage = CustomApiKeyStorage::new("openai");
883 let loaded = storage
884 .load(AuthCredentialsStoreMode::File)
885 .expect("load migrated api key");
886 assert_eq!(loaded.as_deref(), Some("legacy-secret"));
887 assert!(!legacy_path.exists());
888
889 let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
890 .expect("read migrated credential file");
891 assert!(!encrypted.contains("legacy-secret"));
892 }
893}