Skip to main content

murk_cli/
github.rs

1//! GitHub SSH key fetching for `murk authorize github:username`.
2
3use std::fmt::Write;
4use std::time::Duration;
5
6use base64::Engine;
7
8use crate::crypto::{self, MurkRecipient};
9
10/// Errors that can occur when fetching GitHub SSH keys.
11#[derive(Debug)]
12pub enum GitHubError {
13    /// HTTP request failed.
14    Fetch(String),
15    /// No supported SSH keys found for this user.
16    NoKeys(String),
17}
18
19impl std::fmt::Display for GitHubError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            GitHubError::Fetch(msg) => write!(f, "GitHub key fetch failed: {msg}"),
23            GitHubError::NoKeys(user) => write!(
24                f,
25                "no supported SSH keys found for GitHub user {user} (need ed25519 or rsa)"
26            ),
27        }
28    }
29}
30
31/// Fetch all SSH public keys for a GitHub user.
32///
33/// Hits `https://github.com/{username}.keys` (no auth needed) and parses
34/// each line as an SSH public key. Returns all valid keys as recipients
35/// paired with the key type string (e.g., "ssh-ed25519").
36///
37/// Filters to supported types only (ed25519 and rsa). Unsupported key
38/// types (ecdsa, sk-ssh-*) are silently skipped.
39pub fn fetch_keys(username: &str) -> Result<Vec<(MurkRecipient, String)>, GitHubError> {
40    // GitHub usernames: alphanumeric + hyphens, 1-39 chars, no path traversal.
41    if username.is_empty()
42        || username.len() > 39
43        || !username
44            .chars()
45            .all(|c| c.is_ascii_alphanumeric() || c == '-')
46    {
47        return Err(GitHubError::Fetch(format!(
48            "invalid GitHub username: {username}"
49        )));
50    }
51
52    let url = format!("https://github.com/{username}.keys");
53
54    // Defense-in-depth against a compromised or MITM'd upstream:
55    // - max_redirects(0) refuses redirects so we cannot be steered to an
56    //   internal host or cloud metadata service via a 30x response.
57    // - timeout_global caps the whole exchange.
58    // - proxy(None) ignores HTTP_PROXY/HTTPS_PROXY/ALL_PROXY env vars. ureq v3
59    //   honours them by default via Config::default(); a malicious proxy in the
60    //   user's environment could otherwise MITM key fetches before TOFU pinning
61    //   takes effect on first authorize.
62    // - ureq v3 does not retry transient errors by default, so a network
63    //   failure fails closed without quietly hammering the network.
64    // - body limit caps memory; GitHub allows up to 256 keys per user, ~2 KB
65    //   each in the worst case, so 1 MB is generous but bounded.
66    let agent: ureq::Agent = ureq::Agent::config_builder()
67        .max_redirects(0)
68        .timeout_global(Some(Duration::from_secs(30)))
69        .proxy(None)
70        .build()
71        .into();
72
73    let body = agent
74        .get(&url)
75        .call()
76        .map_err(|e| GitHubError::Fetch(format!("{url}: {e}")))?
77        .body_mut()
78        .with_config()
79        .limit(1024 * 1024)
80        .read_to_string()
81        .map_err(|e| GitHubError::Fetch(format!("reading response: {e}")))?;
82
83    if body.trim().is_empty() {
84        return Err(GitHubError::NoKeys(username.into()));
85    }
86
87    parse_github_keys(&body, username)
88}
89
90/// Parse SSH keys from a GitHub `.keys` response body.
91///
92/// Filters to ed25519 and rsa only. Normalizes by stripping comments.
93pub fn parse_github_keys(
94    body: &str,
95    username: &str,
96) -> Result<Vec<(MurkRecipient, String)>, GitHubError> {
97    let mut keys = Vec::new();
98    for line in body.lines() {
99        let line = line.trim();
100        if line.is_empty() {
101            continue;
102        }
103
104        let key_type = line.split_whitespace().next().unwrap_or("");
105
106        if key_type != "ssh-ed25519" && key_type != "ssh-rsa" {
107            continue;
108        }
109
110        if let Ok(recipient) = crypto::parse_recipient(line) {
111            let normalized = match &recipient {
112                MurkRecipient::Ssh(r) => r.to_string(),
113                MurkRecipient::Age(_) => unreachable!("SSH key parsed as age key"),
114                MurkRecipient::Plugin(_) => unreachable!("SSH key parsed as plugin recipient"),
115            };
116            keys.push((recipient, normalized));
117        }
118    }
119
120    if keys.is_empty() {
121        return Err(GitHubError::NoKeys(username.into()));
122    }
123
124    Ok(keys)
125}
126
127/// Compute a SHA-256 fingerprint of an SSH public key string.
128///
129/// Returns a string like `SHA256:abc123...` (base64, no padding).
130pub fn fingerprint(key_string: &str) -> String {
131    use sha2::{Digest, Sha256};
132    let hash = Sha256::digest(key_string.as_bytes());
133    let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
134    format!("SHA256:{encoded}")
135}
136
137/// Check fetched keys against pinned fingerprints.
138///
139/// Returns Ok(()) if pins match or no pins exist (TOFU).
140/// Returns Err with a description of what changed if pins don't match.
141pub fn check_pins(
142    username: &str,
143    fetched_keys: &[(MurkRecipient, String)],
144    pinned: &[String],
145) -> Result<(), String> {
146    if pinned.is_empty() {
147        return Ok(()); // First use — trust on first use.
148    }
149
150    let fetched_fps: Vec<String> = fetched_keys.iter().map(|(_, k)| fingerprint(k)).collect();
151
152    let mut added: Vec<&str> = Vec::new();
153    let mut removed: Vec<&str> = Vec::new();
154
155    for fp in &fetched_fps {
156        if !pinned.contains(fp) {
157            added.push(fp);
158        }
159    }
160    for fp in pinned {
161        if !fetched_fps.contains(fp) {
162            removed.push(fp);
163        }
164    }
165
166    if added.is_empty() && removed.is_empty() {
167        return Ok(());
168    }
169
170    let mut msg = format!("github:{username} keys changed since last authorization\n");
171    for fp in &added {
172        let _ = writeln!(msg, "  + {fp}");
173    }
174    for fp in &removed {
175        let _ = writeln!(msg, "  - {fp}");
176    }
177    msg.push_str("use --force to accept the new keys");
178    Err(msg)
179}
180
181/// Classify an SSH key type for human-readable display.
182///
183/// Returns a short label like "ssh-ed25519" or "ssh-rsa" from
184/// the full key string.
185pub fn key_type_label(key_string: &str) -> &str {
186    key_string.split_whitespace().next().unwrap_or("ssh")
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn key_type_label_ed25519() {
195        assert_eq!(
196            key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
197            "ssh-ed25519"
198        );
199    }
200
201    #[test]
202    fn key_type_label_rsa() {
203        assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
204    }
205
206    #[test]
207    fn key_type_label_empty() {
208        assert_eq!(key_type_label(""), "ssh");
209    }
210
211    const TEST_ED25519_KEY: &str =
212        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJI7KsDGxx+I8XZQwtbgoEYDfuNd9fQ4MzcHHUmtIau9";
213
214    #[test]
215    fn parse_keys_ed25519() {
216        let body = format!("{TEST_ED25519_KEY}\n");
217        let keys = parse_github_keys(&body, "testuser").unwrap();
218        assert_eq!(keys.len(), 1);
219        assert!(keys[0].1.starts_with("ssh-ed25519 "));
220    }
221
222    #[test]
223    fn parse_keys_skips_ecdsa() {
224        let body = "ecdsa-sha2-nistp256 AAAAE2VjZHNh...\n";
225        let result = parse_github_keys(body, "testuser");
226        assert!(result.is_err());
227    }
228
229    #[test]
230    fn parse_keys_skips_blank_lines() {
231        let body = format!("\n\n{TEST_ED25519_KEY}\n\n");
232        let keys = parse_github_keys(&body, "testuser").unwrap();
233        assert_eq!(keys.len(), 1);
234    }
235
236    #[test]
237    fn parse_keys_empty_body() {
238        let result = parse_github_keys("", "testuser");
239        assert!(result.is_err());
240    }
241
242    #[test]
243    fn parse_keys_strips_comment() {
244        let body = format!("{TEST_ED25519_KEY} user@host\n");
245        let keys = parse_github_keys(&body, "testuser").unwrap();
246        assert!(!keys[0].1.contains("user@host"));
247    }
248
249    #[test]
250    fn fetch_rejects_empty_username() {
251        let result = fetch_keys("");
252        assert!(result.is_err());
253        assert!(
254            result
255                .unwrap_err()
256                .to_string()
257                .contains("invalid GitHub username")
258        );
259    }
260
261    #[test]
262    fn fetch_rejects_long_username() {
263        let long = "a".repeat(40);
264        let result = fetch_keys(&long);
265        assert!(result.is_err());
266    }
267
268    #[test]
269    fn fetch_rejects_path_traversal() {
270        let result = fetch_keys("../etc/passwd");
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn github_error_display() {
276        let e = GitHubError::Fetch("connection refused".into());
277        assert!(e.to_string().contains("connection refused"));
278
279        let e = GitHubError::NoKeys("alice".into());
280        assert!(e.to_string().contains("alice"));
281    }
282
283    // ── check_pins tests ──
284
285    #[test]
286    fn check_pins_tofu_accepts_any_keys() {
287        let body = format!("{TEST_ED25519_KEY}\n");
288        let keys = parse_github_keys(&body, "alice").unwrap();
289        assert!(check_pins("alice", &keys, &[]).is_ok());
290    }
291
292    #[test]
293    fn check_pins_matching_passes() {
294        let body = format!("{TEST_ED25519_KEY}\n");
295        let keys = parse_github_keys(&body, "alice").unwrap();
296        let pins: Vec<String> = keys.iter().map(|(_, k)| fingerprint(k)).collect();
297        assert!(check_pins("alice", &keys, &pins).is_ok());
298    }
299
300    #[test]
301    fn check_pins_detects_added_key() {
302        let body = format!("{TEST_ED25519_KEY}\n");
303        let keys = parse_github_keys(&body, "alice").unwrap();
304        // Pinned list is empty (but not TOFU — simulate having had a different key).
305        let old_pins = vec!["SHA256:fakefakefake".to_string()];
306        let result = check_pins("alice", &keys, &old_pins);
307        assert!(result.is_err());
308        let msg = result.unwrap_err();
309        assert!(msg.contains('+'));
310        assert!(msg.contains('-'));
311    }
312
313    #[test]
314    fn check_pins_detects_removed_key() {
315        // No keys fetched, but we had a pin.
316        let old_pins = vec!["SHA256:oldkey".to_string()];
317        let result = check_pins("alice", &[], &old_pins);
318        assert!(result.is_err());
319        assert!(result.unwrap_err().contains("- SHA256:oldkey"));
320    }
321
322    #[test]
323    fn fingerprint_is_deterministic() {
324        let fp1 = fingerprint(TEST_ED25519_KEY);
325        let fp2 = fingerprint(TEST_ED25519_KEY);
326        assert_eq!(fp1, fp2);
327        assert!(fp1.starts_with("SHA256:"));
328    }
329}