github_mcp/core/
credential_storage.rs1use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
8use aes_gcm::{Aes256Gcm, Key, Nonce};
9use keyring::Entry;
10use sha2::{Digest, Sha256};
11
12const SERVICE_NAME: &str = "github-mcp";
13
14fn config_dir() -> PathBuf {
15 let home = std::env::var_os("HOME")
16 .map(PathBuf::from)
17 .unwrap_or_else(|| PathBuf::from("."));
18 home.join(".github-mcp")
19}
20
21fn fallback_file() -> PathBuf {
22 config_dir().join("credentials.enc")
23}
24
25fn encryption_key() -> [u8; 32] {
32 let home = std::env::var("HOME").unwrap_or_default();
33 let mut hasher = Sha256::new();
34 hasher.update(home.as_bytes());
35 hasher.update(SERVICE_NAME.as_bytes());
36 hasher.finalize().into()
37}
38
39fn to_hex(bytes: &[u8]) -> String {
40 bytes.iter().map(|b| format!("{b:02x}")).collect()
41}
42
43fn from_hex(input: &str) -> anyhow::Result<Vec<u8>> {
44 if !input.len().is_multiple_of(2) {
45 anyhow::bail!("odd-length hex string");
46 }
47 (0..input.len())
48 .step_by(2)
49 .map(|i| u8::from_str_radix(&input[i..i + 2], 16).map_err(anyhow::Error::from))
50 .collect()
51}
52
53fn read_store() -> HashMap<String, String> {
54 fs::read_to_string(fallback_file())
55 .ok()
56 .and_then(|contents| serde_json::from_str(&contents).ok())
57 .unwrap_or_default()
58}
59
60fn write_store(store: &HashMap<String, String>) -> anyhow::Result<()> {
61 let dir = config_dir();
62 fs::create_dir_all(&dir)?;
63 let contents = serde_json::to_string(store)?;
64 fs::write(fallback_file(), contents)?;
65
66 #[cfg(unix)]
67 {
68 use std::os::unix::fs::PermissionsExt;
69 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
70 fs::set_permissions(fallback_file(), fs::Permissions::from_mode(0o600))?;
71 }
72
73 Ok(())
74}
75
76fn save_to_file(account: &str, value: &str) -> anyhow::Result<()> {
77 let key_bytes = encryption_key();
78 let key: &Key<Aes256Gcm> = (&key_bytes).into();
79 let cipher = Aes256Gcm::new(key);
80 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
81 let ciphertext = cipher
82 .encrypt(&nonce, value.as_bytes())
83 .map_err(|_| anyhow::anyhow!("failed to encrypt credential"))?;
84
85 let mut store = read_store();
86 store.insert(
87 account.to_string(),
88 format!("{}:{}", to_hex(&nonce), to_hex(&ciphertext)),
89 );
90 write_store(&store)
91}
92
93fn load_from_file(account: &str) -> anyhow::Result<Option<String>> {
94 let store = read_store();
95 let Some(raw) = store.get(account) else {
96 return Ok(None);
97 };
98 let (nonce_hex, ciphertext_hex) = raw
99 .split_once(':')
100 .ok_or_else(|| anyhow::anyhow!("corrupt credential entry for '{account}'"))?;
101
102 let key_bytes = encryption_key();
103 let key: &Key<Aes256Gcm> = (&key_bytes).into();
104 let cipher = Aes256Gcm::new(key);
105 let nonce_bytes = from_hex(nonce_hex)?;
106 let nonce = Nonce::from_slice(&nonce_bytes);
107 let ciphertext = from_hex(ciphertext_hex)?;
108 let plaintext = cipher
109 .decrypt(nonce, ciphertext.as_ref())
110 .map_err(|_| anyhow::anyhow!("failed to decrypt credential for '{account}'"))?;
111
112 Ok(Some(String::from_utf8(plaintext)?))
113}
114
115fn delete_from_file(account: &str) -> anyhow::Result<()> {
116 let mut store = read_store();
117 if store.remove(account).is_some() {
118 write_store(&store)?;
119 }
120 Ok(())
121}
122
123pub fn save_credential(account: &str, value: &str) -> anyhow::Result<()> {
129 match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.set_password(value)) {
130 Ok(()) => Ok(()),
131 Err(err) => {
132 tracing::warn!(error = %err, "OS keychain unavailable; falling back to encrypted file storage");
133 save_to_file(account, value)
134 }
135 }
136}
137
138pub fn load_credential(account: &str) -> anyhow::Result<Option<String>> {
139 match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.get_password()) {
140 Ok(password) => Ok(Some(password)),
141 Err(keyring::Error::NoEntry) => Ok(None),
142 Err(_) => load_from_file(account),
143 }
144}
145
146pub fn delete_credential(account: &str) -> anyhow::Result<()> {
147 match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.delete_credential()) {
148 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
149 Err(_) => delete_from_file(account),
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn hex_round_trips_arbitrary_bytes() {
159 let bytes = [0u8, 1, 15, 16, 255, 42];
160 assert_eq!(from_hex(&to_hex(&bytes)).unwrap(), bytes);
161 }
162
163 #[test]
164 fn file_fallback_round_trips_a_credential() {
165 let dir = tempfile::tempdir().unwrap();
170 unsafe {
172 std::env::set_var("HOME", dir.path());
173 }
174
175 save_to_file("test-account", "s3cr3t").unwrap();
176 let loaded = load_from_file("test-account").unwrap();
177 assert_eq!(loaded.as_deref(), Some("s3cr3t"));
178
179 delete_from_file("test-account").unwrap();
180 assert_eq!(load_from_file("test-account").unwrap(), None);
181 }
182}