russh 0.63.3

A client and server SSH library.
Documentation
use std::fmt::Debug;

use ssh_encoding::{Decode, Encode};

#[doc(hidden)]
pub trait EncodedExt {
    fn encoded(&self) -> ssh_key::Result<Vec<u8>>;
}

impl<E: Encode> EncodedExt for E {
    fn encoded(&self) -> ssh_key::Result<Vec<u8>> {
        let mut buf = Vec::new();
        self.encode(&mut buf)?;
        Ok(buf)
    }
}

mod limited_string {
    use super::*;
    use std::ops::Deref;

    pub struct LimitedString<const N: usize>(String);

    impl<const N: usize> Deref for LimitedString<N> {
        type Target = String;

        fn deref(&self) -> &Self::Target {
            &self.0
        }
    }

    impl<const N: usize> Decode for LimitedString<N> {
        type Error = ssh_encoding::Error;

        fn decode(reader: &mut impl ssh_encoding::Reader) -> Result<Self, Self::Error> {
            reader.read_prefixed(|reader| {
                let len = reader.remaining_len();
                if len > N {
                    return Err(ssh_encoding::Error::Length);
                }

                // Allocate only after the SSH string length has been bounded.
                let mut buf = vec![0; len];
                reader.read(&mut buf)?;
                let value =
                    String::from_utf8(buf).map_err(|_| ssh_encoding::Error::CharacterEncoding)?;
                reader.ensure_finished()?;

                Ok(Self(value))
            })
        }
    }
}

mod name_list {
    use std::ops::Deref;

    use super::*;
    const MAX_NAME_LIST_ENTRIES: usize = 1024;
    const MAX_NAME_LIST_BYTES: usize = 16 * 1024;

    pub struct NameList(pub Vec<String>);

    impl Debug for NameList {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            self.0.fmt(f)
        }
    }

    impl Deref for NameList {
        type Target = [String];

        fn deref(&self) -> &Self::Target {
            &self.0
        }
    }

    impl NameList {
        pub fn as_encoded_string(&self) -> String {
            self.0.join(",")
        }

        pub fn from_encoded_string(value: &str) -> Result<Self, ssh_encoding::Error> {
            // RFC 4251 §5: a name-list may have zero names (a string of
            // zero length). `"".split(',')` yields one empty element,
            // which the per-name validation below would reject.
            if value.is_empty() {
                return Ok(Self(Vec::new()));
            }

            // Some legacy SSH implementations append a comma to name-lists.
            // OpenSSH accepts this, so tolerate exactly one trailing comma for
            // interoperability while continuing to reject all other empty
            // entries.
            let value = value
                .strip_suffix(',')
                .filter(|value| !value.is_empty())
                .unwrap_or(value);

            Ok(Self(value.split(',').try_fold(
                Vec::new(),
                |mut list, name| {
                    if name.is_empty() || !name.is_ascii() {
                        return Err(ssh_encoding::Error::CharacterEncoding);
                    }
                    if list.len() > MAX_NAME_LIST_ENTRIES {
                        Err(ssh_encoding::Error::Length)
                    } else {
                        list.push(name.into());
                        Ok(list)
                    }
                },
            )?))
        }
    }

    impl Encode for NameList {
        fn encoded_len(&self) -> Result<usize, ssh_encoding::Error> {
            self.as_encoded_string().encoded_len()
        }

        fn encode(
            &self,
            writer: &mut impl ssh_encoding::Writer,
        ) -> Result<(), ssh_encoding::Error> {
            self.as_encoded_string().encode(writer)
        }
    }

    impl Decode for NameList {
        fn decode(reader: &mut impl ssh_encoding::Reader) -> Result<Self, ssh_encoding::Error> {
            let s = LimitedString::<MAX_NAME_LIST_BYTES>::decode(reader)?;
            Self::from_encoded_string(&s)
        }

        type Error = ssh_encoding::Error;
    }

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

        #[test]
        fn empty_name_list_is_valid() {
            // RFC 4251 §5 permits a zero-name list. Servers that only
            // offer AEAD ciphers (e.g. hssh) send empty MAC name-lists.
            let nl = NameList::from_encoded_string("").unwrap();
            assert!(nl.0.is_empty());
        }

        #[test]
        fn name_list_round_trip() {
            let nl = NameList::from_encoded_string("a,b,c").unwrap();
            assert_eq!(nl.0, vec!["a", "b", "c"]);
            assert_eq!(nl.as_encoded_string(), "a,b,c");
        }

        #[test]
        fn name_list_rejects_empty_entry() {
            // An empty entry mid-list (",,") is still invalid — only
            // the zero-length whole-list case is allowed.
            assert!(NameList::from_encoded_string("a,,b").is_err());
        }

        #[test]
        fn name_list_accepts_single_trailing_comma() {
            let nl = NameList::from_encoded_string("a,b,").unwrap();
            assert_eq!(nl.0, vec!["a", "b"]);
            assert_eq!(nl.as_encoded_string(), "a,b");
        }

        #[test]
        fn name_list_rejects_other_empty_trailing_entries() {
            assert!(NameList::from_encoded_string(",").is_err());
            assert!(NameList::from_encoded_string("a,,").is_err());
        }
    }
}

