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