1pub(crate) mod encryption;
14pub(crate) mod keyring;
15pub(crate) mod legacy;
16pub(crate) mod mode;
17pub(crate) mod storage;
18
19pub use mode::AuthCredentialsStoreMode;
20pub(crate) use mode::ResolvedStoreMode;
21pub use storage::CredentialStorage;
22
23use std::collections::BTreeMap;
24
25use anyhow::{Context, Result, bail};
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct CredentialIdentity {
35 provider: String,
36 key_name: String,
37}
38
39impl CredentialIdentity {
40 pub fn new(provider: &str, key_name: &str) -> Result<Self> {
43 let provider = provider.trim().to_ascii_lowercase();
44 if provider.is_empty() || !provider.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
45 bail!("credential provider must contain only letters, digits, '-' or '_'");
46 }
47
48 let key_name = key_name.trim().to_ascii_uppercase();
49 let mut chars = key_name.chars();
50 let valid_start = chars.next().is_some_and(|ch| ch.is_ascii_uppercase() || ch == '_');
51 if !valid_start || !chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_') {
52 bail!("credential key name must be a valid environment variable name");
53 }
54
55 Ok(Self { provider, key_name })
56 }
57
58 pub fn provider(&self) -> &str {
60 &self.provider
61 }
62
63 pub fn key_name(&self) -> &str {
65 &self.key_name
66 }
67
68 pub fn uses_default_key_name(&self, default_key_name: &str) -> bool {
70 self.key_name.eq_ignore_ascii_case(default_key_name.trim())
71 }
72}
73
74pub struct CustomApiKeyStorage {
79 provider: String,
80 identity: Option<CredentialIdentity>,
81 storage: CredentialStorage,
82}
83
84impl CustomApiKeyStorage {
85 pub fn new(provider: &str) -> Self {
91 let normalized_provider = provider.trim().to_lowercase();
92 Self {
93 provider: normalized_provider.clone(),
94 identity: None,
95 storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
96 }
97 }
98
99 pub fn for_provider_key(provider: &str, key_name: &str) -> Result<Self> {
101 Self::for_identity(CredentialIdentity::new(provider, key_name)?)
102 }
103
104 pub fn for_identity(identity: CredentialIdentity) -> Result<Self> {
106 let provider = identity.provider().to_owned();
107 let user = format!("api_key:v2:{}:{}", identity.provider(), identity.key_name());
110 Ok(Self {
111 provider,
112 identity: Some(identity),
113 storage: CredentialStorage::new("vtcode", user),
114 })
115 }
116
117 pub fn identity(&self) -> Option<&CredentialIdentity> {
120 self.identity.as_ref()
121 }
122
123 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
125 let api_key = api_key.trim();
126 if api_key.is_empty() {
127 bail!("API key cannot be empty");
128 }
129 self.store_value(api_key, mode)?;
130 Ok(())
131 }
132
133 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
135 if let Some(key) = self.storage.load_with_mode(mode)? {
136 let key = key.trim();
137 return Ok((!key.is_empty()).then(|| key.to_owned()));
138 }
139
140 if self.identity.is_none() {
141 self.load_legacy_auth_json(mode)
142 } else {
143 Ok(None)
144 }
145 }
146
147 pub fn load_with_legacy_fallback(
150 &self,
151 mode: AuthCredentialsStoreMode,
152 allow_legacy: bool,
153 ) -> Result<Option<String>> {
154 if let Some(key) = self.load(mode)? {
155 return Ok(Some(key));
156 }
157 if !allow_legacy || self.identity.is_none() || legacy_provider_account_is_ambiguous(&self.provider) {
158 return Ok(None);
159 }
160
161 let legacy_storage = Self::new(&self.provider);
162 let Some(key) = legacy_storage.load(mode)? else {
163 return Ok(None);
164 };
165
166 self.store_value(&key, mode)
167 .context("failed to migrate provider-only credential to key-scoped storage")?;
168 legacy_storage
172 .storage
173 .clear_with_mode(mode)
174 .context("failed to remove provider-only credential after migration")?;
175 self.load(mode)
176 }
177
178 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
180 self.storage.clear_with_mode(mode)?;
181 if self.identity.is_none() {
182 legacy::clear_for_provider(&self.provider).context("failed to remove legacy plaintext credential")?;
183 }
184 Ok(())
185 }
186
187 pub fn clear_with_legacy_fallback(&self, mode: AuthCredentialsStoreMode, clear_legacy: bool) -> Result<()> {
190 self.clear(mode)?;
191 if clear_legacy && self.identity.is_some() && !legacy_provider_account_is_ambiguous(&self.provider) {
192 Self::new(&self.provider).clear(mode)?;
193 }
194 Ok(())
195 }
196
197 fn store_value(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
198 self.storage.store_with_mode(api_key, mode)?;
199 let persisted = self
200 .storage
201 .load_with_mode(mode)
202 .context("failed to verify persisted API key")?;
203 if persisted.as_deref().map(str::trim) != Some(api_key) {
204 bail!("secure storage did not return the API key after saving");
205 }
206 Ok(())
207 }
208
209 fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
210 let Some(legacy_entry) = legacy::load_for_provider(&self.provider)? else {
211 return Ok(None);
212 };
213
214 if let Err(err) = self.store(&legacy_entry.credentials.api_key, mode) {
215 tracing::warn!(
216 "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
217 self.provider,
218 err
219 );
220 return Err(err).context("failed to migrate legacy API key into secure storage");
221 }
222
223 tracing::warn!(self.provider);
224 self.load(mode)
225 }
226}
227
228fn legacy_provider_account_is_ambiguous(provider: &str) -> bool {
232 provider.rsplit_once("__").is_some_and(|(prefix, suffix)| {
233 !prefix.is_empty() && suffix.bytes().all(|byte| byte == b'_' || byte.is_ascii_digit())
234 })
235}
236
237pub fn migrate_custom_api_keys(
241 custom_api_keys: &BTreeMap<String, String>,
242 mode: AuthCredentialsStoreMode,
243) -> Result<BTreeMap<String, bool>> {
244 let mut results = BTreeMap::new();
245
246 for (provider, api_key) in custom_api_keys {
247 let storage = CustomApiKeyStorage::new(provider);
248 match storage.store(api_key, mode) {
249 Ok(()) => {
250 tracing::info!("Migrated API key for provider '{provider}' to secure storage");
251 let _ignored = results.insert(provider.clone(), true);
252 }
253 Err(e) => {
254 tracing::warn!("Failed to migrate API key for provider '{provider}': {e}");
255 let _ignored = results.insert(provider.clone(), false);
256 }
257 }
258 }
259
260 Ok(results)
261}
262
263pub fn load_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<BTreeMap<String, String>> {
267 let mut api_keys = BTreeMap::new();
268
269 for provider in providers {
270 let storage = CustomApiKeyStorage::new(provider);
271 if let Some(key) = storage.load(mode)? {
272 drop(api_keys.insert(provider.clone(), key));
273 }
274 }
275
276 Ok(api_keys)
277}
278
279pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
281 for provider in providers {
282 let storage = CustomApiKeyStorage::new(provider);
283 if let Err(e) = storage.clear(mode) {
284 tracing::warn!("Failed to clear API key for provider '{provider}': {e}");
285 }
286 }
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use serial_test::serial;
294 use std::path::PathBuf;
295 use tempfile::TempDir;
296
297 struct TestAuthDirGuard {
298 temp_dir: Option<TempDir>,
299 previous: Option<PathBuf>,
300 }
301
302 impl TestAuthDirGuard {
303 fn new() -> Self {
304 let temp_dir = TempDir::new().expect("create temp auth dir");
305 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
306 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
307 .expect("set temp auth dir override");
308 Self { temp_dir: Some(temp_dir), previous }
309 }
310 }
311
312 impl Drop for TestAuthDirGuard {
313 fn drop(&mut self) {
314 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
315 .expect("restore auth dir override");
316 if let Some(temp_dir) = self.temp_dir.take() {
317 temp_dir.close().expect("remove temp auth dir");
318 }
319 }
320 }
321
322 #[test]
323 fn credential_identity_normalizes_provider_and_key_name() {
324 let identity = CredentialIdentity::new(" MiMo ", "mimo_token_plan_key").expect("valid identity");
325
326 assert_eq!(identity.provider(), "mimo");
327 assert_eq!(identity.key_name(), "MIMO_TOKEN_PLAN_KEY");
328 assert!(identity.uses_default_key_name("MIMO_TOKEN_PLAN_KEY"));
329 assert!(!identity.uses_default_key_name("MIMO_API_KEY"));
330 }
331
332 #[test]
333 fn credential_identity_rejects_invalid_names() {
334 assert!(CredentialIdentity::new("my corp", "MYCORP_API_KEY").is_err());
335 assert!(CredentialIdentity::new("mycorp", "MY-CORP-API-KEY").is_err());
336 assert!(CredentialIdentity::new("mycorp", "1MYCORP_API_KEY").is_err());
337 }
338
339 #[test]
340 #[serial]
341 #[cfg(unix)]
342 fn file_api_key_storage_round_trips_with_private_permissions() {
343 use std::fs;
344 use std::os::unix::fs::PermissionsExt;
345
346 let guard = TestAuthDirGuard::new();
347 let storage = CustomApiKeyStorage::new("stepfun");
348 storage
349 .store("test-stepfun-key", AuthCredentialsStoreMode::File)
350 .expect("store API key");
351 assert_eq!(
352 storage.load(AuthCredentialsStoreMode::File).expect("load API key").as_deref(),
353 Some("test-stepfun-key")
354 );
355
356 let auth_dir = guard.temp_dir.as_ref().expect("test auth dir").path();
357 assert_eq!(fs::metadata(auth_dir).expect("auth dir metadata").permissions().mode() & 0o777, 0o700);
358 let credential_file = fs::read_dir(auth_dir)
359 .expect("read auth dir")
360 .map(|entry| entry.expect("credential entry").path())
361 .find(|path| path.extension().and_then(|extension| extension.to_str()) == Some("json"))
362 .expect("credential file");
363 assert_eq!(fs::metadata(credential_file).expect("credential metadata").permissions().mode() & 0o777, 0o600);
364 }
365
366 #[test]
367 #[serial]
368 fn keyring_mode_falls_back_to_encrypted_file_when_keyring_is_unavailable() {
369 let _guard = TestAuthDirGuard::new();
370 let storage = CustomApiKeyStorage::new("stepfun");
371
372 storage
373 .store("test-stepfun-key", AuthCredentialsStoreMode::Keyring)
374 .expect("keyring mode should fall back to encrypted file storage");
375 assert_eq!(
376 storage
377 .load(AuthCredentialsStoreMode::Keyring)
378 .expect("load API key")
379 .as_deref(),
380 Some("test-stepfun-key")
381 );
382 }
383
384 #[test]
385 #[serial]
386 fn key_scoped_storage_keeps_provider_profiles_isolated() {
387 let _guard = TestAuthDirGuard::new();
388 let payg = CustomApiKeyStorage::for_provider_key(" MiMo ", "mimo_api_key").expect("payg storage");
389 let token_plan =
390 CustomApiKeyStorage::for_provider_key("mimo", "MIMO_TOKEN_PLAN_KEY").expect("token-plan storage");
391
392 payg.store("sk-payg", AuthCredentialsStoreMode::File).expect("store payg");
393 token_plan
394 .store("tp-token-plan", AuthCredentialsStoreMode::File)
395 .expect("store token plan");
396
397 assert_eq!(payg.load(AuthCredentialsStoreMode::File).expect("load payg").as_deref(), Some("sk-payg"));
398 assert_eq!(
399 token_plan
400 .load(AuthCredentialsStoreMode::File)
401 .expect("load token plan")
402 .as_deref(),
403 Some("tp-token-plan")
404 );
405 }
406
407 #[test]
408 #[serial]
409 fn ambiguous_old_accounts_are_not_assigned_to_either_identity() {
410 let _guard = TestAuthDirGuard::new();
411 let old = CredentialStorage::new("vtcode", "api_key_a___B".to_owned());
412 old.store_with_mode("ambiguous", AuthCredentialsStoreMode::File)
413 .expect("store old account");
414 let first = CustomApiKeyStorage::for_provider_key("a_", "_B").expect("first identity");
415 let second = CustomApiKeyStorage::for_provider_key("a", "__B").expect("second identity");
416 assert_eq!(first.load(AuthCredentialsStoreMode::File).expect("first load"), None);
417 assert_eq!(second.load(AuthCredentialsStoreMode::File).expect("second load"), None);
418 first.store("first", AuthCredentialsStoreMode::File).expect("store first");
419 second.store("second", AuthCredentialsStoreMode::File).expect("store second");
420 assert_eq!(first.load(AuthCredentialsStoreMode::File).expect("first reload").as_deref(), Some("first"));
421 assert_eq!(second.load(AuthCredentialsStoreMode::File).expect("second reload").as_deref(), Some("second"));
422 assert_eq!(
423 old.load_with_mode(AuthCredentialsStoreMode::File)
424 .expect("old remains")
425 .as_deref(),
426 Some("ambiguous")
427 );
428 }
429
430 #[test]
431 #[serial]
432 fn default_identity_lazily_migrates_provider_only_storage() {
433 let _guard = TestAuthDirGuard::new();
434 let legacy = CustomApiKeyStorage::new("mimo");
435 let target = CustomApiKeyStorage::for_provider_key("mimo", "MIMO_API_KEY").expect("target storage");
436
437 legacy
438 .store("legacy-key", AuthCredentialsStoreMode::File)
439 .expect("store legacy key");
440 assert_eq!(
441 target
442 .load_with_legacy_fallback(AuthCredentialsStoreMode::File, true)
443 .expect("migrate legacy key")
444 .as_deref(),
445 Some("legacy-key")
446 );
447 assert_eq!(legacy.load(AuthCredentialsStoreMode::File).expect("legacy should be cleared"), None);
448 assert_eq!(
449 target
450 .load(AuthCredentialsStoreMode::File)
451 .expect("load migrated key")
452 .as_deref(),
453 Some("legacy-key")
454 );
455 }
456
457 #[test]
458 #[serial]
459 fn ambiguous_provider_only_fallback_is_neither_loaded_nor_cleared() {
460 let _guard = TestAuthDirGuard::new();
461 let old = CredentialStorage::new("vtcode", "api_key_a__1");
463 old.store_with_mode("other-identity", AuthCredentialsStoreMode::File)
464 .expect("store ambiguous account");
465 let target = CustomApiKeyStorage::for_provider_key("a__1", "A_1_API_KEY").expect("target identity");
466 assert_eq!(
467 target
468 .load_with_legacy_fallback(AuthCredentialsStoreMode::File, true)
469 .expect("load"),
470 None
471 );
472 target
473 .clear_with_legacy_fallback(AuthCredentialsStoreMode::File, true)
474 .expect("clear target");
475 assert_eq!(
476 old.load_with_mode(AuthCredentialsStoreMode::File)
477 .expect("old remains")
478 .as_deref(),
479 Some("other-identity")
480 );
481 }
482
483 #[test]
484 fn legacy_provider_ambiguity_distinguishes_valid_account_boundaries() {
485 for provider in ["a__", "a__1", "a___1", "___"] {
486 assert!(legacy_provider_account_is_ambiguous(provider), "{provider}");
487 }
488 for provider in ["a", "a_b", "a__b", "a__1b", "__"] {
489 assert!(!legacy_provider_account_is_ambiguous(provider), "{provider}");
490 }
491 }
492
493 #[test]
494 #[serial]
495 fn non_default_identity_does_not_reuse_provider_only_storage() {
496 let _guard = TestAuthDirGuard::new();
497 let legacy = CustomApiKeyStorage::new("mimo");
498 let token_plan =
499 CustomApiKeyStorage::for_provider_key("mimo", "MIMO_TOKEN_PLAN_KEY").expect("token-plan storage");
500
501 legacy
502 .store("legacy-payg", AuthCredentialsStoreMode::File)
503 .expect("store legacy key");
504 assert_eq!(
505 token_plan
506 .load_with_legacy_fallback(AuthCredentialsStoreMode::File, false)
507 .expect("load token-plan key"),
508 None
509 );
510 assert_eq!(
511 legacy.load(AuthCredentialsStoreMode::File).expect("legacy remains"),
512 Some("legacy-payg".to_string())
513 );
514 }
515
516 #[test]
517 #[serial]
518 fn clearing_one_identity_does_not_clear_another() {
519 let _guard = TestAuthDirGuard::new();
520 let payg = CustomApiKeyStorage::for_provider_key("mimo", "MIMO_API_KEY").expect("payg storage");
521 let token_plan =
522 CustomApiKeyStorage::for_provider_key("mimo", "MIMO_TOKEN_PLAN_KEY").expect("token-plan storage");
523
524 payg.store("sk-payg", AuthCredentialsStoreMode::File).expect("store payg");
525 token_plan
526 .store("tp-token-plan", AuthCredentialsStoreMode::File)
527 .expect("store token plan");
528 payg.clear_with_legacy_fallback(AuthCredentialsStoreMode::File, true)
529 .expect("clear payg");
530
531 assert_eq!(payg.load(AuthCredentialsStoreMode::File).expect("payg cleared"), None);
532 assert_eq!(
533 token_plan
534 .load(AuthCredentialsStoreMode::File)
535 .expect("token plan remains")
536 .as_deref(),
537 Some("tp-token-plan")
538 );
539 }
540}