Skip to main content

murk_cli/
github.rs

1//! GitHub SSH key fetching for `murk authorize github:username`.
2
3use crate::crypto::{self, MurkRecipient};
4
5/// Errors that can occur when fetching GitHub SSH keys.
6#[derive(Debug)]
7pub enum GitHubError {
8    /// HTTP request failed.
9    Fetch(String),
10    /// No supported SSH keys found for this user.
11    NoKeys(String),
12}
13
14impl std::fmt::Display for GitHubError {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            GitHubError::Fetch(msg) => write!(f, "GitHub key fetch failed: {msg}"),
18            GitHubError::NoKeys(user) => write!(
19                f,
20                "no supported SSH keys found for GitHub user {user} (need ed25519 or rsa)"
21            ),
22        }
23    }
24}
25
26/// Fetch all SSH public keys for a GitHub user.
27///
28/// Hits `https://github.com/{username}.keys` (no auth needed) and parses
29/// each line as an SSH public key. Returns all valid keys as recipients
30/// paired with the key type string (e.g., "ssh-ed25519").
31///
32/// Filters to supported types only (ed25519 and rsa). Unsupported key
33/// types (ecdsa, sk-ssh-*) are silently skipped.
34pub fn fetch_keys(username: &str) -> Result<Vec<(MurkRecipient, String)>, GitHubError> {
35    // GitHub usernames: alphanumeric + hyphens, no path traversal.
36    if !username
37        .chars()
38        .all(|c| c.is_ascii_alphanumeric() || c == '-')
39        || username.is_empty()
40    {
41        return Err(GitHubError::Fetch(format!(
42            "invalid GitHub username: {username}"
43        )));
44    }
45
46    let url = format!("https://github.com/{username}.keys");
47
48    let body = ureq::get(&url)
49        .call()
50        .map_err(|e| GitHubError::Fetch(format!("{url}: {e}")))?
51        .into_body()
52        .read_to_string()
53        .map_err(|e| GitHubError::Fetch(format!("reading response: {e}")))?;
54
55    if body.trim().is_empty() {
56        return Err(GitHubError::NoKeys(username.into()));
57    }
58
59    let mut keys = Vec::new();
60    for line in body.lines() {
61        let line = line.trim();
62        if line.is_empty() {
63            continue;
64        }
65
66        // Extract key type (first space-delimited token).
67        let key_type = line.split_whitespace().next().unwrap_or("");
68
69        // Only accept ed25519 and rsa — skip ecdsa, sk-ssh-*, etc.
70        if key_type != "ssh-ed25519" && key_type != "ssh-rsa" {
71            continue;
72        }
73
74        // Normalize: parse and re-serialize (strips any trailing comment).
75        if let Ok(recipient) = crypto::parse_recipient(line) {
76            // Use the normalized (comment-stripped) key string.
77            let normalized = match &recipient {
78                MurkRecipient::Ssh(r) => r.to_string(),
79                MurkRecipient::Age(_) => unreachable!("SSH key parsed as age key"),
80            };
81            keys.push((recipient, normalized));
82        }
83    }
84
85    if keys.is_empty() {
86        return Err(GitHubError::NoKeys(username.into()));
87    }
88
89    Ok(keys)
90}
91
92/// Classify an SSH key type for human-readable display.
93///
94/// Returns a short label like "ssh-ed25519" or "ssh-rsa" from
95/// the full key string.
96pub fn key_type_label(key_string: &str) -> &str {
97    key_string.split_whitespace().next().unwrap_or("ssh")
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn key_type_label_ed25519() {
106        assert_eq!(
107            key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
108            "ssh-ed25519"
109        );
110    }
111
112    #[test]
113    fn key_type_label_rsa() {
114        assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
115    }
116
117    #[test]
118    fn key_type_label_empty() {
119        assert_eq!(key_type_label(""), "ssh");
120    }
121}