ssh-vault 1.2.4

encrypt/decrypt using ssh keys
Documentation
use crate::tools;
use anyhow::{Context, Result};
use rsa::{RsaPublicKey, pkcs8::EncodePublicKey};
use ssh_key::{HashAlg, PublicKey};
use std::{fmt, fs, path::Path};

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fingerprint {
    pub key: String,
    pub fingerprints: Vec<String>,
    pub comment: String,
    pub algorithm: String,
}

impl fmt::Display for Fingerprint {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fn format_fingerprint(fp: &str, width: usize) -> String {
            format!("{:>width$} {}", "", fp, width = width)
        }

        // Access custom width from the formatter arguments
        let custom_width = f.width().unwrap_or(self.key.len());

        writeln!(
            f,
            "{:>width$} Type: {} Comment: {}",
            self.key,
            self.algorithm,
            self.comment,
            width = custom_width
        )?;

        for fp in &self.fingerprints {
            writeln!(f, "{}", format_fingerprint(fp, custom_width))?;
        }

        Ok(())
    }
}

/// Collect fingerprints for all public keys in `~/.ssh`.
///
/// # Errors
///
/// Returns an error if any key cannot be read or parsed.
pub fn fingerprints() -> Result<Vec<Fingerprint>> {
    // Create a vector to store Fingerprint structs
    let mut fingerprints: Vec<Fingerprint> = Vec::new();

    let home = tools::get_home()?;
    let ssh_home = Path::new(&home).join(".ssh");
    if let Ok(entries) = fs::read_dir(ssh_home) {
        for entry in entries.flatten() {
            let path = entry.path();
            if let Some(ext) = path.extension()
                && ext == "pub"
                && let Ok(key) = PublicKey::read_openssh_file(&path)
            {
                // Create a Fingerprint instance
                let mut fingerprint = Fingerprint {
                    key: path
                        .file_name()
                        .unwrap_or_default()
                        .to_string_lossy()
                        .to_string(),
                    comment: key.comment().to_string(),
                    algorithm: key.algorithm().to_string(),
                    ..Default::default()
                };

                fingerprint
                    .fingerprints
                    .push(key.fingerprint(HashAlg::Sha256).to_string());

                if let Some(key_data) = key.key_data().rsa() {
                    let rsa_public_key = RsaPublicKey::try_from(key_data)?;
                    fingerprint
                        .fingerprints
                        .push(format!("MD5 {}", md5_fingerprint(&rsa_public_key)?));
                }

                fingerprints.push(fingerprint);
            }
        }
    }
    Ok(fingerprints)
}

/// Compute fingerprints for a single public key.
///
/// # Errors
///
/// Returns an error if the key cannot be read or parsed.
pub fn fingerprint(key: &str) -> Result<Fingerprint> {
    let path = Path::new(&key);
    let key = PublicKey::read_openssh_file(path)
        .context("Ensure you are passing a valid openssh public key")?;
    let mut fingerprint = Fingerprint {
        key: path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string(),
        comment: key.comment().to_string(),
        algorithm: key.algorithm().to_string(),
        ..Default::default()
    };

    fingerprint
        .fingerprints
        .push(key.fingerprint(HashAlg::Sha256).to_string());

    if let Some(key_data) = key.key_data().rsa() {
        let rsa_public_key = RsaPublicKey::try_from(key_data)?;
        fingerprint
            .fingerprints
            .push(format!("MD5 {}", md5_fingerprint(&rsa_public_key)?));
    }

    Ok(fingerprint)
}

/// Fetch remote SSH keys and return their fingerprints.
///
/// # Errors
///
/// Returns an error if parsing or fingerprint generation fails.
pub fn get_remote_fingerprints(keys: &str, key: Option<u32>) -> Result<Vec<Fingerprint>> {
    // Get only SSH keys from the fetched keys
    let keys = tools::filter_fetched_keys(keys)?;

    // Create a vector to store Fingerprint structs
    let mut fingerprints: Vec<Fingerprint> = Vec::new();

    for (id, line) in keys.lines().enumerate() {
        let u32_id = u32::try_from(id)?;

        if let Some(mut key) = key {
            key = key.saturating_sub(1);

            if key >= u32::try_from(keys.lines().count())? {
                Err(anyhow::anyhow!(
                    "key index not found, try -k with a value between 1 and {}",
                    keys.lines().count()
                ))?;
            }
            if u32_id != key {
                continue;
            }
        }

        if let Ok(key) = PublicKey::from_openssh(line) {
            // Create a Fingerprint instance
            let mut fingerprint = Fingerprint {
                key: format!("ID: {}", id + 1),
                comment: key.comment().to_string(),
                algorithm: key.algorithm().to_string(),
                ..Default::default()
            };

            fingerprint
                .fingerprints
                .push(key.fingerprint(HashAlg::Sha256).to_string());

            if let Some(key_data) = key.key_data().rsa() {
                let rsa_public_key = RsaPublicKey::try_from(key_data)?;
                fingerprint
                    .fingerprints
                    .push(format!("MD5 {}", md5_fingerprint(&rsa_public_key)?));
            }

            fingerprints.push(fingerprint);
        }
    }

    Ok(fingerprints)
}

