Skip to main content

vtcode_auth/
credentials.rs

1//! Generic credential storage with OS keyring and file-based backends.
2//!
3//! This module provides a unified interface for storing sensitive credentials
4//! securely using the OS keyring (macOS Keychain, Windows Credential Manager,
5//! Linux Secret Service) with fallback to AES-256-GCM encrypted files.
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use vtcode_auth::{AuthCredentialsStoreMode, CredentialStorage};
11//!
12//! # fn example() -> anyhow::Result<()> {
13//! // Store a credential using the default mode (keyring)
14//! let storage = CredentialStorage::new("my_app", "api_key");
15//! storage.store("secret_api_key")?;
16//!
17//! // Retrieve the credential
18//! if let Some(value) = storage.load()? {
19//!     println!("Found credential: {}", value);
20//! }
21//!
22//! // Delete the credential
23//! storage.clear()?;
24//! # Ok(())
25//! # }
26//! ```
27
28use 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    /// Per-file random salt for HKDF-style key derivation.
49    /// Older files (version 1) will lack this field; serde defaults to None.
50    #[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/// Preferred storage backend for credentials.
62///
63/// - `Keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential Manager,
64///   Linux Secret Service). This is the default as it's the most secure option.
65/// - `File`: Use AES-256-GCM encrypted file (requires the `file-storage` feature or
66///   custom implementation)
67/// - `Auto`: Try keyring first, fall back to file if unavailable
68#[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    /// Use OS-specific keyring service.
73    /// This is the most secure option as credentials are managed by the OS
74    /// and are not accessible to other users or applications.
75    Keyring,
76    /// Persist credentials in an encrypted file.
77    /// The file is encrypted with AES-256-GCM using a machine-derived key.
78    File,
79    /// Use keyring when available; otherwise, fall back to file.
80    Auto,
81}
82
83impl Default for AuthCredentialsStoreMode {
84    /// Default to keyring on all platforms for maximum security.
85    /// Falls back to file-based storage if keyring is unavailable.
86    fn default() -> Self {
87        Self::Keyring
88    }
89}
90
91impl AuthCredentialsStoreMode {
92    /// Get the effective storage mode, resolving Auto to the best available option.
93    pub fn effective_mode(self) -> Self {
94        match self {
95            Self::Auto => {
96                // Check if keyring is functional by attempting to create an entry
97                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
109/// Check if the OS keyring is functional by attempting a test operation.
110///
111/// This creates a test entry, verifies it can be written and read, then deletes it.
112/// This is more reliable than just checking if Entry creation succeeds.
113///
114/// The result is cached after the first call so that repeated checks (e.g. from
115/// `Auto` mode resolution) do not trigger additional OS keyring popups.
116pub(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        // Create a test entry with a unique name to avoid conflicts
123        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        // Try to write a test value
130        if entry.set_password("test").is_err() {
131            return false;
132        }
133
134        // Try to read it back
135        let functional = entry.get_password().is_ok();
136
137        // Clean up - ignore errors during cleanup (called unconditionally so a
138        // stale entry does not persist when get_password fails after set succeeds).
139        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(
152        &std::collections::HashMap::new(),
153    )?;
154
155    #[cfg(target_os = "macos")]
156    let store = apple_native_keyring_store::keychain::Store::new_with_configuration(
157        &std::collections::HashMap::new(),
158    )?;
159
160    #[cfg(target_os = "windows")]
161    let store = windows_native_keyring_store::Store::new_with_configuration(
162        &std::collections::HashMap::new(),
163    )?;
164
165    #[cfg(not(any(
166        target_os = "linux",
167        target_os = "freebsd",
168        target_os = "macos",
169        target_os = "windows"
170    )))]
171    {
172        return Err(keyring_core::Error::NotSupportedByStore(
173            "VT Code does not have a native keyring store configured for this platform".to_string(),
174        ));
175    }
176
177    keyring_core::set_default_store(store);
178    Ok(())
179}
180
181/// Returns `true` when access to the OS keyring should be skipped.
182///
183/// The native keyring (e.g. macOS Keychain) prompts the user for authorization
184/// the first time each distinct binary touches it. Debug and test binaries are
185/// recompiled with a new code signature on every build, so they would prompt on
186/// every run. To avoid this, keyring access is disabled in debug builds, during
187/// tests, and whenever the `VTCODE_DISABLE_KEYRING` or `CI` environment
188/// variables are set. Callers fall back to encrypted-file storage in that case.
189pub(crate) fn keyring_disabled() -> bool {
190    // Debug builds change their code signature on every compile, which triggers
191    // macOS Keychain authorization popups on each run.  Skip the keyring unless
192    // the user explicitly opts in via VTCODE_DISABLE_KEYRING=0.
193    if cfg!(debug_assertions) {
194        if let Ok(value) = std::env::var("VTCODE_DISABLE_KEYRING") {
195            // Explicit opt-in: the user wants keyring even in debug builds.
196            if matches!(
197                value.trim().to_ascii_lowercase().as_str(),
198                "" | "0" | "false" | "no" | "off"
199            ) {
200                return false;
201            }
202        }
203        return true;
204    }
205
206    if cfg!(test) {
207        return true;
208    }
209
210    if let Ok(value) = std::env::var("VTCODE_DISABLE_KEYRING") {
211        return !matches!(
212            value.trim().to_ascii_lowercase().as_str(),
213            "" | "0" | "false" | "no" | "off"
214        );
215    }
216
217    std::env::var_os("CI").is_some()
218}
219
220pub(crate) fn keyring_entry(
221    service: &str,
222    user: &str,
223) -> keyring_core::Result<keyring_core::Entry> {
224    if keyring_disabled() {
225        return Err(keyring_core::Error::NotSupportedByStore(
226            "VT Code keyring access is disabled (test run or VTCODE_DISABLE_KEYRING/CI set)"
227                .to_string(),
228        ));
229    }
230
231    if keyring_core::get_default_store().is_none() {
232        ensure_native_keyring_store()?;
233    }
234
235    keyring_core::Entry::new(service, user)
236}
237
238/// Generic credential storage interface.
239///
240/// Provides methods to store, load, and clear credentials using either
241/// the OS keyring or file-based storage.
242pub struct CredentialStorage {
243    service: String,
244    user: String,
245}
246
247impl CredentialStorage {
248    /// Create a new credential storage handle.
249    ///
250    /// # Arguments
251    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
252    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
253    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
254        Self {
255            service: service.into(),
256            user: user.into(),
257        }
258    }
259
260    /// Store a credential using the specified mode.
261    ///
262    /// # Arguments
263    /// * `value` - The credential value to store
264    /// * `mode` - The storage mode to use
265    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
266        match mode.effective_mode() {
267            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
268                Ok(()) => {
269                    let _ = self.clear_file();
270                    Ok(())
271                }
272                Err(err) => {
273                    tracing::warn!(
274                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
275                        self.service,
276                        self.user,
277                        err
278                    );
279                    self.store_file(value)
280                        .context("failed to store credential in encrypted file")
281                }
282            },
283            AuthCredentialsStoreMode::File => self.store_file(value),
284            _ => unreachable!(),
285        }
286    }
287
288    /// Store a credential using the default mode (keyring).
289    pub fn store(&self, value: &str) -> Result<()> {
290        self.store_keyring(value)
291    }
292
293    /// Store credential in OS keyring.
294    fn store_keyring(&self, value: &str) -> Result<()> {
295        let entry =
296            keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
297
298        entry
299            .set_password(value)
300            .context("Failed to store credential in OS keyring")?;
301
302        tracing::debug!(
303            "Credential stored in OS keyring for {}/{}",
304            self.service,
305            self.user
306        );
307        Ok(())
308    }
309
310    /// Load a credential using the specified mode.
311    ///
312    /// Returns `None` if no credential exists.
313    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
314        match mode.effective_mode() {
315            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
316                Ok(Some(value)) => Ok(Some(value)),
317                Ok(None) => self.load_file(),
318                Err(err) => {
319                    tracing::warn!(
320                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
321                        self.service,
322                        self.user,
323                        err
324                    );
325                    self.load_file()
326                }
327            },
328            AuthCredentialsStoreMode::File => self.load_file(),
329            _ => unreachable!(),
330        }
331    }
332
333    /// Load a credential using the default mode (keyring).
334    ///
335    /// Returns `None` if no credential exists.
336    pub fn load(&self) -> Result<Option<String>> {
337        self.load_keyring()
338    }
339
340    /// Load credential from OS keyring.
341    fn load_keyring(&self) -> Result<Option<String>> {
342        let entry = match keyring_entry(&self.service, &self.user) {
343            Ok(e) => e,
344            Err(_) => return Ok(None),
345        };
346
347        match entry.get_password() {
348            Ok(value) => Ok(Some(value)),
349            Err(keyring_core::Error::NoEntry) => Ok(None),
350            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
351        }
352    }
353
354    /// Clear (delete) a credential using the specified mode.
355    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
356        match mode.effective_mode() {
357            AuthCredentialsStoreMode::Keyring => {
358                let mut errors = Vec::new();
359
360                if let Err(err) = self.clear_keyring() {
361                    errors.push(err.to_string());
362                }
363                if let Err(err) = self.clear_file() {
364                    errors.push(err.to_string());
365                }
366
367                if errors.is_empty() {
368                    Ok(())
369                } else {
370                    Err(anyhow!(
371                        "Failed to clear credential from secure storage: {}",
372                        errors.join("; ")
373                    ))
374                }
375            }
376            AuthCredentialsStoreMode::File => self.clear_file(),
377            _ => unreachable!(),
378        }
379    }
380
381    /// Clear (delete) a credential using the default mode.
382    pub fn clear(&self) -> Result<()> {
383        self.clear_keyring()
384    }
385
386    /// Clear credential from OS keyring.
387    fn clear_keyring(&self) -> Result<()> {
388        let entry = match keyring_entry(&self.service, &self.user) {
389            Ok(e) => e,
390            Err(_) => return Ok(()),
391        };
392
393        match entry.delete_credential() {
394            Ok(_) => {
395                tracing::debug!(
396                    "Credential cleared from keyring for {}/{}",
397                    self.service,
398                    self.user
399                );
400            }
401            Err(keyring_core::Error::NoEntry) => {}
402            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
403        }
404
405        Ok(())
406    }
407
408    fn store_file(&self, value: &str) -> Result<()> {
409        let path = self.file_path()?;
410        let encrypted = encrypt_credential(value)?;
411        let payload = serde_json::to_vec_pretty(&encrypted)
412            .context("failed to serialize encrypted credential")?;
413        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
414
415        Ok(())
416    }
417
418    fn load_file(&self) -> Result<Option<String>> {
419        let path = self.file_path()?;
420        let data = match fs::read(&path) {
421            Ok(data) => data,
422            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
423            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
424        };
425
426        let encrypted: EncryptedCredential =
427            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
428        decrypt_credential(&encrypted).map(Some)
429    }
430
431    fn clear_file(&self) -> Result<()> {
432        let path = self.file_path()?;
433        match fs::remove_file(path) {
434            Ok(()) => Ok(()),
435            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
436            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
437        }
438    }
439
440    fn file_path(&self) -> Result<std::path::PathBuf> {
441        use sha2::Digest as _;
442
443        let mut hasher = sha2::Sha256::new();
444        hasher.update(self.service.as_bytes());
445        hasher.update([0]);
446        hasher.update(self.user.as_bytes());
447        let digest = hasher.finalize();
448        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
449
450        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
451    }
452}
453
454/// Custom API Key storage for provider-specific keys.
455///
456/// Provides secure storage and retrieval of API keys for custom providers
457/// using the OS keyring or encrypted file storage.
458pub struct CustomApiKeyStorage {
459    provider: String,
460    storage: CredentialStorage,
461}
462
463impl CustomApiKeyStorage {
464    /// Create a new custom API key storage for a specific provider.
465    ///
466    /// # Arguments
467    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
468    pub fn new(provider: &str) -> Self {
469        let normalized_provider = provider.to_lowercase();
470        Self {
471            provider: normalized_provider.clone(),
472            storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
473        }
474    }
475
476    /// Store an API key securely.
477    ///
478    /// # Arguments
479    /// * `api_key` - The API key value to store
480    /// * `mode` - The storage mode to use (defaults to keyring)
481    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
482        self.storage.store_with_mode(api_key, mode)?;
483        clear_legacy_auth_file_if_matches(&self.provider)?;
484        Ok(())
485    }
486
487    /// Retrieve a stored API key.
488    ///
489    /// Returns `None` if no key is stored.
490    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
491        if let Some(key) = self.storage.load_with_mode(mode)? {
492            return Ok(Some(key));
493        }
494
495        self.load_legacy_auth_json(mode)
496    }
497
498    /// Clear (delete) a stored API key.
499    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
500        self.storage.clear_with_mode(mode)?;
501        clear_legacy_auth_file_if_matches(&self.provider)?;
502        Ok(())
503    }
504
505    fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
506        let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
507            return Ok(None);
508        };
509
510        if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
511            tracing::warn!(
512                "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
513                self.provider,
514                err
515            );
516            return Ok(Some(legacy.api_key));
517        }
518
519        clear_legacy_auth_file_if_matches(&self.provider)?;
520        tracing::warn!(
521            "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
522            self.provider
523        );
524        Ok(Some(legacy.api_key))
525    }
526}
527
528fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
529    // Generate a per-file random salt to diversify the encryption key.
530    let rng = SystemRandom::new();
531    let mut salt_bytes = [0_u8; 16];
532    rng.fill(&mut salt_bytes)
533        .map_err(|_| anyhow!("failed to generate credential salt"))?;
534    let salt = STANDARD.encode(salt_bytes);
535
536    let key = derive_file_encryption_key(Some(&salt))?;
537    let mut nonce_bytes = [0_u8; NONCE_LEN];
538    rng.fill(&mut nonce_bytes)
539        .map_err(|_| anyhow!("failed to generate credential nonce"))?;
540
541    let mut ciphertext = value.as_bytes().to_vec();
542    key.seal_in_place_append_tag(
543        Nonce::assume_unique_for_key(nonce_bytes),
544        Aad::empty(),
545        &mut ciphertext,
546    )
547    .map_err(|_| anyhow!("failed to encrypt credential"))?;
548
549    Ok(EncryptedCredential {
550        nonce: STANDARD.encode(nonce_bytes),
551        ciphertext: STANDARD.encode(ciphertext),
552        version: ENCRYPTED_CREDENTIAL_VERSION,
553        salt: Some(salt),
554    })
555}
556
557fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
558    if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
559        return Err(anyhow!("unsupported encrypted credential format"));
560    }
561
562    let nonce_bytes = STANDARD
563        .decode(&encrypted.nonce)
564        .context("failed to decode credential nonce")?;
565    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
566        .try_into()
567        .map_err(|_| anyhow!("invalid credential nonce length"))?;
568    let mut ciphertext = STANDARD
569        .decode(&encrypted.ciphertext)
570        .context("failed to decode credential ciphertext")?;
571
572    // Backward-compatible: older files won't have a salt (version 1 format).
573    let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
574    let plaintext = key
575        .open_in_place(
576            Nonce::assume_unique_for_key(nonce_array),
577            Aad::empty(),
578            &mut ciphertext,
579        )
580        .map_err(|_| anyhow!("failed to decrypt credential"))?;
581
582    String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
583}
584
585fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
586    use ring::digest::SHA256;
587    use ring::digest::digest;
588
589    let mut key_material = Vec::new();
590    if let Ok(hostname) = hostname::get() {
591        key_material.extend_from_slice(hostname.as_encoded_bytes());
592    }
593
594    #[cfg(unix)]
595    {
596        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
597    }
598    #[cfg(not(unix))]
599    {
600        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
601            key_material.extend_from_slice(user.as_bytes());
602        }
603    }
604
605    key_material.extend_from_slice(b"vtcode-credentials-v1");
606
607    // Per-file random salt diversifies keys across credentials so that a
608    // compromise of one file does not expose others encrypted under the same
609    // machine+user key material.
610    if let Some(salt) = salt {
611        key_material.extend_from_slice(salt.as_bytes());
612    }
613
614    let hash = digest(&SHA256, &key_material);
615    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
616        .try_into()
617        .context("credential encryption key was too short")?;
618    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
619        .map_err(|_| anyhow!("invalid credential encryption key"))?;
620    Ok(LessSafeKey::new(unbound))
621}
622
623fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
624    let path = legacy_auth_storage_path()?;
625    let data = match fs::read(&path) {
626        Ok(data) => data,
627        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
628        Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
629    };
630
631    let legacy: LegacyAuthFile =
632        serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
633    let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
634    let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
635    let has_key = !legacy.api_key.trim().is_empty();
636
637    if matches_provider && stores_api_key && has_key {
638        Ok(Some(legacy))
639    } else {
640        Ok(None)
641    }
642}
643
644fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
645    let path = legacy_auth_storage_path()?;
646    let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
647        return Ok(());
648    };
649
650    match fs::remove_file(path) {
651        Ok(()) => Ok(()),
652        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
653        Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
654    }
655}
656
657/// Migrate plain-text API keys from config to secure storage.
658///
659/// This function reads API keys from the provided BTreeMap and stores them
660/// securely using the specified storage mode. After migration, the keys
661/// should be removed from the config file.
662///
663/// # Arguments
664/// * `custom_api_keys` - Map of provider names to API keys (from config)
665/// * `mode` - The storage mode to use
666///
667/// # Returns
668/// A map of providers that were successfully migrated (for tracking purposes)
669pub fn migrate_custom_api_keys_to_keyring(
670    custom_api_keys: &BTreeMap<String, String>,
671    mode: AuthCredentialsStoreMode,
672) -> Result<BTreeMap<String, bool>> {
673    let mut migration_results = BTreeMap::new();
674
675    for (provider, api_key) in custom_api_keys {
676        let storage = CustomApiKeyStorage::new(provider);
677        match storage.store(api_key, mode) {
678            Ok(()) => {
679                tracing::info!(
680                    "Migrated API key for provider '{}' to secure storage",
681                    provider
682                );
683                migration_results.insert(provider.clone(), true);
684            }
685            Err(e) => {
686                tracing::warn!(
687                    "Failed to migrate API key for provider '{}': {}",
688                    provider,
689                    e
690                );
691                migration_results.insert(provider.clone(), false);
692            }
693        }
694    }
695
696    Ok(migration_results)
697}
698
699/// Load all custom API keys from secure storage.
700///
701/// This function retrieves API keys for all providers that have keys stored.
702///
703/// # Arguments
704/// * `providers` - List of provider names to check for stored keys
705/// * `mode` - The storage mode to use
706///
707/// # Returns
708/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
709pub fn load_custom_api_keys(
710    providers: &[String],
711    mode: AuthCredentialsStoreMode,
712) -> Result<BTreeMap<String, String>> {
713    let mut api_keys = BTreeMap::new();
714
715    for provider in providers {
716        let storage = CustomApiKeyStorage::new(provider);
717        if let Some(key) = storage.load(mode)? {
718            api_keys.insert(provider.clone(), key);
719        }
720    }
721
722    Ok(api_keys)
723}
724
725/// Clear all custom API keys from secure storage.
726///
727/// # Arguments
728/// * `providers` - List of provider names to clear
729/// * `mode` - The storage mode to use
730pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
731    for provider in providers {
732        let storage = CustomApiKeyStorage::new(provider);
733        if let Err(e) = storage.clear(mode) {
734            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
735        }
736    }
737    Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use assert_fs::TempDir;
744    use serial_test::serial;
745
746    struct TestAuthDirGuard {
747        temp_dir: Option<TempDir>,
748        previous: Option<std::path::PathBuf>,
749    }
750
751    impl TestAuthDirGuard {
752        fn new() -> Self {
753            let temp_dir = TempDir::new().expect("create temp auth dir");
754            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
755                .expect("read auth dir override");
756            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
757                temp_dir.path().to_path_buf(),
758            ))
759            .expect("set auth dir override");
760
761            Self {
762                temp_dir: Some(temp_dir),
763                previous,
764            }
765        }
766    }
767
768    impl Drop for TestAuthDirGuard {
769        fn drop(&mut self) {
770            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
771                .expect("restore auth dir override");
772            if let Some(temp_dir) = self.temp_dir.take() {
773                temp_dir.close().expect("remove temp auth dir");
774            }
775        }
776    }
777
778    #[test]
779    fn test_storage_mode_default_is_keyring() {
780        assert_eq!(
781            AuthCredentialsStoreMode::default(),
782            AuthCredentialsStoreMode::Keyring
783        );
784    }
785
786    #[test]
787    fn test_storage_mode_effective_mode() {
788        assert_eq!(
789            AuthCredentialsStoreMode::Keyring.effective_mode(),
790            AuthCredentialsStoreMode::Keyring
791        );
792        assert_eq!(
793            AuthCredentialsStoreMode::File.effective_mode(),
794            AuthCredentialsStoreMode::File
795        );
796
797        // Auto should resolve to either Keyring or File
798        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
799        assert!(
800            auto_mode == AuthCredentialsStoreMode::Keyring
801                || auto_mode == AuthCredentialsStoreMode::File
802        );
803    }
804
805    #[test]
806    fn test_storage_mode_serialization() {
807        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
808        assert_eq!(keyring_json, "\"keyring\"");
809
810        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
811        assert_eq!(file_json, "\"file\"");
812
813        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
814        assert_eq!(auto_json, "\"auto\"");
815
816        // Test deserialization
817        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
818        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
819
820        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
821        assert_eq!(parsed, AuthCredentialsStoreMode::File);
822
823        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
824        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
825    }
826
827    #[test]
828    fn test_credential_storage_new() {
829        let storage = CredentialStorage::new("vtcode", "test_key");
830        assert_eq!(storage.service, "vtcode");
831        assert_eq!(storage.user, "test_key");
832    }
833
834    #[test]
835    fn test_is_keyring_functional_check() {
836        // This test just verifies the function doesn't panic
837        // The actual result depends on the OS environment
838        let _functional = is_keyring_functional();
839    }
840
841    #[test]
842    #[serial]
843    fn credential_storage_file_mode_round_trips_without_plaintext() {
844        let _guard = TestAuthDirGuard::new();
845        let storage = CredentialStorage::new("vtcode", "test_key");
846
847        storage
848            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
849            .expect("store encrypted credential");
850
851        let loaded = storage
852            .load_with_mode(AuthCredentialsStoreMode::File)
853            .expect("load encrypted credential");
854        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
855
856        let stored = fs::read_to_string(storage.file_path().expect("credential path"))
857            .expect("read encrypted credential file");
858        assert!(!stored.contains("secret_api_key"));
859    }
860
861    #[test]
862    #[serial]
863    fn keyring_mode_load_falls_back_to_encrypted_file() {
864        let _guard = TestAuthDirGuard::new();
865        let storage = CredentialStorage::new("vtcode", "test_key");
866
867        storage
868            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
869            .expect("store encrypted credential");
870
871        let loaded = storage
872            .load_with_mode(AuthCredentialsStoreMode::Keyring)
873            .expect("load credential");
874        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
875    }
876
877    #[test]
878    #[serial]
879    #[cfg(unix)]
880    fn credential_storage_file_mode_uses_private_permissions() {
881        use std::os::unix::fs::PermissionsExt;
882
883        let _guard = TestAuthDirGuard::new();
884        let storage = CredentialStorage::new("vtcode", "test_key");
885
886        storage
887            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
888            .expect("store encrypted credential");
889
890        let metadata = fs::metadata(storage.file_path().expect("credential path"))
891            .expect("read credential metadata");
892        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
893    }
894
895    #[test]
896    #[serial]
897    #[cfg(unix)]
898    fn credential_storage_file_mode_restricts_existing_file_permissions() {
899        use std::os::unix::fs::PermissionsExt;
900
901        let _guard = TestAuthDirGuard::new();
902        let storage = CredentialStorage::new("vtcode", "test_key");
903
904        storage
905            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
906            .expect("store initial credential");
907
908        let path = storage.file_path().expect("credential path");
909        fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
910            .expect("broaden existing credential permissions");
911
912        storage
913            .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
914            .expect("rewrite credential");
915
916        let metadata = fs::metadata(path).expect("read credential metadata");
917        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
918    }
919
920    #[test]
921    #[serial]
922    fn custom_api_key_load_migrates_legacy_auth_json() {
923        let _guard = TestAuthDirGuard::new();
924        let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
925        fs::write(
926            &legacy_path,
927            r#"{
928  "version": 1,
929  "mode": "api_key",
930  "provider": "openai",
931  "api_key": "legacy-secret",
932  "authenticated_at": 1768406185
933}"#,
934        )
935        .expect("write legacy auth file");
936
937        let storage = CustomApiKeyStorage::new("openai");
938        let loaded = storage
939            .load(AuthCredentialsStoreMode::File)
940            .expect("load migrated api key");
941        assert_eq!(loaded.as_deref(), Some("legacy-secret"));
942        assert!(!legacy_path.exists());
943
944        let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
945            .expect("read migrated credential file");
946        assert!(!encrypted.contains("legacy-secret"));
947    }
948}