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<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 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
186pub 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 #[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 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 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 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}