/// Calculate the MD5 fingerprint of a RSA public key and format it as a colon
/// separated string.
///
/// # Errors
///
/// Returns an error if the key cannot be encoded.
pub fn md5_fingerprint(public_key: &RsaPublicKey) -> Result<String> {
    let public_key_der = public_key.to_public_key_der()?;
    let md5_fingerprint = md5::compute(public_key_der.as_bytes());
    let formatted_fingerprint = format!("{md5_fingerprint:x}")
        .chars()
        .collect::<Vec<char>>()
        .chunks(2)
        .map(|chunk| chunk.iter().collect::<String>())
        .collect::<Vec<String>>()
        .join(":");
    Ok(formatted_fingerprint)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    struct Test {
        key: &'static str,
        fingerprint: &'static str,
    }

    #[test]
    fn test_fingerprints() {
        let tests = [
            Test {
                key: "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== test",
                fingerprint: "55:cd:f2:7e:4c:0b:e5:a7:6e:6c:fc:6b:8e:58:9d:15",
            },
            Test {
                key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCXsxWj7gvLUHbkUDzB6g+DfTdJbIcjH5Ge8ZZcYrTFeZ3hFL/pEfsuDf0Ut87QR0QpTFwM8SHyjKAX1rnF10Y+9ezG3Z4btHFk7SVPW0qqBwoTHFYiRqjgOcQrfQoDAhn9p/h93RCHR6gQPwj5CmDMRmnUcPV9mzjiLyqaqecAjGZj6q6O99Z5/lY2It/fCUcNW0JXBc31SiquvkkYhNjQsQgJxI5KnBMUEdVhk3ItJp8XeDbk2Kq03w0L8XcAqS2BUl4nNF4a5eMgME/tCUjSVYMvqcFIpOUsZhYNE+rt0ElbsMuehdvdLCbb2EBt+n75JgfGOsZCd96JrZiPlq55e0r5uDPz0rVtqnAWQawTtmSwa/VY7GZCf/xB2FvuqoXozWpAgzM7pypVx3JTBZwHx0xe/a0m1RA6+laQ4cCKV6FZWPV8WwUcvvxPknbDsjCeXgVQAxlXMk3pYrcGl61IPv/GaOr1QNPtUFRUuQXfgWh0F5SaU5MeI6HSGvuzooM=",
                fingerprint: "19:b9:77:30:3f:99:15:b7:53:98:0d:ef:d1:8f:33:58",
            },
            Test {
                key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKdb5/i8sIEZ84k+LpJCAxRwxUZsP2MHFWApeB2TSUux ssh-vault",
                fingerprint: "SHA256:HcSHlMDnxnmeh6dsxdTrqOGUPp8Ei78VaF9t3ED21S8",
            },
            Test {
                key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINixf2m2nj8TDeazbWuemUY8ZHNg7znA7hVPN8TJLr2W",
                fingerprint: "SHA256:hgIL5fEHz5zuOWY1CDlUuotdaUl4MvYG7vAgE4q4TzM",
            },
        ];

        for test in &tests {
            let mut fingerprint = String::new();
            let public_key = PublicKey::from_openssh(test.key).unwrap();
            if public_key.algorithm().as_str() == "ssh-rsa" {
                let key_data = public_key.key_data().rsa().unwrap();
                let rsa_public_key = RsaPublicKey::try_from(key_data).unwrap();
                fingerprint = md5_fingerprint(&rsa_public_key).unwrap();
            } else if public_key.algorithm().as_str() == "ssh-ed25519" {
                fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
            }
            assert_eq!(fingerprint, test.fingerprint);
        }
    }
}