1use std::fmt::Write;
4
5use base64::Engine;
6
7use crate::crypto::{self, MurkRecipient};
8
9#[derive(Debug)]
11pub enum GitHubError {
12 Fetch(String),
14 NoKeys(String),
16}
17
18impl std::fmt::Display for GitHubError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 GitHubError::Fetch(msg) => write!(f, "GitHub key fetch failed: {msg}"),
22 GitHubError::NoKeys(user) => write!(
23 f,
24 "no supported SSH keys found for GitHub user {user} (need ed25519 or rsa)"
25 ),
26 }
27 }
28}
29
30pub fn fetch_keys(username: &str) -> Result<Vec<(MurkRecipient, String)>, GitHubError> {
39 if username.is_empty()
41 || username.len() > 39
42 || !username
43 .chars()
44 .all(|c| c.is_ascii_alphanumeric() || c == '-')
45 {
46 return Err(GitHubError::Fetch(format!(
47 "invalid GitHub username: {username}"
48 )));
49 }
50
51 let url = format!("https://github.com/{username}.keys");
52
53 let body = ureq::get(&url)
54 .call()
55 .map_err(|e| GitHubError::Fetch(format!("{url}: {e}")))?
56 .into_body()
57 .read_to_string()
58 .map_err(|e| GitHubError::Fetch(format!("reading response: {e}")))?;
59
60 if body.trim().is_empty() {
61 return Err(GitHubError::NoKeys(username.into()));
62 }
63
64 parse_github_keys(&body, username)
65}
66
67pub fn parse_github_keys(
71 body: &str,
72 username: &str,
73) -> Result<Vec<(MurkRecipient, String)>, GitHubError> {
74 let mut keys = Vec::new();
75 for line in body.lines() {
76 let line = line.trim();
77 if line.is_empty() {
78 continue;
79 }
80
81 let key_type = line.split_whitespace().next().unwrap_or("");
82
83 if key_type != "ssh-ed25519" && key_type != "ssh-rsa" {
84 continue;
85 }
86
87 if let Ok(recipient) = crypto::parse_recipient(line) {
88 let normalized = match &recipient {
89 MurkRecipient::Ssh(r) => r.to_string(),
90 MurkRecipient::Age(_) => unreachable!("SSH key parsed as age key"),
91 MurkRecipient::Plugin(_) => unreachable!("SSH key parsed as plugin recipient"),
92 };
93 keys.push((recipient, normalized));
94 }
95 }
96
97 if keys.is_empty() {
98 return Err(GitHubError::NoKeys(username.into()));
99 }
100
101 Ok(keys)
102}
103
104pub fn fingerprint(key_string: &str) -> String {
108 use sha2::{Digest, Sha256};
109 let hash = Sha256::digest(key_string.as_bytes());
110 let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
111 format!("SHA256:{encoded}")
112}
113
114pub fn check_pins(
119 username: &str,
120 fetched_keys: &[(MurkRecipient, String)],
121 pinned: &[String],
122) -> Result<(), String> {
123 if pinned.is_empty() {
124 return Ok(()); }
126
127 let fetched_fps: Vec<String> = fetched_keys.iter().map(|(_, k)| fingerprint(k)).collect();
128
129 let mut added: Vec<&str> = Vec::new();
130 let mut removed: Vec<&str> = Vec::new();
131
132 for fp in &fetched_fps {
133 if !pinned.contains(fp) {
134 added.push(fp);
135 }
136 }
137 for fp in pinned {
138 if !fetched_fps.contains(fp) {
139 removed.push(fp);
140 }
141 }
142
143 if added.is_empty() && removed.is_empty() {
144 return Ok(());
145 }
146
147 let mut msg = format!("github:{username} keys changed since last authorization\n");
148 for fp in &added {
149 let _ = writeln!(msg, " + {fp}");
150 }
151 for fp in &removed {
152 let _ = writeln!(msg, " - {fp}");
153 }
154 msg.push_str("use --force to accept the new keys");
155 Err(msg)
156}
157
158pub fn key_type_label(key_string: &str) -> &str {
163 key_string.split_whitespace().next().unwrap_or("ssh")
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn key_type_label_ed25519() {
172 assert_eq!(
173 key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
174 "ssh-ed25519"
175 );
176 }
177
178 #[test]
179 fn key_type_label_rsa() {
180 assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
181 }
182
183 #[test]
184 fn key_type_label_empty() {
185 assert_eq!(key_type_label(""), "ssh");
186 }
187
188 const TEST_ED25519_KEY: &str =
189 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJI7KsDGxx+I8XZQwtbgoEYDfuNd9fQ4MzcHHUmtIau9";
190
191 #[test]
192 fn parse_keys_ed25519() {
193 let body = format!("{TEST_ED25519_KEY}\n");
194 let keys = parse_github_keys(&body, "testuser").unwrap();
195 assert_eq!(keys.len(), 1);
196 assert!(keys[0].1.starts_with("ssh-ed25519 "));
197 }
198
199 #[test]
200 fn parse_keys_skips_ecdsa() {
201 let body = "ecdsa-sha2-nistp256 AAAAE2VjZHNh...\n";
202 let result = parse_github_keys(body, "testuser");
203 assert!(result.is_err());
204 }
205
206 #[test]
207 fn parse_keys_skips_blank_lines() {
208 let body = format!("\n\n{TEST_ED25519_KEY}\n\n");
209 let keys = parse_github_keys(&body, "testuser").unwrap();
210 assert_eq!(keys.len(), 1);
211 }
212
213 #[test]
214 fn parse_keys_empty_body() {
215 let result = parse_github_keys("", "testuser");
216 assert!(result.is_err());
217 }
218
219 #[test]
220 fn parse_keys_strips_comment() {
221 let body = format!("{TEST_ED25519_KEY} user@host\n");
222 let keys = parse_github_keys(&body, "testuser").unwrap();
223 assert!(!keys[0].1.contains("user@host"));
224 }
225
226 #[test]
227 fn fetch_rejects_empty_username() {
228 let result = fetch_keys("");
229 assert!(result.is_err());
230 assert!(
231 result
232 .unwrap_err()
233 .to_string()
234 .contains("invalid GitHub username")
235 );
236 }
237
238 #[test]
239 fn fetch_rejects_long_username() {
240 let long = "a".repeat(40);
241 let result = fetch_keys(&long);
242 assert!(result.is_err());
243 }
244
245 #[test]
246 fn fetch_rejects_path_traversal() {
247 let result = fetch_keys("../etc/passwd");
248 assert!(result.is_err());
249 }
250
251 #[test]
252 fn github_error_display() {
253 let e = GitHubError::Fetch("connection refused".into());
254 assert!(e.to_string().contains("connection refused"));
255
256 let e = GitHubError::NoKeys("alice".into());
257 assert!(e.to_string().contains("alice"));
258 }
259
260 #[test]
263 fn check_pins_tofu_accepts_any_keys() {
264 let body = format!("{TEST_ED25519_KEY}\n");
265 let keys = parse_github_keys(&body, "alice").unwrap();
266 assert!(check_pins("alice", &keys, &[]).is_ok());
267 }
268
269 #[test]
270 fn check_pins_matching_passes() {
271 let body = format!("{TEST_ED25519_KEY}\n");
272 let keys = parse_github_keys(&body, "alice").unwrap();
273 let pins: Vec<String> = keys.iter().map(|(_, k)| fingerprint(k)).collect();
274 assert!(check_pins("alice", &keys, &pins).is_ok());
275 }
276
277 #[test]
278 fn check_pins_detects_added_key() {
279 let body = format!("{TEST_ED25519_KEY}\n");
280 let keys = parse_github_keys(&body, "alice").unwrap();
281 let old_pins = vec!["SHA256:fakefakefake".to_string()];
283 let result = check_pins("alice", &keys, &old_pins);
284 assert!(result.is_err());
285 let msg = result.unwrap_err();
286 assert!(msg.contains("+"));
287 assert!(msg.contains("-"));
288 }
289
290 #[test]
291 fn check_pins_detects_removed_key() {
292 let old_pins = vec!["SHA256:oldkey".to_string()];
294 let result = check_pins("alice", &[], &old_pins);
295 assert!(result.is_err());
296 assert!(result.unwrap_err().contains("- SHA256:oldkey"));
297 }
298
299 #[test]
300 fn fingerprint_is_deterministic() {
301 let fp1 = fingerprint(TEST_ED25519_KEY);
302 let fp2 = fingerprint(TEST_ED25519_KEY);
303 assert_eq!(fp1, fp2);
304 assert!(fp1.starts_with("SHA256:"));
305 }
306}