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
131pub(crate) fn keyring_entry(
132 service: &str,
133 user: &str,
134) -> keyring_core::Result<keyring_core::Entry> {
135 if keyring_core::get_default_store().is_none() {
136 keyring::use_native_store(cfg!(target_os = "linux"))?;
137 }
138
139 keyring_core::Entry::new(service, user)
140}
141
142pub struct CredentialStorage {
147 service: String,
148 user: String,
149}
150
151impl CredentialStorage {
152 pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
158 Self {
159 service: service.into(),
160 user: user.into(),
161 }
162 }
163
164 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
170 match mode.effective_mode() {
171 AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
172 Ok(()) => {
173 let _ = self.clear_file();
174 Ok(())
175 }
176 Err(err) => {
177 tracing::warn!(
178 "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
179 self.service,
180 self.user,
181 err
182 );
183 self.store_file(value)
184 .context("failed to store credential in encrypted file")
185 }
186 },
187 AuthCredentialsStoreMode::File => self.store_file(value),
188 _ => unreachable!(),
189 }
190 }
191
192 pub fn store(&self, value: &str) -> Result<()> {
194 self.store_keyring(value)
195 }
196
197 fn store_keyring(&self, value: &str) -> Result<()> {
199 let entry =
200 keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
201
202 entry
203 .set_password(value)
204 .context("Failed to store credential in OS keyring")?;
205
206 tracing::debug!(
207 "Credential stored in OS keyring for {}/{}",
208 self.service,
209 self.user
210 );
211 Ok(())
212 }
213
214 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
218 match mode.effective_mode() {
219 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
220 Ok(Some(value)) => Ok(Some(value)),
221 Ok(None) => self.load_file(),
222 Err(err) => {
223 tracing::warn!(
224 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
225 self.service,
226 self.user,
227 err
228 );
229 self.load_file()
230 }
231 },
232 AuthCredentialsStoreMode::File => self.load_file(),
233 _ => unreachable!(),
234 }
235 }
236
237 pub fn load(&self) -> Result<Option<String>> {
241 self.load_keyring()
242 }
243
244 fn load_keyring(&self) -> Result<Option<String>> {
246 let entry = match keyring_entry(&self.service, &self.user) {
247 Ok(e) => e,
248 Err(_) => return Ok(None),
249 };
250
251 match entry.get_password() {
252 Ok(value) => Ok(Some(value)),
253 Err(keyring_core::Error::NoEntry) => Ok(None),
254 Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
255 }
256 }
257
258 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
260 match mode.effective_mode() {
261 AuthCredentialsStoreMode::Keyring => {
262 let mut errors = Vec::new();
263
264 if let Err(err) = self.clear_keyring() {
265 errors.push(err.to_string());
266 }
267 if let Err(err) = self.clear_file() {
268 errors.push(err.to_string());
269 }
270
271 if errors.is_empty() {
272 Ok(())
273 } else {
274 Err(anyhow!(
275 "Failed to clear credential from secure storage: {}",
276 errors.join("; ")
277 ))
278 }
279 }
280 AuthCredentialsStoreMode::File => self.clear_file(),
281 _ => unreachable!(),
282 }
283 }
284
285 pub fn clear(&self) -> Result<()> {
287 self.clear_keyring()
288 }
289
290 fn clear_keyring(&self) -> Result<()> {
292 let entry = match keyring_entry(&self.service, &self.user) {
293 Ok(e) => e,
294 Err(_) => return Ok(()),
295 };
296
297 match entry.delete_credential() {
298 Ok(_) => {
299 tracing::debug!(
300 "Credential cleared from keyring for {}/{}",
301 self.service,
302 self.user
303 );
304 }
305 Err(keyring_core::Error::NoEntry) => {}
306 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
307 }
308
309 Ok(())
310 }
311
312 fn store_file(&self, value: &str) -> Result<()> {
313 let path = self.file_path()?;
314 let encrypted = encrypt_credential(value)?;
315 let payload = serde_json::to_vec_pretty(&encrypted)
316 .context("failed to serialize encrypted credential")?;
317 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
318
319 Ok(())
320 }
321
322 fn load_file(&self) -> Result<Option<String>> {
323 let path = self.file_path()?;
324 let data = match fs::read(&path) {
325 Ok(data) => data,
326 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
327 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
328 };
329
330 let encrypted: EncryptedCredential =
331 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
332 decrypt_credential(&encrypted).map(Some)
333 }
334
335 fn clear_file(&self) -> Result<()> {
336 let path = self.file_path()?;
337 match fs::remove_file(path) {
338 Ok(()) => Ok(()),
339 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
340 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
341 }
342 }
343
344 fn file_path(&self) -> Result<std::path::PathBuf> {
345 use sha2::Digest as _;
346
347 let mut hasher = sha2::Sha256::new();
348 hasher.update(self.service.as_bytes());
349 hasher.update([0]);
350 hasher.update(self.user.as_bytes());
351 let digest = hasher.finalize();
352 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
353
354 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
355 }
356}
357
358pub struct CustomApiKeyStorage {
363 provider: String,
364 storage: CredentialStorage,
365}
366
367impl CustomApiKeyStorage {
368 pub fn new(provider: &str) -> Self {
373 let normalized_provider = provider.to_lowercase();
374 Self {
375 provider: normalized_provider.clone(),
376 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
377 }
378 }
379
380 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
386 self.storage.store_with_mode(api_key, mode)?;
387 clear_legacy_auth_file_if_matches(&self.provider)?;
388 Ok(())
389 }
390
391 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
395 if let Some(key) = self.storage.load_with_mode(mode)? {
396 return Ok(Some(key));
397 }
398
399 self.load_legacy_auth_json(mode)
400 }
401
402 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
404 self.storage.clear_with_mode(mode)?;
405 clear_legacy_auth_file_if_matches(&self.provider)?;
406 Ok(())
407 }
408
409 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
410 let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
411 return Ok(None);
412 };
413
414 if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
415 tracing::warn!(
416 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
417 self.provider,
418 err
419 );
420 return Ok(Some(legacy.api_key));
421 }
422
423 clear_legacy_auth_file_if_matches(&self.provider)?;
424 tracing::warn!(
425 "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
426 self.provider
427 );
428 Ok(Some(legacy.api_key))
429 }
430}
431
432fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
433 let key = derive_file_encryption_key()?;
434 let rng = SystemRandom::new();
435 let mut nonce_bytes = [0_u8; NONCE_LEN];
436 rng.fill(&mut nonce_bytes)
437 .map_err(|_| anyhow!("failed to generate credential nonce"))?;
438
439 let mut ciphertext = value.as_bytes().to_vec();
440 key.seal_in_place_append_tag(
441 Nonce::assume_unique_for_key(nonce_bytes),
442 Aad::empty(),
443 &mut ciphertext,
444 )
445 .map_err(|_| anyhow!("failed to encrypt credential"))?;
446
447 Ok(EncryptedCredential {
448 nonce: STANDARD.encode(nonce_bytes),
449 ciphertext: STANDARD.encode(ciphertext),
450 version: ENCRYPTED_CREDENTIAL_VERSION,
451 })
452}
453
454fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
455 if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
456 return Err(anyhow!("unsupported encrypted credential format"));
457 }
458
459 let nonce_bytes = STANDARD
460 .decode(&encrypted.nonce)
461 .context("failed to decode credential nonce")?;
462 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
463 .try_into()
464 .map_err(|_| anyhow!("invalid credential nonce length"))?;
465 let mut ciphertext = STANDARD
466 .decode(&encrypted.ciphertext)
467 .context("failed to decode credential ciphertext")?;
468
469 let key = derive_file_encryption_key()?;
470 let plaintext = key
471 .open_in_place(
472 Nonce::assume_unique_for_key(nonce_array),
473 Aad::empty(),
474 &mut ciphertext,
475 )
476 .map_err(|_| anyhow!("failed to decrypt credential"))?;
477
478 String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
479}
480
481fn derive_file_encryption_key() -> Result<LessSafeKey> {
482 use ring::digest::SHA256;
483 use ring::digest::digest;
484
485 let mut key_material = Vec::new();
486 if let Ok(hostname) = hostname::get() {
487 key_material.extend_from_slice(hostname.as_encoded_bytes());
488 }
489
490 #[cfg(unix)]
491 {
492 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
493 }
494 #[cfg(not(unix))]
495 {
496 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
497 key_material.extend_from_slice(user.as_bytes());
498 }
499 }
500
501 key_material.extend_from_slice(b"vtcode-credentials-v1");
502
503 let hash = digest(&SHA256, &key_material);
504 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
505 .try_into()
506 .context("credential encryption key was too short")?;
507 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
508 .map_err(|_| anyhow!("invalid credential encryption key"))?;
509 Ok(LessSafeKey::new(unbound))
510}
511
512fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
513 let path = legacy_auth_storage_path()?;
514 let data = match fs::read(&path) {
515 Ok(data) => data,
516 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
517 Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
518 };
519
520 let legacy: LegacyAuthFile =
521 serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
522 let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
523 let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
524 let has_key = !legacy.api_key.trim().is_empty();
525
526 if matches_provider && stores_api_key && has_key {
527 Ok(Some(legacy))
528 } else {
529 Ok(None)
530 }
531}
532
533fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
534 let path = legacy_auth_storage_path()?;
535 let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
536 return Ok(());
537 };
538
539 match fs::remove_file(path) {
540 Ok(()) => Ok(()),
541 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
542 Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
543 }
544}
545
546pub fn migrate_custom_api_keys_to_keyring(
559 custom_api_keys: &BTreeMap<String, String>,
560 mode: AuthCredentialsStoreMode,
561) -> Result<BTreeMap<String, bool>> {
562 let mut migration_results = BTreeMap::new();
563
564 for (provider, api_key) in custom_api_keys {
565 let storage = CustomApiKeyStorage::new(provider);
566 match storage.store(api_key, mode) {
567 Ok(()) => {
568 tracing::info!(
569 "Migrated API key for provider '{}' to secure storage",
570 provider
571 );
572 migration_results.insert(provider.clone(), true);
573 }
574 Err(e) => {
575 tracing::warn!(
576 "Failed to migrate API key for provider '{}': {}",
577 provider,
578 e
579 );
580 migration_results.insert(provider.clone(), false);
581 }
582 }
583 }
584
585 Ok(migration_results)
586}
587
588pub fn load_custom_api_keys(
599 providers: &[String],
600 mode: AuthCredentialsStoreMode,
601) -> Result<BTreeMap<String, String>> {
602 let mut api_keys = BTreeMap::new();
603
604 for provider in providers {
605 let storage = CustomApiKeyStorage::new(provider);
606 if let Some(key) = storage.load(mode)? {
607 api_keys.insert(provider.clone(), key);
608 }
609 }
610
611 Ok(api_keys)
612}
613
614pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
620 for provider in providers {
621 let storage = CustomApiKeyStorage::new(provider);
622 if let Err(e) = storage.clear(mode) {
623 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
624 }
625 }
626 Ok(())
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use assert_fs::TempDir;
633 use serial_test::serial;
634
635 struct TestAuthDirGuard {
636 temp_dir: Option<TempDir>,
637 previous: Option<std::path::PathBuf>,
638 }
639
640 impl TestAuthDirGuard {
641 fn new() -> Self {
642 let temp_dir = TempDir::new().expect("create temp auth dir");
643 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
644 .expect("read auth dir override");
645 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
646 temp_dir.path().to_path_buf(),
647 ))
648 .expect("set auth dir override");
649
650 Self {
651 temp_dir: Some(temp_dir),
652 previous,
653 }
654 }
655 }
656
657 impl Drop for TestAuthDirGuard {
658 fn drop(&mut self) {
659 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
660 .expect("restore auth dir override");
661 if let Some(temp_dir) = self.temp_dir.take() {
662 temp_dir.close().expect("remove temp auth dir");
663 }
664 }
665 }
666
667 #[test]
668 fn test_storage_mode_default_is_keyring() {
669 assert_eq!(
670 AuthCredentialsStoreMode::default(),
671 AuthCredentialsStoreMode::Keyring
672 );
673 }
674
675 #[test]
676 fn test_storage_mode_effective_mode() {
677 assert_eq!(
678 AuthCredentialsStoreMode::Keyring.effective_mode(),
679 AuthCredentialsStoreMode::Keyring
680 );
681 assert_eq!(
682 AuthCredentialsStoreMode::File.effective_mode(),
683 AuthCredentialsStoreMode::File
684 );
685
686 let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
688 assert!(
689 auto_mode == AuthCredentialsStoreMode::Keyring
690 || auto_mode == AuthCredentialsStoreMode::File
691 );
692 }
693
694 #[test]
695 fn test_storage_mode_serialization() {
696 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
697 assert_eq!(keyring_json, "\"keyring\"");
698
699 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
700 assert_eq!(file_json, "\"file\"");
701
702 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
703 assert_eq!(auto_json, "\"auto\"");
704
705 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
707 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
708
709 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
710 assert_eq!(parsed, AuthCredentialsStoreMode::File);
711
712 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
713 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
714 }
715
716 #[test]
717 fn test_credential_storage_new() {
718 let storage = CredentialStorage::new("vtcode", "test_key");
719 assert_eq!(storage.service, "vtcode");
720 assert_eq!(storage.user, "test_key");
721 }
722
723 #[test]
724 fn test_is_keyring_functional_check() {
725 let _functional = is_keyring_functional();
728 }
729
730 #[test]
731 #[serial]
732 fn credential_storage_file_mode_round_trips_without_plaintext() {
733 let _guard = TestAuthDirGuard::new();
734 let storage = CredentialStorage::new("vtcode", "test_key");
735
736 storage
737 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
738 .expect("store encrypted credential");
739
740 let loaded = storage
741 .load_with_mode(AuthCredentialsStoreMode::File)
742 .expect("load encrypted credential");
743 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
744
745 let stored = fs::read_to_string(storage.file_path().expect("credential path"))
746 .expect("read encrypted credential file");
747 assert!(!stored.contains("secret_api_key"));
748 }
749
750 #[test]
751 #[serial]
752 fn keyring_mode_load_falls_back_to_encrypted_file() {
753 let _guard = TestAuthDirGuard::new();
754 let storage = CredentialStorage::new("vtcode", "test_key");
755
756 storage
757 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
758 .expect("store encrypted credential");
759
760 let loaded = storage
761 .load_with_mode(AuthCredentialsStoreMode::Keyring)
762 .expect("load credential");
763 assert_eq!(loaded.as_deref(), Some("secret_api_key"));
764 }
765
766 #[test]
767 #[serial]
768 #[cfg(unix)]
769 fn credential_storage_file_mode_uses_private_permissions() {
770 use std::os::unix::fs::PermissionsExt;
771
772 let _guard = TestAuthDirGuard::new();
773 let storage = CredentialStorage::new("vtcode", "test_key");
774
775 storage
776 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
777 .expect("store encrypted credential");
778
779 let metadata = fs::metadata(storage.file_path().expect("credential path"))
780 .expect("read credential metadata");
781 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
782 }
783
784 #[test]
785 #[serial]
786 #[cfg(unix)]
787 fn credential_storage_file_mode_restricts_existing_file_permissions() {
788 use std::os::unix::fs::PermissionsExt;
789
790 let _guard = TestAuthDirGuard::new();
791 let storage = CredentialStorage::new("vtcode", "test_key");
792
793 storage
794 .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
795 .expect("store initial credential");
796
797 let path = storage.file_path().expect("credential path");
798 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
799 .expect("broaden existing credential permissions");
800
801 storage
802 .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
803 .expect("rewrite credential");
804
805 let metadata = fs::metadata(path).expect("read credential metadata");
806 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
807 }
808
809 #[test]
810 #[serial]
811 fn custom_api_key_load_migrates_legacy_auth_json() {
812 let _guard = TestAuthDirGuard::new();
813 let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
814 fs::write(
815 &legacy_path,
816 r#"{
817 "version": 1,
818 "mode": "api_key",
819 "provider": "openai",
820 "api_key": "legacy-secret",
821 "authenticated_at": 1768406185
822}"#,
823 )
824 .expect("write legacy auth file");
825
826 let storage = CustomApiKeyStorage::new("openai");
827 let loaded = storage
828 .load(AuthCredentialsStoreMode::File)
829 .expect("load migrated api key");
830 assert_eq!(loaded.as_deref(), Some("legacy-secret"));
831 assert!(!legacy_path.exists());
832
833 let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
834 .expect("read migrated credential file");
835 assert!(!encrypted.contains("legacy-secret"));
836 }
837}