use md5::{Digest, Md5};
use super::types::SshKexInit;
pub fn version_banner_prefix() -> &'static str {
"SSH-2.0-"
}
pub fn kexinit_message_byte() -> u8 {
20
}
pub fn newkeys_message_byte() -> u8 {
21
}
pub fn compute_hassh(
kex: &[String],
enc: &[String],
mac: &[String],
compression: &[String],
) -> String {
let mut s = String::new();
s.push_str(&kex.join(","));
s.push(';');
s.push_str(&enc.join(","));
s.push(';');
s.push_str(&mac.join(","));
s.push(';');
s.push_str(&compression.join(","));
let digest = Md5::digest(s.as_bytes());
hex::encode(digest)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
Truncated { need: usize, have: usize },
InvalidName,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Truncated { need, have } => {
write!(f, "truncated SSH KEXINIT payload: need {need}, have {have}")
}
Self::InvalidName => f.write_str("non-UTF-8 algorithm name in name-list"),
}
}
}
impl std::error::Error for ParseError {}
impl From<ParseError> for crate::Error {
fn from(e: ParseError) -> Self {
use crate::error::{ErrorCode, Module};
let code = match &e {
ParseError::Truncated { .. } => ErrorCode::Truncated,
ParseError::InvalidName => ErrorCode::Parse,
};
crate::Error::with_code(Module::Ssh, code, e.to_string())
}
}
pub fn parse_kexinit_payload(payload: &[u8], from_client: bool) -> Result<SshKexInit, ParseError> {
if payload.len() < 16 {
return Err(ParseError::Truncated {
need: 16,
have: payload.len(),
});
}
let mut cursor = &payload[16..];
let kex_algorithms = take_name_list(&mut cursor)?;
let server_host_key_algorithms = take_name_list(&mut cursor)?;
let encryption_c2s = take_name_list(&mut cursor)?;
let encryption_s2c = take_name_list(&mut cursor)?;
let mac_c2s = take_name_list(&mut cursor)?;
let mac_s2c = take_name_list(&mut cursor)?;
let compression_c2s = take_name_list(&mut cursor)?;
let compression_s2c = take_name_list(&mut cursor)?;
let languages_c2s = take_name_list(&mut cursor)?;
let languages_s2c = take_name_list(&mut cursor)?;
if cursor.is_empty() {
return Err(ParseError::Truncated { need: 1, have: 0 });
}
let first_kex_packet_follows = cursor[0] != 0;
let hassh = if from_client {
compute_hassh(&kex_algorithms, &encryption_c2s, &mac_c2s, &compression_c2s)
} else {
compute_hassh(&kex_algorithms, &encryption_s2c, &mac_s2c, &compression_s2c)
};
Ok(SshKexInit {
from_client,
kex_algorithms,
server_host_key_algorithms,
encryption_c2s,
encryption_s2c,
mac_c2s,
mac_s2c,
compression_c2s,
compression_s2c,
languages_c2s,
languages_s2c,
first_kex_packet_follows,
hassh,
})
}
fn take_name_list(cursor: &mut &[u8]) -> Result<Vec<String>, ParseError> {
if cursor.len() < 4 {
return Err(ParseError::Truncated {
need: 4,
have: cursor.len(),
});
}
let len = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]) as usize;
if cursor.len() < 4 + len {
return Err(ParseError::Truncated {
need: 4 + len,
have: cursor.len(),
});
}
let raw = &cursor[4..4 + len];
*cursor = &cursor[4 + len..];
let s = std::str::from_utf8(raw).map_err(|_| ParseError::InvalidName)?;
if s.is_empty() {
return Ok(Vec::new());
}
Ok(s.split(',').map(str::to_string).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn name_list(items: &[&str]) -> Vec<u8> {
let joined = items.join(",");
let mut v = (joined.len() as u32).to_be_bytes().to_vec();
v.extend_from_slice(joined.as_bytes());
v
}
#[allow(clippy::too_many_arguments)]
fn kexinit_payload(
kex: &[&str],
host_keys: &[&str],
enc_c2s: &[&str],
enc_s2c: &[&str],
mac_c2s: &[&str],
mac_s2c: &[&str],
comp_c2s: &[&str],
comp_s2c: &[&str],
) -> Vec<u8> {
let mut p = vec![0u8; 16]; p.extend(name_list(kex));
p.extend(name_list(host_keys));
p.extend(name_list(enc_c2s));
p.extend(name_list(enc_s2c));
p.extend(name_list(mac_c2s));
p.extend(name_list(mac_s2c));
p.extend(name_list(comp_c2s));
p.extend(name_list(comp_s2c));
p.extend(name_list(&[])); p.extend(name_list(&[])); p.push(0); p.extend_from_slice(&[0u8; 4]); p
}
#[test]
fn hassh_matches_published_reference_vector() {
let hassh = compute_hassh(
&["a".to_string(), "b".to_string()],
&["c".to_string()],
&["d".to_string()],
&["none".to_string()],
);
assert_eq!(hassh.len(), 32);
assert!(
hassh
.chars()
.all(|c| c.is_ascii_hexdigit() && (c.is_ascii_digit() || c.is_ascii_lowercase()))
);
assert_eq!(
hassh,
compute_hassh(
&["a".to_string(), "b".to_string()],
&["c".to_string()],
&["d".to_string()],
&["none".to_string()],
)
);
}
#[test]
fn client_and_server_hassh_differ_when_c2s_neq_s2c() {
let payload = kexinit_payload(
&["curve25519-sha256"],
&["ssh-ed25519"],
&["aes128-ctr"], &["chacha20-poly1305"], &["hmac-sha2-256"], &["hmac-sha2-512"], &["none"],
&["none"],
);
let client = parse_kexinit_payload(&payload, true).unwrap();
let server = parse_kexinit_payload(&payload, false).unwrap();
assert_ne!(client.hassh, server.hassh);
}
#[test]
fn parses_typical_openssh_kexinit() {
let payload = kexinit_payload(
&[
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
],
&["ssh-ed25519", "rsa-sha2-512"],
&["chacha20-poly1305@openssh.com", "aes128-ctr"],
&["chacha20-poly1305@openssh.com", "aes128-ctr"],
&["umac-64-etm@openssh.com", "hmac-sha2-256"],
&["umac-64-etm@openssh.com", "hmac-sha2-256"],
&["none", "zlib@openssh.com"],
&["none", "zlib@openssh.com"],
);
let m = parse_kexinit_payload(&payload, true).unwrap();
assert_eq!(m.kex_algorithms.len(), 3);
assert_eq!(
m.encryption_c2s,
vec!["chacha20-poly1305@openssh.com", "aes128-ctr"]
);
assert!(m.from_client);
assert!(!m.first_kex_packet_follows);
assert_eq!(m.hassh.len(), 32);
}
#[test]
fn rejects_truncated_payload() {
let p = vec![0u8; 16];
assert!(parse_kexinit_payload(&p, true).is_err());
}
#[test]
fn rejects_truncated_name_list_length() {
let mut p = vec![0u8; 16];
p.extend_from_slice(&1000u32.to_be_bytes());
assert!(parse_kexinit_payload(&p, true).is_err());
}
#[test]
fn empty_name_list_decodes_as_empty_vec() {
let payload = kexinit_payload(&[], &[], &[], &[], &[], &[], &[], &[]);
let m = parse_kexinit_payload(&payload, true).unwrap();
assert!(m.kex_algorithms.is_empty());
assert_eq!(m.hassh, format!("{:x}", Md5::digest(";;;".as_bytes())));
}
#[test]
fn version_banner_prefix_is_locked() {
assert_eq!(version_banner_prefix(), "SSH-2.0-");
}
#[test]
fn kexinit_byte_is_locked() {
assert_eq!(kexinit_message_byte(), 20);
assert_eq!(newkeys_message_byte(), 21);
}
}