From ff06ea5c72b78174fdf7b741c6af3c26468dbe0f Mon Sep 17 00:00:00 2001 From: Noratrieb <48135649+Noratrieb@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:59:23 +0200 Subject: [PATCH] start client --- Cargo.lock | 129 ++++++++++++++++++ fakesshd/smoke-test.sh | 16 +++ ssh-connection/src/lib.rs | 16 +-- ssh-protocol/src/lib.rs | 10 +- ssh-transport/src/client.rs | 197 ++++++++++++++++++++++++++++ ssh-transport/src/crypto.rs | 64 ++++++++- ssh-transport/src/crypto/encrypt.rs | 4 +- ssh-transport/src/lib.rs | 3 +- ssh-transport/src/numbers.rs | 8 +- ssh-transport/src/packet.rs | 50 +++++-- ssh-transport/src/parse.rs | 15 ++- ssh-transport/src/server.rs | 101 ++++++-------- ssh/Cargo.toml | 9 ++ ssh/src/main.rs | 81 +++++++++++- 14 files changed, 598 insertions(+), 105 deletions(-) create mode 100755 fakesshd/smoke-test.sh create mode 100644 ssh-transport/src/client.rs diff --git a/Cargo.lock b/Cargo.lock index 5552bec..5e84cb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,55 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -157,6 +206,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "const-oid" version = "0.9.6" @@ -401,6 +496,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -446,6 +547,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.11" @@ -918,6 +1025,16 @@ dependencies = [ [[package]] name = "ssh" version = "0.1.0" +dependencies = [ + "clap", + "eyre", + "rand", + "ssh-protocol", + "ssh-transport", + "tokio", + "tracing", + "tracing-subscriber", +] [[package]] name = "ssh-connection" @@ -958,6 +1075,12 @@ dependencies = [ "x25519-dalek", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1110,6 +1233,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.0" diff --git a/fakesshd/smoke-test.sh b/fakesshd/smoke-test.sh new file mode 100755 index 0000000..c359caf --- /dev/null +++ b/fakesshd/smoke-test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +cargo build -p fakesshd + +cargo run -p fakesshd & + +sleep 1 + +ssh -p 2222 localhost true +ssh -p 2222 -oCiphers=aes256-gcm@openssh.com \ + -oHostKeyAlgorithms=ecdsa-sha2-nistp256 \ + -oKexAlgorithms=ecdh-sha2-nistp256 127.0.0.1 true + +pkill fakesshd diff --git a/ssh-connection/src/lib.rs b/ssh-connection/src/lib.rs index 2e5c736..45c6f35 100644 --- a/ssh-connection/src/lib.rs +++ b/ssh-connection/src/lib.rs @@ -4,7 +4,7 @@ use tracing::{debug, info, trace, warn}; use ssh_transport::packet::Packet; use ssh_transport::Result; -use ssh_transport::{client_error, numbers}; +use ssh_transport::{numbers, peer_error}; /// A channel number (on our side). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -171,7 +171,7 @@ impl ServerChannelsState { let our_number = self.next_channel_id; self.next_channel_id = ChannelNumber(self.next_channel_id.0.checked_add(1).ok_or_else(|| { - client_error!("created too many channels, overflowed the counter") + peer_error!("created too many channels, overflowed the counter") })?); self.packets_to_send @@ -213,7 +213,7 @@ impl ServerChannelsState { channel.peer_window_size = channel .peer_window_size .checked_add(bytes_to_add) - .ok_or_else(|| client_error!("window size larger than 2^32"))?; + .ok_or_else(|| peer_error!("window size larger than 2^32"))?; if !channel.queued_data.is_empty() { let limit = @@ -232,14 +232,14 @@ impl ServerChannelsState { .our_window_size .checked_sub(data.len() as u32) .ok_or_else(|| { - client_error!( + peer_error!( "sent more data than the window allows: {} while the window is {}", data.len(), channel.our_window_size ) })?; if channel.our_max_packet_size < (data.len() as u32) { - return Err(client_error!( + return Err(peer_error!( "data bigger than allowed packet size: {} while the max packet size is {}", data.len(), channel.our_max_packet_size @@ -376,7 +376,7 @@ impl ServerChannelsState { _ => { todo!( "unsupported packet: {} ({packet_type})", - numbers::packet_type_to_string(packet_type).unwrap_or("") + numbers::packet_type_to_string(packet_type) ); } } @@ -512,7 +512,7 @@ impl ServerChannelsState { fn validate_channel(&self, number: u32) -> Result { if !self.channels.contains_key(&ChannelNumber(number)) { - return Err(client_error!("unknown channel: {number}")); + return Err(peer_error!("unknown channel: {number}")); } Ok(ChannelNumber(number)) } @@ -520,7 +520,7 @@ impl ServerChannelsState { fn channel(&mut self, number: ChannelNumber) -> Result<&mut Channel> { self.channels .get_mut(&number) - .ok_or_else(|| client_error!("unknown channel: {number:?}")) + .ok_or_else(|| peer_error!("unknown channel: {number:?}")) } } diff --git a/ssh-protocol/src/lib.rs b/ssh-protocol/src/lib.rs index 14bed53..944e4c8 100644 --- a/ssh-protocol/src/lib.rs +++ b/ssh-protocol/src/lib.rs @@ -77,7 +77,7 @@ impl ServerConnection { pub mod auth { use std::collections::VecDeque; - use ssh_transport::{client_error, numbers, packet::Packet, parse::NameList, Result}; + use ssh_transport::{peer_error, numbers, packet::Packet, parse::NameList, Result}; use tracing::info; pub struct BadAuth { @@ -105,7 +105,7 @@ pub mod auth { let mut auth_req = packet.payload_parser(); if auth_req.u8()? != numbers::SSH_MSG_USERAUTH_REQUEST { - return Err(client_error!("did not send SSH_MSG_SERVICE_REQUEST")); + return Err(peer_error!("did not send SSH_MSG_SERVICE_REQUEST")); } let username = auth_req.utf8_string()?; let service_name = auth_req.utf8_string()?; @@ -121,7 +121,7 @@ pub mod auth { } if service_name != "ssh-connection" { - return Err(client_error!( + return Err(peer_error!( "client tried to unsupported service: {service_name}" )); } @@ -130,7 +130,7 @@ pub mod auth { "password" => { let change_password = auth_req.bool()?; if change_password { - return Err(client_error!("client tried to change password unprompted")); + return Err(peer_error!("client tried to change password unprompted")); } let password = auth_req.utf8_string()?; @@ -146,7 +146,7 @@ pub mod auth { self.is_authenticated = true; } _ if self.has_failed => { - return Err(client_error!( + return Err(peer_error!( "client tried unsupported method twice: {method_name}" )); } diff --git a/ssh-transport/src/client.rs b/ssh-transport/src/client.rs new file mode 100644 index 0000000..22189b6 --- /dev/null +++ b/ssh-transport/src/client.rs @@ -0,0 +1,197 @@ +use std::{collections::VecDeque, mem}; + +use tracing::{debug, info, trace}; + +use crate::{ + crypto::{self, AlgorithmName, AlgorithmNegotiation}, + numbers, + packet::{Packet, PacketTransport, ProtocolIdentParser}, + parse::{NameList, Parser, Writer}, + peer_error, Msg, Result, SshRng, SshStatus, +}; + +pub struct ClientConnection { + state: ClientState, + packet_transport: PacketTransport, + rng: Box, + + plaintext_packets: VecDeque, +} + +enum ClientState { + ProtoExchange { + client_ident: Vec, + ident_parser: ProtocolIdentParser, + }, + KexInit { + client_ident: Vec, + }, +} + +impl ClientConnection { + pub fn new(rng: impl SshRng + Send + Sync + 'static) -> Self { + let client_ident = b"SSH-2.0-FakeSSH\r\n".to_vec(); + + let mut packet_transport = PacketTransport::new(); + packet_transport.queue_send_protocol_info(client_ident.clone()); + + Self { + state: ClientState::ProtoExchange { + ident_parser: ProtocolIdentParser::new(), + client_ident, + }, + packet_transport, + rng: Box::new(rng), + + plaintext_packets: VecDeque::new(), + } + } + + pub fn recv_bytes(&mut self, bytes: &[u8]) -> Result<()> { + if let ClientState::ProtoExchange { + ident_parser, + client_ident, + } = &mut self.state + { + ident_parser.recv_bytes(bytes); + if ident_parser.get_peer_ident().is_some() { + let client_ident = mem::take(client_ident); + // This moves to the next state. + self.send_kexinit(client_ident); + return Ok(()); + } + return Ok(()); + } + + self.packet_transport.recv_bytes(bytes)?; + + while let Some(packet) = self.packet_transport.recv_next_packet() { + let packet_type = packet.payload.get(0).unwrap_or(&0xFF); + let packet_type_string = numbers::packet_type_to_string(*packet_type); + + trace!(%packet_type, %packet_type_string, packet_len = %packet.payload.len(), "Received packet"); + + // Handle some packets ignoring the state. + match packet.payload.get(0).copied() { + Some(numbers::SSH_MSG_DISCONNECT) => { + // + let mut disconnect = Parser::new(&packet.payload[1..]); + let reason = disconnect.u32()?; + let description = disconnect.utf8_string()?; + let _language_tag = disconnect.utf8_string()?; + + let reason_string = + numbers::disconnect_reason_to_string(reason).unwrap_or(""); + + info!(%reason, %reason_string, %description, "Server disconnecting"); + + return Err(SshStatus::Disconnect); + } + _ => {} + } + + match &mut self.state { + ClientState::ProtoExchange { .. } => unreachable!("handled above"), + ClientState::KexInit { client_ident } => { + let mut kexinit = packet.payload_parser(); + let packet_type = kexinit.u8()?; + if packet_type != numbers::SSH_MSG_KEXINIT { + return Err(peer_error!( + "expected SSH_MSG_KEXINIT, found {}", + numbers::packet_type_to_string(packet_type) + )); + } +/* + let cookie = kexinit.array::<16>()?; + let kex_algorithm = kexinit.name_list()?; + let kex_algorithms = AlgorithmNegotiation { + supported: vec![ + crypto::KEX_CURVE_25519_SHA256, + crypto::KEX_ECDH_SHA2_NISTP256, + ], + }; + let kex_algorithm = kex_algorithms.find(kex_algorithm.0)?; + debug!(name = %kex_algorithm.name(), "Using KEX algorithm"); + + let server_hostkey_algorithm = kexinit.name_list()?; + let server_hostkey_algorithms = AlgorithmNegotiation { + supported: vec![ + crypto::hostkey_ed25519(ED25519_PRIVKEY_BYTES.to_vec()), + crypto::hostkey_ecdsa_sha2_p256(ECDSA_P256_PRIVKEY_BYTES.to_vec()), + ], + }; + let server_hostkey_algorithm = + server_hostkey_algorithms.find(server_hostkey_algorithm.0)?; + debug!(name = %server_hostkey_algorithm.name(), "Using host key algorithm"); + + let encryption_algorithms_client_to_server = kexinit.name_list()?; + let encryption_algorithms_client_to_server = select_alg( + encryption_algorithms_client_to_server, + [ + crypto::encrypt::CHACHA20POLY1305, + crypto::encrypt::AES256_GCM, + ], + ); + let encryption_algorithms_server_to_client = kexinit.name_list()?; + let encryption_algorithms_server_to_client = select_alg( + encryption_algorithms_server_to_client, + [ + crypto::encrypt::CHACHA20POLY1305, + crypto::encrypt::AES256_GCM, + ], + ); + let mac_algorithms_client_to_server = kexinit.name_list()?; + select_alg(mac_algorithms_client_to_server, ["hmac-sha2-256"])?; + let mac_algorithms_server_to_client = kexinit.name_list()?; + select_alg(mac_algorithms_server_to_client, ["hmac-sha2-256"])?; + + let compression_algorithms_client_to_server = kexinit.name_list()?; + select_alg(compression_algorithms_client_to_server, ["none"])?; + let compression_algorithms_server_to_client = kexinit.name_list()?; + select_alg(compression_algorithms_server_to_client, ["none"])?; + let _languages_client_to_server = kexinit.name_list()?; + let _languages_server_to_client = kexinit.name_list()?; + let first_kex_packet_follows = kexinit.bool()?; + if first_kex_packet_follows { + return Err(peer_error!("does not support guessed kex init packages")); + }*/ + } + } + } + Ok(()) + } + + pub fn next_msg_to_send(&mut self) -> Option { + self.packet_transport.next_msg_to_send() + } + + fn send_kexinit(&mut self, client_ident: Vec) { + let mut cookie = [0; 16]; + self.rng.fill_bytes(&mut cookie); + + let mut kexinit = Writer::new(); + kexinit.u8(numbers::SSH_MSG_KEXINIT); + kexinit.array(cookie); + kexinit.name_list(NameList::multi("curve25519-sha256,ecdh-sha2-nistp256")); // kex_algorithms + kexinit.name_list(NameList::multi("ssh-ed25519,ecdsa-sha2-nistp256")); // server_host_key_algorithms + kexinit.name_list(NameList::multi( + "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com", + )); // encryption_algorithms_client_to_server + kexinit.name_list(NameList::multi( + "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com", + )); // encryption_algorithms_server_to_client + kexinit.name_list(NameList::one("hmac-sha2-256")); // mac_algorithms_client_to_server + kexinit.name_list(NameList::one("hmac-sha2-256")); // mac_algorithms_server_to_client + kexinit.name_list(NameList::one("none")); // compression_algorithms_client_to_server + kexinit.name_list(NameList::one("none")); // compression_algorithms_server_to_client + kexinit.name_list(NameList::none()); // languages_client_to_server + kexinit.name_list(NameList::none()); // languages_server_to_client + kexinit.bool(false); // first_kex_packet_follows + kexinit.u32(0); // reserved + let kexinit = kexinit.finish(); + + self.packet_transport + .queue_packet(Packet { payload: kexinit }); + self.state = ClientState::KexInit { client_ident }; + } +} diff --git a/ssh-transport/src/crypto.rs b/ssh-transport/src/crypto.rs index 458060e..cba30a3 100644 --- a/ssh-transport/src/crypto.rs +++ b/ssh-transport/src/crypto.rs @@ -4,16 +4,22 @@ use p256::ecdsa::signature::Signer; use sha2::Digest; use crate::{ - client_error, packet::{EncryptedPacket, MsgKind, Packet, RawPacket}, parse::{self, Writer}, - Msg, Result, SshRng, + peer_error, Msg, Result, SshRng, }; pub trait AlgorithmName { fn name(&self) -> &'static str; } +// Dummy algorithm. +impl AlgorithmName for &'static str { + fn name(&self) -> &'static str { + self + } +} + #[derive(Clone, Copy)] pub struct KexAlgorithm { name: &'static str, @@ -42,7 +48,7 @@ pub const KEX_CURVE_25519_SHA256: KexAlgorithm = KexAlgorithm { let server_public_key = x25519_dalek::PublicKey::from(&secret); // Q_S let Ok(arr) = <[u8; 32]>::try_from(client_public_key) else { - return Err(crate::client_error!( + return Err(crate::peer_error!( "invalid x25519 public key length, should be 32, was: {}", client_public_key.len() )); @@ -65,7 +71,7 @@ pub const KEX_ECDH_SHA2_NISTP256: KexAlgorithm = KexAlgorithm { let client_public_key = p256::PublicKey::from_sec1_bytes(client_public_key).map_err(|_| { - crate::client_error!( + crate::peer_error!( "invalid p256 public key length: {}", client_public_key.len() ) @@ -196,12 +202,58 @@ impl AlgorithmNegotiation { } } - Err(client_error!( - "client does not support any matching algorithm: supported: {client_supports:?}" + Err(peer_error!( + "peer does not support any matching algorithm: peer supports: {client_supports:?}" )) } } +pub struct SupportedAlgorithms { + pub key_exchange: AlgorithmNegotiation, + pub hostkey: AlgorithmNegotiation, + pub encryption_to_peer: AlgorithmNegotiation, + pub encryption_from_peer: AlgorithmNegotiation, + pub mac_to_peer: AlgorithmNegotiation<&'static str>, + pub mac_from_peer: AlgorithmNegotiation<&'static str>, + pub compression_to_peer: AlgorithmNegotiation<&'static str>, + pub compression_from_peer: AlgorithmNegotiation<&'static str>, +} + +impl SupportedAlgorithms { + /// A secure default using elliptic curves and AEAD. + pub fn secure() -> Self { + Self { + key_exchange: AlgorithmNegotiation { + supported: vec![KEX_CURVE_25519_SHA256, KEX_ECDH_SHA2_NISTP256], + }, + hostkey: AlgorithmNegotiation { + supported: vec![ + hostkey_ed25519(crate::server::ED25519_PRIVKEY_BYTES.to_vec()), + hostkey_ecdsa_sha2_p256(crate::server::ECDSA_P256_PRIVKEY_BYTES.to_vec()), + ], + }, + encryption_to_peer: AlgorithmNegotiation { + supported: vec![encrypt::CHACHA20POLY1305, encrypt::AES256_GCM], + }, + encryption_from_peer: AlgorithmNegotiation { + supported: vec![encrypt::CHACHA20POLY1305, encrypt::AES256_GCM], + }, + mac_to_peer: AlgorithmNegotiation { + supported: vec!["hmac-sha2-256"], + }, + mac_from_peer: AlgorithmNegotiation { + supported: vec!["hmac-sha2-256"], + }, + compression_to_peer: AlgorithmNegotiation { + supported: vec!["none"], + }, + compression_from_peer: AlgorithmNegotiation { + supported: vec!["none"], + }, + } + } +} + pub(crate) struct Session { session_id: [u8; 32], client_to_server: Tunnel, diff --git a/ssh-transport/src/crypto/encrypt.rs b/ssh-transport/src/crypto/encrypt.rs index 9d2392c..66395e1 100644 --- a/ssh-transport/src/crypto/encrypt.rs +++ b/ssh-transport/src/crypto/encrypt.rs @@ -108,7 +108,7 @@ impl ChaCha20Poly1305OpenSsh { let read_tag = poly1305::Tag::from_slice(&bytes.full_packet()[tag_offset..]); if !bool::from(mac.ct_eq(read_tag)) { - return Err(crate::client_error!( + return Err(crate::peer_error!( "failed to decrypt: invalid poly1305 MAC" )); } @@ -199,7 +199,7 @@ impl<'a> Aes256GcmOpenSsh<'a> { encrypted_packet_content, (&tag).into(), ) - .map_err(|_| crate::client_error!("failed to decrypt: invalid GCM MAC"))?; + .map_err(|_| crate::peer_error!("failed to decrypt: invalid GCM MAC"))?; self.inc_nonce(); Packet::from_full(encrypted_packet_content) diff --git a/ssh-transport/src/lib.rs b/ssh-transport/src/lib.rs index 59ed974..23e9805 100644 --- a/ssh-transport/src/lib.rs +++ b/ssh-transport/src/lib.rs @@ -1,3 +1,4 @@ +pub mod client; mod crypto; pub mod numbers; pub mod packet; @@ -54,7 +55,7 @@ impl rand_core::RngCore for SshRngRandAdapter<'_> { } #[macro_export] -macro_rules! client_error { +macro_rules! peer_error { ($($tt:tt)*) => { $crate::SshStatus::ClientError(::std::format!($($tt)*)) }; diff --git a/ssh-transport/src/numbers.rs b/ssh-transport/src/numbers.rs index bdfcdd3..d830086 100644 --- a/ssh-transport/src/numbers.rs +++ b/ssh-transport/src/numbers.rs @@ -54,8 +54,8 @@ pub const SSH_MSG_CHANNEL_REQUEST: u8 = 98; pub const SSH_MSG_CHANNEL_SUCCESS: u8 = 99; pub const SSH_MSG_CHANNEL_FAILURE: u8 = 100; -pub fn packet_type_to_string(packet_type: u8) -> Option<&'static str> { - Some(match packet_type { +pub fn packet_type_to_string(packet_type: u8) -> &'static str { + match packet_type { 1 => "SSH_MSG_DISCONNECT", 2 => "SSH_MSG_IGNORE", 3 => "SSH_MSG_UNIMPLEMENTED", @@ -84,8 +84,8 @@ pub fn packet_type_to_string(packet_type: u8) -> Option<&'static str> { 98 => "SSH_MSG_CHANNEL_REQUEST", 99 => "SSH_MSG_CHANNEL_SUCCESS", 100 => "SSH_MSG_CHANNEL_FAILURE", - _ => return None, - }) + _ => return "", + } } pub const SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT: u32 = 1; diff --git a/ssh-transport/src/packet.rs b/ssh-transport/src/packet.rs index a4aad8e..b6386f3 100644 --- a/ssh-transport/src/packet.rs +++ b/ssh-transport/src/packet.rs @@ -1,11 +1,14 @@ mod ctors; use std::collections::VecDeque; +use std::mem; + +use tracing::debug; use crate::crypto::{EncryptionAlgorithm, Keys, Plaintext, Session}; use crate::parse::{NameList, Parser, Writer}; use crate::Result; -use crate::{client_error, numbers}; +use crate::{peer_error, numbers}; /// Frames the byte stream into packets. pub(crate) struct PacketTransport { @@ -15,7 +18,7 @@ pub(crate) struct PacketTransport { recv_packets: VecDeque, recv_next_seq_nr: u64, - send_packets: VecDeque, + msgs_to_send: VecDeque, send_next_seq_nr: u64, } @@ -48,7 +51,7 @@ impl PacketTransport { recv_packets: VecDeque::new(), recv_next_seq_nr: 0, - send_packets: VecDeque::new(), + msgs_to_send: VecDeque::new(), send_next_seq_nr: 0, } } @@ -95,10 +98,11 @@ impl PacketTransport { // Private: Make sure all sending goes through variant-specific functions here. fn queue_send_msg(&mut self, msg: Msg) { - self.send_packets.push_back(msg); + self.msgs_to_send.push_back(msg); } + pub(crate) fn next_msg_to_send(&mut self) -> Option { - self.send_packets.pop_front() + self.msgs_to_send.pop_front() } pub(crate) fn set_key( @@ -154,18 +158,18 @@ impl Packet { pub(crate) fn from_full(bytes: &[u8]) -> Result { let Some(padding_length) = bytes.first() else { - return Err(client_error!("empty packet")); + return Err(peer_error!("empty packet")); }; let Some(payload_len) = (bytes.len() - 1).checked_sub(*padding_length as usize) else { - return Err(client_error!("packet padding longer than packet")); + return Err(peer_error!("packet padding longer than packet")); }; let payload = &bytes[1..][..payload_len]; // TODO: handle the annoying decryption special case differnt where its +0 instead of +4 // also TODO: this depends on the cipher! //if (bytes.len() + 4) % 8 != 0 { - // return Err(client_error!("full packet length must be multiple of 8: {}", bytes.len())); + // return Err(peer_error!("full packet length must be multiple of 8: {}", bytes.len())); //} Ok(Self { @@ -244,7 +248,7 @@ impl<'a> KeyExchangeInitPacket<'a> { let kind = c.u8()?; if kind != numbers::SSH_MSG_KEXINIT { - return Err(client_error!( + return Err(peer_error!( "expected SSH_MSG_KEXINIT packet, found {kind}" )); } @@ -313,7 +317,7 @@ impl<'a> KeyExchangeEcDhInitPacket<'a> { let kind = c.u8()?; if kind != numbers::SSH_MSG_KEX_ECDH_INIT { - return Err(client_error!( + return Err(peer_error!( "expected SSH_MSG_KEXDH_INIT packet, found {kind}" )); } @@ -411,7 +415,7 @@ impl PacketParser { // 'padding_length', 'payload', 'random padding', and 'mac'). // Implementations SHOULD support longer packets, where they might be needed. if packet_length > 500_000 { - return Err(client_error!( + return Err(peer_error!( "packet too large (>500_000): {packet_length}" )); } @@ -439,6 +443,30 @@ impl PacketParser { } } +pub(crate) struct ProtocolIdentParser(Vec); + +impl ProtocolIdentParser { + pub(crate) fn new() -> Self { + Self(Vec::new()) + } + pub(crate) fn recv_bytes(&mut self, bytes: &[u8]) { + self.0.extend_from_slice(bytes); + } + pub(crate) fn get_peer_ident(&mut self) -> Option> { + if self.0.windows(2).any(|win| win == b"\r\n") { + // TODO: care that its SSH 2.0 instead of anythin anything else + // The peer will not send any more information than this until we respond, so discord the rest of the bytes. + let peer_ident = mem::take(&mut self.0); + let peer_ident_string = String::from_utf8_lossy(&peer_ident); + debug!(identification = %peer_ident_string, "Peer identifier"); + + Some(peer_ident) + } else { + None + } + } +} + #[cfg(test)] mod tests { use crate::packet::PacketParser; diff --git a/ssh-transport/src/parse.rs b/ssh-transport/src/parse.rs index 626bf1b..cb4742c 100644 --- a/ssh-transport/src/parse.rs +++ b/ssh-transport/src/parse.rs @@ -24,7 +24,7 @@ impl<'a> Parser<'a> { pub fn array(&mut self) -> Result<[u8; N]> { assert!(N < 100_000); if self.0.len() < N { - return Err(crate::client_error!("packet too short")); + return Err(crate::peer_error!("packet too short")); } let result = self.0[..N].try_into().unwrap(); self.0 = &self.0[N..]; @@ -33,10 +33,10 @@ impl<'a> Parser<'a> { pub fn slice(&mut self, len: usize) -> Result<&'a [u8]> { if self.0.len() < len { - return Err(crate::client_error!("packet too short")); + return Err(crate::peer_error!("packet too short")); } if len > 100_000 { - return Err(crate::client_error!("bytes too long: {len}")); + return Err(crate::peer_error!("bytes too long: {len}")); } let result = &self.0[..len]; self.0 = &self.0[len..]; @@ -48,7 +48,7 @@ impl<'a> Parser<'a> { match b { 0 => Ok(false), 1 => Ok(true), - _ => Err(crate::client_error!("invalid bool: {b}")), + _ => Err(crate::peer_error!("invalid bool: {b}")), } } @@ -70,7 +70,7 @@ impl<'a> Parser<'a> { pub fn utf8_string(&mut self) -> Result<&'a str> { let s = self.string()?; let Ok(s) = str::from_utf8(s) else { - return Err(crate::client_error!("name-list is invalid UTF-8")); + return Err(crate::peer_error!("name-list is invalid UTF-8")); }; Ok(s) } @@ -148,10 +148,13 @@ pub struct NameList<'a>(pub &'a str); impl<'a> NameList<'a> { pub fn one(item: &'a str) -> Self { if item.contains(',') { - //panic!("tried creating name list with comma in item: {item}"); + panic!("tried creating name list with comma in item: {item}"); } Self(item) } + pub fn multi(items: &'a str) -> Self { + Self(items) + } pub fn none() -> NameList<'static> { NameList("") } diff --git a/ssh-transport/src/server.rs b/ssh-transport/src/server.rs index 5f48c56..fdf9bf3 100644 --- a/ssh-transport/src/server.rs +++ b/ssh-transport/src/server.rs @@ -3,11 +3,14 @@ use std::{collections::VecDeque, mem::take}; use crate::crypto::{ self, AlgorithmName, AlgorithmNegotiation, EncryptionAlgorithm, HostKeySigningAlgorithm, + SupportedAlgorithms, +}; +use crate::packet::{ + KeyExchangeEcDhInitPacket, KeyExchangeInitPacket, Packet, PacketTransport, ProtocolIdentParser, }; -use crate::packet::{KeyExchangeEcDhInitPacket, KeyExchangeInitPacket, Packet, PacketTransport}; use crate::parse::{NameList, Parser, Writer}; -use crate::{client_error, Msg, SshRng, SshStatus}; use crate::{numbers, Result}; +use crate::{peer_error, Msg, SshRng, SshStatus}; use sha2::Digest; use tracing::{debug, info, trace}; @@ -24,7 +27,7 @@ pub struct ServerConnection { enum ServerState { ProtoExchange { - received: Vec, + ident_parser: ProtocolIdentParser, }, KeyExchangeInit { client_identification: Vec, @@ -52,7 +55,7 @@ impl ServerConnection { pub fn new(rng: impl SshRng + Send + Sync + 'static) -> Self { Self { state: ServerState::ProtoExchange { - received: Vec::new(), + ident_parser: ProtocolIdentParser::new(), }, packet_transport: PacketTransport::new(), rng: Box::new(rng), @@ -60,18 +63,11 @@ impl ServerConnection { plaintext_packets: VecDeque::new(), } } -} -impl ServerConnection { pub fn recv_bytes(&mut self, bytes: &[u8]) -> Result<()> { - if let ServerState::ProtoExchange { received } = &mut self.state { - received.extend_from_slice(bytes); - if received.windows(2).any(|win| win == b"\r\n") { - // TODO: care that its SSH 2.0 instead of anythin anything else - // The client will not send any more information than this until we respond, so discord the rest of the bytes. - let client_identification = received.to_owned(); - let client_ident_string = String::from_utf8_lossy(&client_identification); - debug!(identification = %client_ident_string, "Client identifier"); + if let ServerState::ProtoExchange { ident_parser } = &mut self.state { + ident_parser.recv_bytes(bytes); + if let Some(client_identification) = ident_parser.get_peer_ident() { self.packet_transport .queue_send_protocol_info(SERVER_IDENTIFICATION.to_vec()); self.state = ServerState::KeyExchangeInit { @@ -86,8 +82,7 @@ impl ServerConnection { while let Some(packet) = self.packet_transport.recv_next_packet() { let packet_type = packet.payload.get(0).unwrap_or(&0xFF); - let packet_type_string = - numbers::packet_type_to_string(*packet_type).unwrap_or(""); + let packet_type_string = numbers::packet_type_to_string(*packet_type); trace!(%packet_type, %packet_type_string, packet_len = %packet.payload.len(), "Received packet"); @@ -122,69 +117,54 @@ impl ServerConnection { if list.iter().any(|alg| alg == expected) { Ok(expected) } else { - Err(client_error!( + Err(peer_error!( "client does not support algorithm {expected}. supported: {list:?}", )) } }; - let kex_algorithms = AlgorithmNegotiation { - supported: vec![ - crypto::KEX_CURVE_25519_SHA256, - crypto::KEX_ECDH_SHA2_NISTP256, - ], - }; - let kex_algorithm = kex_algorithms.find(kex.kex_algorithms.0)?; + let sup_algs = SupportedAlgorithms::secure(); + + let kex_algorithm = sup_algs.key_exchange.find(kex.kex_algorithms.0)?; debug!(name = %kex_algorithm.name(), "Using KEX algorithm"); - let hostkey_algorithms = AlgorithmNegotiation { - supported: vec![ - crypto::hostkey_ed25519(ED25519_PRIVKEY_BYTES.to_vec()), - crypto::hostkey_ecdsa_sha2_p256(ECDSA_P256_PRIVKEY_BYTES.to_vec()), - ], - }; let server_host_key_algorithm = - hostkey_algorithms.find(kex.server_host_key_algorithms.0)?; + sup_algs.hostkey.find(kex.server_host_key_algorithms.0)?; debug!(name = %server_host_key_algorithm.name(), "Using host key algorithm"); // TODO: Implement aes128-ctr let _ = crypto::encrypt::ENC_AES128_CTR; - let encryption_algorithms_client_to_server = AlgorithmNegotiation { - supported: vec![ - crypto::encrypt::CHACHA20POLY1305, - crypto::encrypt::AES256_GCM, - ], - }; - let encryption_algorithms_server_to_client = AlgorithmNegotiation { - supported: vec![ - crypto::encrypt::CHACHA20POLY1305, - crypto::encrypt::AES256_GCM, - ], - }; - - let encryption_client_to_server = encryption_algorithms_client_to_server + let encryption_client_to_server = sup_algs + .encryption_from_peer .find(kex.encryption_algorithms_client_to_server.0)?; debug!(name = %encryption_client_to_server.name(), "Using encryption algorithm C->S"); - let encryption_server_to_client = encryption_algorithms_server_to_client + let encryption_server_to_client = sup_algs + .encryption_to_peer .find(kex.encryption_algorithms_server_to_client.0)?; debug!(name = %encryption_server_to_client.name(), "Using encryption algorithm S->C"); - let mac_algorithm_client_to_server = - require_algorithm("hmac-sha2-256", kex.mac_algorithms_client_to_server)?; - let mac_algorithm_server_to_client = - require_algorithm("hmac-sha2-256", kex.mac_algorithms_server_to_client)?; - let compression_algorithm_client_to_server = - require_algorithm("none", kex.compression_algorithms_client_to_server)?; - let compression_algorithm_server_to_client = - require_algorithm("none", kex.compression_algorithms_server_to_client)?; + let mac_algorithm_client_to_server = sup_algs + .mac_from_peer + .find(kex.mac_algorithms_client_to_server.0)?; + let mac_algorithm_server_to_client = sup_algs + .mac_to_peer + .find(kex.mac_algorithms_server_to_client.0)?; + debug!("x"); + let compression_algorithm_client_to_server = sup_algs + .compression_from_peer + .find(kex.compression_algorithms_client_to_server.0)?; + let compression_algorithm_server_to_client = sup_algs + .compression_to_peer + .find(kex.compression_algorithms_server_to_client.0)?; + debug!("x"); let _ = kex.languages_client_to_server; let _ = kex.languages_server_to_client; if kex.first_kex_packet_follows { - return Err(client_error!( + return Err(peer_error!( "the client wants to send a guessed packet, that's annoying :(" )); } @@ -314,7 +294,7 @@ impl ServerConnection { encryption_server_to_client, } => { if packet.payload != [numbers::SSH_MSG_NEWKEYS] { - return Err(client_error!("did not send SSH_MSG_NEWKEYS")); + return Err(peer_error!("did not send SSH_MSG_NEWKEYS")); } self.packet_transport.queue_packet(Packet { @@ -331,14 +311,14 @@ impl ServerConnection { ServerState::ServiceRequest => { // TODO: this should probably move out of here? unsure. if packet.payload.first() != Some(&numbers::SSH_MSG_SERVICE_REQUEST) { - return Err(client_error!("did not send SSH_MSG_SERVICE_REQUEST")); + return Err(peer_error!("did not send SSH_MSG_SERVICE_REQUEST")); } let mut p = Parser::new(&packet.payload[1..]); let service = p.utf8_string()?; debug!(%service, "Client requesting service"); if service != "ssh-userauth" { - return Err(client_error!("only supports ssh-userauth")); + return Err(peer_error!("only supports ssh-userauth")); } self.packet_transport.queue_packet(Packet { @@ -385,12 +365,13 @@ impl ServerConnection { /// 1Ref4AwwRVdSFyJLGbj2AAAAB3Rlc3RrZXkBAgMEBQY= /// -----END OPENSSH PRIVATE KEY----- /// ``` -const ED25519_PRIVKEY_BYTES: &[u8; 32] = &[ +// todo: remove this lol, lmao +pub(crate) const ED25519_PRIVKEY_BYTES: &[u8; 32] = &[ 0x92, 0x7a, 0xc9, 0x31, 0xb8, 0x4b, 0x49, 0xae, 0xbf, 0x4b, 0xed, 0x99, 0x1b, 0xa6, 0x88, 0x17, 0x0b, 0x9a, 0x4a, 0x44, 0xd5, 0x47, 0xc7, 0x5b, 0x9e, 0x31, 0x7d, 0xa1, 0xd5, 0x75, 0x27, 0x99, ]; -const ECDSA_P256_PRIVKEY_BYTES: &[u8; 32] = &[ +pub(crate) const ECDSA_P256_PRIVKEY_BYTES: &[u8; 32] = &[ 0x89, 0xdd, 0x0c, 0x96, 0x22, 0x85, 0x10, 0xec, 0x3c, 0xa4, 0xa1, 0xb8, 0xac, 0x2a, 0x77, 0xa8, 0xd4, 0x4d, 0xcb, 0x9d, 0x90, 0x25, 0xc6, 0xd8, 0x3a, 0x02, 0x74, 0x4f, 0x9e, 0x44, 0xcd, 0xa3, ]; diff --git a/ssh/Cargo.toml b/ssh/Cargo.toml index d6a3a3b..7cd7db1 100644 --- a/ssh/Cargo.toml +++ b/ssh/Cargo.toml @@ -4,3 +4,12 @@ version = "0.1.0" edition = "2021" [dependencies] +ssh-protocol = { path = "../ssh-protocol" } +ssh-transport = { path = "../ssh-transport" } +clap = { version = "4.5.15", features = ["derive"] } +eyre = "0.6.12" +rand = "0.8.5" +tokio = { version = "1.39.2", features = ["full"] } +tracing-subscriber = { version = "0.3.18", features = ["json", "env-filter"] } + +tracing.workspace = true diff --git a/ssh/src/main.rs b/ssh/src/main.rs index e7a11a9..7f5674d 100644 --- a/ssh/src/main.rs +++ b/ssh/src/main.rs @@ -1,3 +1,80 @@ -fn main() { - println!("Hello, world!"); +use clap::Parser; + +use eyre::Context; +use rand::RngCore; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, +}; +use tracing::{info, trace}; + +use ssh_protocol::{ + transport::{self}, + SshStatus, +}; +use tracing_subscriber::EnvFilter; + +struct ThreadRngRand; +impl ssh_protocol::transport::SshRng for ThreadRngRand { + fn fill_bytes(&mut self, dest: &mut [u8]) { + rand::thread_rng().fill_bytes(dest); + } +} + +#[derive(clap::Parser, Debug)] +struct Args { + #[arg(short = 'p', long, default_value_t = 22)] + port: u16, + destination: String, + command: Vec, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let args = Args::parse(); + + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + let mut conn = TcpStream::connect(&format!("{}:{}", args.destination, args.port)) + .await + .wrap_err("connecting")?; + + let mut state = transport::client::ClientConnection::new(ThreadRngRand); + + let mut buf = [0; 1024]; + + loop { + while let Some(msg) = state.next_msg_to_send() { + trace!("Writing packet {msg:?}"); + conn.write_all(&msg.to_bytes()) + .await + .wrap_err("writing response")?; + } + + let read = conn + .read(&mut buf) + .await + .wrap_err("reading from connection")?; + if read == 0 { + info!("Did not read any bytes from TCP stream, EOF"); + return Ok(()); + } + + if let Err(err) = state.recv_bytes(&buf[..read]) { + match err { + SshStatus::ClientError(err) => { + info!(?err, "disconnecting client after invalid operation"); + return Ok(()); + } + SshStatus::ServerError(err) => { + return Err(err); + } + SshStatus::Disconnect => { + info!("Received disconnect from client"); + return Ok(()); + } + } + } + } }