Skip to main content

vtcode_auth/credentials/
storage.rs

1//! Generic credential storage that orchestrates the keyring and file backends.
2
3use anyhow::{Context, Result, anyhow};
4use base64::Engine;
5use std::fs;
6
7use super::encryption;
8use super::keyring;
9use super::mode::AuthCredentialsStoreMode;
10use crate::storage_paths::auth_storage_dir;
11use crate::storage_paths::write_private_file;
12
13/// Generic credential storage interface.
14///
15/// Provides methods to store, load, and clear credentials using either
16/// the OS keyring or file-based storage.
17pub struct CredentialStorage {
18    service: String,
19    user: String,
20}
21
22impl CredentialStorage {
23    /// Create a new credential storage handle.
24    pub(crate) fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
25        Self { service: service.into(), user: user.into() }
26    }
27
28    /// Store a credential using the specified mode.
29    pub(crate) fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
30        match mode.effective_mode() {
31            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
32                Ok(()) => {
33                    if let Err(err) = self.store_file(value) {
34                        tracing::warn!(
35                            "Failed to write encrypted file backup for {}/{}: {}",
36                            self.service,
37                            self.user,
38                            err
39                        );
40                    }
41                    Ok(())
42                }
43                Err(err) => {
44                    tracing::warn!(
45                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
46                        self.service,
47                        self.user,
48                        err
49                    );
50                    self.store_file(value).context("failed to store credential in encrypted file")
51                }
52            },
53            AuthCredentialsStoreMode::File => self.store_file(value),
54            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
55        }
56    }
57
58    /// Store a credential using `Auto` mode.
59    pub fn store(&self, value: &str) -> Result<()> {
60        self.store_with_mode(value, AuthCredentialsStoreMode::Auto)
61    }
62
63    /// Load a credential using the specified mode.
64    pub(crate) fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
65        match mode.effective_mode() {
66            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
67                Ok(Some(value)) => Ok(Some(value)),
68                Ok(None) => self.load_file(),
69                Err(err) => {
70                    tracing::warn!(
71                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
72                        self.service,
73                        self.user,
74                        err
75                    );
76                    self.load_file()
77                }
78            },
79            AuthCredentialsStoreMode::File => self.load_file(),
80            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
81        }
82    }
83
84    /// Load a credential using `Auto` mode.
85    pub fn load(&self) -> Result<Option<String>> {
86        self.load_with_mode(AuthCredentialsStoreMode::Auto)
87    }
88
89    /// Clear (delete) a credential using the specified mode.
90    pub(crate) fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
91        match mode.effective_mode() {
92            AuthCredentialsStoreMode::Keyring => {
93                let mut errors = Vec::new();
94
95                if let Err(err) = self.clear_keyring() {
96                    errors.push(err.to_string());
97                }
98                if let Err(err) = self.clear_file() {
99                    errors.push(err.to_string());
100                }
101
102                if errors.is_empty() {
103                    Ok(())
104                } else {
105                    Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
106                }
107            }
108            AuthCredentialsStoreMode::File => self.clear_file(),
109            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
110        }
111    }
112
113    /// Clear a credential using `Auto` mode.
114    pub fn clear(&self) -> Result<()> {
115        self.clear_with_mode(AuthCredentialsStoreMode::Auto)
116    }
117
118    // ------------------------------------------------------------------
119    // Private backend helpers
120    // ------------------------------------------------------------------
121
122    fn store_keyring(&self, value: &str) -> Result<()> {
123        let entry = keyring::entry(&self.service, &self.user).context("Failed to access OS keyring")?;
124        entry.set_password(value).context("Failed to store credential in OS keyring")?;
125        tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
126        Ok(())
127    }
128
129    fn load_keyring(&self) -> Result<Option<String>> {
130        let entry = match keyring::entry(&self.service, &self.user) {
131            Ok(e) => e,
132            Err(_) => return Ok(None),
133        };
134
135        match entry.get_password() {
136            Ok(value) => Ok(Some(value)),
137            Err(keyring_core::Error::NoEntry) => Ok(None),
138            Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
139        }
140    }
141
142    fn clear_keyring(&self) -> Result<()> {
143        let entry = match keyring::entry(&self.service, &self.user) {
144            Ok(e) => e,
145            Err(_) => return Ok(()),
146        };
147
148        match entry.delete_credential() {
149            Ok(_) => {
150                tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
151            }
152            Err(keyring_core::Error::NoEntry) => {}
153            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
154        }
155
156        Ok(())
157    }
158
159    fn store_file(&self, value: &str) -> Result<()> {
160        let path = self.file_path()?;
161        let encrypted = encryption::encrypt(value)?;
162        let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
163        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
164        Ok(())
165    }
166
167    fn load_file(&self) -> Result<Option<String>> {
168        let path = self.file_path()?;
169        let data = match fs::read(&path) {
170            Ok(data) => data,
171            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
172            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
173        };
174
175        let encrypted: encryption::EncryptedCredential =
176            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
177        encryption::decrypt(&encrypted).map(Some)
178    }
179
180    fn clear_file(&self) -> Result<()> {
181        let path = self.file_path()?;
182        match fs::remove_file(path) {
183            Ok(()) => Ok(()),
184            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
185            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
186        }
187    }
188
189    fn file_path(&self) -> Result<std::path::PathBuf> {
190        use sha2::Digest as _;
191
192        let mut hasher = sha2::Sha256::new();
193        hasher.update(self.service.as_bytes());
194        hasher.update([0]);
195        hasher.update(self.user.as_bytes());
196        let digest = hasher.finalize();
197        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
198
199        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
200    }
201}