pub use limited_string::LimitedString;
pub use name_list::NameList;

pub(crate) mod macros {
    #[allow(clippy::crate_in_macro_def)]
    macro_rules! map_err {
        ($result:expr) => {
            $result.map_err(|e| crate::Error::from(e))
        };
    }

    pub(crate) use map_err;
}

#[cfg(any(feature = "ring", feature = "aws-lc-rs"))]
pub(crate) use macros::map_err;

#[doc(hidden)]
pub fn sign_with_hash_alg(key: &PrivateKeyWithHashAlg, data: &[u8]) -> ssh_key::Result<Vec<u8>> {
    Ok(match key.key_data() {
        #[cfg(feature = "rsa")]
        ssh_key::private::KeypairData::Rsa(rsa_keypair) => {
            let ssh_key::Algorithm::Rsa { hash } = key.algorithm() else {
                unreachable!();
            };
            signature::Signer::try_sign(&(rsa_keypair, hash), data)?.encoded()?
        }
        // With the `rsa` feature off the RSA arm above is compiled out. The
        // catch-all cannot honour the negotiated hash, so it fails with
        // `Rsa { hash: None }` only after the peer has accepted the key
        // (USERAUTH_PK_OK) -- a confusing error that points at the credential,
        // not the build. Fail here instead, naming the missing feature and
        // reporting the algorithm that was actually negotiated.
        // See https://github.com/Eugeny/russh/issues/758.
        #[cfg(not(feature = "rsa"))]
        ssh_key::private::KeypairData::Rsa(_) => {
            log::error!(
                "cannot sign with an RSA key: russh was built without the `rsa` \
                 feature (enable it, e.g. `features = [\"rsa\"]`)"
            );
            return Err(ssh_key::Error::AlgorithmUnsupported {
                algorithm: key.algorithm(),
            });
        }
        keypair => signature::Signer::try_sign(keypair, data)?.encoded()?,
    })
}

mod algorithm {
    use ssh_key::{Algorithm, HashAlg};

    pub trait AlgorithmExt {
        fn hash_alg(&self) -> Option<HashAlg>;
        fn with_hash_alg(&self, hash_alg: Option<HashAlg>) -> Self;
        fn new_certificate_ext(algo: &str) -> Result<Self, ssh_key::Error>
        where
            Self: Sized;
    }

    impl AlgorithmExt for Algorithm {
        fn hash_alg(&self) -> Option<HashAlg> {
            match self {
                Algorithm::Rsa { hash } => *hash,
                _ => None,
            }
        }

        fn with_hash_alg(&self, hash_alg: Option<HashAlg>) -> Self {
            match self {
                Algorithm::Rsa { .. } => Algorithm::Rsa { hash: hash_alg },
                x => x.clone(),
            }
        }

        fn new_certificate_ext(algo: &str) -> Result<Self, ssh_key::Error> {
            match algo {
                "rsa-sha2-256-cert-v01@openssh.com" => Ok(Algorithm::Rsa {
                    hash: Some(HashAlg::Sha256),
                }),
                "rsa-sha2-512-cert-v01@openssh.com" => Ok(Algorithm::Rsa {
                    hash: Some(HashAlg::Sha512),
                }),
                x => Algorithm::new_certificate(x),
            }
        }
    }
}

#[doc(hidden)]
pub use algorithm::AlgorithmExt;

use crate::keys::key::PrivateKeyWithHashAlg;

#[cfg(all(test, not(feature = "rsa"), any(feature = "ring", feature = "aws-lc-rs")))]
mod tests {
    use std::sync::Arc;

    use ssh_key::{HashAlg, PrivateKey};

    use super::*;

