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, 1-39 chars, no path traversal.
36    if username.is_empty()
37        || username.len() > 39
38        || !username
39            .chars()
40            .all(|c| c.is_ascii_alphanumeric() || c == '-')
41    {
42        return Err(GitHubError::Fetch(format!(
43            "invalid GitHub username: {username}"
44        )));
45    }
46
47    let url = format!("https://github.com/{username}.keys");
48
49    let body = ureq::get(&url)
50        .call()
51        .map_err(|e| GitHubError::Fetch(format!("{url}: {e}")))?
52        .into_body()
53        .read_to_string()
54        .map_err(|e| GitHubError::Fetch(format!("reading response: {e}")))?;
55
56    if body.trim().is_empty() {
57        return Err(GitHubError::NoKeys(username.into()));
58    }
59
60    let mut keys = Vec::new();
61    for line in body.lines() {
62        let line = line.trim();
63        if line.is_empty() {
64            continue;
65        }
66
67        // Extract key type (first space-delimited token).
68        let key_type = line.split_whitespace().next().unwrap_or("");
69
70        // Only accept ed25519 and rsa — skip ecdsa, sk-ssh-*, etc.
71        if key_type != "ssh-ed25519" && key_type != "ssh-rsa" {
72            continue;
73        }
74
75        // Normalize: parse and re-serialize (strips any trailing comment).
76        if let Ok(recipient) = crypto::parse_recipient(line) {
77            // Use the normalized (comment-stripped) key string.
78            let normalized = match &recipient {
79                MurkRecipient::Ssh(r) => r.to_string(),
80                MurkRecipient::Age(_) => unreachable!("SSH key parsed as age key"),
81            };
82            keys.push((recipient, normalized));
83        }
84    }
85
86    if keys.is_empty() {
87        return Err(GitHubError::NoKeys(username.into()));
88    }
89
90    Ok(keys)
91}
92
93/// Classify an SSH key type for human-readable display.
94///
95/// Returns a short label like "ssh-ed25519" or "ssh-rsa" from
96/// the full key string.
97pub fn key_type_label(key_string: &str) -> &str {
98    key_string.split_whitespace().next().unwrap_or("ssh")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn key_type_label_ed25519() {
107        assert_eq!(
108            key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
109            "ssh-ed25519"
110        );
111    }
112
113    #[test]
114    fn key_type_label_rsa() {
115        assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
116    }
117
118    #[test]
119    fn key_type_label_empty() {
120        assert_eq!(key_type_label(""), "ssh");
121    }
122}