返回 CodeWhale
credentials.rs
根目录 / crates / config / src / credentials.rs
1 //! Canonical provider-credential writes shared by the CLI (`auth set`),
2 //! the runtime API secret route, and any future host. Owning this here keeps
3 //! every writer on the same transactional discipline: snapshot the prior
4 //! secret, write the durable backend, refuse plaintext config fallback, and
5 //! roll both stores back if either leg fails.
6
7 use anyhow::{Context, Result};
8
9 use crate::provider_kind::ProviderKind;
10 use crate::{ConfigStore, Secrets};
11
12 /// Resolve the store for credential-adjacent writes: provider selection,
13 /// `auth_mode` markers, and the plaintext-free metadata that accompanies a
14 /// saved key.
15 ///
16 /// Credentials and their metadata are user-global — a key saved while
17 /// working in one repo must be visible from every other repo, and the secret
18 /// store already is. When the ambient config path is a workspace-scoped
19 /// document (`<repo>/.codewhale/config.toml`), credential writes must not
20 /// bind the provider or write auth markers there: the binding would be
21 /// invisible from every other repo and would invite plaintext keys into a
22 /// committable repo file. Returns a store loaded on the user-global document
23 /// in that case, or `None` when the ambient store is already correctly
24 /// scoped, so key + provider binding + auth markers share one user-global
25 /// scope by default.
26 pub fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
27 if !crate::config_path_is_workspace_scoped(store.path()) {
28 return Ok(None);
29 }
30 let global = crate::default_config_path()?;
31 ConfigStore::load(Some(global)).map(Some)
32 }
33
34 /// The secret-store slot a provider's key occupies. Shared-account families
35 /// (SiliconFlow China, the Model Studio variants) collapse onto one slot;
36 /// see [`ProviderKind::secret_store_slot`].
37 #[must_use]
38 pub fn provider_slot(provider: ProviderKind) -> &'static str {
39 provider.secret_store_slot()
40 }
41
42 /// Remove any plaintext `api_key` left in the config for `provider`.
43 pub fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
44 store.config.providers.for_provider_mut(provider).api_key = None;
45 }
46
47 /// Plaintext-free metadata that accompanies a saved key.
48 ///
49 /// Saving a credential never writes a model: every provider resolves an
50 /// unset model to its own default, and model choice belongs to the
51 /// model/config commands.
52 pub fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
53 // The root `auth_mode` is the active provider's fallback marker. Writing
54 // it for an inactive provider would leak `api_key` onto the active route
55 // (e.g. a keyless local Ollama), so only the saved provider's own table
56 // is marked unless it is the active one.
57 if provider == store.config.provider {
58 store.config.auth_mode = Some("api_key".to_string());
59 }
60 let provider_config = store.config.providers.for_provider_mut(provider);
61 provider_config.auth_mode = Some("api_key".to_string());
62 provider_config.external_credentials = None;
63 if provider == ProviderKind::Xai {
64 provider_config.oauth_credential_generation = None;
65 }
66 }
67
68 /// ChatGPT plan access uses Codewhale's issued OAuth registration. A saved
69 /// API key would belong to a different billing route.
70 pub const OPENAI_CODEX_API_KEY_REFUSAL: &str = "Sign in with ChatGPT via `codewhale auth chatgpt` to use your plan allowance with Codewhale-owned credentials. Use the openai provider for a separately billed API key. Codewhale does not store an API key for this provider.";
71
72 /// Persist a provider credential to the durable secret store without silently
73 /// downgrading a backend failure to plaintext config storage.
74 ///
75 /// Returns `true` when the key landed in the secret store (config then holds
76 /// metadata only). Callers must not print or echo `api_key`.
77 pub fn set_provider_api_key(
78 store: &mut ConfigStore,
79 secrets: &Secrets,
80 provider: ProviderKind,
81 api_key: &str,
82 ) -> Result<bool> {
83 anyhow::ensure!(
84 provider != ProviderKind::OpenaiCodex,
85 OPENAI_CODEX_API_KEY_REFUSAL
86 );
87 // #6528: strip pasted invisible characters and whitespace in one place.
88 let api_key = codewhale_secrets::normalize_api_key(api_key);
89 anyhow::ensure!(!api_key.is_empty(), "Refusing to save an empty API key.");
90 let api_key = api_key.as_str();
91 if provider == ProviderKind::Xai {
92 return crate::with_xai_oauth_revocation_transaction(|| {
93 set_provider_api_key_unlocked(store, secrets, provider, api_key)
94 });
95 }
96 set_provider_api_key_unlocked(store, secrets, provider, api_key)
97 }
98
99 fn set_provider_api_key_unlocked(
100 store: &mut ConfigStore,
101 secrets: &Secrets,
102 provider: ProviderKind,
103 api_key: &str,
104 ) -> Result<bool> {
105 let original_config = store.config.clone();
106 prepare_provider_api_key_metadata(store, provider);
107 let slot = provider_slot(provider);
108 // A readable prior value is required before a secret-store write so a
109 // later config failure can restore the exact prior state. If the backend
110 // cannot provide that snapshot, fail before changing the config file.
111 let prior_secret = secrets.get(slot);
112 let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
113 Ok(_) => match secrets.set(slot, api_key) {
114 Ok(()) => {
115 clear_provider_api_key_from_config(store, provider);
116 true
117 }
118 Err(err) => {
119 store.config = original_config;
120 return Err(anyhow::anyhow!(
121 "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
122 crate::quote_os_path(store.path())
123 ));
124 }
125 },
126 Err(error) => {
127 store.config = original_config;
128 return Err(anyhow::anyhow!(
129 "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
130 crate::quote_os_path(store.path())
131 ));
132 }
133 };
134 if let Err(error) = store.save() {
135 store.config = original_config;
136 if secret_store_saved {
137 let current = secrets
138 .get(slot)
139 .map_err(|rollback| anyhow::anyhow!(
140 "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
141 ))?;
142 if current.as_deref() == Some(api_key) {
143 match prior_secret.expect("snapshot succeeded before secret write") {
144 Some(previous) => secrets.set(slot, &previous),
145 None => secrets.delete(slot),
146 }
147 .map_err(|rollback| anyhow::anyhow!(
148 "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
149 ))?;
150 }
151 }
152 return Err(error);
153 }
154 crate::scrub_plaintext_api_keys_from_config_backup(store.path())
155 .context("failed to scrub plaintext API keys from config backup")?;
156 Ok(secret_store_saved)
157 }
158
159 /// What a credential clear actually accomplished.
160 ///
161 /// The secret-store leg can fail after the config leg has already been
162 /// persisted. Reporting that separately is the point: a caller that prints
163 /// "cleared" while the key is still sitting in the keyring has lied about a
164 /// security-relevant action.
165 #[derive(Debug, Clone, PartialEq, Eq)]
166 pub struct ClearOutcome {
167 /// The secret-store slot the clear targeted.
168 pub slot: &'static str,
169 /// `None` when the secret store accepted the delete or holds no key for the
170 /// slot; otherwise the backend error, already stringified so it carries no
171 /// credential material.
172 pub secret_store_error: Option<String>,
173 }
174
175 impl ClearOutcome {
176 /// True only when both the config and the secret store were cleared.
177 #[must_use]
178 pub fn is_complete(&self) -> bool {
179 self.secret_store_error.is_none()
180 }
181 }
182
183 /// Remove a provider credential from config and the durable secret store.
184 ///
185 /// Shared by `codewhale auth clear` and the runtime API's credential route so
186 /// both get the same ordering and the same rollback: the config document is
187 /// snapshotted and restored if its save fails, and the secret store is only
188 /// touched once the config write has landed. A secret-store failure is
189 /// returned rather than swallowed, because the config no longer advertises a
190 /// key that the backend may still hold.
191 ///
192 /// This deliberately does not clear external-consent or environment-sourced
193 /// credentials: Codewhale does not own those, and a caller must refuse the
194 /// request instead of implying it revoked something it cannot reach.
195 pub fn clear_provider_api_key(
196 store: &mut ConfigStore,
197 secrets: &Secrets,
198 provider: ProviderKind,
199 ) -> Result<ClearOutcome> {
200 let slot = provider_slot(provider);
201 let original_config = store.config.clone();
202 clear_provider_api_key_from_config(store, provider);
203 // Only xAI carries OAuth generation and consent state alongside the key,
204 // and `codewhale auth clear` has always cleared those three together. Every
205 // other provider keeps its `auth_mode` marker deliberately: the route is
206 // still an API-key route, it simply has no key now, which is exactly the
207 // `missing` credential state a client needs to see.
208 if provider == ProviderKind::Xai {
209 let xai = store.config.providers.for_provider_mut(provider);
210 xai.oauth_credential_generation = None;
211 xai.auth_mode = None;
212 xai.external_credentials = None;
213 }
214 if let Err(error) = store.save() {
215 store.config = original_config;
216 return Err(error);
217 }
218 // A backend that refuses every delete (a read-only store) but holds no key
219 // for this slot has nothing left to revoke, so that refusal is not a
220 // failure. Both callers get this rule from here.
221 let secret_store_error = secrets
222 .delete(slot)
223 .err()
224 .filter(|_| !matches!(secrets.get(slot), Ok(None)))
225 .map(|error| error.to_string());
226 Ok(ClearOutcome {
227 slot,
228 secret_store_error,
229 })
230 }
231
232 #[cfg(test)]
233 mod tests {
234 use super::*;
235 use codewhale_secrets::{KeyringStore, SecretsError};
236 use std::sync::Arc;
237
238 /// A store that refuses every delete and holds `.0` for every slot.
239 struct UndeletableStore(Option<&'static str>);
240
241 impl KeyringStore for UndeletableStore {
242 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
243 Ok(self.0.map(str::to_string))
244 }
245
246 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
247 Err(SecretsError::ReadOnly)
248 }
249
250 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
251 Err(SecretsError::Keyring("test delete failure".to_string()))
252 }
253
254 fn backend_name(&self) -> &'static str {
255 "undeletable test store"
256 }
257 }
258
259 #[test]
260 fn a_refused_delete_fails_the_clear_only_while_the_store_still_holds_a_key() {
261 let dir = tempfile::tempdir().expect("tempdir");
262 let path = dir.path().join("config.toml");
263 for (held, fails) in [(Some("sk-keyring-fixture"), true), (None, false)] {
264 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
265 store.config.providers.deepseek.api_key = Some("sk-config-fixture".to_string());
266 let secrets = Secrets::new(Arc::new(UndeletableStore(held)));
267 let outcome = clear_provider_api_key(&mut store, &secrets, ProviderKind::Deepseek)
268 .expect("the config leg saves");
269 assert!(store.config.providers.deepseek.api_key.is_none());
270 assert_eq!(outcome.is_complete(), !fails, "delete refusal completion");
271 if let Some(error) = outcome.secret_store_error {
272 assert!(
273 !error.contains("sk-keyring-fixture"),
274 "credential leaked into error"
275 );
276 }
277 }
278 }
279
280 fn store_with(body: &str) -> (tempfile::TempDir, ConfigStore) {
281 let dir = tempfile::tempdir().expect("tempdir");
282 let path = dir.path().join("config.toml");
283 std::fs::write(&path, body).expect("seed config");
284 let store = ConfigStore::load(Some(path)).expect("store loads");
285 (dir, store)
286 }
287
288 fn in_memory_secrets() -> Secrets {
289 Secrets::new(std::sync::Arc::new(
290 codewhale_secrets::InMemoryKeyringStore::new(),
291 ))
292 }
293
294 #[test]
295 fn saving_a_key_for_an_inactive_provider_leaves_root_auth_mode_alone() {
296 let (_dir, mut store) = store_with("provider = \"ollama\"\n");
297 let secrets = in_memory_secrets();
298
299 set_provider_api_key(&mut store, &secrets, ProviderKind::Openrouter, "or-key")
300 .expect("save succeeds");
301
302 let saved = ConfigStore::load(Some(store.path().to_path_buf())).expect("reload");
303 assert_eq!(saved.config.provider, ProviderKind::Ollama);
304 assert_eq!(saved.config.auth_mode, None);
305 assert_eq!(
306 saved.config.providers.openrouter.auth_mode.as_deref(),
307 Some("api_key")
308 );
309 }
310
311 #[test]
312 fn saving_a_key_for_the_active_provider_still_marks_root_auth_mode() {
313 let (_dir, mut store) = store_with("provider = \"openrouter\"\n");
314 let secrets = in_memory_secrets();
315
316 set_provider_api_key(&mut store, &secrets, ProviderKind::Openrouter, "or-key")
317 .expect("save succeeds");
318
319 let saved = ConfigStore::load(Some(store.path().to_path_buf())).expect("reload");
320 assert_eq!(saved.config.auth_mode.as_deref(), Some("api_key"));
321 }
322
323 #[test]
324 fn openai_codex_key_save_is_refused_and_keeps_external_consent() {
325 let (_dir, mut store) = store_with(
326 "provider = \"openai-codex\"\n\n[providers.openai-codex]\nauth_mode = \"oauth\"\n",
327 );
328 store.config.providers.openai_codex.external_credentials =
329 Some(crate::ExternalCredentialConsentToml::read_only(
330 ProviderKind::OpenaiCodex,
331 crate::ExternalCredentialSource::CodexCli,
332 std::path::PathBuf::from("/synthetic/codex/auth.json"),
333 ));
334 store.save().expect("seed consent");
335 let before = std::fs::read_to_string(store.path()).expect("config before");
336 let secrets = in_memory_secrets();
337
338 let error = set_provider_api_key(&mut store, &secrets, ProviderKind::OpenaiCodex, "k")
339 .expect_err("openai-codex keys are not stored");
340
341 assert!(
342 error.to_string().contains("Sign in with ChatGPT"),
343 "{error}"
344 );
345 assert_eq!(
346 std::fs::read_to_string(store.path()).expect("config after"),
347 before
348 );
349 assert!(
350 store
351 .config
352 .providers
353 .openai_codex
354 .external_credentials
355 .is_some()
356 );
357 assert_eq!(
358 secrets
359 .get(provider_slot(ProviderKind::OpenaiCodex))
360 .expect("read"),
361 None
362 );
363 }
364 }
365
365 lines RUST