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}
49
50#[derive(Debug, Deserialize)]
51struct LegacyAuthFile {
52    mode: String,
53    provider: String,
54    api_key: String,
55}
56
57/// Preferred storage backend for credentials.
58///
59/// - `Keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential Manager,
60///   Linux Secret Service). This is the default as it's the most secure option.
61/// - `File`: Use AES-256-GCM encrypted file (requires the `file-storage` feature or
62///   custom implementation)
63/// - `Auto`: Try keyring first, fall back to file if unavailable
64#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67pub enum AuthCredentialsStoreMode {
68    /// Use OS-specific keyring service.
69    /// This is the most secure option as credentials are managed by the OS
70    /// and are not accessible to other users or applications.
71    Keyring,
72    /// Persist credentials in an encrypted file.
73    /// The file is encrypted with AES-256-GCM using a machine-derived key.
74    File,
75    /// Use keyring when available; otherwise, fall back to file.
76    Auto,
77}
78
79impl Default for AuthCredentialsStoreMode {
80    /// Default to keyring on all platforms for maximum security.
81    /// Falls back to file-based storage if keyring is unavailable.
82    fn default() -> Self {
83        Self::Keyring
84    }
85}
86
87impl AuthCredentialsStoreMode {
88    /// Get the effective storage mode, resolving Auto to the best available option.
89    pub fn effective_mode(self) -> Self {
90        match self {
91            Self::Auto => {
92                // Check if keyring is functional by attempting to create an entry
93                if is_keyring_functional() {
94                    Self::Keyring
95                } else {
96                    tracing::debug!("Keyring not available, falling back to file storage");
97                    Self::File
98                }
99            }
100            mode => mode,
101        }
102    }
103}
104
105/// Check if the OS keyring is functional by attempting a test operation.
106///
107/// This creates a test entry, verifies it can be written and read, then deletes it.
108/// This is more reliable than just checking if Entry creation succeeds.
109pub(crate) fn is_keyring_functional() -> bool {
110    // Create a test entry with a unique name to avoid conflicts
111    let test_user = format!("test_{}", std::process::id());
112    let entry = match keyring::Entry::new("vtcode", &test_user) {
113        Ok(e) => e,
114        Err(_) => return false,
115    };
116
117    // Try to write a test value
118    if entry.set_password("test").is_err() {
119        return false;
120    }
121
122    // Try to read it back
123    let functional = entry.get_password().is_ok();
124
125    // Clean up - ignore errors during cleanup
126    let _ = entry.delete_credential();
127
128    functional
129}
130
131/// Generic credential storage interface.
132///
133/// Provides methods to store, load, and clear credentials using either
134/// the OS keyring or file-based storage.
135pub struct CredentialStorage {
136    service: String,
137    user: String,
138}
139
140impl CredentialStorage {
141    /// Create a new credential storage handle.
142    ///
143    /// # Arguments
144    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
145    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
146    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
147        Self {
148            service: service.into(),
149            user: user.into(),
150        }
151    }
152
153    /// Store a credential using the specified mode.
154    ///
155    /// # Arguments
156    /// * `value` - The credential value to store
157    /// * `mode` - The storage mode to use
158    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
159        match mode.effective_mode() {
160            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
161                Ok(()) => {
162                    let _ = self.clear_file();
163                    Ok(())
164                }
165                Err(err) => {
166                    tracing::warn!(
167                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
168                        self.service,
169                        self.user,
170                        err
171                    );
172                    self.store_file(value)
173                        .context("failed to store credential in encrypted file")
174                }
175            },
176            AuthCredentialsStoreMode::File => self.store_file(value),
177            _ => unreachable!(),
178        }
179    }
180
181    /// Store a credential using the default mode (keyring).
182    pub fn store(&self, value: &str) -> Result<()> {
183        self.store_keyring(value)
184    }
185
186    /// Store credential in OS keyring.
187    fn store_keyring(&self, value: &str) -> Result<()> {
188        let entry = keyring::Entry::new(&self.service, &self.user)
189            .context("Failed to access OS keyring")?;
190
191        entry
192            .set_password(value)
193            .context("Failed to store credential in OS keyring")?;
194
195        tracing::debug!(
196            "Credential stored in OS keyring for {}/{}",
197            self.service,
198            self.user
199        );
200        Ok(())
201    }
202
203    /// Load a credential using the specified mode.
204    ///
205    /// Returns `None` if no credential exists.
206    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
207        match mode.effective_mode() {
208            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
209                Ok(Some(value)) => Ok(Some(value)),
210                Ok(None) => self.load_file(),
211                Err(err) => {
212                    tracing::warn!(
213                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
214                        self.service,
215                        self.user,
216                        err
217                    );
218                    self.load_file()
219                }
220            },
221            AuthCredentialsStoreMode::File => self.load_file(),
222            _ => unreachable!(),
223        }
224    }
225
226    /// Load a credential using the default mode (keyring).
227    ///
228    /// Returns `None` if no credential exists.
229    pub fn load(&self) -> Result<Option<String>> {
230        self.load_keyring()
231    }
232
233    /// Load credential from OS keyring.
234    fn load_keyring(&self) -> Result<Option<String>> {
235        let entry = match keyring::Entry::new(&self.service, &self.user) {
236            Ok(e) => e,
237            Err(_) => return Ok(None),
238        };
239
240        match entry.get_password() {
241            Ok(value) => Ok(Some(value)),
242            Err(keyring::Error::NoEntry) => Ok(None),
243            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
244        }
245    }
246
247    /// Clear (delete) a credential using the specified mode.
248    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
249        match mode.effective_mode() {
250            AuthCredentialsStoreMode::Keyring => {
251                let mut errors = Vec::new();
252
253                if let Err(err) = self.clear_keyring() {
254                    errors.push(err.to_string());
255                }
256                if let Err(err) = self.clear_file() {
257                    errors.push(err.to_string());
258                }
259
260                if errors.is_empty() {
261                    Ok(())
262                } else {
263                    Err(anyhow!(
264                        "Failed to clear credential from secure storage: {}",
265                        errors.join("; ")
266                    ))
267                }
268            }
269            AuthCredentialsStoreMode::File => self.clear_file(),
270            _ => unreachable!(),
271        }
272    }
273
274    /// Clear (delete) a credential using the default mode.
275    pub fn clear(&self) -> Result<()> {
276        self.clear_keyring()
277    }
278
279    /// Clear credential from OS keyring.
280    fn clear_keyring(&self) -> Result<()> {
281        let entry = match keyring::Entry::new(&self.service, &self.user) {
282            Ok(e) => e,
283            Err(_) => return Ok(()),
284        };
285
286        match entry.delete_credential() {
287            Ok(_) => {
288                tracing::debug!(
289                    "Credential cleared from keyring for {}/{}",
290                    self.service,
291                    self.user
292                );
293            }
294            Err(keyring::Error::NoEntry) => {}
295            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
296        }
297
298        Ok(())
299    }
300
301    fn store_file(&self, value: &str) -> Result<()> {
302        let path = self.file_path()?;
303        let encrypted = encrypt_credential(value)?;
304        let payload = serde_json::to_vec_pretty(&encrypted)
305            .context("failed to serialize encrypted credential")?;
306        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
307
308        Ok(())
309    }
310
311    fn load_file(&self) -> Result<Option<String>> {
312        let path = self.file_path()?;
313        let data = match fs::read(&path) {
314            Ok(data) => data,
315            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
316            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
317        };
318
319        let encrypted: EncryptedCredential =
320            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
321        decrypt_credential(&encrypted).map(Some)
322    }
323
324    fn clear_file(&self) -> Result<()> {
325        let path = self.file_path()?;
326        match fs::remove_file(path) {
327            Ok(()) => Ok(()),
328            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
329            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
330        }
331    }
332
333    fn file_path(&self) -> Result<std::path::PathBuf> {
334        use sha2::Digest as _;
335
336        let mut hasher = sha2::Sha256::new();
337        hasher.update(self.service.as_bytes());
338        hasher.update([0]);
339        hasher.update(self.user.as_bytes());
340        let digest = hasher.finalize();
341        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
342
343        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
344    }
345}
346
347/// Custom API Key storage for provider-specific keys.
348///
349/// Provides secure storage and retrieval of API keys for custom providers
350/// using the OS keyring or encrypted file storage.
351pub struct CustomApiKeyStorage {
352    provider: String,
353    storage: CredentialStorage,
354}
355
356impl CustomApiKeyStorage {
357    /// Create a new custom API key storage for a specific provider.
358    ///
359    /// # Arguments
360    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
361    pub fn new(provider: &str) -> Self {
362        let normalized_provider = provider.to_lowercase();
363        Self {
364            provider: normalized_provider.clone(),
365            storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
366        }
367    }
368
369    /// Store an API key securely.
370    ///
371    /// # Arguments
372    /// * `api_key` - The API key value to store
373    /// * `mode` - The storage mode to use (defaults to keyring)
374    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
375        self.storage.store_with_mode(api_key, mode)?;
376        clear_legacy_auth_file_if_matches(&self.provider)?;
377        Ok(())
378    }
379
380    /// Retrieve a stored API key.
381    ///
382    /// Returns `None` if no key is stored.
383    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
384        if let Some(key) = self.storage.load_with_mode(mode)? {
385            return Ok(Some(key));
386        }
387
388        self.load_legacy_auth_json(mode)
389    }
390
391    /// Clear (delete) a stored API key.
392    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
393        self.storage.clear_with_mode(mode)?;
394        clear_legacy_auth_file_if_matches(&self.provider)?;
395        Ok(())
396    }
397
398    fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
399        let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
400            return Ok(None);
401        };
402
403        if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
404            tracing::warn!(
405                "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
406                self.provider,
407                err
408            );
409            return Ok(Some(legacy.api_key));
410        }
411
412        clear_legacy_auth_file_if_matches(&self.provider)?;
413        tracing::warn!(
414            "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
415            self.provider
416        );
417        Ok(Some(legacy.api_key))
418    }
419}
420
421fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
422    let key = derive_file_encryption_key()?;
423    let rng = SystemRandom::new();
424    let mut nonce_bytes = [0_u8; NONCE_LEN];
425    rng.fill(&mut nonce_bytes)
426        .map_err(|_| anyhow!("failed to generate credential nonce"))?;
427
428    let mut ciphertext = value.as_bytes().to_vec();
429    key.seal_in_place_append_tag(
430        Nonce::assume_unique_for_key(nonce_bytes),
431        Aad::empty(),
432        &mut ciphertext,
433    )
434    .map_err(|_| anyhow!("failed to encrypt credential"))?;
435
436    Ok(EncryptedCredential {
437        nonce: STANDARD.encode(nonce_bytes),
438        ciphertext: STANDARD.encode(ciphertext),
439        version: ENCRYPTED_CREDENTIAL_VERSION,
440    })
441}
442
443fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
444    if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
445        return Err(anyhow!("unsupported encrypted credential format"));
446    }
447
448    let nonce_bytes = STANDARD
449        .decode(&encrypted.nonce)
450        .context("failed to decode credential nonce")?;
451    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
452        .try_into()
453        .map_err(|_| anyhow!("invalid credential nonce length"))?;
454    let mut ciphertext = STANDARD
455        .decode(&encrypted.ciphertext)
456        .context("failed to decode credential ciphertext")?;
457
458    let key = derive_file_encryption_key()?;
459    let plaintext = key
460        .open_in_place(
461            Nonce::assume_unique_for_key(nonce_array),
462            Aad::empty(),
463            &mut ciphertext,
464        )
465        .map_err(|_| anyhow!("failed to decrypt credential"))?;
466
467    String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
468}
469
470fn derive_file_encryption_key() -> Result<LessSafeKey> {
471    use ring::digest::SHA256;
472    use ring::digest::digest;
473
474    let mut key_material = Vec::new();
475    if let Ok(hostname) = hostname::get() {
476        key_material.extend_from_slice(hostname.as_encoded_bytes());
477    }
478
479    #[cfg(unix)]
480    {
481        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
482    }
483    #[cfg(not(unix))]
484    {
485        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
486            key_material.extend_from_slice(user.as_bytes());
487        }
488    }
489
490    key_material.extend_from_slice(b"vtcode-credentials-v1");
491
492    let hash = digest(&SHA256, &key_material);
493    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
494        .try_into()
495        .context("credential encryption key was too short")?;
496    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
497        .map_err(|_| anyhow!("invalid credential encryption key"))?;
498    Ok(LessSafeKey::new(unbound))
499}
500
501fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
502    let path = legacy_auth_storage_path()?;
503    let data = match fs::read(&path) {
504        Ok(data) => data,
505        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
506        Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
507    };
508
509    let legacy: LegacyAuthFile =
510        serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
511    let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
512    let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
513    let has_key = !legacy.api_key.trim().is_empty();
514
515    if matches_provider && stores_api_key && has_key {
516        Ok(Some(legacy))
517    } else {
518        Ok(None)
519    }
520}
521
522fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
523    let path = legacy_auth_storage_path()?;
524    let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
525        return Ok(());
526    };
527
528    match fs::remove_file(path) {
529        Ok(()) => Ok(()),
530        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
531        Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
532    }
533}
534
535/// Migrate plain-text API keys from config to secure storage.
536///
537/// This function reads API keys from the provided BTreeMap and stores them
538/// securely using the specified storage mode. After migration, the keys
539/// should be removed from the config file.
540///
541/// # Arguments
542/// * `custom_api_keys` - Map of provider names to API keys (from config)
543/// * `mode` - The storage mode to use
544///
545/// # Returns
546/// A map of providers that were successfully migrated (for tracking purposes)
547pub fn migrate_custom_api_keys_to_keyring(
548    custom_api_keys: &BTreeMap<String, String>,
549    mode: AuthCredentialsStoreMode,
550) -> Result<BTreeMap<String, bool>> {
551    let mut migration_results = BTreeMap::new();
552
553    for (provider, api_key) in custom_api_keys {
554        let storage = CustomApiKeyStorage::new(provider);
555        match storage.store(api_key, mode) {
556            Ok(()) => {
557                tracing::info!(
558                    "Migrated API key for provider '{}' to secure storage",
559                    provider
560                );
561                migration_results.insert(provider.clone(), true);
562            }
563            Err(e) => {
564                tracing::warn!(
565                    "Failed to migrate API key for provider '{}': {}",
566                    provider,
567                    e
568                );
569                migration_results.insert(provider.clone(), false);
570            }
571        }
572    }
573
574    Ok(migration_results)
575}
576
577/// Load all custom API keys from secure storage.
578///
579/// This function retrieves API keys for all providers that have keys stored.
580///
581/// # Arguments
582/// * `providers` - List of provider names to check for stored keys
583/// * `mode` - The storage mode to use
584///
585/// # Returns
586/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
587pub fn load_custom_api_keys(
588    providers: &[String],
589    mode: AuthCredentialsStoreMode,
590) -> Result<BTreeMap<String, String>> {
591    let mut api_keys = BTreeMap::new();
592
593    for provider in providers {
594        let storage = CustomApiKeyStorage::new(provider);
595        if let Some(key) = storage.load(mode)? {
596            api_keys.insert(provider.clone(), key);
597        }
598    }
599
600    Ok(api_keys)
601}
602
603/// Clear all custom API keys from secure storage.
604///
605/// # Arguments
606/// * `providers` - List of provider names to clear
607/// * `mode` - The storage mode to use
608pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
609    for provider in providers {
610        let storage = CustomApiKeyStorage::new(provider);
611        if let Err(e) = storage.clear(mode) {
612            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
613        }
614    }
615    Ok(())
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use assert_fs::TempDir;
622    use serial_test::serial;
623
624    struct TestAuthDirGuard {
625        temp_dir: Option<TempDir>,
626        previous: Option<std::path::PathBuf>,
627    }
628
629    impl TestAuthDirGuard {
630        fn new() -> Self {
631            let temp_dir = TempDir::new().expect("create temp auth dir");
632            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
633                .expect("read auth dir override");
634            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
635                temp_dir.path().to_path_buf(),
636            ))
637            .expect("set auth dir override");
638
639            Self {
640                temp_dir: Some(temp_dir),
641                previous,
642            }
643        }
644    }
645
646    impl Drop for TestAuthDirGuard {
647        fn drop(&mut self) {
648            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
649                .expect("restore auth dir override");
650            if let Some(temp_dir) = self.temp_dir.take() {
651                temp_dir.close().expect("remove temp auth dir");
652            }
653        }
654    }
655
656    #[test]
657    fn test_storage_mode_default_is_keyring() {
658        assert_eq!(
659            AuthCredentialsStoreMode::default(),
660            AuthCredentialsStoreMode::Keyring
661        );
662    }
663
664    #[test]
665    fn test_storage_mode_effective_mode() {
666        assert_eq!(
667            AuthCredentialsStoreMode::Keyring.effective_mode(),
668            AuthCredentialsStoreMode::Keyring
669        );
670        assert_eq!(
671            AuthCredentialsStoreMode::File.effective_mode(),
672            AuthCredentialsStoreMode::File
673        );
674
675        // Auto should resolve to either Keyring or File
676        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
677        assert!(
678            auto_mode == AuthCredentialsStoreMode::Keyring
679                || auto_mode == AuthCredentialsStoreMode::File
680        );
681    }
682
683    #[test]
684    fn test_storage_mode_serialization() {
685        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
686        assert_eq!(keyring_json, "\"keyring\"");
687
688        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
689        assert_eq!(file_json, "\"file\"");
690
691        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
692        assert_eq!(auto_json, "\"auto\"");
693
694        // Test deserialization
695        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
696        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
697
698        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
699        assert_eq!(parsed, AuthCredentialsStoreMode::File);
700
701        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
702        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
703    }
704
705    #[test]
706    fn test_credential_storage_new() {
707        let storage = CredentialStorage::new("vtcode", "test_key");
708        assert_eq!(storage.service, "vtcode");
709        assert_eq!(storage.user, "test_key");
710    }
711
712    #[test]
713    fn test_is_keyring_functional_check() {
714        // This test just verifies the function doesn't panic
715        // The actual result depends on the OS environment
716        let _functional = is_keyring_functional();
717    }
718
719    #[test]
720    #[serial]
721    fn credential_storage_file_mode_round_trips_without_plaintext() {
722        let _guard = TestAuthDirGuard::new();
723        let storage = CredentialStorage::new("vtcode", "test_key");
724
725        storage
726            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
727            .expect("store encrypted credential");
728
729        let loaded = storage
730            .load_with_mode(AuthCredentialsStoreMode::File)
731            .expect("load encrypted credential");
732        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
733
734        let stored = fs::read_to_string(storage.file_path().expect("credential path"))
735            .expect("read encrypted credential file");
736        assert!(!stored.contains("secret_api_key"));
737    }
738
739    #[test]
740    #[serial]
741    fn keyring_mode_load_falls_back_to_encrypted_file() {
742        let _guard = TestAuthDirGuard::new();
743        let storage = CredentialStorage::new("vtcode", "test_key");
744
745        storage
746            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
747            .expect("store encrypted credential");
748
749        let loaded = storage
750            .load_with_mode(AuthCredentialsStoreMode::Keyring)
751            .expect("load credential");
752        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
753    }
754
755    #[test]
756    #[serial]
757    #[cfg(unix)]
758    fn credential_storage_file_mode_uses_private_permissions() {
759        use std::os::unix::fs::PermissionsExt;
760
761        let _guard = TestAuthDirGuard::new();
762        let storage = CredentialStorage::new("vtcode", "test_key");
763
764        storage
765            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
766            .expect("store encrypted credential");
767
768        let metadata = fs::metadata(storage.file_path().expect("credential path"))
769            .expect("read credential metadata");
770        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
771    }
772
773    #[test]
774    #[serial]
775    #[cfg(unix)]
776    fn credential_storage_file_mode_restricts_existing_file_permissions() {
777        use std::os::unix::fs::PermissionsExt;
778
779        let _guard = TestAuthDirGuard::new();
780        let storage = CredentialStorage::new("vtcode", "test_key");
781
782        storage
783            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
784            .expect("store initial credential");
785
786        let path = storage.file_path().expect("credential path");
787        fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
788            .expect("broaden existing credential permissions");
789
790        storage
791            .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
792            .expect("rewrite credential");
793
794        let metadata = fs::metadata(path).expect("read credential metadata");
795        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
796    }
797
798    #[test]
799    #[serial]
800    fn custom_api_key_load_migrates_legacy_auth_json() {
801        let _guard = TestAuthDirGuard::new();
802        let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
803        fs::write(
804            &legacy_path,
805            r#"{
806  "version": 1,
807  "mode": "api_key",
808  "provider": "openai",
809  "api_key": "legacy-secret",
810  "authenticated_at": 1768406185
811}"#,
812        )
813        .expect("write legacy auth file");
814
815        let storage = CustomApiKeyStorage::new("openai");
816        let loaded = storage
817            .load(AuthCredentialsStoreMode::File)
818            .expect("load migrated api key");
819        assert_eq!(loaded.as_deref(), Some("legacy-secret"));
820        assert!(!legacy_path.exists());
821
822        let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
823            .expect("read migrated credential file");
824        assert!(!encrypted.contains("legacy-secret"));
825    }
826}