use crate::{cache, config, tools, vault::fingerprint};
use anyhow::{Result, anyhow};
use reqwest::header::HeaderMap;
use rsa::RsaPublicKey;
use ssh_key::{HashAlg, PublicKey};
use std::collections::HashMap;
use url::Url;
const GITHUB_BASE_URL: &str = "https://github.com";
const SSHKEYS_ONLINE: &str = "https://ssh-keys.online/new";
/// Fetch the ssh keys from GitHub or configured endpoint.
///
/// # Errors
///
/// Returns an error if the URL is invalid or the request fails.
pub fn get_keys(user: &str) -> Result<String> {
let mut cache = true;
let url = if user.starts_with("http://") || user.starts_with("https://") {
Url::parse(user)?
} else if user == "new" {
cache = false;
// get the config from ~/.config/ssh-vault/config.yml
let config = config::get()?;
Url::parse(
&config
.get_string("sshkeys_online")
.unwrap_or_else(|_| String::from(SSHKEYS_ONLINE)),
)?
} else {
Url::parse(&format!("{GITHUB_BASE_URL}/{user}.keys"))?
};
request(url.as_str(), cache)
}
/// Perform a GET request and optionally cache the response.
///
/// # Errors
///
/// Returns an error if the URL is invalid, the request fails, or the response
/// cannot be read.
pub fn request(url: &str, cache: bool) -> Result<String> {
let url = Url::parse(url)?;
let cache_key = format!("{:x}", md5::compute(url.as_str().as_bytes()));
// load from cache
if let Ok(key) = cache::get(&cache_key) {
Ok(key)
} else {
// get the headers
let headers: HeaderMap = get_headers()?;
// Create a client
let client = reqwest::blocking::Client::builder()
.user_agent("ssh-vault")
.default_headers(headers)
.build()?;
// Make a GET request
let res = client.get(url).send()?;
if res.status().is_success() {
// Read the response body
let body = res.text()?;
if cache {
cache::put(&cache_key, &body)?;
}
Ok(body)
} else {
Err(anyhow!("Request failed with status: {}", res.status()))
}
}
}
// Get the HTTP headers from the config
fn get_headers() -> Result<HeaderMap> {
let mut config_headers: HashMap<String, String> = HashMap::new();
// get the config from ~/.config/ssh-vault/config.yml
let config = config::get()?;
if let Ok(http_headers) = config.get_table("http_headers") {
for (key, value) in &http_headers {
config_headers.insert(key.clone(), value.to_string());
}
}
let headers: HeaderMap = (&config_headers).try_into().unwrap_or_default();
Ok(headers)
}
/// Get the user key from fetched keys by index or fingerprint.
///
/// # Errors
///
/// Returns an error if the requested key is not found or cannot be parsed.
pub fn get_user_key(
keys: &str,
key: Option<u32>,
fingerprint: &Option<String>,
) -> Result<PublicKey> {
// Get only SSH keys from the fetched keys
let keys = tools::filter_fetched_keys(keys)?;
let key = key.map_or(0, |mut key| {
key = key.saturating_sub(1);
key
});
for (id, line) in keys.lines().enumerate() {
let u32_id = u32::try_from(id)?;
if key >= u32::try_from(keys.lines().count())? {
Err(anyhow!(
"key index not found, try -k with a value between 1 and {}",
keys.lines().count()
))?;
}
// parse the line as a public key
if let Ok(public_key) = PublicKey::from_openssh(line) {
// if fingerprint is provided, check if it matches
if let Some(f) = &fingerprint {
if public_key.fingerprint(HashAlg::Sha256).to_string() == *f {
return Ok(public_key);
}
// get the MD5 fingerprint
if let Some(key_data) = public_key.key_data().rsa() {
let rsa_public_key = RsaPublicKey::try_from(key_data)?;
if fingerprint::md5_fingerprint(&rsa_public_key)?.as_bytes() == f.as_bytes() {
return Ok(public_key);
}
}
} else if u32_id == key {
return Ok(public_key);
}
}
}
Err(anyhow!("key not found"))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::indexing_slicing,
clippy::cast_possible_truncation
)]
mod tests {
use super::*;
use crate::vault::fingerprint::Fingerprint;
use crate::vault::fingerprint::get_remote_fingerprints;
const KEYS: &str = "
# random comment
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDjjM4JEyg1T8j5YICtqslLNp2UGg80CppTM3ZYu73pEmDhMwbLfdhuI56AQZgWViFsF/7QHDJPcRY2Piu38b4kizTSM0QHEOC7CTo+vnzxptlKLGT1y2mcY1P9VXzCBMSWQN9/vGasgl/sUp1zcTvVT0CjjA6k1dJM6/+aDVtCsFa851VkwbeIsWl5BAHLyL+ur5BX93/BxYnRcYl7ooheuEWWokyWJ0IwEFToPMHAthTbDn1P17wYF43oscTORsFBfkP1JLBKHPDPJCGcBgQButL/srLJf6o44fScAYL99s1dQ/Qqv31aygDmwLdKEDldNnWEaJZ+iidEiIlPtAnLYGnVVA4u+NA2p3egrUrLWmpPjMX6XSb2VRHllzCcY4vZ4F2ud2TFaYG6N+9+vRCdxB+LFcHhm7ottI4vnC5P1bbMagjmFne0+TSKrAfMCw59eiQd8yZVMoE2yPXjFOQt6EOBvB4OHv1AaVt2q0PGqSkv5vIhgsKJWx/6IUj0Kz24hDiMipFb0jL3xstvizAllpC6yF26Ju/nwF03eJJGGxJjrxYd4P5/rY6SWY3yakiUN7pUBgUK2Ok3K3/+BTy5Aag8OXcvOZJumr2X2Wn9DweQeCRjC8UqFDKALqA/3vopZ2S59V4WOg3sV94hEig/KHLISNge1Uatn+qosK2sPw==
# another random comment
space
# another random comment
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCXsxWj7gvLUHbkUDzB6g+DfTdJbIcjH5Ge8ZZcYrTFeZ3hFL/pEfsuDf0Ut87QR0QpTFwM8SHyjKAX1rnF10Y+9ezG3Z4btHFk7SVPW0qqBwoTHFYiRqjgOcQrfQoDAhn9p/h93RCHR6gQPwj5CmDMRmnUcPV9mzjiLyqaqecAjGZj6q6O99Z5/lY2It/fCUcNW0JXBc31SiquvkkYhNjQsQgJxI5KnBMUEdVhk3ItJp8XeDbk2Kq03w0L8XcAqS2BUl4nNF4a5eMgME/tCUjSVYMvqcFIpOUsZhYNE+rt0ElbsMuehdvdLCbb2EBt+n75JgfGOsZCd96JrZiPlq55e0r5uDPz0rVtqnAWQawTtmSwa/VY7GZCf/xB2FvuqoXozWpAgzM7pypVx3JTBZwHx0xe/a0m1RA6+laQ4cCKV6FZWPV8WwUcvvxPknbDsjCeXgVQAxlXMk3pYrcGl61IPv/GaOr1QNPtUFRUuQXfgWh0F5SaU5MeI6HSGvuzooM= vault@ssh-vault.online
---
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINixf2m2nj8TDeazbWuemUY8ZHNg7znA7hVPN8TJLr2W
+++
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKdb5/i8sIEZ84k+LpJCAxRwxUZsP2MHFWApeB2TSUux ssh-vault
Fin
";
fn get_expected() -> Vec<Fingerprint> {
vec![
Fingerprint {
key: "ID: 1".to_string(),
fingerprints: vec![
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string(),
"MD5 55:cd:f2:7e:4c:0b:e5:a7:6e:6c:fc:6b:8e:58:9d:15".to_string(),
],
comment: String::new(),
algorithm: "ssh-rsa".to_string(),
},
Fingerprint {
key: "ID: 2".to_string(),
fingerprints: vec![
"SHA256:O09r+CSX4Ub8S3klaRp86ahCLbBkxhbaXW7v8y/ANCI".to_string(),
"MD5 19:b9:77:30:3f:99:15:b7:53:98:0d:ef:d1:8f:33:58".to_string(),
],
comment: "vault@ssh-vault.online".to_string(),
algorithm: "ssh-rsa".to_string(),
},
Fingerprint {
key: "ID: 3".to_string(),
fingerprints: vec![
"SHA256:hgIL5fEHz5zuOWY1CDlUuotdaUl4MvYG7vAgE4q4TzM".to_string(),
],
comment: String::new(),
algorithm: "ssh-ed25519".to_string(),
},
Fingerprint {
key: "ID: 4".to_string(),
fingerprints: vec![
"SHA256:HcSHlMDnxnmeh6dsxdTrqOGUPp8Ei78VaF9t3ED21S8".to_string(),
],
comment: "ssh-vault".to_string(),
algorithm: "ssh-ed25519".to_string(),
},
]
}
#[test]
fn test_get_remote_fingerprints() {
let f = get_remote_fingerprints(KEYS, None).unwrap();
assert_eq!(f, get_expected());
}
#[test]
fn test_get_remote_fingerprints_with_key() {
for i in 1..=4 {
assert_eq!(
get_expected()[i - 1],
get_remote_fingerprints(KEYS, Some(i as u32)).unwrap()[0]
);
}
}
#[test]
fn test_get_remote_fingerprints_with_key_0_1() {
// key 0 and 1 should be the same
assert_eq!(
get_expected()[0],
get_remote_fingerprints(KEYS, Some(0)).unwrap()[0]
);
assert_eq!(
get_expected()[0],
get_remote_fingerprints(KEYS, Some(1)).unwrap()[0]
);
// ensure key 0 and 1 are not the same as key 2
assert_ne!(
get_expected()[0],
get_remote_fingerprints(KEYS, Some(2)).unwrap()[0]
);
}
#[test]
fn test_get_remote_fingerprints_with_empty_keys() {
assert!(get_remote_fingerprints(KEYS, Some(10)).is_err());
assert!(get_remote_fingerprints("", None).is_err());
assert!(get_remote_fingerprints("", Some(1)).is_err());
}
#[test]
fn test_get_user_key() {
let key = get_user_key(KEYS, Some(1), &None).unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
);
}
#[test]
fn test_get_user_key_3() {
let key = get_user_key(KEYS, Some(3), &None).unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:hgIL5fEHz5zuOWY1CDlUuotdaUl4MvYG7vAgE4q4TzM".to_string()
);
}
#[test]
fn test_get_user_key_0_1() {
// key 0 and 1 should be the same
let key = get_user_key(KEYS, None, &None).unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
);
let key = get_user_key(KEYS, Some(0), &None).unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
);
let key = get_user_key(KEYS, Some(1), &None).unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
);
}
#[test]
fn test_get_user_key_with_fingerprint() {
let key = get_user_key(
KEYS,
None,
&Some("SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()),
)
.unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
);
}
#[test]
fn test_get_user_key_with_fingerprint_md5_rsa() {
let key = get_user_key(
KEYS,
None,
&Some("55:cd:f2:7e:4c:0b:e5:a7:6e:6c:fc:6b:8e:58:9d:15".to_string()),
)
.unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:12mLJQInCFoL9JOPJwPGb/FUEe459PY1yZEZqNGVZtA".to_string()
);
let key = get_user_key(
KEYS,
None,
&Some("19:b9:77:30:3f:99:15:b7:53:98:0d:ef:d1:8f:33:58".to_string()),
)
.unwrap();
assert_eq!(
key.fingerprint(HashAlg::Sha256).to_string(),
"SHA256:O09r+CSX4Ub8S3klaRp86ahCLbBkxhbaXW7v8y/ANCI".to_string()
);
}
#[test]
fn test_get_user_key_with_empty_keys() {
assert!(get_user_key("", Some(10), &None).is_err());
assert!(get_user_key("", None, &None).is_err());
assert!(get_user_key("", Some(1), &None).is_err());
}
#[test]
fn test_get_user_key_with_key_out_of_range() {
assert!(get_user_key(KEYS, Some(10), &None).is_err());
}
#[test]
fn test_get_headers() {
let headers = get_headers().unwrap();
assert!(headers.is_empty());
}
}