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 };
92 keys.push((recipient, normalized));
93 }
94 }
95
96 if keys.is_empty() {
97 return Err(GitHubError::NoKeys(username.into()));
98 }
99
100 Ok(keys)
101}
102
103pub fn fingerprint(key_string: &str) -> String {
107 use sha2::{Digest, Sha256};
108 let hash = Sha256::digest(key_string.as_bytes());
109 let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
110 format!("SHA256:{encoded}")
111}
112
113pub fn check_pins(
118 username: &str,
119 fetched_keys: &[(MurkRecipient, String)],
120 pinned: &[String],
121) -> Result<(), String> {
122 if pinned.is_empty() {
123 return Ok(()); }
125
126 let fetched_fps: Vec<String> = fetched_keys.iter().map(|(_, k)| fingerprint(k)).collect();
127
128 let mut added: Vec<&str> = Vec::new();
129 let mut removed: Vec<&str> = Vec::new();
130
131 for fp in &fetched_fps {
132 if !pinned.contains(fp) {
133 added.push(fp);
134 }
135 }
136 for fp in pinned {
137 if !fetched_fps.contains(fp) {
138 removed.push(fp);
139 }
140 }
141
142 if added.is_empty() && removed.is_empty() {
143 return Ok(());
144 }
145
146 let mut msg = format!("github:{username} keys changed since last authorization\n");
147 for fp in &added {
148 let _ = writeln!(msg, " + {fp}");
149 }
150 for fp in &removed {
151 let _ = writeln!(msg, " - {fp}");
152 }
153 msg.push_str("use --force to accept the new keys");
154 Err(msg)
155}
156
157pub fn key_type_label(key_string: &str) -> &str {
162 key_string.split_whitespace().next().unwrap_or("ssh")
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn key_type_label_ed25519() {
171 assert_eq!(
172 key_type_label("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."),
173 "ssh-ed25519"
174 );
175 }
176
177 #[test]
178 fn key_type_label_rsa() {
179 assert_eq!(key_type_label("ssh-rsa AAAAB3NzaC1yc2EAAAA..."), "ssh-rsa");
180 }
181
182 #[test]
183 fn key_type_label_empty() {
184 assert_eq!(key_type_label(""), "ssh");
185 }
186
187 const TEST_ED25519_KEY: &str =
188 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJI7KsDGxx+I8XZQwtbgoEYDfuNd9fQ4MzcHHUmtIau9";
189
190 #[test]
191 fn parse_keys_ed25519() {
192 let body = format!("{TEST_ED25519_KEY}\n");
193 let keys = parse_github_keys(&body, "testuser").unwrap();
194 assert_eq!(keys.len(), 1);
195 assert!(keys[0].1.starts_with("ssh-ed25519 "));
196 }
197
198 #[test]
199 fn parse_keys_skips_ecdsa() {
200 let body = "ecdsa-sha2-nistp256 AAAAE2VjZHNh...\n";
201 let result = parse_github_keys(body, "testuser");
202 assert!(result.is_err());
203 }
204
205 #[test]
206 fn parse_keys_skips_blank_lines() {
207 let body = format!("\n\n{TEST_ED25519_KEY}\n\n");
208 let keys = parse_github_keys(&body, "testuser").unwrap();
209 assert_eq!(keys.len(), 1);
210 }
211
212 #[test]
213 fn parse_keys_empty_body() {
214 let result = parse_github_keys("", "testuser");
215 assert!(result.is_err());
216 }
217
218 #[test]
219 fn parse_keys_strips_comment() {
220 let body = format!("{TEST_ED25519_KEY} user@host\n");
221 let keys = parse_github_keys(&body, "testuser").unwrap();
222 assert!(!keys[0].1.contains("user@host"));
223 }
224
225 #[test]
226 fn fetch_rejects_empty_username() {
227 let result = fetch_keys("");
228 assert!(result.is_err());
229 assert!(
230 result
231 .unwrap_err()
232 .to_string()
233 .contains("invalid GitHub username")
234 );
235 }
236
237 #[test]
238 fn fetch_rejects_long_username() {
239 let long = "a".repeat(40);
240 let result = fetch_keys(&long);
241 assert!(result.is_err());
242 }
243
244 #[test]
245 fn fetch_rejects_path_traversal() {
246 let result = fetch_keys("../etc/passwd");
247 assert!(result.is_err());
248 }
249
250 #[test]
251 fn github_error_display() {
252 let e = GitHubError::Fetch("connection refused".into());
253 assert!(e.to_string().contains("connection refused"));
254
255 let e = GitHubError::NoKeys("alice".into());
256 assert!(e.to_string().contains("alice"));
257 }
258}