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