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