    // OpenSSH-format RSA keys parse even with the `rsa` feature off (only
    // PKCS#1/#8 parsing is gated), so a caller can hold an RSA keypair the
    // signer cannot honour. It must fail loudly rather than sign as SHA-1 and
    // be rejected by the peer after USERAUTH_PK_OK.
    // See https://github.com/Eugeny/russh/issues/758.
    const RSA_OPENSSH: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAQEAzXFzBprJRnloMqy6YBEIHGSkA2HhAkXPfNqb66NW4vZRo5jyVqB9\nStvPc1mnLVdMBLLsPhs+/QNd9DP+1a2rbkHbA112mGlFjX28GeD7cJ8tbSLbO9Ovqs9Fr9\n7PTWLCgIwObWNcmedCHRZp1o61ZO7ez+YPtrl+97UPjwyvr9pJ9P5vtIFpWfUpzlUzA3OI\nlGEy3ozWAC3nz7BqeIrP6shQV92Z6YvzA4GmqzCIHGYu3LjMbykpmrgPvTbOVGk5MlV61u\n3VmpwwYAxfBYIIsW1CVFDYgoNhtz6CHrcL+yjaaMJ6XNdNv0Fkm718k3J/tr+NhaFxMGSd\nx/kLPozW+QAAA7jZ6aUF2emlBQAAAAdzc2gtcnNhAAABAQDNcXMGmslGeWgyrLpgEQgcZK\nQDYeECRc982pvro1bi9lGjmPJWoH1K289zWactV0wEsuw+Gz79A130M/7VratuQdsDXXaY\naUWNfbwZ4Ptwny1tIts706+qz0Wv3s9NYsKAjA5tY1yZ50IdFmnWjrVk7t7P5g+2uX73tQ\n+PDK+v2kn0/m+0gWlZ9SnOVTMDc4iUYTLejNYALefPsGp4is/qyFBX3Znpi/MDgaarMIgc\nZi7cuMxvKSmauA+9Ns5UaTkyVXrW7dWanDBgDF8FggixbUJUUNiCg2G3PoIetwv7KNpown\npc102/QWSbvXyTcn+2v42FoXEwZJ3H+Qs+jNb5AAAAAwEAAQAAAQAXOOu3o2d8/9w3Xi7z\nDPnNu9KOT1IP37REeLGHQT9hCFbSF0fNYvbGr3ITp96t0WBWZ6MsogfMscS9BeOYY7ktDW\nSkTLxLHhsukzff9P53DIcl/vqpGJSSyjsmVhk4tiEmRq6aztB41Rs0DoSILDl030twVKES\nFoWzmlPX56c0SVRzbbqrZr2D458iK4TxmAa2kmFJk0EicGpgZWNC8zcHKhkAblPb1PdfFE\nirphsql3yPa1InoGrp/X/uHXsT3MjDxgXwOUPsQHG2t2kLKbdPCJ1Cci3wysXpzvGfytOK\neheuvUMTcLDSw4Vf7nadT9CR2pP4QJsAbfdWza1aQt3BAAAAgG+4pVf6y26Ki/Q41mD8a6\nNv3wFNTkTgMVWXNezJ2zGhuaZgYqLu5n6GS1s0qyZzW7xfwyowajttWH4haHqddi1jYC53\nrKE0vLHsBiMlc/pyvr0QueWyAXy0OKcZ9YzS36H2A5dznpLuwnkgI/Aw7RsfgnXp2YdmpS\n+jl3L7OSDYAAAAgQD3idiLn9RDCEXBf4foi6r5j2B5h9sOIX5ixdfAlDZtcMd8eRVOFJNl\n1GpqJRqeh5QM2oV657fqHlqouSM0ooTwSHYBDpQDGV43u+YHFqGkKb3OC24sN/RejNyLDA\naP8MSCv8JhvjWnlMT9iFI+Es+1AjAov0s0T2K2L3xDbk6kqwAAAIEA1Hc8oE88mYLa+7x2\nykqMjoPb9b8J4I3oBvIUQIy+wKqe9K5s6rh3nIaaAnhOEwahOqayb0jfvkv4bc1taHBgQS\ndJ2lO2kZ+AZDZbboOD+Gi5iOnNphtCem/h2LTQ/crjcDx1QGfDOsfqtF1ggzXFJVNqJaUA\n+fC7anjMQbvHCusAAAAAAQID\n-----END OPENSSH PRIVATE KEY-----\n";

    #[test]
    fn rsa_signing_without_feature_fails_loudly() {
        let rsa = Arc::new(PrivateKey::from_openssh(RSA_OPENSSH).unwrap());
        let key = PrivateKeyWithHashAlg::new(rsa, Some(HashAlg::Sha512));
        // Fails at signing time, and names the negotiated algorithm
        // (`Rsa { hash: Some(Sha512) }`) rather than the misleading
        // `Rsa { hash: None }` the bare signer would report.
        match sign_with_hash_alg(&key, b"payload") {
            Err(ssh_key::Error::AlgorithmUnsupported {
                algorithm: ssh_key::Algorithm::Rsa { hash: Some(HashAlg::Sha512) },
            }) => {}
            other => panic!("expected AlgorithmUnsupported naming rsa-sha2-512, got {other:?}"),
        }
    }
}