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            };
92            keys.push((recipient, normalized));
93        }
94    }
95
96    if keys.is_empty() {
97        return Err(GitHubError::NoKeys(username.into()));
98    }
99
100    Ok(keys)
101}
102
103/// Compute a SHA-256 fingerprint of an SSH public key string.
104///
105/// Returns a string like `SHA256:abc123...` (base64, no padding).
106pub fn fingerprint(key_string: &str) -> String {
107    use sha2::{Digest, Sha256};
108    let hash = Sha256::digest(key_string.as_bytes());
109    let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
110    format!("SHA256:{encoded}")
111}
112
113/// Check fetched keys against pinned fingerprints.
114///
115/// Returns Ok(()) if pins match or no pins exist (TOFU).
116/// Returns Err with a description of what changed if pins don't match.
117pub fn check_pins(
118    username: &str,
119    fetched_keys: &[(MurkRecipient, String)],
120    pinned: &[String],
121) -> Result<(), String> {
122    if pinned.is_empty() {
123        return Ok(()); // First use — trust on first use.
124    }
125
126    let fetched_fps: Vec<String> = fetched_keys.iter().map(|(_, k)| fingerprint(k)).collect();
127
128    let mut added: Vec<&str> = Vec::new();
129    let mut removed: Vec<&str> = Vec::new();
130
131    for fp in &fetched_fps {
132        if !pinned.contains(fp) {
133            added.push(fp);
134        }
135    }
136    for fp in pinned {
137        if !fetched_fps.contains(fp) {
138            removed.push(fp);
139        }
140    }
141
142    if added.is_empty() && removed.is_empty() {
143        return Ok(());
144    }
145
146    let mut msg = format!("github:{username} keys changed since last authorization\n");
147    for fp in &added {
148        let _ = writeln!(msg, "  + {fp}");
149    }
150    for fp in &removed {
151        let _ = writeln!(msg, "  - {fp}");
152    }
153    msg.push_str("use --force to accept the new keys");
154    Err(msg)
155}
156
157/// Classify an SSH key type for human-readable display.
158///
159/// Returns a short label like "ssh-ed25519" or "ssh-rsa" from
160/// the full key string.
161pub fn key_type_label(key_string: &str) -> &str {
162    key_string.split_whitespace().next().unwrap_or("ssh")
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn key_type_label_ed25519() {
171        assert_eq!(
172            key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
173            "ssh-ed25519"
174        );
175    }
176
177    #[test]
178    fn key_type_label_rsa() {
179        assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
180    }
181
182    #[test]
183    fn key_type_label_empty() {
184        assert_eq!(key_type_label(""), "ssh");
185    }
186
187    const TEST_ED25519_KEY: &str =
188        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJI7KsDGxx+I8XZQwtbgoEYDfuNd9fQ4MzcHHUmtIau9";
189
190    #[test]
191    fn parse_keys_ed25519() {
192        let body = format!("{TEST_ED25519_KEY}\n");
193        let keys = parse_github_keys(&body, "testuser").unwrap();
194        assert_eq!(keys.len(), 1);
195        assert!(keys[0].1.starts_with("ssh-ed25519 "));
196    }
197
198    #[test]
199    fn parse_keys_skips_ecdsa() {
200        let body = "ecdsa-sha2-nistp256 AAAAE2VjZHNh...\n";
201        let result = parse_github_keys(body, "testuser");
202        assert!(result.is_err());
203    }
204
205    #[test]
206    fn parse_keys_skips_blank_lines() {
207        let body = format!("\n\n{TEST_ED25519_KEY}\n\n");
208        let keys = parse_github_keys(&body, "testuser").unwrap();
209        assert_eq!(keys.len(), 1);
210    }
211
212    #[test]
213    fn parse_keys_empty_body() {
214        let result = parse_github_keys("", "testuser");
215        assert!(result.is_err());
216    }
217
218    #[test]
219    fn parse_keys_strips_comment() {
220        let body = format!("{TEST_ED25519_KEY} user@host\n");
221        let keys = parse_github_keys(&body, "testuser").unwrap();
222        assert!(!keys[0].1.contains("user@host"));
223    }
224
225    #[test]
226    fn fetch_rejects_empty_username() {
227        let result = fetch_keys("");
228        assert!(result.is_err());
229        assert!(
230            result
231                .unwrap_err()
232                .to_string()
233                .contains("invalid GitHub username")
234        );
235    }
236
237    #[test]
238    fn fetch_rejects_long_username() {
239        let long = "a".repeat(40);
240        let result = fetch_keys(&long);
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn fetch_rejects_path_traversal() {
246        let result = fetch_keys("../etc/passwd");
247        assert!(result.is_err());
248    }
249
250    #[test]
251    fn github_error_display() {
252        let e = GitHubError::Fetch("connection refused".into());
253        assert!(e.to_string().contains("connection refused"));
254
255        let e = GitHubError::NoKeys("alice".into());
256        assert!(e.to_string().contains("alice"));
257    }
258}