use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use keyring::Entry;
use sha2::{Digest, Sha256};
const SERVICE_NAME: &str = "github-mcp";
fn config_dir() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".github-mcp")
}
fn fallback_file() -> PathBuf {
config_dir().join("credentials.enc")
}
fn encryption_key() -> [u8; 32] {
let home = std::env::var("HOME").unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(home.as_bytes());
hasher.update(SERVICE_NAME.as_bytes());
hasher.finalize().into()
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex(input: &str) -> anyhow::Result<Vec<u8>> {
if !input.len().is_multiple_of(2) {
anyhow::bail!("odd-length hex string");
}
(0..input.len())
.step_by(2)
.map(|i| u8::from_str_radix(&input[i..i + 2], 16).map_err(anyhow::Error::from))
.collect()
}
fn read_store() -> HashMap<String, String> {
fs::read_to_string(fallback_file())
.ok()
.and_then(|contents| serde_json::from_str(&contents).ok())
.unwrap_or_default()
}
fn write_store(store: &HashMap<String, String>) -> anyhow::Result<()> {
let dir = config_dir();
fs::create_dir_all(&dir)?;
let contents = serde_json::to_string(store)?;
fs::write(fallback_file(), contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
fs::set_permissions(fallback_file(), fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn save_to_file(account: &str, value: &str) -> anyhow::Result<()> {
let key_bytes = encryption_key();
let key: &Key<Aes256Gcm> = (&key_bytes).into();
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, value.as_bytes())
.map_err(|_| anyhow::anyhow!("failed to encrypt credential"))?;
let mut store = read_store();
store.insert(
account.to_string(),
format!("{}:{}", to_hex(&nonce), to_hex(&ciphertext)),
);
write_store(&store)
}
fn load_from_file(account: &str) -> anyhow::Result<Option<String>> {
let store = read_store();
let Some(raw) = store.get(account) else {
return Ok(None);
};
let (nonce_hex, ciphertext_hex) = raw
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("corrupt credential entry for '{account}'"))?;
let key_bytes = encryption_key();
let key: &Key<Aes256Gcm> = (&key_bytes).into();
let cipher = Aes256Gcm::new(key);
let nonce_bytes = from_hex(nonce_hex)?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = from_hex(ciphertext_hex)?;
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|_| anyhow::anyhow!("failed to decrypt credential for '{account}'"))?;
Ok(Some(String::from_utf8(plaintext)?))
}
fn delete_from_file(account: &str) -> anyhow::Result<()> {
let mut store = read_store();
if store.remove(account).is_some() {
write_store(&store)?;
}
Ok(())
}
pub fn save_credential(account: &str, value: &str) -> anyhow::Result<()> {
match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.set_password(value)) {
Ok(()) => Ok(()),
Err(err) => {
tracing::warn!(error = %err, "OS keychain unavailable; falling back to encrypted file storage");
save_to_file(account, value)
}
}
}
pub fn load_credential(account: &str) -> anyhow::Result<Option<String>> {
match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.get_password()) {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(_) => load_from_file(account),
}
}
pub fn delete_credential(account: &str) -> anyhow::Result<()> {
match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.delete_credential()) {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(_) => delete_from_file(account),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trips_arbitrary_bytes() {
let bytes = [0u8, 1, 15, 16, 255, 42];
assert_eq!(from_hex(&to_hex(&bytes)).unwrap(), bytes);
}
#[test]
fn file_fallback_round_trips_a_credential() {
let dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("HOME", dir.path());
}
save_to_file("test-account", "s3cr3t").unwrap();
let loaded = load_from_file("test-account").unwrap();
assert_eq!(loaded.as_deref(), Some("s3cr3t"));
delete_from_file("test-account").unwrap();
assert_eq!(load_from_file("test-account").unwrap(), None);
}
}