Skip to main content

redisctl_core/config/
credential.rs

1//! Credential storage abstraction with optional keyring support
2//!
3//! This module provides a unified interface for storing and retrieving credentials,
4//! with support for:
5//! - OS keyring (when feature enabled)
6//! - Plaintext storage (fallback)
7//! - Environment variable override
8
9use super::error::{ConfigError, Result};
10use std::env;
11
12/// Whether process environment variables may override stored profile values.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum EnvironmentOverrides {
15    /// Resolve supported environment variables before stored profile values.
16    Enabled,
17    /// Resolve only stored profile values and keyring references.
18    Disabled,
19}
20
21/// Prefix that indicates a value should be retrieved from the keyring
22const KEYRING_PREFIX: &str = "keyring:";
23
24/// Service name for keyring entries
25#[cfg(feature = "secure-storage")]
26const SERVICE_NAME: &str = "redisctl";
27
28/// Entry name the availability read uses, and the stem `probe_writable` builds its per-call key
29/// from. One name for both so there is a single reserved key rather than a magic one per check.
30#[cfg(feature = "secure-storage")]
31const PROBE_ENTRY: &str = "__probe__";
32
33/// Distinguishes concurrent probes within one process, so parallel tests don't collide either.
34#[cfg(feature = "secure-storage")]
35static PROBE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
36
37/// Storage backend for credentials
38#[derive(Debug, Clone)]
39pub enum CredentialStorage {
40    /// Store in OS keyring
41    #[cfg(feature = "secure-storage")]
42    Keyring,
43    /// Store as plaintext
44    Plaintext,
45}
46
47/// Credential store abstraction
48pub struct CredentialStore {
49    #[cfg(feature = "secure-storage")]
50    storage: CredentialStorage,
51}
52
53impl Default for CredentialStore {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl CredentialStore {
60    /// Create a new credential store with automatic backend selection
61    pub fn new() -> Self {
62        #[cfg(feature = "secure-storage")]
63        {
64            // Try to use keyring if available
65            if Self::is_keyring_available() {
66                Self {
67                    storage: CredentialStorage::Keyring,
68                }
69            } else {
70                Self {
71                    storage: CredentialStorage::Plaintext,
72                }
73            }
74        }
75        #[cfg(not(feature = "secure-storage"))]
76        {
77            Self {}
78        }
79    }
80
81    /// Create a store that always uses plaintext (no keyring). Useful for tests and for the
82    /// explicit `--allow-plaintext` opt-in when no keyring is available.
83    pub fn plaintext() -> Self {
84        #[cfg(feature = "secure-storage")]
85        {
86            Self {
87                storage: CredentialStorage::Plaintext,
88            }
89        }
90        #[cfg(not(feature = "secure-storage"))]
91        {
92            Self {}
93        }
94    }
95
96    /// Whether the keyring backend answers at all, from a read.
97    ///
98    /// Reads the probe entry and judges the answer rather than discarding it: `Entry::new` only
99    /// builds a handle, so returning `true` whenever it succeeded made this unconditional and
100    /// left `new()` selecting `Keyring` on every machine with the feature compiled in. `NoEntry`
101    /// is the healthy answer — the entry is absent, and the backend said so. Anything else
102    /// (`NoStorageAccess`, `PlatformFailure`, …) is the backend refusing to talk.
103    ///
104    /// A read is all this does, so constructing a store never writes. Confirming the backend can
105    /// *hold* a value needs [`CredentialStore::probe_writable`].
106    #[cfg(feature = "secure-storage")]
107    fn is_keyring_available() -> bool {
108        match keyring::Entry::new(SERVICE_NAME, PROBE_ENTRY) {
109            Ok(entry) => !matches!(
110                entry.get_password(),
111                Err(keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_))
112            ),
113            Err(_) => false,
114        }
115    }
116
117    /// Confirm the backend can actually hold a credential, by storing a throwaway value and
118    /// reading it back.
119    ///
120    /// The keyring availability check only reads, and a backend can answer a read and
121    /// still refuse a write — a locked macOS keychain, a Windows credential store the session
122    /// cannot write, keyutils in a container without `CONFIG_KEYS`. Callers about to create
123    /// something they cannot recreate — a minted API key, whose secret is returned once — should
124    /// ask here first.
125    ///
126    /// What this does *not* establish: on Linux the backend is keyutils (see the `keyring`
127    /// features in the workspace `Cargo.toml`), where the value lives in an in-memory kernel
128    /// keyring. A write and read-back inside one process succeeds there even when the value will
129    /// not be visible to the next `redisctl` run — the same absence [`Self::get_credential`]
130    /// reports after a reboot.
131    pub fn probe_writable(&self) -> Result<()> {
132        #[cfg(feature = "secure-storage")]
133        {
134            if !matches!(self.storage, CredentialStorage::Keyring) {
135                return Ok(());
136            }
137            const PROBE_VALUE: &str = "redisctl-probe";
138
139            // Per call, not a shared constant: two runs probing the same entry race, and the one
140            // that reads after the other's cleanup sees `NoEntry` and refuses a login its own
141            // keyring would have served.
142            let key = format!(
143                "{PROBE_ENTRY}-{}-{}",
144                std::process::id(),
145                PROBE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
146            );
147            let entry = keyring::Entry::new(SERVICE_NAME, &key)
148                .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
149            entry.set_password(PROBE_VALUE).map_err(|e| {
150                ConfigError::KeyringError(format!("the keyring rejected a test write: {e}"))
151            })?;
152            let read_back = entry.get_password().map_err(|e| {
153                ConfigError::KeyringError(format!("the keyring did not return a test write: {e}"))
154            });
155            // Leave nothing behind whatever the read said.
156            let _ = entry.delete_credential();
157            if read_back? != PROBE_VALUE {
158                return Err(ConfigError::KeyringError(
159                    "the keyring returned a different value than was written".to_string(),
160                ));
161            }
162        }
163        Ok(())
164    }
165
166    /// Store a credential value
167    pub fn store_credential(&self, key: &str, value: &str) -> Result<String> {
168        #[cfg(feature = "secure-storage")]
169        {
170            match self.storage {
171                CredentialStorage::Keyring => {
172                    let entry = keyring::Entry::new(SERVICE_NAME, key)
173                        .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
174                    entry.set_password(value).map_err(|e| {
175                        ConfigError::KeyringError(format!(
176                            "Failed to store credential in keyring: {}",
177                            e
178                        ))
179                    })?;
180                    // Return the reference string that will be stored in config
181                    Ok(format!("{}{}", KEYRING_PREFIX, key))
182                }
183                CredentialStorage::Plaintext => Ok(value.to_string()),
184            }
185        }
186        #[cfg(not(feature = "secure-storage"))]
187        {
188            // Without secure-storage feature, always use plaintext
189            let _ = key; // Not used without secure-storage
190            Ok(value.to_string())
191        }
192    }
193
194    /// Retrieve a credential value
195    ///
196    /// Resolution order:
197    /// 1. Check environment variables in order (if env vars provided)
198    /// 2. If value starts with "keyring:", retrieve from keyring
199    /// 3. Otherwise, return the value as-is (plaintext)
200    pub fn get_credential(&self, value: &str, env_var: Option<&str>) -> Result<String> {
201        match env_var {
202            Some(env_var) => self.get_credential_with_environment(
203                value,
204                &[env_var],
205                EnvironmentOverrides::Enabled,
206            ),
207            None => self.get_credential_with_environment(value, &[], EnvironmentOverrides::Enabled),
208        }
209    }
210
211    /// Retrieve a credential value with support for multiple environment variable aliases.
212    ///
213    /// Environment variables are checked in order, and the first set value wins.
214    pub fn get_credential_with_env_vars(&self, value: &str, env_vars: Vec<&str>) -> Result<String> {
215        self.get_credential_with_environment(value, &env_vars, EnvironmentOverrides::Enabled)
216    }
217
218    /// Retrieve a credential with an explicit environment override policy.
219    ///
220    /// This is used by callers that load an explicit configuration file and
221    /// require its credential values to be isolated from the process
222    /// environment.
223    pub fn get_credential_with_environment(
224        &self,
225        value: &str,
226        env_vars: &[&str],
227        environment_overrides: EnvironmentOverrides,
228    ) -> Result<String> {
229        if environment_overrides == EnvironmentOverrides::Enabled {
230            for var in env_vars {
231                if let Ok(env_value) = env::var(var) {
232                    return Ok(env_value);
233                }
234            }
235        }
236
237        // Check if this is a keyring reference
238        if value.starts_with(KEYRING_PREFIX) {
239            #[cfg(feature = "secure-storage")]
240            {
241                let key = value.trim_start_matches(KEYRING_PREFIX);
242                let entry = keyring::Entry::new(SERVICE_NAME, key)
243                    .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
244                entry.get_password().map_err(|e| match e {
245                    // The entry is gone rather than unreadable. On Linux the backing store is an
246                    // in-memory kernel keyring, so this is expected after a reboot and the config
247                    // itself is fine — say so, because the generic wording sends people to check
248                    // their config file syntax.
249                    keyring::Error::NoEntry => ConfigError::KeyringError(format!(
250                        "credential '{key}' is no longer in the OS keyring, so any profile \
251                         referencing it cannot be used until it is stored again. On Linux the \
252                         keyring does not survive a reboot. Store it again with \
253                         `redisctl profile set <name> --type <cloud|enterprise>`, or for a Redis \
254                         Cloud profile sign in again with \
255                         `redisctl --profile <name> cloud auth login`."
256                    )),
257                    other => ConfigError::KeyringError(format!(
258                        "Failed to retrieve credential '{key}' from keyring: {other}"
259                    )),
260                })
261            }
262            #[cfg(not(feature = "secure-storage"))]
263            {
264                Err(ConfigError::CredentialError(
265                    "Credential references keyring but secure-storage feature is not enabled"
266                        .to_string(),
267                ))
268            }
269        } else {
270            // Plain text value
271            Ok(value.to_string())
272        }
273    }
274
275    /// Delete a credential from storage
276    pub fn delete_credential(&self, key: &str) -> Result<()> {
277        #[cfg(feature = "secure-storage")]
278        {
279            match self.storage {
280                CredentialStorage::Keyring => {
281                    let entry = keyring::Entry::new(SERVICE_NAME, key)
282                        .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
283                    match entry.delete_credential() {
284                        Ok(()) => Ok(()),
285                        Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
286                        Err(e) => Err(ConfigError::KeyringError(format!(
287                            "Failed to delete credential from keyring: {}",
288                            e
289                        ))),
290                    }
291                }
292                CredentialStorage::Plaintext => Ok(()), // Nothing to delete for plaintext
293            }
294        }
295        #[cfg(not(feature = "secure-storage"))]
296        {
297            let _ = key; // Not used without secure-storage
298            Ok(()) // Nothing to delete for plaintext
299        }
300    }
301
302    /// Check if a value is a keyring reference
303    pub fn is_keyring_reference(value: &str) -> bool {
304        value.starts_with(KEYRING_PREFIX)
305    }
306
307    /// Get the current storage backend
308    pub fn storage_backend(&self) -> &str {
309        #[cfg(feature = "secure-storage")]
310        {
311            match self.storage {
312                CredentialStorage::Keyring => "keyring",
313                CredentialStorage::Plaintext => "plaintext",
314            }
315        }
316        #[cfg(not(feature = "secure-storage"))]
317        {
318            "plaintext"
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_plaintext_storage() {
329        let store = CredentialStore::new();
330
331        // Plaintext values should be returned as-is
332        let result = store.get_credential("my-api-key", None).unwrap();
333        assert_eq!(result, "my-api-key");
334    }
335
336    #[test]
337    fn test_env_var_override() {
338        unsafe {
339            env::set_var("TEST_CREDENTIAL", "env-value");
340        }
341
342        let store = CredentialStore::new();
343        let result = store
344            .get_credential("config-value", Some("TEST_CREDENTIAL"))
345            .unwrap();
346        assert_eq!(result, "env-value");
347
348        unsafe {
349            env::remove_var("TEST_CREDENTIAL");
350        }
351    }
352
353    #[test]
354    #[serial_test::serial(credential_alias_env)]
355    fn test_env_var_alias_override_uses_first_available() {
356        unsafe {
357            env::set_var("TEST_CREDENTIAL_ALIAS_2", "alias-value");
358        }
359
360        let store = CredentialStore::new();
361        let result = store
362            .get_credential_with_env_vars(
363                "config-value",
364                vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
365            )
366            .unwrap();
367        assert_eq!(result, "alias-value");
368
369        unsafe {
370            env::remove_var("TEST_CREDENTIAL_ALIAS_2");
371        }
372    }
373
374    #[test]
375    #[serial_test::serial(credential_alias_env)]
376    fn test_env_var_alias_override_prefers_first_set() {
377        unsafe {
378            env::set_var("TEST_CREDENTIAL_ALIAS_1", "preferred-value");
379            env::set_var("TEST_CREDENTIAL_ALIAS_2", "fallback-value");
380        }
381
382        let store = CredentialStore::new();
383        let result = store
384            .get_credential_with_env_vars(
385                "config-value",
386                vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
387            )
388            .unwrap();
389        assert_eq!(result, "preferred-value");
390
391        unsafe {
392            env::remove_var("TEST_CREDENTIAL_ALIAS_1");
393            env::remove_var("TEST_CREDENTIAL_ALIAS_2");
394        }
395    }
396
397    #[test]
398    fn test_keyring_reference_detection() {
399        assert!(CredentialStore::is_keyring_reference("keyring:my-key"));
400        assert!(!CredentialStore::is_keyring_reference("my-key"));
401        assert!(!CredentialStore::is_keyring_reference(""));
402    }
403
404    #[cfg(feature = "secure-storage")]
405    #[test]
406    #[ignore = "Requires keyring service to be available"]
407    fn test_keyring_storage() {
408        let store = CredentialStore::new();
409
410        // Store a credential
411        let key = "test-credential";
412        let value = "test-value";
413        let reference = store.store_credential(key, value).unwrap();
414
415        // Should return a keyring reference
416        assert!(reference.starts_with(KEYRING_PREFIX));
417
418        // Retrieve it back
419        let retrieved = store.get_credential(&reference, None).unwrap();
420        assert_eq!(retrieved, value);
421
422        // Clean up
423        let _ = store.delete_credential(key);
424    }
425
426    /// Nothing to probe without a keyring, and nothing may be written looking.
427    #[test]
428    fn probe_writable_is_a_no_op_for_plaintext() {
429        assert!(CredentialStore::plaintext().probe_writable().is_ok());
430    }
431
432    /// The probe has to be repeatable — it runs on every login, and each run must leave the
433    /// keyring able to serve the next one.
434    ///
435    /// Repeatability is what is asserted rather than the absence of a specific entry: the key is
436    /// derived per call, so there is no fixed name to look for, and an `is_err()` on a read would
437    /// pass just as well against a keyring that answers nothing at all.
438    #[cfg(feature = "secure-storage")]
439    #[test]
440    #[ignore = "Requires keyring service to be available"]
441    fn probe_writable_accepts_a_working_keyring_repeatedly() {
442        let store = CredentialStore::new();
443        store.probe_writable().unwrap();
444        store.probe_writable().unwrap();
445
446        // A real credential still round-trips afterwards: the probe took nothing with it.
447        let reference = store.store_credential("probe-neighbour", "kept").unwrap();
448        assert_eq!(store.get_credential(&reference, None).unwrap(), "kept");
449        let _ = store.delete_credential("probe-neighbour");
450    }
451}