1use std::fmt::Write;
4use std::time::Duration;
5
6use base64::Engine;
7
8use crate::crypto::{self, MurkRecipient};
9
10#[derive(Debug)]
12pub enum GitHubError {
13 Fetch(String),
15 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
31pub fn fetch_keys(username: &str) -> Result<Vec<(MurkRecipient, String)>, GitHubError> {
40 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 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
90pub 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
127pub 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
137pub 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(()); }
149
150 let fetched_fps: Vec<String> = fetched_keys.iter().map(|(_, k)| fingerprint(k)).collect();
151
152 let mut added: Vec<&str> = Vec::new();
153 let mut removed: Vec<&str> = Vec::new();
154
155 for fp in &fetched_fps {
156 if !pinned.contains(fp) {
157 added.push(fp);
158 }
159 }
160 for fp in pinned {
161 if !fetched_fps.contains(fp) {
162 removed.push(fp);
163 }
164 }
165
166 if added.is_empty() && removed.is_empty() {
167 return Ok(());
168 }
169
170 let mut msg = format!("github:{username} keys changed since last authorization\n");
171 for fp in &added {
172 let _ = writeln!(msg, " + {fp}");
173 }
174 for fp in &removed {
175 let _ = writeln!(msg, " - {fp}");
176 }
177 msg.push_str("use --force to accept the new keys");
178 Err(msg)
179}
180
181pub fn key_type_label(key_string: &str) -> &str {
186 key_string.split_whitespace().next().unwrap_or("ssh")
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn key_type_label_ed25519() {
195 assert_eq!(
196 key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
197 "ssh-ed25519"
198 );
199 }
200
201 #[test]
202 fn key_type_label_rsa() {
203 assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
204 }
205
206 #[test]
207 fn key_type_label_empty() {
208 assert_eq!(key_type_label(""), "ssh");
209 }
210
211 const TEST_ED25519_KEY: &str =
212 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJI7KsDGxx+I8XZQwtbgoEYDfuNd9fQ4MzcHHUmtIau9";
213
214 #[test]
215 fn parse_keys_ed25519() {
216 let body = format!("{TEST_ED25519_KEY}\n");
217 let keys = parse_github_keys(&body, "testuser").unwrap();
218 assert_eq!(keys.len(), 1);
219 assert!(keys[0].1.starts_with("ssh-ed25519 "));
220 }
221
222 #[test]
223 fn parse_keys_skips_ecdsa() {
224 let body = "ecdsa-sha2-nistp256 AAAAE2VjZHNh...\n";
225 let result = parse_github_keys(body, "testuser");
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn parse_keys_skips_blank_lines() {
231 let body = format!("\n\n{TEST_ED25519_KEY}\n\n");
232 let keys = parse_github_keys(&body, "testuser").unwrap();
233 assert_eq!(keys.len(), 1);
234 }
235
236 #[test]
237 fn parse_keys_empty_body() {
238 let result = parse_github_keys("", "testuser");
239 assert!(result.is_err());
240 }
241
242 #[test]
243 fn parse_keys_strips_comment() {
244 let body = format!("{TEST_ED25519_KEY} user@host\n");
245 let keys = parse_github_keys(&body, "testuser").unwrap();
246 assert!(!keys[0].1.contains("user@host"));
247 }
248
249 #[test]
250 fn fetch_rejects_empty_username() {
251 let result = fetch_keys("");
252 assert!(result.is_err());
253 assert!(
254 result
255 .unwrap_err()
256 .to_string()
257 .contains("invalid GitHub username")
258 );
259 }
260
261 #[test]
262 fn fetch_rejects_long_username() {
263 let long = "a".repeat(40);
264 let result = fetch_keys(&long);
265 assert!(result.is_err());
266 }
267
268 #[test]
269 fn fetch_rejects_path_traversal() {
270 let result = fetch_keys("../etc/passwd");
271 assert!(result.is_err());
272 }
273
274 #[test]
275 fn github_error_display() {
276 let e = GitHubError::Fetch("connection refused".into());
277 assert!(e.to_string().contains("connection refused"));
278
279 let e = GitHubError::NoKeys("alice".into());
280 assert!(e.to_string().contains("alice"));
281 }
282
283 #[test]
286 fn check_pins_tofu_accepts_any_keys() {
287 let body = format!("{TEST_ED25519_KEY}\n");
288 let keys = parse_github_keys(&body, "alice").unwrap();
289 assert!(check_pins("alice", &keys, &[]).is_ok());
290 }
291
292 #[test]
293 fn check_pins_matching_passes() {
294 let body = format!("{TEST_ED25519_KEY}\n");
295 let keys = parse_github_keys(&body, "alice").unwrap();
296 let pins: Vec<String> = keys.iter().map(|(_, k)| fingerprint(k)).collect();
297 assert!(check_pins("alice", &keys, &pins).is_ok());
298 }
299
300 #[test]
301 fn check_pins_detects_added_key() {
302 let body = format!("{TEST_ED25519_KEY}\n");
303 let keys = parse_github_keys(&body, "alice").unwrap();
304 let old_pins = vec!["SHA256:fakefakefake".to_string()];
306 let result = check_pins("alice", &keys, &old_pins);
307 assert!(result.is_err());
308 let msg = result.unwrap_err();
309 assert!(msg.contains('+'));
310 assert!(msg.contains('-'));
311 }
312
313 #[test]
314 fn check_pins_detects_removed_key() {
315 let old_pins = vec!["SHA256:oldkey".to_string()];
317 let result = check_pins("alice", &[], &old_pins);
318 assert!(result.is_err());
319 assert!(result.unwrap_err().contains("- SHA256:oldkey"));
320 }
321
322 #[test]
323 fn fingerprint_is_deterministic() {
324 let fp1 = fingerprint(TEST_ED25519_KEY);
325 let fp2 = fingerprint(TEST_ED25519_KEY);
326 assert_eq!(fp1, fp2);
327 assert!(fp1.starts_with("SHA256:"));
328 }
329}