Skip to main content

murk_cli/
github.rs

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