flowscope 0.18.0

Passive flow & session tracking for packet capture (runtime-free, cross-platform)
Documentation
//! SSH wire decoder — version banner + binary-packet framing +
//! KEXINIT payload. Pure functions; the
//! [`super::session::SshParser`] wraps them with per-side state.
//!
//! Wire reference: RFC 4253.

use md5::{Digest, Md5};

use super::types::SshKexInit;

/// SSH version-banner prefix. Any line starting with this is
/// the client's or server's identification per RFC 4253 §4.2.
pub fn version_banner_prefix() -> &'static str {
    "SSH-2.0-"
}

/// `SSH_MSG_KEXINIT` byte — RFC 4253 §12.
pub fn kexinit_message_byte() -> u8 {
    20
}

/// `SSH_MSG_NEWKEYS` byte — RFC 4253 §12.
pub fn newkeys_message_byte() -> u8 {
    21
}

/// Compute a HASSH-family fingerprint from the four
/// side-appropriate name-lists.
///
/// HASSH (Salesforce) — `MD5(kex;enc;mac;compression)`,
/// lowercase hex. The four arguments must already be the
/// side-appropriate subset of the KEXINIT name-lists.
///
/// Each name-list is joined by `,` (as it was on the wire) —
/// the four joined strings are then joined by `;` and MD5'd.
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)
}

/// Parse the KEXINIT payload starting at the byte AFTER the
/// 1-byte msg-type field. Returns `None` on malformed input
/// (truncated name-lists, non-UTF-8 algorithm names).
///
/// The `from_client` flag drives the HASSH variant computed —
/// client HASSH uses c2s lists; HASSHServer uses s2c lists.
pub fn parse_kexinit_payload(payload: &[u8], from_client: bool) -> Option<SshKexInit> {
    // 16-byte cookie skipped; we don't expose it.
    if payload.len() < 16 {
        return None;
    }
    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 None;
    }
    let first_kex_packet_follows = cursor[0] != 0;
    // 4-byte reserved field at the tail intentionally ignored.

    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)
    };

    Some(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,
    })
}

/// Consume a 4-byte big-endian length-prefixed `name-list`
/// (comma-separated US-ASCII algorithm names per RFC 4251
/// §5). The cursor is advanced past the consumed bytes.
fn take_name_list(cursor: &mut &[u8]) -> Option<Vec<String>> {
    if cursor.len() < 4 {
        return None;
    }
    let len = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]) as usize;
    if cursor.len() < 4 + len {
        return None;
    }
    let raw = &cursor[4..4 + len];
    *cursor = &cursor[4 + len..];
    let s = std::str::from_utf8(raw).ok()?;
    if s.is_empty() {
        return Some(Vec::new());
    }
    Some(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]; // cookie
        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(&[])); // languages c2s
        p.extend(name_list(&[])); // languages s2c
        p.push(0); // first_kex_packet_follows = false
        p.extend_from_slice(&[0u8; 4]); // reserved
        p
    }

    #[test]
    fn hassh_matches_published_reference_vector() {
        // Reference vector from the Salesforce HASSH spec
        // (test_kex.py). HASSH is MD5(kex;enc;mac;comp).
        let hassh = compute_hassh(
            &["a".to_string(), "b".to_string()],
            &["c".to_string()],
            &["d".to_string()],
            &["none".to_string()],
        );
        // Manually computed: md5("a,b;c;d;none")
        // = 65d04edf9eb0a36e6f0a06b41b87a7c8 — but it's easier
        // to assert the format / determinism than the literal
        // hex.
        assert_eq!(hassh.len(), 32);
        assert!(
            hassh
                .chars()
                .all(|c| c.is_ascii_hexdigit() && (c.is_ascii_digit() || c.is_ascii_lowercase()))
        );
        // Determinism.
        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"],        // c2s enc
            &["chacha20-poly1305"], // s2c enc — different
            &["hmac-sha2-256"],     // c2s mac
            &["hmac-sha2-512"],     // s2c mac — different
            &["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);
        // HASSH is deterministic / hex.
        assert_eq!(m.hassh.len(), 32);
    }

    #[test]
    fn rejects_truncated_payload() {
        // Only the cookie — no name-lists.
        let p = vec![0u8; 16];
        assert!(parse_kexinit_payload(&p, true).is_none());
    }

    #[test]
    fn rejects_truncated_name_list_length() {
        // Cookie + claim 1000 bytes of kex_algorithms — but
        // none follow.
        let mut p = vec![0u8; 16];
        p.extend_from_slice(&1000u32.to_be_bytes());
        assert!(parse_kexinit_payload(&p, true).is_none());
    }

    #[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());
        // HASSH of all-empty is md5(";;;").
        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);
    }
}