From a52b6b37d7115709840f76aead9477b967c98d92 Mon Sep 17 00:00:00 2001 From: Noratrieb <48135649+Noratrieb@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:59:32 +0200 Subject: [PATCH] agent --- Cargo.lock | 47 ++++- Cargo.toml | 1 + ssh-agent-client/Cargo.toml | 10 ++ ssh-agent-client/README.md | 4 + ssh-agent-client/src/lib.rs | 336 ++++++++++++++++++++++++++++++++++++ ssh-agentctl/Cargo.toml | 16 ++ ssh-agentctl/here | 7 + ssh-agentctl/here.pub | 1 + ssh-agentctl/src/main.rs | 156 +++++++++++++++++ ssh-protocol/src/lib.rs | 20 ++- ssh-transport/Cargo.toml | 1 + ssh-transport/src/client.rs | 30 +++- ssh-transport/src/key.rs | 59 +++++++ ssh-transport/src/lib.rs | 2 +- ssh-transport/src/packet.rs | 32 +++- ssh-transport/src/parse.rs | 37 +++- ssh/Cargo.toml | 2 + ssh/src/main.rs | 12 ++ sshdos/src/main.rs | 89 +++------- 19 files changed, 770 insertions(+), 92 deletions(-) create mode 100644 ssh-agent-client/Cargo.toml create mode 100644 ssh-agent-client/README.md create mode 100644 ssh-agent-client/src/lib.rs create mode 100644 ssh-agentctl/Cargo.toml create mode 100644 ssh-agentctl/here create mode 100644 ssh-agentctl/here.pub create mode 100644 ssh-agentctl/src/main.rs create mode 100644 ssh-transport/src/key.rs diff --git a/Cargo.lock b/Cargo.lock index 017b04a..5b0b1f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -208,9 +214,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.15" +version = "4.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019" dependencies = [ "clap_builder", "clap_derive", @@ -597,6 +603,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex-literal" version = "0.4.1" @@ -1155,6 +1167,7 @@ dependencies = [ "eyre", "rand", "rpassword", + "ssh-agent-client", "ssh-protocol", "ssh-transport", "tokio", @@ -1162,6 +1175,31 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ssh-agent-client" +version = "0.1.0" +dependencies = [ + "eyre", + "ssh-transport", + "tokio", + "tracing", +] + +[[package]] +name = "ssh-agentctl" +version = "0.1.0" +dependencies = [ + "clap", + "eyre", + "hex", + "rpassword", + "sha2", + "ssh-agent-client", + "ssh-transport", + "tokio", + "tracing-subscriber", +] + [[package]] name = "ssh-connection" version = "0.1.0" @@ -1186,6 +1224,7 @@ version = "0.1.0" dependencies = [ "aes", "aes-gcm", + "base64", "chacha20", "crypto-bigint", "ctr", @@ -1251,9 +1290,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.39.2" +version = "1.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" +checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" dependencies = [ "backtrace", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 217205a..1f0fe13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "ssh-connection", "ssh-protocol", "ssh-transport", + "ssh-agent-client", "ssh-agentctl", ] resolver = "2" diff --git a/ssh-agent-client/Cargo.toml b/ssh-agent-client/Cargo.toml new file mode 100644 index 0000000..f415d7f --- /dev/null +++ b/ssh-agent-client/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ssh-agent-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +eyre = "0.6.12" +ssh-transport = { path = "../ssh-transport" } +tokio = { version = "1.39.3", features = ["net"] } +tracing.workspace = true diff --git a/ssh-agent-client/README.md b/ssh-agent-client/README.md new file mode 100644 index 0000000..43874a4 --- /dev/null +++ b/ssh-agent-client/README.md @@ -0,0 +1,4 @@ +# ssh-agent-client + +Client for the SSH agent protocol specified in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent +and https://github.com/openssh/openssh-portable/blob/a76a6b85108e3032c8175611ecc5746e7131f876/PROTOCOL.agent. diff --git a/ssh-agent-client/src/lib.rs b/ssh-agent-client/src/lib.rs new file mode 100644 index 0000000..a76ea4c --- /dev/null +++ b/ssh-agent-client/src/lib.rs @@ -0,0 +1,336 @@ +use eyre::{bail, eyre, Context}; +use ssh_transport::{ + packet::PacketParser, + parse::{Parser, Writer}, + SshStatus, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{debug, trace}; + +/// A message to send to the byte stream. +pub enum Request { + RemoveAllIdentities, + ListIdentities, + Sign { + key_blob: Vec, + data: Vec, + flags: u32, + }, + Lock { + passphrase: String, + }, + Unlock { + passphrase: String, + }, + Extension(ExtensionRequest), +} + +pub enum ExtensionRequest { + Query, +} + +impl Request { + pub fn to_bytes(&self) -> Vec { + let mut p = Writer::new(); + match self { + Self::RemoveAllIdentities => p.u8(numbers::SSH_AGENTC_REMOVE_ALL_IDENTITIES), + Self::ListIdentities => p.u8(numbers::SSH_AGENTC_REQUEST_IDENTITIES), + Self::Sign { + key_blob, + data, + flags, + } => { + p.u8(numbers::SSH_AGENTC_SIGN_REQUEST); + p.string(&key_blob); + p.string(&data); + p.u32(*flags); + } + Self::Lock { passphrase } => { + p.u8(numbers::SSH_AGENTC_LOCK); + p.string(passphrase.as_bytes()); + } + Self::Unlock { passphrase } => { + p.u8(numbers::SSH_AGENTC_UNLOCK); + p.string(passphrase.as_bytes()); + } + Self::Extension(ext) => { + p.u8(numbers::SSH_AGENTC_EXTENSION); + match ext { + ExtensionRequest::Query => { + p.string(b"query"); + } + } + } + } + + let mut buf = p.finish(); + let len = u32::try_from(buf.len()).unwrap(); + buf.splice(0..0, len.to_be_bytes()); + buf + } +} + +/// A server response for an agent message. +#[derive(Debug)] +pub enum ServerResponse { + /// SSH_AGENT_SUCCESS + Success, + /// SSH_AGENT_FAILURE + Failure, + + IdentitiesAnswer { + identities: Vec, + }, + + /// SSH_AGENT_SIGN_RESPONSE + SignResponse { + signature: Vec, + }, + + Extension(ExtensionResponse), +} + +#[derive(Debug)] +pub enum ExtensionResponse { + Query { types: Vec }, +} + +/// A single identity in SSH_AGENT_IDENTITIES_ANSWER. +#[derive(Debug)] +pub struct IdentityAnswer { + pub key_blob: Vec, + pub comment: String, +} + +impl ServerResponse { + pub fn parse(bytes: &[u8]) -> eyre::Result { + let bytes = &bytes[4..]; + let mut p = Parser::new(bytes); + let msg_type = p.u8()?; + trace!(%msg_type, msg_type_str = %numbers::server_response_type_to_string(msg_type), "Received message"); + let resp = match msg_type { + numbers::SSH_AGENT_FAILURE => Self::Failure, + numbers::SSH_AGENT_SUCCESS => Self::Success, + numbers::SSH_AGENT_IDENTITIES_ANSWER => { + let nkeys = p.u32()?; + let mut identities = Vec::new(); + for _ in 0..nkeys { + let key_blob = p.string()?; + let comment = p.utf8_string()?; + identities.push(IdentityAnswer { + key_blob: key_blob.to_owned(), + comment: comment.to_owned(), + }); + } + Self::IdentitiesAnswer { identities } + } + numbers::SSH_AGENT_SIGN_RESPONSE => { + let signature = p.string()?; + Self::SignResponse { + signature: signature.to_owned(), + } + } + numbers::SSH_AGENT_EXTENSION_RESPONSE => { + let ext_type = p.utf8_string()?; + trace!(?ext_type, "Received extension response"); + match ext_type { + "query" => { + let mut types = Vec::new(); + while p.has_data() { + let name = p.utf8_string()?; + types.push(name.to_owned()); + } + Self::Extension(ExtensionResponse::Query { types }) + } + _ => bail!("unknown extension response type: {ext_type}"), + } + } + _ => bail!( + "unknown server message: {msg_type} ({})", + numbers::server_response_type_to_string(msg_type) + ), + }; + Ok(resp) + } +} + +pub struct AgentConnection { + packets: PacketParser, +} + +impl AgentConnection { + pub fn new() -> Self { + Self { + packets: PacketParser::new(), + } + } + + pub fn recv_bytes<'a>( + &'a mut self, + mut bytes: &'a [u8], + ) -> impl Iterator> + 'a { + std::iter::from_fn(move || -> Option> { + if bytes.len() == 0 { + return None; + } + match self.packets.recv_plaintext_bytes(bytes) { + Err(err) => Some(Err(match err { + SshStatus::PeerError(err) => eyre!(err), + SshStatus::Disconnect => unreachable!(), + })), + Ok(None) => None, + Ok(Some((consumed, data))) => { + bytes = &bytes[consumed..]; + self.packets = PacketParser::new(); + Some(ServerResponse::parse(&data)) + } + } + }) + } +} + +pub struct SocketAgentConnection { + conn: AgentConnection, + uds: tokio::net::UnixStream, +} + +impl SocketAgentConnection { + pub async fn from_env() -> eyre::Result { + let sock = std::env::var("SSH_AUTH_SOCK").wrap_err("$SSH_AUTH_SOCK not found")?; + + debug!(%sock, "Connecting to SSH agent"); + + let socket = tokio::net::UnixSocket::new_stream() + .wrap_err("creating unix stream socket")? + .connect(&sock) + .await + .wrap_err_with(|| format!("connecting to Unix stream socket on {sock}"))?; + + Ok(Self { + conn: AgentConnection::new(), + uds: socket, + }) + } + + pub async fn remove_all_identities(&mut self) -> eyre::Result<()> { + self.send(Request::RemoveAllIdentities).await?; + self.generic_response().await + } + + pub async fn list_identities(&mut self) -> eyre::Result> { + self.send(Request::ListIdentities).await?; + + let resp = self.get_response().await?; + match resp { + ServerResponse::IdentitiesAnswer { identities } => Ok(identities), + _ => bail!("unexpected response: {resp:?}"), + } + } + + pub async fn sign( + &mut self, + key_blob: &[u8], + data: &[u8], + flags: u32, + ) -> eyre::Result> { + self.send(Request::Sign { + key_blob: key_blob.to_owned(), + data: data.to_owned(), + flags, + }) + .await?; + + let resp = self.get_response().await?; + match resp { + ServerResponse::SignResponse { signature } => Ok(signature), + _ => bail!("unexpected response: {resp:?}"), + } + } + + pub async fn lock(&mut self, passphrase: &str) -> eyre::Result<()> { + self.send(Request::Lock { + passphrase: passphrase.to_owned(), + }) + .await?; + self.generic_response().await + } + + pub async fn unlock(&mut self, passphrase: &str) -> eyre::Result<()> { + self.send(Request::Unlock { + passphrase: passphrase.to_owned(), + }) + .await?; + self.generic_response().await + } + + pub async fn extension_query(&mut self) -> eyre::Result> { + self.send(Request::Extension(ExtensionRequest::Query)) + .await?; + let resp = self.get_response().await?; + match resp { + ServerResponse::Extension(ExtensionResponse::Query { types }) => Ok(types), + _ => bail!("unexpected response: {resp:?}"), + } + } + + async fn generic_response(&mut self) -> eyre::Result<()> { + let resp = self.get_response().await?; + + match resp { + ServerResponse::Success => Ok(()), + ServerResponse::Failure => bail!("agent operation failed"), + _ => bail!("unexpected response: {resp:?}"), + } + } + + async fn send(&mut self, msg: Request) -> eyre::Result<()> { + self.uds.write_all(&msg.to_bytes()).await?; + Ok(()) + } + + async fn get_response(&mut self) -> eyre::Result { + let mut buf = [0_u8; 1024]; + loop { + let read = self.uds.read(&mut buf).await?; + let bytes = &buf[..read]; + // In practice, the server won't send more than one packet. + if let Some(resp) = self.conn.recv_bytes(bytes).next() { + return resp; + } + } + } +} + +pub mod numbers { + pub const SSH_AGENTC_REQUEST_IDENTITIES: u8 = 11; + pub const SSH_AGENTC_SIGN_REQUEST: u8 = 13; + pub const SSH_AGENTC_ADD_IDENTITY: u8 = 17; + pub const SSH_AGENTC_REMOVE_IDENTITY: u8 = 18; + pub const SSH_AGENTC_REMOVE_ALL_IDENTITIES: u8 = 19; + pub const SSH_AGENTC_ADD_SMARTCARD_KEY: u8 = 20; + pub const SSH_AGENTC_REMOVE_SMARTCARD_KEY: u8 = 21; + pub const SSH_AGENTC_LOCK: u8 = 22; + pub const SSH_AGENTC_UNLOCK: u8 = 23; + pub const SSH_AGENTC_ADD_ID_CONSTRAINED: u8 = 25; + pub const SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED: u8 = 26; + pub const SSH_AGENTC_EXTENSION: u8 = 27; + + pub const SSH_AGENT_FAILURE: u8 = 5; + pub const SSH_AGENT_SUCCESS: u8 = 6; + pub const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12; + pub const SSH_AGENT_SIGN_RESPONSE: u8 = 14; + pub const SSH_AGENT_EXTENSION_FAILURE: u8 = 28; + pub const SSH_AGENT_EXTENSION_RESPONSE: u8 = 29; + + pub fn server_response_type_to_string(response_type: u8) -> &'static str { + match response_type { + SSH_AGENT_FAILURE => "SSH_AGENT_FAILURE", + SSH_AGENT_SUCCESS => "SSH_AGENT_SUCCESS", + SSH_AGENT_IDENTITIES_ANSWER => "SSH_AGENT_IDENTITIES_ANSWER", + SSH_AGENT_SIGN_RESPONSE => "SSH_AGENT_SIGN_RESPONSE", + SSH_AGENT_EXTENSION_FAILURE => "SSH_AGENT_EXTENSION_FAILURE", + SSH_AGENT_EXTENSION_RESPONSE => "SSH_AGENT_EXTENSION_RESPONSE", + _ => "", + } + } +} diff --git a/ssh-agentctl/Cargo.toml b/ssh-agentctl/Cargo.toml new file mode 100644 index 0000000..28c4e71 --- /dev/null +++ b/ssh-agentctl/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ssh-agentctl" +version = "0.1.0" +edition = "2021" + +[dependencies] +ssh-agent-client = { path = "../ssh-agent-client" } +ssh-transport = { path = "../ssh-transport" } + +clap = { version = "4.5.16", features = ["derive"] } +eyre = "0.6.12" +tokio = { version = "1.39.3", features = ["full"] } +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +rpassword = "7.3.1" +sha2 = "0.10.8" +hex = "0.4.3" diff --git a/ssh-agentctl/here b/ssh-agentctl/here new file mode 100644 index 0000000..0f32710 --- /dev/null +++ b/ssh-agentctl/here @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDpK6HZbsijDttnop9lQyLLGXZi7lS5Hb3bY7DKMDC1vAAAAIhd37wfXd+8 +HwAAAAtzc2gtZWQyNTUxOQAAACDpK6HZbsijDttnop9lQyLLGXZi7lS5Hb3bY7DKMDC1vA +AAAEBCev7X+rchYbMmzYfiyBzZhV/RaZZhYh+MR4/Ktcu0l+krodluyKMO22ein2VDIssZ +dmLuVLkdvdtjsMowMLW8AAAAA3V3dQEC +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh-agentctl/here.pub b/ssh-agentctl/here.pub new file mode 100644 index 0000000..63f938a --- /dev/null +++ b/ssh-agentctl/here.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOkrodluyKMO22ein2VDIssZdmLuVLkdvdtjsMowMLW8 uwu diff --git a/ssh-agentctl/src/main.rs b/ssh-agentctl/src/main.rs new file mode 100644 index 0000000..cd001fd --- /dev/null +++ b/ssh-agentctl/src/main.rs @@ -0,0 +1,156 @@ +use std::path::PathBuf; + +use clap::Parser; +use eyre::{bail, Context}; +use ssh_agent_client::{IdentityAnswer, SocketAgentConnection}; +use ssh_transport::key::SshPubkey; + +#[derive(clap::Parser, Debug)] +struct Args { + #[command(subcommand)] + command: Subcommand, +} + +#[derive(clap::Subcommand, Debug)] +enum Subcommand { + /// Remove all identities from the agent, SSH_AGENTC_REMOVE_ALL_IDENTITIES + RemoveAllIdentities, + /// List all identities in the agent, SSH_AGENTC_REQUEST_IDENTITIES + ListIdentities { + #[arg(short, long = "key-id")] + key_id: bool, + }, + /// Sign a blob, SSH_AGENTC_SIGN_REQUEST + Sign { + /// The key-id of the key, obtained with list-identities --key-id + #[arg(short, long = "key")] + key: Option, + file: PathBuf, + }, + /// Temporarily lock the agent with a passphrase, SSH_AGENTC_LOCK + Lock, + /// Temporarily unlock a temporarily locked agent with a passphrase, SSH_AGENTC_UNLOCK + Unlock, + /// Query all available extension types SSH_AGENTC_EXTENSION/query + ExtensionQuery, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let args = Args::parse(); + + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + let mut agent = ssh_agent_client::SocketAgentConnection::from_env().await?; + + match args.command { + Subcommand::RemoveAllIdentities => { + agent.remove_all_identities().await?; + println!("Removed all identities from the agent"); + } + Subcommand::ListIdentities { key_id } => { + list_ids(&mut agent, key_id).await?; + } + Subcommand::Sign { file, key } => { + let file = std::fs::read(&file) + .wrap_err_with(|| format!("reading file {}", file.display()))?; + + let ids = agent + .list_identities() + .await + .wrap_err("listing identities")?; + + let key = match ids.len() { + 0 => { + bail!("no keys found"); + } + 1 => { + let id = &ids[0]; + if let Some(key) = key { + if key_id(id) != key { + eprintln!("error: key {key} not found. pass a key-id found below:"); + list_ids(&mut agent, true).await?; + eprintln!( + "note: there is only one key, passing the key-id is not required" + ); + std::process::exit(1); + } + } + id + } + _ => { + let Some(key) = key else { + eprintln!("error: missing argument --key. pass the key-id found below:"); + list_ids(&mut agent, true).await?; + std::process::exit(1); + }; + + let Some(id) = ids.iter().find(|item| key_id(item) == key) else { + eprintln!("error: key {key} not found. pass a key-id from below"); + list_ids(&mut agent, true).await?; + std::process::exit(1); + }; + id + } + }; + + let signature = agent.sign(&key.key_blob, &file, 0).await?; + } + Subcommand::Lock => { + let passphrase = + tokio::task::spawn_blocking(|| rpassword::prompt_password("passphrase: ")) + .await? + .wrap_err("failed to prompt passphrase")?; + agent.lock(&passphrase).await?; + println!("Locked SSH agent"); + } + Subcommand::Unlock => { + let passphrase = + tokio::task::spawn_blocking(|| rpassword::prompt_password("passphrase: ")) + .await? + .wrap_err("failed to prompt passphrase")?; + agent.unlock(&passphrase).await?; + println!("Unlocked SSH agent"); + } + Subcommand::ExtensionQuery => { + let extensions = agent.extension_query().await?; + for ext in extensions { + println!("{ext}"); + } + } + } + + Ok(()) +} + +async fn list_ids(agent: &mut SocketAgentConnection, print_key_id: bool) -> eyre::Result<()> { + let ids = agent.list_identities().await?; + for id in ids { + print_key(id, print_key_id); + } + Ok(()) +} + +fn print_key(id: IdentityAnswer, show_key_id: bool) { + let key = SshPubkey::from_wire_encoding(&id.key_blob); + match key { + Ok(key) => { + if show_key_id { + print!("{} ", key_id(&id)); + } + println!("{key} {}", id.comment); + } + Err(key) => { + eprintln!("{key}"); + println!(" {}", id.comment); + } + } +} + +fn key_id(key: &IdentityAnswer) -> String { + use sha2::Digest; + let digest = sha2::Sha256::digest(&key.key_blob); + hex::encode(&digest[..4]) +} diff --git a/ssh-protocol/src/lib.rs b/ssh-protocol/src/lib.rs index 86fd40b..b3234bb 100644 --- a/ssh-protocol/src/lib.rs +++ b/ssh-protocol/src/lib.rs @@ -111,8 +111,9 @@ impl ClientConnection { self.transport.recv_bytes(bytes)?; if let ClientConnectionState::Setup(auth) = &mut self.state { - if self.transport.is_open() { + if let Some(session_ident) = self.transport.is_open() { let mut auth = mem::take(auth).unwrap(); + auth.set_session_identifier(session_ident); for to_send in auth.packets_to_send() { self.transport.send_plaintext_packet(to_send); } @@ -314,10 +315,12 @@ pub mod auth { packets_to_send: VecDeque, user_requests: VecDeque, is_authenticated: bool, + session_identifier: Option<[u8; 32]>, } pub enum ClientUserRequest { Password, + PrivateKeySign { session_identifier: [u8; 32] }, Banner(Vec), } @@ -333,9 +336,15 @@ pub mod auth { username, user_requests: VecDeque::new(), is_authenticated: false, + session_identifier: None, } } + pub fn set_session_identifier(&mut self, ident: [u8; 32]) { + assert!(self.session_identifier.is_none()); + self.session_identifier = Some(ident); + } + pub fn is_authenticated(&self) -> bool { self.is_authenticated } @@ -379,6 +388,15 @@ pub mod auth { if authentications.iter().any(|item| item == "password") { self.user_requests.push_back(ClientUserRequest::Password); + } else if authentications.iter().any(|item| item == "publickey") { + // + // TODO: Ask the server whether there are any keys we can use instead of just yoloing the signature. + self.user_requests + .push_back(ClientUserRequest::PrivateKeySign { + session_identifier: self + .session_identifier + .expect("set_session_identifier has not been called"), + }); } else { return Err(peer_error!( "server does not support password authentication" diff --git a/ssh-transport/Cargo.toml b/ssh-transport/Cargo.toml index 0edb481..5ffa24e 100644 --- a/ssh-transport/Cargo.toml +++ b/ssh-transport/Cargo.toml @@ -18,6 +18,7 @@ subtle = "2.6.1" x25519-dalek = "2.0.1" tracing.workspace = true +base64 = "0.22.1" [dev-dependencies] hex-literal = "0.4.1" diff --git a/ssh-transport/src/client.rs b/ssh-transport/src/client.rs index ae45553..18ede14 100644 --- a/ssh-transport/src/client.rs +++ b/ssh-transport/src/client.rs @@ -49,8 +49,12 @@ enum ClientState { encryption_client_to_server: EncryptionAlgorithm, encryption_server_to_client: EncryptionAlgorithm, }, - ServiceRequest, - Open, + ServiceRequest { + session_identifier: [u8; 32], + }, + Open { + session_identifier: [u8; 32], + }, } impl ClientConnection { @@ -229,6 +233,7 @@ impl ClientConnection { let kex_secret = mem::take(kex_secret).unwrap(); let shared_secret = (kex_secret.exchange)(server_ephermal_key)?; + // The exchange hash serves as the session identifier. let hash = crypto::key_exchange_hash( client_ident, server_ident, @@ -279,13 +284,15 @@ impl ClientConnection { false, ); - debug!("Requestin ssh-userauth service"); + debug!("Requesting ssh-userauth service"); self.packet_transport .queue_packet(Packet::new_msg_service_request(b"ssh-userauth")); - self.state = ClientState::ServiceRequest; + self.state = ClientState::ServiceRequest { + session_identifier: *h, + }; } - ClientState::ServiceRequest => { + ClientState::ServiceRequest { session_identifier } => { let mut accept = packet.payload_parser(); let packet_type = accept.u8()?; if packet_type != numbers::SSH_MSG_SERVICE_ACCEPT { @@ -297,9 +304,11 @@ impl ClientConnection { } debug!("Connection has been opened successfully"); - self.state = ClientState::Open; + self.state = ClientState::Open { + session_identifier: *session_identifier, + }; } - ClientState::Open => { + ClientState::Open { .. } => { self.plaintext_packets.push_back(packet); } } @@ -319,8 +328,11 @@ impl ClientConnection { self.packet_transport.queue_packet(packet); } - pub fn is_open(&self) -> bool { - matches!(self.state, ClientState::Open) + pub fn is_open(&self) -> Option<[u8; 32]> { + match self.state { + ClientState::Open { session_identifier } => Some(session_identifier), + _ => None, + } } fn send_kexinit(&mut self, client_ident: Vec, server_ident: Vec) { diff --git a/ssh-transport/src/key.rs b/ssh-transport/src/key.rs new file mode 100644 index 0000000..9bf31f3 --- /dev/null +++ b/ssh-transport/src/key.rs @@ -0,0 +1,59 @@ +//! Operations on SSH keys. + +use std::fmt::Display; + +use base64::Engine; + +use crate::parse::{self, ParseError, Parser, Writer}; + +pub enum SshPubkey { + Ed25519 { public_key: [u8; 32] }, +} + +impl SshPubkey { + /// Parses an SSH public key from its wire encoding as specified in + /// RFC4253, RFC5656, and RFC8709. + pub fn from_wire_encoding(bytes: &[u8]) -> parse::Result { + let mut p = Parser::new(bytes); + let alg = p.utf8_string()?; + + let k = match alg { + "ssh-ed25519" => { + let len = p.u32()?; + if len != 32 { + return Err(ParseError(format!("incorrect ed25519 len: {len}"))); + } + let public_key = p.array::<32>()?; + Self::Ed25519 { public_key } + } + _ => return Err(ParseError(format!("unsupported key type: {alg}"))), + }; + Ok(k) + } + + pub fn to_wire_encoding(&self) -> Vec { + let mut p = Writer::new(); + match self { + Self::Ed25519 { public_key } => { + p.string(b"ssh-ed25519"); + p.string(public_key); + } + } + p.finish() + } +} + +impl Display for SshPubkey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ed25519 { .. } => { + let encoded_pubkey = b64encode(&self.to_wire_encoding()); + write!(f, "ssh-ed25519 {encoded_pubkey}") + } + } + } +} + +fn b64encode(bytes: &[u8]) -> String { + base64::prelude::BASE64_STANDARD_NO_PAD.encode(bytes) +} diff --git a/ssh-transport/src/lib.rs b/ssh-transport/src/lib.rs index 77ecaa7..8a189b0 100644 --- a/ssh-transport/src/lib.rs +++ b/ssh-transport/src/lib.rs @@ -1,5 +1,6 @@ pub mod client; mod crypto; +pub mod key; pub mod numbers; pub mod packet; pub mod parse; @@ -20,7 +21,6 @@ pub enum SshStatus { pub type Result = std::result::Result; - pub trait SshRng { fn fill_bytes(&mut self, dest: &mut [u8]); } diff --git a/ssh-transport/src/packet.rs b/ssh-transport/src/packet.rs index 8e6b527..b462e98 100644 --- a/ssh-transport/src/packet.rs +++ b/ssh-transport/src/packet.rs @@ -5,7 +5,7 @@ use std::mem; use tracing::{debug, trace}; -use crate::crypto::{EncryptionAlgorithm, Keys, Plaintext, Session}; +use crate::crypto::{self, EncryptionAlgorithm, Keys, Plaintext, Session}; use crate::parse::{NameList, Parser, Writer}; use crate::Result; use crate::{numbers, peer_error}; @@ -347,19 +347,38 @@ impl RawPacket { } } -struct PacketParser { +pub struct PacketParser { // The length of the packet. packet_length: Option, // The raw data *encrypted*, including the length. raw_data: Vec, + done: bool, } impl PacketParser { - fn new() -> Self { + pub fn new() -> Self { Self { packet_length: None, raw_data: Vec::new(), + done: false, } } + + /// Parse a raw packet body out of a plaintext stream of bytes. + /// # Returns + /// - `Err()` - if the packet was invalid + /// - `Ok(None)` - if the packet is incomplete and needs more data + /// - `Ok(Some(consumed, all_data))` if a packet has been parsed. + /// `consumed` is the amount of bytes from `bytes` that were actually consumed, + /// `all_data` is the entire packet including the length. + pub fn recv_plaintext_bytes(&mut self, bytes: &[u8]) -> Result)>> { + let Some((consumed, data)) = self.recv_bytes_inner(bytes, &mut crypto::Plaintext, 0)? + else { + return Ok(None); + }; + self.done = true; + Ok(Some((consumed, data.raw))) + } + fn recv_bytes( &mut self, bytes: &[u8], @@ -378,6 +397,11 @@ impl PacketParser { keys: &mut dyn Keys, next_seq_nr: u64, ) -> Result> { + assert!( + !self.done, + "Passed bytes to packet parser even after it was completed" + ); + let mut consumed = 0; let packet_length = match self.packet_length { Some(packet_length) => { @@ -460,7 +484,7 @@ impl ProtocolIdentParser { // 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"); + debug!(identification = %peer_ident_string.trim(), "Peer identifier"); Some(peer_ident) } else { diff --git a/ssh-transport/src/parse.rs b/ssh-transport/src/parse.rs index cb4742c..649a28e 100644 --- a/ssh-transport/src/parse.rs +++ b/ssh-transport/src/parse.rs @@ -1,7 +1,24 @@ use core::str; -use std::fmt::Debug; +use std::fmt::{Debug, Display}; -use crate::Result; +use crate::SshStatus; + +#[derive(Debug)] +pub struct ParseError(pub String); +impl Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for ParseError {} + +impl From for SshStatus { + fn from(err: ParseError) -> Self { + Self::PeerError(err.0) + } +} + +pub type Result = std::result::Result; /// A simplified `byteorder` clone that emits client errors when the data is too short. pub struct Parser<'a>(&'a [u8]); @@ -11,6 +28,10 @@ impl<'a> Parser<'a> { Self(data) } + pub fn has_data(&self) -> bool { + !self.0.is_empty() + } + pub fn u8(&mut self) -> Result { let arr = self.array::<1>()?; Ok(arr[0]) @@ -24,7 +45,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::peer_error!("packet too short")); + return Err(ParseError(format!("packet too short"))); } let result = self.0[..N].try_into().unwrap(); self.0 = &self.0[N..]; @@ -33,10 +54,10 @@ impl<'a> Parser<'a> { pub fn slice(&mut self, len: usize) -> Result<&'a [u8]> { if self.0.len() < len { - return Err(crate::peer_error!("packet too short")); + return Err(ParseError(format!("packet too short"))); } if len > 100_000 { - return Err(crate::peer_error!("bytes too long: {len}")); + return Err(ParseError(format!("bytes too long: {len}"))); } let result = &self.0[..len]; self.0 = &self.0[len..]; @@ -48,7 +69,7 @@ impl<'a> Parser<'a> { match b { 0 => Ok(false), 1 => Ok(true), - _ => Err(crate::peer_error!("invalid bool: {b}")), + _ => Err(ParseError(format!("invalid bool: {b}"))), } } @@ -70,7 +91,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::peer_error!("name-list is invalid UTF-8")); + return Err(ParseError(format!("name-list is invalid UTF-8"))); }; Ok(s) } @@ -165,7 +186,7 @@ impl<'a> NameList<'a> { impl Debug for NameList<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) + Debug::fmt(&self.0, f) } } diff --git a/ssh/Cargo.toml b/ssh/Cargo.toml index 3e21b29..e21388b 100644 --- a/ssh/Cargo.toml +++ b/ssh/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" [dependencies] ssh-protocol = { path = "../ssh-protocol" } ssh-transport = { path = "../ssh-transport" } +ssh-agent-client = { path = "../ssh-agent-client" } + clap = { version = "4.5.15", features = ["derive"] } eyre = "0.6.12" rand = "0.8.5" diff --git a/ssh/src/main.rs b/ssh/src/main.rs index e894ae6..95836b7 100644 --- a/ssh/src/main.rs +++ b/ssh/src/main.rs @@ -79,6 +79,18 @@ async fn main() -> eyre::Result<()> { let _ = send_op.blocking_send(Operation::PasswordEntered(password)); }); } + ssh_protocol::auth::ClientUserRequest::PrivateKeySign { + session_identifier: _, + } => { + // TODO: move + let mut agent = ssh_agent_client::SocketAgentConnection::from_env() + .await + .wrap_err("failed to connect to SSH agent")?; + let identities = agent.list_identities().await?; + for identity in identities { + debug!(comment = ?identity.comment, "Found identity"); + } + } ssh_protocol::auth::ClientUserRequest::Banner(banner) => { let banner = String::from_utf8_lossy(&banner); std::io::stdout().write(&banner.as_bytes())?; diff --git a/sshdos/src/main.rs b/sshdos/src/main.rs index 8643b0f..d650129 100644 --- a/sshdos/src/main.rs +++ b/sshdos/src/main.rs @@ -1,5 +1,4 @@ use std::{ - io::Write, sync::{ atomic::{AtomicUsize, Ordering}, Arc, @@ -15,7 +14,7 @@ use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream, }; -use tracing::{debug, error, info}; +use tracing::{error, info}; use ssh_protocol::{ transport::{self}, @@ -41,11 +40,6 @@ struct Args { #[arg(short = 'c', long)] chill: bool, destination: String, - command: Vec, -} - -enum Operation { - PasswordEntered(std::io::Result), } #[tokio::main] @@ -86,7 +80,7 @@ async fn main() -> eyre::Result<()> { async fn execute_attempt(args: &Args) -> eyre::Result<()> { let conn = TcpStream::connect(&format!("{}:{}", args.destination, args.port)).await?; - let result = execute_attempt_inner(args, conn).await; + let result = execute_attempt_inner(conn).await; if args.chill { info!("Chilling, taking up space"); @@ -96,7 +90,7 @@ async fn execute_attempt(args: &Args) -> eyre::Result<()> { result } -async fn execute_attempt_inner(args: &Args, mut conn: TcpStream) -> eyre::Result<()> { +async fn execute_attempt_inner(mut conn: TcpStream) -> eyre::Result<()> { let username = "dos"; let mut transport = transport::client::ClientConnection::new(ThreadRngRand); @@ -107,8 +101,6 @@ async fn execute_attempt_inner(args: &Args, mut conn: TcpStream) -> eyre::Result ssh_protocol::auth::ClientAuth::new(username.as_bytes().to_vec()), ); - let (send_op, mut recv_op) = tokio::sync::mpsc::channel::(10); - let mut buf = [0; 1024]; loop { @@ -118,66 +110,33 @@ async fn execute_attempt_inner(args: &Args, mut conn: TcpStream) -> eyre::Result .wrap_err("writing response")?; } - if let Some(auth) = state.auth() { - for req in auth.user_requests() { - match req { - ssh_protocol::auth::ClientUserRequest::Password => { - let username = username.to_owned(); - let destination = args.destination.clone(); - let send_op = send_op.clone(); - std::thread::spawn(move || { - let password = rpassword::prompt_password(format!( - "{}@{}'s password: ", - username, destination - )); - let _ = send_op.blocking_send(Operation::PasswordEntered(password)); - }); - } - ssh_protocol::auth::ClientUserRequest::Banner(banner) => { - let banner = String::from_utf8_lossy(&banner); - std::io::stdout().write(&banner.as_bytes())?; - } - } - } + if let Some(_) = state.auth() { + unreachable!(); } - tokio::select! { - read = conn.read(&mut buf) => { - let read = read.wrap_err("reading from connection")?; - if read == 0 { - info!("Did not read any bytes from TCP stream, EOF"); + 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::PeerError(err) => { + if err == "early abort" { + // Expected. + return Ok(()); + } + error!(?err, "disconnecting client after invalid operation"); return Ok(()); } - if let Err(err) = state.recv_bytes(&buf[..read]) { - match err { - SshStatus::PeerError(err) => { - if err == "early abort" { - // Expected. - return Ok(()); - } - error!(?err, "disconnecting client after invalid operation"); - return Ok(()); - } - SshStatus::Disconnect => { - error!("Received disconnect from server"); - return Ok(()); - } - } + SshStatus::Disconnect => { + error!("Received disconnect from server"); + return Ok(()); } } - op = recv_op.recv() => { - match op { - Some(Operation::PasswordEntered(password)) => { - if let Some(auth) = state.auth() { - auth.send_password(&password?); - } else { - debug!("Ignoring entered password as the state has moved on"); - } - } - None => {} - } - state.progress(); - } } } }