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};
27
28/// Preferred storage backend for credentials.
29///
30/// - `Keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential Manager,
31///   Linux Secret Service). This is the default as it's the most secure option.
32/// - `File`: Use AES-256-GCM encrypted file (requires the `file-storage` feature or
33///   custom implementation)
34/// - `Auto`: Try keyring first, fall back to file if unavailable
35#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum AuthCredentialsStoreMode {
38    /// Use OS-specific keyring service.
39    /// This is the most secure option as credentials are managed by the OS
40    /// and are not accessible to other users or applications.
41    Keyring,
42    /// Persist credentials in an encrypted file.
43    /// The file is encrypted with AES-256-GCM using a machine-derived key.
44    File,
45    /// Use keyring when available; otherwise, fall back to file.
46    Auto,
47}
48
49impl Default for AuthCredentialsStoreMode {
50    /// Default to keyring on all platforms for maximum security.
51    /// Falls back to file-based storage if keyring is unavailable.
52    fn default() -> Self {
53        Self::Keyring
54    }
55}
56
57impl AuthCredentialsStoreMode {
58    /// Get the effective storage mode, resolving Auto to the best available option.
59    pub fn effective_mode(self) -> Self {
60        match self {
61            Self::Auto => {
62                // Check if keyring is functional by attempting to create an entry
63                if is_keyring_functional() {
64                    Self::Keyring
65                } else {
66                    tracing::debug!("Keyring not available, falling back to file storage");
67                    Self::File
68                }
69            }
70            mode => mode,
71        }
72    }
73}
74
75/// Check if the OS keyring is functional by attempting a test operation.
76///
77/// This creates a test entry, verifies it can be written and read, then deletes it.
78/// This is more reliable than just checking if Entry creation succeeds.
79pub(crate) fn is_keyring_functional() -> bool {
80    // Create a test entry with a unique name to avoid conflicts
81    let test_user = format!("test_{}", std::process::id());
82    let entry = match keyring::Entry::new("vtcode", &test_user) {
83        Ok(e) => e,
84        Err(_) => return false,
85    };
86
87    // Try to write a test value
88    if entry.set_password("test").is_err() {
89        return false;
90    }
91
92    // Try to read it back
93    let functional = entry.get_password().is_ok();
94
95    // Clean up - ignore errors during cleanup
96    let _ = entry.delete_credential();
97
98    functional
99}
100
101/// Generic credential storage interface.
102///
103/// Provides methods to store, load, and clear credentials using either
104/// the OS keyring or file-based storage.
105pub struct CredentialStorage {
106    service: String,
107    user: String,
108}
109
110impl CredentialStorage {
111    /// Create a new credential storage handle.
112    ///
113    /// # Arguments
114    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
115    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
116    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
117        Self {
118            service: service.into(),
119            user: user.into(),
120        }
121    }
122
123    /// Store a credential using the specified mode.
124    ///
125    /// # Arguments
126    /// * `value` - The credential value to store
127    /// * `mode` - The storage mode to use
128    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
129        match mode.effective_mode() {
130            AuthCredentialsStoreMode::Keyring => self.store_keyring(value),
131            AuthCredentialsStoreMode::File => Err(anyhow!(
132                "File storage requires the file_storage feature or custom implementation"
133            )),
134            _ => unreachable!(),
135        }
136    }
137
138    /// Store a credential using the default mode (keyring).
139    pub fn store(&self, value: &str) -> Result<()> {
140        self.store_keyring(value)
141    }
142
143    /// Store credential in OS keyring.
144    fn store_keyring(&self, value: &str) -> Result<()> {
145        let entry = keyring::Entry::new(&self.service, &self.user)
146            .context("Failed to access OS keyring")?;
147
148        entry
149            .set_password(value)
150            .context("Failed to store credential in OS keyring")?;
151
152        tracing::debug!(
153            "Credential stored in OS keyring for {}/{}",
154            self.service,
155            self.user
156        );
157        Ok(())
158    }
159
160    /// Load a credential using the specified mode.
161    ///
162    /// Returns `None` if no credential exists.
163    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
164        match mode.effective_mode() {
165            AuthCredentialsStoreMode::Keyring => self.load_keyring(),
166            AuthCredentialsStoreMode::File => Err(anyhow!(
167                "File storage requires the file_storage feature or custom implementation"
168            )),
169            _ => unreachable!(),
170        }
171    }
172
173    /// Load a credential using the default mode (keyring).
174    ///
175    /// Returns `None` if no credential exists.
176    pub fn load(&self) -> Result<Option<String>> {
177        self.load_keyring()
178    }
179
180    /// Load credential from OS keyring.
181    fn load_keyring(&self) -> Result<Option<String>> {
182        let entry = match keyring::Entry::new(&self.service, &self.user) {
183            Ok(e) => e,
184            Err(_) => return Ok(None),
185        };
186
187        match entry.get_password() {
188            Ok(value) => Ok(Some(value)),
189            Err(keyring::Error::NoEntry) => Ok(None),
190            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
191        }
192    }
193
194    /// Clear (delete) a credential using the specified mode.
195    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
196        match mode.effective_mode() {
197            AuthCredentialsStoreMode::Keyring => self.clear_keyring(),
198            AuthCredentialsStoreMode::File => Ok(()), // File storage not implemented here
199            _ => unreachable!(),
200        }
201    }
202
203    /// Clear (delete) a credential using the default mode.
204    pub fn clear(&self) -> Result<()> {
205        self.clear_keyring()
206    }
207
208    /// Clear credential from OS keyring.
209    fn clear_keyring(&self) -> Result<()> {
210        let entry = match keyring::Entry::new(&self.service, &self.user) {
211            Ok(e) => e,
212            Err(_) => return Ok(()),
213        };
214
215        match entry.delete_credential() {
216            Ok(_) => {
217                tracing::debug!(
218                    "Credential cleared from keyring for {}/{}",
219                    self.service,
220                    self.user
221                );
222            }
223            Err(keyring::Error::NoEntry) => {}
224            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
225        }
226
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_storage_mode_default_is_keyring() {
237        assert_eq!(
238            AuthCredentialsStoreMode::default(),
239            AuthCredentialsStoreMode::Keyring
240        );
241    }
242
243    #[test]
244    fn test_storage_mode_effective_mode() {
245        assert_eq!(
246            AuthCredentialsStoreMode::Keyring.effective_mode(),
247            AuthCredentialsStoreMode::Keyring
248        );
249        assert_eq!(
250            AuthCredentialsStoreMode::File.effective_mode(),
251            AuthCredentialsStoreMode::File
252        );
253
254        // Auto should resolve to either Keyring or File
255        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
256        assert!(
257            auto_mode == AuthCredentialsStoreMode::Keyring
258                || auto_mode == AuthCredentialsStoreMode::File
259        );
260    }
261
262    #[test]
263    fn test_storage_mode_serialization() {
264        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
265        assert_eq!(keyring_json, "\"keyring\"");
266
267        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
268        assert_eq!(file_json, "\"file\"");
269
270        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
271        assert_eq!(auto_json, "\"auto\"");
272
273        // Test deserialization
274        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
275        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
276
277        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
278        assert_eq!(parsed, AuthCredentialsStoreMode::File);
279
280        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
281        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
282    }
283
284    #[test]
285    fn test_credential_storage_new() {
286        let storage = CredentialStorage::new("vtcode", "test_key");
287        assert_eq!(storage.service, "vtcode");
288        assert_eq!(storage.user, "test_key");
289    }
290
291    #[test]
292    fn test_is_keyring_functional_check() {
293        // This test just verifies the function doesn't panic
294        // The actual result depends on the OS environment
295        let _functional = is_keyring_functional();
296    }
297}