Skip to main content

github_mcp/core/
credential_storage.rs

1// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
2
3use 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
14/// Resolves the user's home directory cross-platform: `HOME` (Unix/macOS),
15/// falling back to `USERPROFILE` (Windows), falling back to the current
16/// directory if neither is set — without this fallback, Windows deployments
17/// silently resolve config/credential paths to `.` (cwd) instead of erroring
18/// or using the user's actual profile directory.
19pub fn resolve_home_dir() -> PathBuf {
20    std::env::var_os("HOME")
21        .or_else(|| std::env::var_os("USERPROFILE"))
22        .map(PathBuf::from)
23        .unwrap_or_else(|| PathBuf::from("."))
24}
25
26fn config_dir() -> PathBuf {
27    resolve_home_dir().join(".github-mcp")
28}
29
30fn fallback_file() -> PathBuf {
31    config_dir().join("credentials.enc")
32}
33
34/// Derived from a machine/user-specific value so the encrypted fallback
35/// file isn't portable to another machine by design, mirroring the OS
36/// keychain's own implicit scoping — REQ-2.3.5. AES-256-GCM (authenticated
37/// encryption) rather than `targets::typescript`'s AES-256-CBC: an
38/// equally-simple choice available in this crate's toolchain that also
39/// detects tampering, which CBC alone does not.
40fn encryption_key() -> [u8; 32] {
41    let home = resolve_home_dir();
42    let mut hasher = Sha256::new();
43    hasher.update(home.to_string_lossy().as_bytes());
44    hasher.update(SERVICE_NAME.as_bytes());
45    hasher.finalize().into()
46}
47
48fn to_hex(bytes: &[u8]) -> String {
49    bytes.iter().map(|b| format!("{b:02x}")).collect()
50}
51
52fn from_hex(input: &str) -> anyhow::Result<Vec<u8>> {
53    if !input.len().is_multiple_of(2) {
54        anyhow::bail!("odd-length hex string");
55    }
56    (0..input.len())
57        .step_by(2)
58        .map(|i| u8::from_str_radix(&input[i..i + 2], 16).map_err(anyhow::Error::from))
59        .collect()
60}
61
62fn read_store() -> HashMap<String, String> {
63    fs::read_to_string(fallback_file())
64        .ok()
65        .and_then(|contents| serde_json::from_str(&contents).ok())
66        .unwrap_or_default()
67}
68
69fn write_store(store: &HashMap<String, String>) -> anyhow::Result<()> {
70    let dir = config_dir();
71    fs::create_dir_all(&dir)?;
72    let contents = serde_json::to_string(store)?;
73    fs::write(fallback_file(), contents)?;
74
75    #[cfg(unix)]
76    {
77        use std::os::unix::fs::PermissionsExt;
78        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
79        fs::set_permissions(fallback_file(), fs::Permissions::from_mode(0o600))?;
80    }
81
82    Ok(())
83}
84
85fn save_to_file(account: &str, value: &str) -> anyhow::Result<()> {
86    let key_bytes = encryption_key();
87    let key: &Key<Aes256Gcm> = (&key_bytes).into();
88    let cipher = Aes256Gcm::new(key);
89    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
90    let ciphertext = cipher
91        .encrypt(&nonce, value.as_bytes())
92        .map_err(|_| anyhow::anyhow!("failed to encrypt credential"))?;
93
94    let mut store = read_store();
95    store.insert(
96        account.to_string(),
97        format!("{}:{}", to_hex(&nonce), to_hex(&ciphertext)),
98    );
99    write_store(&store)
100}
101
102fn load_from_file(account: &str) -> anyhow::Result<Option<String>> {
103    let store = read_store();
104    let Some(raw) = store.get(account) else {
105        return Ok(None);
106    };
107    let (nonce_hex, ciphertext_hex) = raw
108        .split_once(':')
109        .ok_or_else(|| anyhow::anyhow!("corrupt credential entry for '{account}'"))?;
110
111    let key_bytes = encryption_key();
112    let key: &Key<Aes256Gcm> = (&key_bytes).into();
113    let cipher = Aes256Gcm::new(key);
114    let nonce_bytes = from_hex(nonce_hex)?;
115    let nonce = Nonce::from_slice(&nonce_bytes);
116    let ciphertext = from_hex(ciphertext_hex)?;
117    let plaintext = cipher
118        .decrypt(nonce, ciphertext.as_ref())
119        .map_err(|_| anyhow::anyhow!("failed to decrypt credential for '{account}'"))?;
120
121    Ok(Some(String::from_utf8(plaintext)?))
122}
123
124fn delete_from_file(account: &str) -> anyhow::Result<()> {
125    let mut store = read_store();
126    if store.remove(account).is_some() {
127        write_store(&store)?;
128    }
129    Ok(())
130}
131
132/// Persists a credential to the OS-native keychain when available (macOS
133/// Keychain, Windows Credential Manager, Linux Secret Service via the
134/// `keyring` crate), falling back to an AES-256-GCM-encrypted local file
135/// when the platform backend is unavailable (e.g. no D-Bus secret-service
136/// daemon running, common in minimal containers) — REQ-2.3.5.
137pub fn save_credential(account: &str, value: &str) -> anyhow::Result<()> {
138    match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.set_password(value)) {
139        Ok(()) => Ok(()),
140        Err(err) => {
141            tracing::warn!(error = %err, "OS keychain unavailable; falling back to encrypted file storage");
142            save_to_file(account, value)
143        }
144    }
145}
146
147pub fn load_credential(account: &str) -> anyhow::Result<Option<String>> {
148    match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.get_password()) {
149        Ok(password) => Ok(Some(password)),
150        // A clean "no entry" from the keychain doesn't rule out a
151        // credential that only ever landed in the encrypted-file fallback
152        // (e.g. saved while the keychain backend was unavailable) — always
153        // consult the file before reporting nothing found, whether the
154        // keychain returned `NoEntry` or a harder error.
155        Err(_) => load_from_file(account),
156    }
157}
158
159pub fn delete_credential(account: &str) -> anyhow::Result<()> {
160    match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.delete_credential()) {
161        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
162        Err(_) => delete_from_file(account),
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn hex_round_trips_arbitrary_bytes() {
172        let bytes = [0u8, 1, 15, 16, 255, 42];
173        assert_eq!(from_hex(&to_hex(&bytes)).unwrap(), bytes);
174    }
175
176    #[test]
177    fn file_fallback_round_trips_a_credential() {
178        // Exercises the encrypted-file path directly rather than through
179        // `save_credential`/`load_credential`, since the OS keychain (the
180        // primary path) may or may not be available in the test
181        // environment and this test should be deterministic either way.
182        let dir = tempfile::tempdir().unwrap();
183        // SAFETY: test-only env mutation, single-threaded within this test.
184        unsafe {
185            std::env::set_var("HOME", dir.path());
186        }
187
188        save_to_file("test-account", "s3cr3t").unwrap();
189        let loaded = load_from_file("test-account").unwrap();
190        assert_eq!(loaded.as_deref(), Some("s3cr3t"));
191
192        delete_from_file("test-account").unwrap();
193        assert_eq!(load_from_file("test-account").unwrap(), None);
194    }
195}