Skip to content

Commit dd3f8d3

Browse files
committed
feat: add secure API key storage via system keychain
Optional keyring integration behind `secure-storage` feature flag. Adds set-key/get-key commands and keyring fallback in config loading chain (CLI > env > keyring > config file > defaults).
1 parent ae5c2d1 commit dd3f8d3

6 files changed

Lines changed: 135 additions & 0 deletions

File tree

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.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,16 @@ indicatif = "0.18"
5656
tracing = "0.1"
5757
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
5858

59+
# Secure storage (optional)
60+
keyring = { version = "3", optional = true }
61+
5962
# Utilities
6063
regex = "1.12"
6164

65+
[features]
66+
default = []
67+
secure-storage = ["keyring"]
68+
6269
[profile.release]
6370
lto = true
6471
strip = true

src/app.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ impl App {
261261
clap_complete::generate(*shell, &mut cmd, "commitbee", &mut std::io::stdout());
262262
Ok(())
263263
}
264+
#[cfg(feature = "secure-storage")]
265+
Commands::SetKey { provider } => self.set_api_key(provider),
266+
#[cfg(feature = "secure-storage")]
267+
Commands::GetKey { provider } => self.get_api_key(provider),
264268
}
265269
}
266270

@@ -341,6 +345,88 @@ impl App {
341345
Ok(())
342346
}
343347

348+
// ─── Keyring Commands ───
349+
350+
#[cfg(feature = "secure-storage")]
351+
fn set_api_key(&self, provider: &str) -> Result<()> {
352+
let provider_lower = provider.to_lowercase();
353+
if provider_lower != "openai" && provider_lower != "anthropic" {
354+
return Err(Error::Config(format!(
355+
"Keyring storage is only for cloud providers (openai, anthropic), got '{}'",
356+
provider
357+
)));
358+
}
359+
360+
eprintln!(
361+
"Enter API key for {} (input will be hidden):",
362+
style(&provider_lower).bold()
363+
);
364+
365+
let key = dialoguer::Password::new()
366+
.with_prompt("API key")
367+
.interact()
368+
.map_err(|e| Error::Dialog(e.to_string()))?;
369+
370+
if key.trim().is_empty() {
371+
return Err(Error::Config("API key cannot be empty".into()));
372+
}
373+
374+
let entry = keyring::Entry::new("commitbee", &provider_lower)
375+
.map_err(|e| Error::Keyring(e.to_string()))?;
376+
entry
377+
.set_password(&key)
378+
.map_err(|e| Error::Keyring(e.to_string()))?;
379+
380+
eprintln!(
381+
"{} API key stored for {}",
382+
style("✓").green().bold(),
383+
provider_lower
384+
);
385+
Ok(())
386+
}
387+
388+
#[cfg(feature = "secure-storage")]
389+
fn get_api_key(&self, provider: &str) -> Result<()> {
390+
let provider_lower = provider.to_lowercase();
391+
if provider_lower != "openai" && provider_lower != "anthropic" {
392+
return Err(Error::Config(format!(
393+
"Keyring storage is only for cloud providers (openai, anthropic), got '{}'",
394+
provider
395+
)));
396+
}
397+
398+
let entry = keyring::Entry::new("commitbee", &provider_lower)
399+
.map_err(|e| Error::Keyring(e.to_string()))?;
400+
401+
match entry.get_password() {
402+
Ok(_) => {
403+
eprintln!(
404+
"{} API key for {} is stored in keychain",
405+
style("✓").green().bold(),
406+
provider_lower
407+
);
408+
}
409+
Err(keyring::Error::NoEntry) => {
410+
eprintln!(
411+
"{} No API key found for {} in keychain",
412+
style("✗").red().bold(),
413+
provider_lower
414+
);
415+
eprintln!(
416+
" Store one with: {}",
417+
style(format!("commitbee set-key {}", provider_lower)).yellow()
418+
);
419+
}
420+
Err(e) => {
421+
return Err(Error::Keyring(e.to_string()));
422+
}
423+
}
424+
425+
Ok(())
426+
}
427+
428+
// ─── Output Helpers ───
429+
344430
fn print_status(&self, msg: &str) {
345431
eprintln!("{} {}", style("→").cyan(), msg);
346432
}

src/cli.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,16 @@ pub enum Commands {
5555
#[arg(value_enum)]
5656
shell: clap_complete::Shell,
5757
},
58+
/// Store API key in system keychain
59+
#[cfg(feature = "secure-storage")]
60+
SetKey {
61+
/// Provider to store key for (openai, anthropic)
62+
provider: String,
63+
},
64+
/// Check if API key exists in system keychain
65+
#[cfg(feature = "secure-storage")]
66+
GetKey {
67+
/// Provider to check key for (openai, anthropic)
68+
provider: String,
69+
},
5870
}

src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,17 @@ impl Config {
189189
};
190190
}
191191

192+
// Keyring fallback (if still no key and secure-storage feature is enabled)
193+
#[cfg(feature = "secure-storage")]
194+
if config.api_key.is_none() && config.provider != Provider::Ollama {
195+
let provider_name = config.provider.to_string();
196+
if let Ok(entry) = keyring::Entry::new("commitbee", &provider_name) {
197+
if let Ok(key) = entry.get_password() {
198+
config.api_key = Some(key);
199+
}
200+
}
201+
}
202+
192203
// CLI overrides (highest priority)
193204
config.apply_cli(cli);
194205
config.validate()?;

src/error.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ pub enum Error {
8989

9090
#[error("Dialog error: {0}")]
9191
Dialog(String),
92+
93+
#[cfg(feature = "secure-storage")]
94+
#[error("Keyring error: {0}")]
95+
#[diagnostic(
96+
code(commitbee::keyring::error),
97+
help("Check your system keychain configuration")
98+
)]
99+
Keyring(String),
92100
}
93101

94102
impl From<dialoguer::Error> for Error {

0 commit comments

Comments
 (0)