| 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 |