Skip to content

Commit 5934efb

Browse files
feat: add secure credential storage using OS keyring (#360)
* feat: add secure credential storage using OS keyring - Add optional keyring crate dependency with 'secure-storage' feature - Create credential storage abstraction layer with keyring/plaintext fallback - Update profile commands to support --use-keyring flag - Modify connection manager to resolve credentials through keyring - Support environment variable override for all credentials - Update documentation with secure storage instructions Implements #180 * docs: add comprehensive documentation for secure credential storage - Add security best practices reference page covering all storage methods - Update authentication docs with keyring storage option - Update installation docs to mention secure-storage feature - Update profiles documentation with detailed keyring usage - Add security page to documentation index Completes documentation for #180
1 parent 4171649 commit 5934efb

File tree

14 files changed

+957
-29
lines changed

14 files changed

+957
-29
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,54 @@ export REDIS_ENTERPRISE_PASSWORD="your-password"
4848
Or use profiles for multiple environments:
4949

5050
```bash
51-
# Create a profile
51+
# Create a profile with plaintext storage (default)
5252
redisctl profile set cloud-prod \
53-
--cloud-api-key="$REDIS_CLOUD_API_KEY" \
54-
--cloud-secret-key="$REDIS_CLOUD_SECRET_KEY"
53+
--deployment cloud \
54+
--api-key "$REDIS_CLOUD_API_KEY" \
55+
--api-secret "$REDIS_CLOUD_SECRET_KEY"
56+
57+
# Create a profile with secure OS keyring storage (recommended)
58+
redisctl profile set cloud-secure \
59+
--deployment cloud \
60+
--api-key "$REDIS_CLOUD_API_KEY" \
61+
--api-secret "$REDIS_CLOUD_SECRET_KEY" \
62+
--use-keyring # Requires 'secure-storage' feature
5563

5664
# Use the profile
5765
redisctl --profile cloud-prod cloud database list
5866
```
5967

68+
### Profile Storage
69+
6070
Profiles are stored in:
6171
- **Linux/macOS**: `~/.config/redisctl/config.toml`
6272
- **Windows**: `%APPDATA%\redis\redisctl\config.toml`
6373

74+
### Secure Credential Storage
75+
76+
When compiled with the `secure-storage` feature, redisctl can store sensitive credentials in your OS keyring:
77+
78+
- **macOS**: Keychain
79+
- **Windows**: Windows Credential Store
80+
- **Linux**: Secret Service (GNOME Keyring, KWallet)
81+
82+
To use secure storage:
83+
```bash
84+
# Install with secure storage support
85+
cargo install redisctl --features secure-storage
86+
87+
# Create profile with keyring storage
88+
redisctl profile set prod \
89+
--deployment cloud \
90+
--api-key "your-key" \
91+
--api-secret "your-secret" \
92+
--use-keyring
93+
94+
# The config file will contain references like:
95+
# api_key = "keyring:prod-api-key"
96+
# api_secret = "keyring:prod-api-secret"
97+
```
98+
6499
Example configuration file:
65100
```toml
66101
default_profile = "cloud-prod"

crates/redisctl/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ toml = { workspace = true }
4949
directories = { workspace = true }
5050
shellexpand = "3.1"
5151

52+
# Secure storage
53+
keyring = { version = "3.6", optional = true }
54+
5255
[target.'cfg(unix)'.dependencies]
5356
pager = "0.16"
5457

@@ -58,6 +61,7 @@ default = ["full"]
5861
full = ["cloud", "enterprise"]
5962
cloud = []
6063
enterprise = []
64+
secure-storage = ["dep:keyring"]
6165

6266
[dev-dependencies]
6367
assert_cmd = "2.0"

crates/redisctl/src/cli.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ pub enum ProfileCommands {
201201
/// Allow insecure connections (for Enterprise profiles)
202202
#[arg(long)]
203203
insecure: bool,
204+
205+
/// Store credentials in OS keyring instead of config file
206+
#[cfg(feature = "secure-storage")]
207+
#[arg(long)]
208+
use_keyring: bool,
204209
},
205210

206211
/// Remove a profile

crates/redisctl/src/config.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use std::fs;
1515
use std::path::PathBuf;
1616
use tracing::{debug, info, trace};
1717

18+
use crate::credential_store::CredentialStore;
19+
1820
/// Main configuration structure
1921
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
2022
pub struct Config {
@@ -116,6 +118,74 @@ impl Profile {
116118
}
117119
)
118120
}
121+
122+
/// Get resolved Cloud credentials (with keyring support)
123+
pub fn resolve_cloud_credentials(&self) -> Result<Option<(String, String, String)>> {
124+
match &self.credentials {
125+
ProfileCredentials::Cloud {
126+
api_key,
127+
api_secret,
128+
api_url,
129+
} => {
130+
let store = CredentialStore::new();
131+
132+
// Resolve each credential with environment variable fallback
133+
let resolved_key = store
134+
.get_credential(api_key, Some("REDIS_CLOUD_API_KEY"))
135+
.context("Failed to resolve API key")?;
136+
let resolved_secret = store
137+
.get_credential(api_secret, Some("REDIS_CLOUD_API_SECRET"))
138+
.context("Failed to resolve API secret")?;
139+
let resolved_url = store
140+
.get_credential(api_url, Some("REDIS_CLOUD_API_URL"))
141+
.context("Failed to resolve API URL")?;
142+
143+
Ok(Some((resolved_key, resolved_secret, resolved_url)))
144+
}
145+
_ => Ok(None),
146+
}
147+
}
148+
149+
/// Get resolved Enterprise credentials (with keyring support)
150+
#[allow(clippy::type_complexity)]
151+
pub fn resolve_enterprise_credentials(
152+
&self,
153+
) -> Result<Option<(String, String, Option<String>, bool)>> {
154+
match &self.credentials {
155+
ProfileCredentials::Enterprise {
156+
url,
157+
username,
158+
password,
159+
insecure,
160+
} => {
161+
let store = CredentialStore::new();
162+
163+
// Resolve each credential with environment variable fallback
164+
let resolved_url = store
165+
.get_credential(url, Some("REDIS_ENTERPRISE_URL"))
166+
.context("Failed to resolve URL")?;
167+
let resolved_username = store
168+
.get_credential(username, Some("REDIS_ENTERPRISE_USER"))
169+
.context("Failed to resolve username")?;
170+
let resolved_password = password
171+
.as_ref()
172+
.map(|p| {
173+
store
174+
.get_credential(p, Some("REDIS_ENTERPRISE_PASSWORD"))
175+
.context("Failed to resolve password")
176+
})
177+
.transpose()?;
178+
179+
Ok(Some((
180+
resolved_url,
181+
resolved_username,
182+
resolved_password,
183+
*insecure,
184+
)))
185+
}
186+
_ => Ok(None),
187+
}
188+
}
119189
}
120190

121191
impl Config {

crates/redisctl/src/connection.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,20 @@ impl ConnectionManager {
7373
});
7474
}
7575

76+
// Use the new resolve method which handles keyring lookup
7677
let (api_key, api_secret, api_url) = profile
77-
.cloud_credentials()
78+
.resolve_cloud_credentials()
79+
.context("Failed to resolve Cloud credentials")?
7880
.context("Profile is not configured for Redis Cloud")?;
7981

8082
// Check for partial overrides before consuming the Options
8183
let has_overrides =
8284
env_api_key.is_some() || env_api_secret.is_some() || env_api_url.is_some();
8385

8486
// Allow partial environment variable overrides
85-
let key = env_api_key.unwrap_or_else(|| api_key.to_string());
86-
let secret = env_api_secret.unwrap_or_else(|| api_secret.to_string());
87-
let url = env_api_url.unwrap_or_else(|| api_url.to_string());
87+
let key = env_api_key.unwrap_or(api_key);
88+
let secret = env_api_secret.unwrap_or(api_secret);
89+
let url = env_api_url.unwrap_or(api_url);
8890

8991
if has_overrides {
9092
debug!("Applied partial environment variable overrides");
@@ -173,8 +175,10 @@ impl ConnectionManager {
173175
});
174176
}
175177

178+
// Use the new resolve method which handles keyring lookup
176179
let (url, username, password, insecure) = profile
177-
.enterprise_credentials()
180+
.resolve_enterprise_credentials()
181+
.context("Failed to resolve Enterprise credentials")?
178182
.context("Profile is not configured for Redis Enterprise")?;
179183

180184
// Check for partial overrides before consuming the Options
@@ -184,9 +188,9 @@ impl ConnectionManager {
184188
|| env_insecure.is_some();
185189

186190
// Allow partial environment variable overrides
187-
let final_url = env_url.unwrap_or_else(|| url.to_string());
188-
let final_user = env_user.unwrap_or_else(|| username.to_string());
189-
let final_password = env_password.or_else(|| password.map(|p| p.to_string()));
191+
let final_url = env_url.unwrap_or(url);
192+
let final_user = env_user.unwrap_or(username);
193+
let final_password = env_password.or(password);
190194
let final_insecure = env_insecure
191195
.as_ref()
192196
.map(|s| s.to_lowercase() == "true" || s == "1")

0 commit comments

Comments
 (0)