返回 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 if provider == ProviderKind::Deepseek {
46 store.config.api_key = None;
47 }
48 }
49
50 /// Plaintext-free metadata that accompanies a saved key.
51 pub fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
52 store.config.auth_mode = Some("api_key".to_string());
53 let provider_config = store.config.providers.for_provider_mut(provider);
54 provider_config.auth_mode = Some("api_key".to_string());
55 provider_config.external_credentials = None;
56 if provider == ProviderKind::Xai {
57 provider_config.oauth_credential_generation = None;
58 }
59 if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
60 store.config.default_text_model = Some(
61 store
62 .config
63 .providers
64 .deepseek
65 .model
66 .clone()
67 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
68 );
69 }
70 }
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 if provider == ProviderKind::Xai {
84 return crate::with_xai_oauth_revocation_transaction(|| {
85 set_provider_api_key_unlocked(store, secrets, provider, api_key)
86 });
87 }
88 set_provider_api_key_unlocked(store, secrets, provider, api_key)
89 }
90
91 fn set_provider_api_key_unlocked(
92 store: &mut ConfigStore,
93 secrets: &Secrets,
94 provider: ProviderKind,
95 api_key: &str,
96 ) -> Result<bool> {
97 let original_config = store.config.clone();
98 prepare_provider_api_key_metadata(store, provider);
99 let slot = provider_slot(provider);
100 // A readable prior value is required before a secret-store write so a
101 // later config failure can restore the exact prior state. If the backend
102 // cannot provide that snapshot, fail before changing the config file.
103 let prior_secret = secrets.get(slot);
104 let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
105 Ok(_) => match secrets.set(slot, api_key) {
106 Ok(()) => {
107 clear_provider_api_key_from_config(store, provider);
108 true
109 }
110 Err(err) => {
111 store.config = original_config;
112 return Err(anyhow::anyhow!(
113 "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.",
114 crate::quote_os_path(store.path())
115 ));
116 }
117 },
118 Err(error) => {
119 store.config = original_config;
120 return Err(anyhow::anyhow!(
121 "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.",
122 crate::quote_os_path(store.path())
123 ));
124 }
125 };
126 if let Err(error) = store.save() {
127 store.config = original_config;
128 if secret_store_saved {
129 let current = secrets
130 .get(slot)
131 .map_err(|rollback| anyhow::anyhow!(
132 "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
133 ))?;
134 if current.as_deref() == Some(api_key) {
135 match prior_secret.expect("snapshot succeeded before secret write") {
136 Some(previous) => secrets.set(slot, &previous),
137 None => secrets.delete(slot),
138 }
139 .map_err(|rollback| anyhow::anyhow!(
140 "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
141 ))?;
142 }
143 }
144 return Err(error);
145 }
146 crate::scrub_plaintext_api_keys_from_config_backup(store.path())
147 .context("failed to scrub plaintext API keys from config backup")?;
148 Ok(secret_store_saved)
149 }
150
151 /// What a credential clear actually accomplished.
152 ///
153 /// The secret-store leg can fail after the config leg has already been
154 /// persisted. Reporting that separately is the point: a caller that prints
155 /// "cleared" while the key is still sitting in the keyring has lied about a
156 /// security-relevant action.
157 #[derive(Debug, Clone, PartialEq, Eq)]
158 pub struct ClearOutcome {
159 /// The secret-store slot the clear targeted.
160 pub slot: &'static str,
161 /// `None` when the secret store accepted the delete; otherwise the backend
162 /// error, already stringified so it carries no credential material.
163 pub secret_store_error: Option<String>,
164 }
165
166 impl ClearOutcome {
167 /// True only when both the config and the secret store were cleared.
168 #[must_use]
169 pub fn is_complete(&self) -> bool {
170 self.secret_store_error.is_none()
171 }
172 }
173
174 /// Remove a provider credential from config and the durable secret store.
175 ///
176 /// Shared by `codewhale auth clear` and the runtime API's credential route so
177 /// both get the same ordering and the same rollback: the config document is
178 /// snapshotted and restored if its save fails, and the secret store is only
179 /// touched once the config write has landed. A secret-store failure is
180 /// returned rather than swallowed, because the config no longer advertises a
181 /// key that the backend may still hold.
182 ///
183 /// This deliberately does not clear external-consent or environment-sourced
184 /// credentials: Codewhale does not own those, and a caller must refuse the
185 /// request instead of implying it revoked something it cannot reach.
186 pub fn clear_provider_api_key(
187 store: &mut ConfigStore,
188 secrets: &Secrets,
189 provider: ProviderKind,
190 ) -> Result<ClearOutcome> {
191 let slot = provider_slot(provider);
192 let original_config = store.config.clone();
193 clear_provider_api_key_from_config(store, provider);
194 // Only xAI carries OAuth generation and consent state alongside the key,
195 // and `codewhale auth clear` has always cleared those three together. Every
196 // other provider keeps its `auth_mode` marker deliberately: the route is
197 // still an API-key route, it simply has no key now, which is exactly the
198 // `missing` credential state a client needs to see.
199 if provider == ProviderKind::Xai {
200 let xai = store.config.providers.for_provider_mut(provider);
201 xai.oauth_credential_generation = None;
202 xai.auth_mode = None;
203 xai.external_credentials = None;
204 }
205 if let Err(error) = store.save() {
206 store.config = original_config;
207 return Err(error);
208 }
209 let secret_store_error = secrets.delete(slot).err().map(|error| error.to_string());
210 Ok(ClearOutcome {
211 slot,
212 secret_store_error,
213 })
214 }
215
215 lines RUST