vtcode_auth/credentials/
storage.rs1use 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
13pub struct CredentialStorage {
18 service: String,
19 user: String,
20}
21
22impl CredentialStorage {
23 pub(crate) fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
25 Self { service: service.into(), user: user.into() }
26 }
27
28 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 pub(crate) fn store_exact_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
65 match mode.effective_mode() {
66 AuthCredentialsStoreMode::Keyring => self.store_keyring(value),
67 AuthCredentialsStoreMode::File => self.store_file(value),
68 AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
69 }
70 }
71
72 pub(crate) fn store_json<T: serde::Serialize>(&self, value: &T, mode: AuthCredentialsStoreMode) -> Result<()> {
74 let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
75 self.store_with_mode(&serialized, mode)
76 }
77
78 pub(crate) fn store_json_exact_with_mode<T: serde::Serialize>(
80 &self,
81 value: &T,
82 mode: AuthCredentialsStoreMode,
83 ) -> Result<()> {
84 let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
85 self.store_exact_with_mode(&serialized, mode)
86 }
87
88 pub fn store(&self, value: &str) -> Result<()> {
90 self.store_with_mode(value, AuthCredentialsStoreMode::Auto)
91 }
92
93 pub(crate) fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
95 match mode.effective_mode() {
96 AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
97 Ok(Some(value)) => Ok(Some(value)),
98 Ok(None) => self.load_file(),
99 Err(err) => {
100 tracing::warn!(
101 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
102 self.service,
103 self.user,
104 err
105 );
106 self.load_file()
107 }
108 },
109 AuthCredentialsStoreMode::File => self.load_file(),
110 AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
111 }
112 }
113
114 pub(crate) fn load_exact_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
119 match mode.effective_mode() {
120 AuthCredentialsStoreMode::Keyring => self.load_keyring(),
121 AuthCredentialsStoreMode::File => self.load_file(),
122 AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
123 }
124 }
125
126 pub(crate) fn load_json<T: serde::de::DeserializeOwned>(
128 &self,
129 mode: AuthCredentialsStoreMode,
130 ) -> Result<Option<T>> {
131 let Some(serialized) = self.load_with_mode(mode)? else {
132 return Ok(None);
133 };
134 serde_json::from_str(&serialized)
135 .context("failed to deserialize credential")
136 .map(Some)
137 }
138
139 pub(crate) fn load_json_exact_with_mode<T: serde::de::DeserializeOwned>(
141 &self,
142 mode: AuthCredentialsStoreMode,
143 ) -> Result<Option<T>> {
144 let Some(serialized) = self.load_exact_with_mode(mode)? else {
145 return Ok(None);
146 };
147 serde_json::from_str(&serialized)
148 .context("failed to deserialize credential")
149 .map(Some)
150 }
151
152 pub fn load(&self) -> Result<Option<String>> {
154 self.load_with_mode(AuthCredentialsStoreMode::Auto)
155 }
156
157 pub(crate) fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
159 match mode.effective_mode() {
160 AuthCredentialsStoreMode::Keyring => {
161 let mut errors = Vec::new();
162
163 if let Err(err) = self.clear_keyring() {
164 errors.push(err.to_string());
165 }
166 if let Err(err) = self.clear_file() {
167 errors.push(err.to_string());
168 }
169
170 if errors.is_empty() {
171 Ok(())
172 } else {
173 Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
174 }
175 }
176 AuthCredentialsStoreMode::File => self.clear_file(),
177 AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
178 }
179 }
180
181 pub(crate) fn clear_exact_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
188 match mode.effective_mode() {
189 AuthCredentialsStoreMode::Keyring => self.clear_keyring(),
190 AuthCredentialsStoreMode::File => self.clear_file(),
191 AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
192 }
193 }
194
195 pub fn clear(&self) -> Result<()> {
197 self.clear_with_mode(AuthCredentialsStoreMode::Auto)
198 }
199
200 fn store_keyring(&self, value: &str) -> Result<()> {
205 let entry = keyring::entry(&self.service, &self.user).context("Failed to access OS keyring")?;
206 entry.set_password(value).context("Failed to store credential in OS keyring")?;
207 tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
208 Ok(())
209 }
210
211 fn load_keyring(&self) -> Result<Option<String>> {
212 let entry = match keyring::entry(&self.service, &self.user) {
213 Ok(e) => e,
214 Err(_) => return Ok(None),
215 };
216
217 match entry.get_password() {
218 Ok(value) => Ok(Some(value)),
219 Err(keyring_core::Error::NoEntry) => Ok(None),
220 Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
221 }
222 }
223
224 fn clear_keyring(&self) -> Result<()> {
225 let entry = match keyring::entry(&self.service, &self.user) {
226 Ok(e) => e,
227 Err(_) => return Ok(()),
228 };
229
230 match entry.delete_credential() {
231 Ok(_) => {
232 tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
233 }
234 Err(keyring_core::Error::NoEntry) => {}
235 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
236 }
237
238 Ok(())
239 }
240
241 fn store_file(&self, value: &str) -> Result<()> {
242 let path = self.file_path()?;
243 let encrypted = encryption::encrypt(value)?;
244 let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
245 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
246 Ok(())
247 }
248
249 fn load_file(&self) -> Result<Option<String>> {
250 let path = self.file_path()?;
251 let data = match fs::read(&path) {
252 Ok(data) => data,
253 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
254 Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
255 };
256
257 let encrypted: encryption::EncryptedCredential =
258 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
259 encryption::decrypt(&encrypted).map(Some)
260 }
261
262 fn clear_file(&self) -> Result<()> {
263 let path = self.file_path()?;
264 match fs::remove_file(path) {
265 Ok(()) => Ok(()),
266 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
267 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
268 }
269 }
270
271 fn file_path(&self) -> Result<std::path::PathBuf> {
272 use sha2::Digest as _;
273
274 let mut hasher = sha2::Sha256::new();
275 hasher.update(self.service.as_bytes());
276 hasher.update([0]);
277 hasher.update(self.user.as_bytes());
278 let digest = hasher.finalize();
279 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
280
281 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
282 }
283
284 #[cfg(test)]
285 pub(crate) fn file_path_for_tests(&self) -> Result<std::path::PathBuf> {
286 self.file_path()
287 }
288}