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