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                    // Keep the encrypted file in sync as a reliability fallback.
251                    // The macOS Keychain (and other OS keyrings) can have
252                    // transient read failures — the security daemon may be
253                    // busy, the keychain may be momentarily locked, or the
254                    // first-access authorization prompt may time out. When
255                    // that happens, `load_with_mode` falls back to the
256                    // encrypted file. If we cleared the file on successful
257                    // keyring store (as we did previously), a transient
258                    // keyring read failure would find an empty file and the
259                    // key would be silently "lost" for that session, causing
260                    // the user to be re-prompted. The file is AES-256-GCM
261                    // encrypted with a machine-derived key, so keeping it
262                    // does not reduce security.
263                    if let Err(err) = self.store_file(value) {
264                        tracing::warn!(
265                            "Failed to write encrypted file backup for {}/{}: {}",
266                            self.service,
267                            self.user,
268                            err
269                        );
270                    }
271                    Ok(())
272                }
273                Err(err) => {
274                    tracing::warn!(
275                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
276                        self.service,
277                        self.user,
278                        err
279                    );
280                    self.store_file(value).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 = keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
296
297        entry.set_password(value).context("Failed to store credential in OS keyring")?;
298
299        tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
300        Ok(())
301    }
302
303    /// Load a credential using the specified mode.
304    ///
305    /// Returns `None` if no credential exists.
306    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
307        match mode.effective_mode() {
308            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
309                Ok(Some(value)) => Ok(Some(value)),
310                Ok(None) => self.load_file(),
311                Err(err) => {
312                    tracing::warn!(
313                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
314                        self.service,
315                        self.user,
316                        err
317                    );
318                    self.load_file()
319                }
320            },
321            AuthCredentialsStoreMode::File => self.load_file(),
322            _ => unreachable!(),
323        }
324    }
325
326    /// Load a credential using the default mode (keyring).
327    ///
328    /// Returns `None` if no credential exists.
329    pub fn load(&self) -> Result<Option<String>> {
330        self.load_keyring()
331    }
332
333    /// Load credential from OS keyring.
334    fn load_keyring(&self) -> Result<Option<String>> {
335        let entry = match keyring_entry(&self.service, &self.user) {
336            Ok(e) => e,
337            Err(_) => return Ok(None),
338        };
339
340        match entry.get_password() {
341            Ok(value) => Ok(Some(value)),
342            Err(keyring_core::Error::NoEntry) => Ok(None),
343            Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
344        }
345    }
346
347    /// Clear (delete) a credential using the specified mode.
348    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
349        match mode.effective_mode() {
350            AuthCredentialsStoreMode::Keyring => {
351                let mut errors = Vec::new();
352
353                if let Err(err) = self.clear_keyring() {
354                    errors.push(err.to_string());
355                }
356                if let Err(err) = self.clear_file() {
357                    errors.push(err.to_string());
358                }
359
360                if errors.is_empty() {
361                    Ok(())
362                } else {
363                    Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
364                }
365            }
366            AuthCredentialsStoreMode::File => self.clear_file(),
367            _ => unreachable!(),
368        }
369    }
370
371    /// Clear (delete) a credential using the default mode.
372    pub fn clear(&self) -> Result<()> {
373        self.clear_keyring()
374    }
375
376    /// Clear credential from OS keyring.
377    fn clear_keyring(&self) -> Result<()> {
378        let entry = match keyring_entry(&self.service, &self.user) {
379            Ok(e) => e,
380            Err(_) => return Ok(()),
381        };
382
383        match entry.delete_credential() {
384            Ok(_) => {
385                tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
386            }
387            Err(keyring_core::Error::NoEntry) => {}
388            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
389        }
390
391        Ok(())
392    }
393
394    fn store_file(&self, value: &str) -> Result<()> {
395        let path = self.file_path()?;
396        let encrypted = encrypt_credential(value)?;
397        let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
398        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
399
400        Ok(())
401    }
402
403    fn load_file(&self) -> Result<Option<String>> {
404        let path = self.file_path()?;
405        let data = match fs::read(&path) {
406            Ok(data) => data,
407            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
408            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
409        };
410
411        let encrypted: EncryptedCredential =
412            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
413        decrypt_credential(&encrypted).map(Some)
414    }
415
416    fn clear_file(&self) -> Result<()> {
417        let path = self.file_path()?;
418        match fs::remove_file(path) {
419            Ok(()) => Ok(()),
420            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
421            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
422        }
423    }
424
425    fn file_path(&self) -> Result<std::path::PathBuf> {
426        use sha2::Digest as _;
427
428        let mut hasher = sha2::Sha256::new();
429        hasher.update(self.service.as_bytes());
430        hasher.update([0]);
431        hasher.update(self.user.as_bytes());
432        let digest = hasher.finalize();
433        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
434
435        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
436    }
437}
438
439/// Custom API Key storage for provider-specific keys.
440///
441/// Provides secure storage and retrieval of API keys for custom providers
442/// using the OS keyring or encrypted file storage.
443pub struct CustomApiKeyStorage {
444    provider: String,
445    storage: CredentialStorage,
446}
447
448impl CustomApiKeyStorage {
449    /// Create a new custom API key storage for a specific provider.
450    ///
451    /// # Arguments
452    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
453    pub fn new(provider: &str) -> Self {
454        let normalized_provider = provider.to_lowercase();
455        Self {
456            provider: normalized_provider.clone(),
457            storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
458        }
459    }
460
461    /// Store an API key securely.
462    ///
463    /// # Arguments
464    /// * `api_key` - The API key value to store
465    /// * `mode` - The storage mode to use (defaults to keyring)
466    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
467        self.storage.store_with_mode(api_key, mode)?;
468        clear_legacy_auth_file_if_matches(&self.provider)?;
469        Ok(())
470    }
471
472    /// Retrieve a stored API key.
473    ///
474    /// Returns `None` if no key is stored.
475    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
476        if let Some(key) = self.storage.load_with_mode(mode)? {
477            return Ok(Some(key));
478        }
479
480        self.load_legacy_auth_json(mode)
481    }
482
483    /// Clear (delete) a stored API key.
484    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
485        self.storage.clear_with_mode(mode)?;
486        clear_legacy_auth_file_if_matches(&self.provider)?;
487        Ok(())
488    }
489
490    fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
491        let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
492            return Ok(None);
493        };
494
495        if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
496            tracing::warn!(
497                "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
498                self.provider,
499                err
500            );
501            return Ok(Some(legacy.api_key));
502        }
503
504        clear_legacy_auth_file_if_matches(&self.provider)?;
505        tracing::warn!(
506            "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
507            self.provider
508        );
509        Ok(Some(legacy.api_key))
510    }
511}
512
513fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
514    // Generate a per-file random salt to diversify the encryption key.
515    let rng = SystemRandom::new();
516    let mut salt_bytes = [0_u8; 16];
517    rng.fill(&mut salt_bytes)
518        .map_err(|_| anyhow!("failed to generate credential salt"))?;
519    let salt = STANDARD.encode(salt_bytes);
520
521    let key = derive_file_encryption_key(Some(&salt))?;
522    let mut nonce_bytes = [0_u8; NONCE_LEN];
523    rng.fill(&mut nonce_bytes)
524        .map_err(|_| anyhow!("failed to generate credential nonce"))?;
525
526    let mut ciphertext = value.as_bytes().to_vec();
527    key.seal_in_place_append_tag(Nonce::assume_unique_for_key(nonce_bytes), Aad::empty(), &mut ciphertext)
528        .map_err(|_| anyhow!("failed to encrypt credential"))?;
529
530    Ok(EncryptedCredential {
531        nonce: STANDARD.encode(nonce_bytes),
532        ciphertext: STANDARD.encode(ciphertext),
533        version: ENCRYPTED_CREDENTIAL_VERSION,
534        salt: Some(salt),
535    })
536}
537
538fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
539    if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
540        return Err(anyhow!("unsupported encrypted credential format"));
541    }
542
543    let nonce_bytes = STANDARD.decode(&encrypted.nonce).context("failed to decode credential nonce")?;
544    let nonce_array: [u8; NONCE_LEN] =
545        nonce_bytes.try_into().map_err(|_| anyhow!("invalid credential nonce length"))?;
546    let mut ciphertext = STANDARD
547        .decode(&encrypted.ciphertext)
548        .context("failed to decode credential ciphertext")?;
549
550    // Backward-compatible: older files won't have a salt (version 1 format).
551    let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
552    let plaintext = key
553        .open_in_place(Nonce::assume_unique_for_key(nonce_array), Aad::empty(), &mut ciphertext)
554        .map_err(|_| anyhow!("failed to decrypt credential"))?;
555
556    String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
557}
558
559fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
560    use ring::digest::SHA256;
561    use ring::digest::digest;
562
563    let mut key_material = Vec::new();
564    if let Ok(hostname) = hostname::get() {
565        key_material.extend_from_slice(hostname.as_encoded_bytes());
566    }
567
568    #[cfg(unix)]
569    {
570        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
571    }
572    #[cfg(not(unix))]
573    {
574        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
575            key_material.extend_from_slice(user.as_bytes());
576        }
577    }
578
579    key_material.extend_from_slice(b"vtcode-credentials-v1");
580
581    // Per-file random salt diversifies keys across credentials so that a
582    // compromise of one file does not expose others encrypted under the same
583    // machine+user key material.
584    if let Some(salt) = salt {
585        key_material.extend_from_slice(salt.as_bytes());
586    }
587
588    let hash = digest(&SHA256, &key_material);
589    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
590        .try_into()
591        .context("credential encryption key was too short")?;
592    let unbound =
593        UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("invalid credential encryption key"))?;
594    Ok(LessSafeKey::new(unbound))
595}
596
597fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
598    let path = legacy_auth_storage_path()?;
599    let data = match fs::read(&path) {
600        Ok(data) => data,
601        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
602        Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
603    };
604
605    let legacy: LegacyAuthFile = serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
606    let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
607    let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
608    let has_key = !legacy.api_key.trim().is_empty();
609
610    if matches_provider && stores_api_key && has_key {
611        Ok(Some(legacy))
612    } else {
613        Ok(None)
614    }
615}
616
617fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
618    let path = legacy_auth_storage_path()?;
619    let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
620        return Ok(());
621    };
622
623    match fs::remove_file(path) {
624        Ok(()) => Ok(()),
625        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
626        Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
627    }
628}
629
630/// Migrate plain-text API keys from config to secure storage.
631///
632/// This function reads API keys from the provided BTreeMap and stores them
633/// securely using the specified storage mode. After migration, the keys
634/// should be removed from the config file.
635///
636/// # Arguments
637/// * `custom_api_keys` - Map of provider names to API keys (from config)
638/// * `mode` - The storage mode to use
639///
640/// # Returns
641/// A map of providers that were successfully migrated (for tracking purposes)
642pub fn migrate_custom_api_keys_to_keyring(
643    custom_api_keys: &BTreeMap<String, String>,
644    mode: AuthCredentialsStoreMode,
645) -> Result<BTreeMap<String, bool>> {
646    let mut migration_results = BTreeMap::new();
647
648    for (provider, api_key) in custom_api_keys {
649        let storage = CustomApiKeyStorage::new(provider);
650        match storage.store(api_key, mode) {
651            Ok(()) => {
652                tracing::info!("Migrated API key for provider '{}' to secure storage", provider);
653                migration_results.insert(provider.clone(), true);
654            }
655            Err(e) => {
656                tracing::warn!("Failed to migrate API key for provider '{}': {}", provider, e);
657                migration_results.insert(provider.clone(), false);
658            }
659        }
660    }
661
662    Ok(migration_results)
663}
664
665/// Load all custom API keys from secure storage.
666///
667/// This function retrieves API keys for all providers that have keys stored.
668///
669/// # Arguments
670/// * `providers` - List of provider names to check for stored keys
671/// * `mode` - The storage mode to use
672///
673/// # Returns
674/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
675pub fn load_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<BTreeMap<String, String>> {
676    let mut api_keys = BTreeMap::new();
677
678    for provider in providers {
679        let storage = CustomApiKeyStorage::new(provider);
680        if let Some(key) = storage.load(mode)? {
681            api_keys.insert(provider.clone(), key);
682        }
683    }
684
685    Ok(api_keys)
686}
687
688/// Clear all custom API keys from secure storage.
689///
690/// # Arguments
691/// * `providers` - List of provider names to clear
692/// * `mode` - The storage mode to use
693pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
694    for provider in providers {
695        let storage = CustomApiKeyStorage::new(provider);
696        if let Err(e) = storage.clear(mode) {
697            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
698        }
699    }
700    Ok(())
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use assert_fs::TempDir;
707    use serial_test::serial;
708
709    struct TestAuthDirGuard {
710        temp_dir: Option<TempDir>,
711        previous: Option<std::path::PathBuf>,
712    }
713
714    impl TestAuthDirGuard {
715        fn new() -> Self {
716            let temp_dir = TempDir::new().expect("create temp auth dir");
717            let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
718            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
719                .expect("set auth dir override");
720
721            Self { temp_dir: Some(temp_dir), previous }
722        }
723    }
724
725    impl Drop for TestAuthDirGuard {
726        fn drop(&mut self) {
727            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
728                .expect("restore auth dir override");
729            if let Some(temp_dir) = self.temp_dir.take() {
730                temp_dir.close().expect("remove temp auth dir");
731            }
732        }
733    }
734
735    #[test]
736    fn test_storage_mode_default_is_keyring() {
737        assert_eq!(AuthCredentialsStoreMode::default(), AuthCredentialsStoreMode::Keyring);
738    }
739
740    #[test]
741    fn test_storage_mode_effective_mode() {
742        assert_eq!(AuthCredentialsStoreMode::Keyring.effective_mode(), AuthCredentialsStoreMode::Keyring);
743        assert_eq!(AuthCredentialsStoreMode::File.effective_mode(), AuthCredentialsStoreMode::File);
744
745        // Auto should resolve to either Keyring or File
746        let auto_permission = AuthCredentialsStoreMode::Auto.effective_mode();
747        assert!(
748            auto_permission == AuthCredentialsStoreMode::Keyring || auto_permission == AuthCredentialsStoreMode::File
749        );
750    }
751
752    #[test]
753    fn test_storage_mode_serialization() {
754        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
755        assert_eq!(keyring_json, "\"keyring\"");
756
757        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
758        assert_eq!(file_json, "\"file\"");
759
760        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
761        assert_eq!(auto_json, "\"auto\"");
762
763        // Test deserialization
764        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
765        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
766
767        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
768        assert_eq!(parsed, AuthCredentialsStoreMode::File);
769
770        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
771        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
772    }
773
774    #[test]
775    fn test_credential_storage_new() {
776        let storage = CredentialStorage::new("vtcode", "test_key");
777        assert_eq!(storage.service, "vtcode");
778        assert_eq!(storage.user, "test_key");
779    }
780
781    #[test]
782    fn test_is_keyring_functional_check() {
783        // This test just verifies the function doesn't panic
784        // The actual result depends on the OS environment
785        let _functional = is_keyring_functional();
786    }
787
788    #[test]
789    #[serial]
790    fn credential_storage_file_mode_round_trips_without_plaintext() {
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::File)
800            .expect("load encrypted credential");
801        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
802
803        let stored =
804            fs::read_to_string(storage.file_path().expect("credential path")).expect("read encrypted credential file");
805        assert!(!stored.contains("secret_api_key"));
806    }
807
808    #[test]
809    #[serial]
810    fn keyring_mode_load_falls_back_to_encrypted_file() {
811        let _guard = TestAuthDirGuard::new();
812        let storage = CredentialStorage::new("vtcode", "test_key");
813
814        storage
815            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
816            .expect("store encrypted credential");
817
818        let loaded = storage
819            .load_with_mode(AuthCredentialsStoreMode::Keyring)
820            .expect("load credential");
821        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
822    }
823
824    #[test]
825    #[serial]
826    #[cfg(unix)]
827    fn credential_storage_file_mode_uses_private_permissions() {
828        use std::os::unix::fs::PermissionsExt;
829
830        let _guard = TestAuthDirGuard::new();
831        let storage = CredentialStorage::new("vtcode", "test_key");
832
833        storage
834            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
835            .expect("store encrypted credential");
836
837        let metadata = fs::metadata(storage.file_path().expect("credential path")).expect("read credential metadata");
838        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
839    }
840
841    #[test]
842    #[serial]
843    #[cfg(unix)]
844    fn credential_storage_file_mode_restricts_existing_file_permissions() {
845        use std::os::unix::fs::PermissionsExt;
846
847        let _guard = TestAuthDirGuard::new();
848        let storage = CredentialStorage::new("vtcode", "test_key");
849
850        storage
851            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
852            .expect("store initial credential");
853
854        let path = storage.file_path().expect("credential path");
855        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("broaden existing credential permissions");
856
857        storage
858            .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
859            .expect("rewrite credential");
860
861        let metadata = fs::metadata(path).expect("read credential metadata");
862        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
863    }
864
865    #[test]
866    #[serial]
867    fn keyring_store_keeps_encrypted_file_as_fallback() {
868        // Reliability contract: after storing via keyring mode, the encrypted
869        // file must remain present and readable so a later transient keyring
870        // read failure (busy keychain, momentary lock, auth-prompt timeout) can
871        // recover. Previously the file was cleared on keyring success, which
872        // silently lost the key when the keyring later failed to read.
873        let _guard = TestAuthDirGuard::new();
874        let storage = CredentialStorage::new("vtcode", "reliability_key");
875
876        storage
877            .store_with_mode("rk-secret", AuthCredentialsStoreMode::Keyring)
878            .expect("store credential");
879
880        // A keyring-mode load must fall back to the encrypted file and return
881        // the key (the keyring is unavailable under the test harness).
882        let loaded_keyring = storage
883            .load_with_mode(AuthCredentialsStoreMode::Keyring)
884            .expect("load credential");
885        assert_eq!(loaded_keyring.as_deref(), Some("rk-secret"));
886
887        // The encrypted file must still exist and be independently loadable.
888        let loaded_file = storage
889            .load_with_mode(AuthCredentialsStoreMode::File)
890            .expect("load file credential");
891        assert_eq!(loaded_file.as_deref(), Some("rk-secret"));
892    }
893
894    #[test]
895    #[serial]
896    fn custom_api_key_storage_isolates_keys_per_provider() {
897        // Multiple providers must each keep an independent key, and clearing one
898        // must not disturb another — so revisiting a provider does not re-prompt.
899        let _guard = TestAuthDirGuard::new();
900        let anthropic = CustomApiKeyStorage::new("anthropic");
901        let openrouter = CustomApiKeyStorage::new("openrouter");
902
903        anthropic
904            .store("anthropic-key", AuthCredentialsStoreMode::File)
905            .expect("store anthropic key");
906        openrouter
907            .store("openrouter-key", AuthCredentialsStoreMode::File)
908            .expect("store openrouter key");
909
910        assert_eq!(anthropic.load(AuthCredentialsStoreMode::File).unwrap().as_deref(), Some("anthropic-key"));
911        assert_eq!(openrouter.load(AuthCredentialsStoreMode::File).unwrap().as_deref(), Some("openrouter-key"));
912
913        // Clearing one provider must not affect the other.
914        anthropic.clear(AuthCredentialsStoreMode::File).expect("clear anthropic key");
915        assert_eq!(anthropic.load(AuthCredentialsStoreMode::File).unwrap(), None);
916        assert_eq!(openrouter.load(AuthCredentialsStoreMode::File).unwrap().as_deref(), Some("openrouter-key"));
917    }
918
919    #[test]
920    #[serial]
921    fn custom_api_key_load_migrates_legacy_auth_json() {
922        let _guard = TestAuthDirGuard::new();
923        let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
924        fs::write(
925            &legacy_path,
926            r#"{
927  "version": 1,
928  "mode": "api_key",
929  "provider": "openai",
930  "api_key": "legacy-secret",
931  "authenticated_at": 1768406185
932}"#,
933        )
934        .expect("write legacy auth file");
935
936        let storage = CustomApiKeyStorage::new("openai");
937        let loaded = storage.load(AuthCredentialsStoreMode::File).expect("load migrated api key");
938        assert_eq!(loaded.as_deref(), Some("legacy-secret"));
939        assert!(!legacy_path.exists());
940
941        let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
942            .expect("read migrated credential file");
943        assert!(!encrypted.contains("legacy-secret"));
944    }
945}