Skip to main content

vtcode_config/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_config::auth::credentials::{CredentialStorage, AuthCredentialsStoreMode};
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 serde::{Deserialize, Serialize};
30use std::collections::BTreeMap;
31
32/// Preferred storage backend for credentials.
33///
34/// - `Keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential Manager,
35///   Linux Secret Service). This is the default as it's the most secure option.
36/// - `File`: Use AES-256-GCM encrypted file (requires the `file-storage` feature or
37///   custom implementation)
38/// - `Auto`: Try keyring first, fall back to file if unavailable
39#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(rename_all = "lowercase")]
42pub enum AuthCredentialsStoreMode {
43    /// Use OS-specific keyring service.
44    /// This is the most secure option as credentials are managed by the OS
45    /// and are not accessible to other users or applications.
46    Keyring,
47    /// Persist credentials in an encrypted file.
48    /// The file is encrypted with AES-256-GCM using a machine-derived key.
49    File,
50    /// Use keyring when available; otherwise, fall back to file.
51    Auto,
52}
53
54impl Default for AuthCredentialsStoreMode {
55    /// Default to keyring on all platforms for maximum security.
56    /// Falls back to file-based storage if keyring is unavailable.
57    fn default() -> Self {
58        Self::Keyring
59    }
60}
61
62impl AuthCredentialsStoreMode {
63    /// Get the effective storage mode, resolving Auto to the best available option.
64    pub fn effective_mode(self) -> Self {
65        match self {
66            Self::Auto => {
67                // Check if keyring is functional by attempting to create an entry
68                if is_keyring_functional() {
69                    Self::Keyring
70                } else {
71                    tracing::debug!("Keyring not available, falling back to file storage");
72                    Self::File
73                }
74            }
75            mode => mode,
76        }
77    }
78}
79
80/// Check if the OS keyring is functional by attempting a test operation.
81///
82/// This creates a test entry, verifies it can be written and read, then deletes it.
83/// This is more reliable than just checking if Entry creation succeeds.
84pub(crate) fn is_keyring_functional() -> bool {
85    // Create a test entry with a unique name to avoid conflicts
86    let test_user = format!("test_{}", std::process::id());
87    let entry = match keyring::Entry::new("vtcode", &test_user) {
88        Ok(e) => e,
89        Err(_) => return false,
90    };
91
92    // Try to write a test value
93    if entry.set_password("test").is_err() {
94        return false;
95    }
96
97    // Try to read it back
98    let functional = entry.get_password().is_ok();
99
100    // Clean up - ignore errors during cleanup
101    let _ = entry.delete_credential();
102
103    functional
104}
105
106/// Generic credential storage interface.
107///
108/// Provides methods to store, load, and clear credentials using either
109/// the OS keyring or file-based storage.
110pub struct CredentialStorage {
111    service: String,
112    user: String,
113}
114
115impl CredentialStorage {
116    /// Create a new credential storage handle.
117    ///
118    /// # Arguments
119    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
120    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
121    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
122        Self {
123            service: service.into(),
124            user: user.into(),
125        }
126    }
127
128    /// Store a credential using the specified mode.
129    ///
130    /// # Arguments
131    /// * `value` - The credential value to store
132    /// * `mode` - The storage mode to use
133    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
134        match mode.effective_mode() {
135            AuthCredentialsStoreMode::Keyring => self.store_keyring(value),
136            AuthCredentialsStoreMode::File => Err(anyhow!(
137                "File storage requires the file_storage feature or custom implementation"
138            )),
139            _ => unreachable!(),
140        }
141    }
142
143    /// Store a credential using the default mode (keyring).
144    pub fn store(&self, value: &str) -> Result<()> {
145        self.store_keyring(value)
146    }
147
148    /// Store credential in OS keyring.
149    fn store_keyring(&self, value: &str) -> Result<()> {
150        let entry = keyring::Entry::new(&self.service, &self.user)
151            .context("Failed to access OS keyring")?;
152
153        entry
154            .set_password(value)
155            .context("Failed to store credential in OS keyring")?;
156
157        tracing::debug!(
158            "Credential stored in OS keyring for {}/{}",
159            self.service,
160            self.user
161        );
162        Ok(())
163    }
164
165    /// Load a credential using the specified mode.
166    ///
167    /// Returns `None` if no credential exists.
168    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
169        match mode.effective_mode() {
170            AuthCredentialsStoreMode::Keyring => self.load_keyring(),
171            AuthCredentialsStoreMode::File => Err(anyhow!(
172                "File storage requires the file_storage feature or custom implementation"
173            )),
174            _ => unreachable!(),
175        }
176    }
177
178    /// Load a credential using the default mode (keyring).
179    ///
180    /// Returns `None` if no credential exists.
181    pub fn load(&self) -> Result<Option<String>> {
182        self.load_keyring()
183    }
184
185    /// Load credential from OS keyring.
186    fn load_keyring(&self) -> Result<Option<String>> {
187        let entry = match keyring::Entry::new(&self.service, &self.user) {
188            Ok(e) => e,
189            Err(_) => return Ok(None),
190        };
191
192        match entry.get_password() {
193            Ok(value) => Ok(Some(value)),
194            Err(keyring::Error::NoEntry) => Ok(None),
195            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
196        }
197    }
198
199    /// Clear (delete) a credential using the specified mode.
200    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
201        match mode.effective_mode() {
202            AuthCredentialsStoreMode::Keyring => self.clear_keyring(),
203            AuthCredentialsStoreMode::File => Ok(()), // File storage not implemented here
204            _ => unreachable!(),
205        }
206    }
207
208    /// Clear (delete) a credential using the default mode.
209    pub fn clear(&self) -> Result<()> {
210        self.clear_keyring()
211    }
212
213    /// Clear credential from OS keyring.
214    fn clear_keyring(&self) -> Result<()> {
215        let entry = match keyring::Entry::new(&self.service, &self.user) {
216            Ok(e) => e,
217            Err(_) => return Ok(()),
218        };
219
220        match entry.delete_credential() {
221            Ok(_) => {
222                tracing::debug!(
223                    "Credential cleared from keyring for {}/{}",
224                    self.service,
225                    self.user
226                );
227            }
228            Err(keyring::Error::NoEntry) => {}
229            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
230        }
231
232        Ok(())
233    }
234}
235
236/// Custom API Key storage for provider-specific keys.
237///
238/// Provides secure storage and retrieval of API keys for custom providers
239/// using the OS keyring or encrypted file storage.
240pub struct CustomApiKeyStorage {
241    storage: CredentialStorage,
242}
243
244impl CustomApiKeyStorage {
245    /// Create a new custom API key storage for a specific provider.
246    ///
247    /// # Arguments
248    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
249    pub fn new(provider: &str) -> Self {
250        Self {
251            storage: CredentialStorage::new(
252                "vtcode",
253                format!("api_key_{}", provider.to_lowercase()),
254            ),
255        }
256    }
257
258    /// Store an API key securely.
259    ///
260    /// # Arguments
261    /// * `api_key` - The API key value to store
262    /// * `mode` - The storage mode to use (defaults to keyring)
263    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
264        self.storage.store_with_mode(api_key, mode)
265    }
266
267    /// Retrieve a stored API key.
268    ///
269    /// Returns `None` if no key is stored.
270    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
271        self.storage.load_with_mode(mode)
272    }
273
274    /// Clear (delete) a stored API key.
275    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
276        self.storage.clear_with_mode(mode)
277    }
278}
279
280/// Migrate plain-text API keys from config to secure storage.
281///
282/// This function reads API keys from the provided BTreeMap and stores them
283/// securely using the specified storage mode. After migration, the keys
284/// should be removed from the config file.
285///
286/// # Arguments
287/// * `custom_api_keys` - Map of provider names to API keys (from config)
288/// * `mode` - The storage mode to use
289///
290/// # Returns
291/// A map of providers that were successfully migrated (for tracking purposes)
292pub fn migrate_custom_api_keys_to_keyring(
293    custom_api_keys: &BTreeMap<String, String>,
294    mode: AuthCredentialsStoreMode,
295) -> Result<BTreeMap<String, bool>> {
296    let mut migration_results = BTreeMap::new();
297
298    for (provider, api_key) in custom_api_keys {
299        let storage = CustomApiKeyStorage::new(provider);
300        match storage.store(api_key, mode) {
301            Ok(()) => {
302                tracing::info!(
303                    "Migrated API key for provider '{}' to secure storage",
304                    provider
305                );
306                migration_results.insert(provider.clone(), true);
307            }
308            Err(e) => {
309                tracing::warn!(
310                    "Failed to migrate API key for provider '{}': {}",
311                    provider,
312                    e
313                );
314                migration_results.insert(provider.clone(), false);
315            }
316        }
317    }
318
319    Ok(migration_results)
320}
321
322/// Load all custom API keys from secure storage.
323///
324/// This function retrieves API keys for all providers that have keys stored.
325///
326/// # Arguments
327/// * `providers` - List of provider names to check for stored keys
328/// * `mode` - The storage mode to use
329///
330/// # Returns
331/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
332pub fn load_custom_api_keys(
333    providers: &[String],
334    mode: AuthCredentialsStoreMode,
335) -> Result<BTreeMap<String, String>> {
336    let mut api_keys = BTreeMap::new();
337
338    for provider in providers {
339        let storage = CustomApiKeyStorage::new(provider);
340        if let Some(key) = storage.load(mode)? {
341            api_keys.insert(provider.clone(), key);
342        }
343    }
344
345    Ok(api_keys)
346}
347
348/// Clear all custom API keys from secure storage.
349///
350/// # Arguments
351/// * `providers` - List of provider names to clear
352/// * `mode` - The storage mode to use
353pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
354    for provider in providers {
355        let storage = CustomApiKeyStorage::new(provider);
356        if let Err(e) = storage.clear(mode) {
357            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
358        }
359    }
360    Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_storage_mode_default_is_keyring() {
369        assert_eq!(
370            AuthCredentialsStoreMode::default(),
371            AuthCredentialsStoreMode::Keyring
372        );
373    }
374
375    #[test]
376    fn test_storage_mode_effective_mode() {
377        assert_eq!(
378            AuthCredentialsStoreMode::Keyring.effective_mode(),
379            AuthCredentialsStoreMode::Keyring
380        );
381        assert_eq!(
382            AuthCredentialsStoreMode::File.effective_mode(),
383            AuthCredentialsStoreMode::File
384        );
385
386        // Auto should resolve to either Keyring or File
387        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
388        assert!(
389            auto_mode == AuthCredentialsStoreMode::Keyring
390                || auto_mode == AuthCredentialsStoreMode::File
391        );
392    }
393
394    #[test]
395    fn test_storage_mode_serialization() {
396        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
397        assert_eq!(keyring_json, "\"keyring\"");
398
399        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
400        assert_eq!(file_json, "\"file\"");
401
402        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
403        assert_eq!(auto_json, "\"auto\"");
404
405        // Test deserialization
406        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
407        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
408
409        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
410        assert_eq!(parsed, AuthCredentialsStoreMode::File);
411
412        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
413        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
414    }
415
416    #[test]
417    fn test_credential_storage_new() {
418        let storage = CredentialStorage::new("vtcode", "test_key");
419        assert_eq!(storage.service, "vtcode");
420        assert_eq!(storage.user, "test_key");
421    }
422
423    #[test]
424    fn test_is_keyring_functional_check() {
425        // This test just verifies the function doesn't panic
426        // The actual result depends on the OS environment
427        let _functional = is_keyring_functional();
428    }
429}