ssh_vault/vault/
remote.rs

1use crate::{cache, config, tools, vault::fingerprint};
2use anyhow::{Result, anyhow};
3use reqwest::header::HeaderMap;
4use rsa::RsaPublicKey;
5use ssh_key::{HashAlg, PublicKey};
6use std::collections::HashMap;
7use url::Url;
8
9const GITHUB_BASE_URL: &str = "https://github.com";
10const SSHKEYS_ONLINE: &str = "https://ssh-keys.online/new";
11
12/// Fetch the ssh keys from GitHub or configured endpoint.
13///
14/// # Errors
15///
16/// Returns an error if the URL is invalid or the request fails.
17pub fn get_keys(user: &str) -> Result<String> {
18    let mut cache = true;
19
20    let url = if user.starts_with("http://") || user.starts_with("https://") {
21        Url::parse(user)?
22    } else if user == "new" {
23        cache = false;
24
25        // get the config from ~/.config/ssh-vault/config.yml
26        let config = config::get()?;
27
28        Url::parse(
29            &config
30                .get_string("sshkeys_online")
31                .unwrap_or_else(|_| String::from(SSHKEYS_ONLINE)),
32        )?
33    } else {
34        Url::parse(&format!("{GITHUB_BASE_URL}/{user}.keys"))?
35    };
36
37    request(url.as_str(), cache)
38}
39
40/// Perform a GET request and optionally cache the response.
41///
42/// # Errors
43///
44/// Returns an error if the URL is invalid, the request fails, or the response
45/// cannot be read.
46pub fn request(url: &str, cache: bool) -> Result<String> {
47    let url = Url::parse(url)?;
48
49    let cache_key = format!("{:x}", md5::compute(url.as_str().as_bytes()));
50
51    // load from cache
52    if let Ok(key) = cache::get(&cache_key) {
53        Ok(key)
54    } else {
55        // get the headers
56        let headers: HeaderMap = get_headers()?;
57
58        // Create a client
59        let client = reqwest::blocking::Client::builder()
60            .user_agent("ssh-vault")
61            .default_headers(headers)
62            .build()?;
63
64        // Make a GET request
65        let res = client.get(url).send()?;
66
67        if res.status().is_success() {
68            // Read the response body
69            let body = res.text()?;
70
71            if cache {
72                cache::put(&cache_key, &body)?;
73            }
74            Ok(body)
75        } else {
76            Err(anyhow!("Request failed with status: {}", res.status()))
77        }
78    }
79}
80
81// Get the HTTP headers from the config
82fn get_headers() -> Result<HeaderMap> {
83    let mut config_headers: HashMap<String, String> = HashMap::new();
84
85    // get the config from ~/.config/ssh-vault/config.yml
86    let config = config::get()?;
87
88    if let Ok(http_headers) = config.get_table("http_headers") {
89        for (key, value) in &http_headers {
90            config_headers.insert(key.clone(), value.to_string());
91        }
92    }
93
94    let headers: HeaderMap = (&config_headers).try_into().unwrap_or_default();
95
96    Ok(headers)
97}
98
99/// Get the user key from fetched keys by index or fingerprint.
100///
101/// # Errors
102///
103/// Returns an error if the requested key is not found or cannot be parsed.
104pub fn get_user_key(
105    keys: &str,
106    key: Option<u32>,
107    fingerprint: &Option<String>,
108) -> Result<PublicKey> {
109    // Get only SSH keys from the fetched keys
110    let keys = tools::filter_fetched_keys(keys)?;
111
112    let key = key.map_or(0, |mut key| {
113        key = key.saturating_sub(1);
114        key
115    });
116
117    for (id, line) in keys.lines().enumerate() {
118        let u32_id = u32::try_from(id)?;
119        if key >= u32::try_from(keys.lines().count())? {
120            Err(anyhow!(
121                "key index not found, try -k with a value between 1 and {}",
122                keys.lines().count()
123            ))?;
124        }
125
126        // parse the line as a public key
127        if let Ok(public_key) = PublicKey::from_openssh(line) {
128            // if fingerprint is provided, check if it matches
129            if let Some(f) = &fingerprint {
130                if public_key.fingerprint(HashAlg::Sha256).to_string() == *f {
131                    return Ok(public_key);
132                }
133
134                // get the MD5 fingerprint
135                if let Some(key_data) = public_key.key_data().rsa() {
136                    let rsa_public_key = RsaPublicKey::try_from(key_data)?;
137                    if fingerprint::md5_fingerprint(&rsa_public_key)?.as_bytes() == f.as_bytes() {
138                        return Ok(public_key);
139                    }
140                }
141            } else if u32_id == key {
142                return Ok(public_key);
143            }
144        }
145    }
146
147    Err(anyhow!("key not found"))
148}
149#[cfg(test)]
150#[allow(
151    clippy::unwrap_used,
152    clippy::indexing_slicing,
153    clippy::cast_possible_truncation
154)]
155mod tests {
156    use super::*;
157    use crate::vault::fingerprint::Fingerprint;
158    use crate::vault::fingerprint::get_remote_fingerprints;
159
160    const KEYS: &str = "
161# random comment
162ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDjjM4JEyg1T8j5YICtqslLNp2UGg80CppTM3ZYu73pEmDhMwbLfdhuI56AQZgWViFsF/7QHDJPcRY2Piu38b4kizTSM0QHEOC7CTo+vnzxptlKLGT1y2mcY1P9VXzCBMSWQN9/vGasgl/sUp1zcTvVT0CjjA6k1dJM6/+aDVtCsFa851VkwbeIsWl5BAHLyL+ur5BX93/BxYnRcYl7ooheuEWWokyWJ0IwEFToPMHAthTbDn1P17wYF43oscTORsFBfkP1JLBKHPDPJCGcBgQButL/srLJf6o44fScAYL99s1dQ/Qqv31aygDmwLdKEDldNnWEaJZ+iidEiIlPtAnLYGnVVA4u+NA2p3egrUrLWmpPjMX6XSb2VRHllzCcY4vZ4F2ud2TFaYG6N+9+vRCdxB+LFcHhm7ottI4vnC5P1bbMagjmFne0+TSKrAfMCw59eiQd8yZVMoE2yPXjFOQt6EOBvB4OHv1AaVt2q0PGqSkv5vIhgsKJWx/6IUj0Kz24hDiMipFb0jL3xstvizAllpC6yF26Ju/nwF03eJJGGxJjrxYd4P5/rY6SWY3yakiUN7pUBgUK2Ok3K3/+BTy5Aag8OXcvOZJumr2X2Wn9DweQeCRjC8UqFDKALqA/3vopZ2S59V4WOg3sV94hEig/KHLISNge1Uatn+qosK2sPw==
163# another random comment
164space
165
166# another random comment
167ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCXsxWj7gvLUHbkUDzB6g+DfTdJbIcjH5Ge8ZZcYrTFeZ3hFL/pEfsuDf0Ut87QR0QpTFwM8SHyjKAX1rnF10Y+9ezG3Z4btHFk7SVPW0qqBwoTHFYiRqjgOcQrfQoDAhn9p/h93RCHR6gQPwj5CmDMRmnUcPV9mzjiLyqaqecAjGZj6q6O99Z5/lY2It/fCUcNW0JXBc31SiquvkkYhNjQsQgJxI5KnBMUEdVhk3ItJp8XeDbk2Kq03w0L8XcAqS2BUl4nNF4a5eMgME/tCUjSVYMvqcFIpOUsZhYNE+rt0ElbsMuehdvdLCbb2EBt+n75JgfGOsZCd96JrZiPlq55e0r5uDPz0rVtqnAWQawTtmSwa/VY7GZCf/xB2FvuqoXozWpAgzM7pypVx3JTBZwHx0xe/a0m1RA6+laQ4cCKV6FZWPV8WwUcvvxPknbDsjCeXgVQAxlXMk3pYrcGl61IPv/GaOr1QNPtUFRUuQXfgWh0F5SaU5MeI6HSGvuzooM= vault@ssh-vault.online
168---
169ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINixf2m2nj8TDeazbWuemUY8ZHNg7znA7hVPN8TJLr2W
170+++
171ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKdb5/i8sIEZ84k+LpJCAxRwxUZsP2MHFWApeB2TSUux ssh-vault
172
173Fin
174";
175
176    fn get_expected() -> Vec<Fingerprint> {
177        vec![
178            Fingerprint {
179                key: "ID: 1".to_string(),
180                fingerprints: vec![
181                    "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string(),
182                    "MD5 55:cd:f2:7e:4c:0b:e5:a7:6e:6c:fc:6b:8e:58:9d:15".to_string(),
183                ],
184                comment: String::new(),
185                algorithm: "ssh-rsa".to_string(),
186            },
187            Fingerprint {
188                key: "ID: 2".to_string(),
189                fingerprints: vec![
190                    "SHA256:O09r+CSX4Ub8S3klaRp86ahCLbBkxhbaXW7v8y/ANCI".to_string(),
191                    "MD5 19:b9:77:30:3f:99:15:b7:53:98:0d:ef:d1:8f:33:58".to_string(),
192                ],
193                comment: "vault@ssh-vault.online".to_string(),
194                algorithm: "ssh-rsa".to_string(),
195            },
196            Fingerprint {
197                key: "ID: 3".to_string(),
198                fingerprints: vec![
199                    "SHA256:hgIL5fEHz5zuOWY1CDlUuotdaUl4MvYG7vAgE4q4TzM".to_string(),
200                ],
201                comment: String::new(),
202                algorithm: "ssh-ed25519".to_string(),
203            },
204            Fingerprint {
205                key: "ID: 4".to_string(),
206                fingerprints: vec![
207                    "SHA256:HcSHlMDnxnmeh6dsxdTrqOGUPp8Ei78VaF9t3ED21S8".to_string(),
208                ],
209                comment: "ssh-vault".to_string(),
210                algorithm: "ssh-ed25519".to_string(),
211            },
212        ]
213    }
214
215    #[test]
216    fn test_get_remote_fingerprints() {
217        let f = get_remote_fingerprints(KEYS, None).unwrap();
218        assert_eq!(f, get_expected());
219    }
220
221    #[test]
222    fn test_get_remote_fingerprints_with_key() {
223        for i in 1..=4 {
224            assert_eq!(
225                get_expected()[i - 1],
226                get_remote_fingerprints(KEYS, Some(i as u32)).unwrap()[0]
227            );
228        }
229    }
230
231    #[test]
232    fn test_get_remote_fingerprints_with_key_0_1() {
233        // key 0 and 1 should be the same
234        assert_eq!(
235            get_expected()[0],
236            get_remote_fingerprints(KEYS, Some(0)).unwrap()[0]
237        );
238
239        assert_eq!(
240            get_expected()[0],
241            get_remote_fingerprints(KEYS, Some(1)).unwrap()[0]
242        );
243
244        // ensure key 0 and 1 are not the same as key 2
245        assert_ne!(
246            get_expected()[0],
247            get_remote_fingerprints(KEYS, Some(2)).unwrap()[0]
248        );
249    }
250
251    #[test]
252    fn test_get_remote_fingerprints_with_empty_keys() {
253        assert!(get_remote_fingerprints(KEYS, Some(10)).is_err());
254        assert!(get_remote_fingerprints("", None).is_err());
255        assert!(get_remote_fingerprints("", Some(1)).is_err());
256    }
257
258    #[test]
259    fn test_get_user_key() {
260        let key = get_user_key(KEYS, Some(1), &None).unwrap();
261        assert_eq!(
262            key.fingerprint(HashAlg::Sha256).to_string(),
263            "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
264        );
265    }
266
267    #[test]
268    fn test_get_user_key_3() {
269        let key = get_user_key(KEYS, Some(3), &None).unwrap();
270        assert_eq!(
271            key.fingerprint(HashAlg::Sha256).to_string(),
272            "SHA256:hgIL5fEHz5zuOWY1CDlUuotdaUl4MvYG7vAgE4q4TzM".to_string()
273        );
274    }
275
276    #[test]
277    fn test_get_user_key_0_1() {
278        // key 0 and 1 should be the same
279        let key = get_user_key(KEYS, None, &None).unwrap();
280        assert_eq!(
281            key.fingerprint(HashAlg::Sha256).to_string(),
282            "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
283        );
284        let key = get_user_key(KEYS, Some(0), &None).unwrap();
285        assert_eq!(
286            key.fingerprint(HashAlg::Sha256).to_string(),
287            "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
288        );
289        let key = get_user_key(KEYS, Some(1), &None).unwrap();
290        assert_eq!(
291            key.fingerprint(HashAlg::Sha256).to_string(),
292            "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
293        );
294    }
295
296    #[test]
297    fn test_get_user_key_with_fingerprint() {
298        let key = get_user_key(
299            KEYS,
300            None,
301            &Some("SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()),
302        )
303        .unwrap();
304        assert_eq!(
305            key.fingerprint(HashAlg::Sha256).to_string(),
306            "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
307        );
308    }
309
310    #[test]
311    fn test_get_user_key_with_fingerprint_md5_rsa() {
312        let key = get_user_key(
313            KEYS,
314            None,
315            &Some("55:cd:f2:7e:4c:0b:e5:a7:6e:6c:fc:6b:8e:58:9d:15".to_string()),
316        )
317        .unwrap();
318        assert_eq!(
319            key.fingerprint(HashAlg::Sha256).to_string(),
320            "SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
321        );
322
323        let key = get_user_key(
324            KEYS,
325            None,
326            &Some("19:b9:77:30:3f:99:15:b7:53:98:0d:ef:d1:8f:33:58".to_string()),
327        )
328        .unwrap();
329        assert_eq!(
330            key.fingerprint(HashAlg::Sha256).to_string(),
331            "SHA256:O09r+CSX4Ub8S3klaRp86ahCLbBkxhbaXW7v8y/ANCI".to_string()
332        );
333    }
334
335    #[test]
336    fn test_get_user_key_with_empty_keys() {
337        assert!(get_user_key("", Some(10), &None).is_err());
338        assert!(get_user_key("", None, &None).is_err());
339        assert!(get_user_key("", Some(1), &None).is_err());
340    }
341
342    #[test]
343    fn test_get_user_key_with_key_out_of_range() {
344        assert!(get_user_key(KEYS, Some(10), &None).is_err());
345    }
346
347    #[test]
348    fn test_get_headers() {
349        let headers = get_headers().unwrap();
350        assert!(headers.is_empty());
351    }
352}