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