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