Skip to main content

murk_cli/
github.rs

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