This commit is contained in:
nora 2024-08-22 20:59:32 +02:00
parent 9b49e09983
commit a52b6b37d7
19 changed files with 770 additions and 92 deletions

47
Cargo.lock generated
View file

@ -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",

View file

@ -6,6 +6,7 @@ members = [
"ssh-connection",
"ssh-protocol",
"ssh-transport",
"ssh-agent-client", "ssh-agentctl",
]
resolver = "2"

View file

@ -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

View file

@ -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.

336
ssh-agent-client/src/lib.rs Normal file
View file

@ -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<u8>,
data: Vec<u8>,
flags: u32,
},
Lock {
passphrase: String,
},
Unlock {
passphrase: String,
},
Extension(ExtensionRequest),
}
pub enum ExtensionRequest {
Query,
}
impl Request {
pub fn to_bytes(&self) -> Vec<u8> {
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<IdentityAnswer>,
},
/// SSH_AGENT_SIGN_RESPONSE
SignResponse {
signature: Vec<u8>,
},
Extension(ExtensionResponse),
}
#[derive(Debug)]
pub enum ExtensionResponse {
Query { types: Vec<String> },
}
/// A single identity in SSH_AGENT_IDENTITIES_ANSWER.
#[derive(Debug)]
pub struct IdentityAnswer {
pub key_blob: Vec<u8>,
pub comment: String,
}
impl ServerResponse {
pub fn parse(bytes: &[u8]) -> eyre::Result<Self> {
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<Item = eyre::Result<ServerResponse>> + 'a {
std::iter::from_fn(move || -> Option<eyre::Result<ServerResponse>> {
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<Self> {
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<Vec<IdentityAnswer>> {
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<Vec<u8>> {
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<Vec<String>> {
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<ServerResponse> {
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",
_ => "<unknown>",
}
}
}

16
ssh-agentctl/Cargo.toml Normal file
View file

@ -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"

7
ssh-agentctl/here Normal file
View file

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDpK6HZbsijDttnop9lQyLLGXZi7lS5Hb3bY7DKMDC1vAAAAIhd37wfXd+8
HwAAAAtzc2gtZWQyNTUxOQAAACDpK6HZbsijDttnop9lQyLLGXZi7lS5Hb3bY7DKMDC1vA
AAAEBCev7X+rchYbMmzYfiyBzZhV/RaZZhYh+MR4/Ktcu0l+krodluyKMO22ein2VDIssZ
dmLuVLkdvdtjsMowMLW8AAAAA3V3dQEC
-----END OPENSSH PRIVATE KEY-----

1
ssh-agentctl/here.pub Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOkrodluyKMO22ein2VDIssZdmLuVLkdvdtjsMowMLW8 uwu

156
ssh-agentctl/src/main.rs Normal file
View file

@ -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<String>,
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!("<unknown> {}", id.comment);
}
}
}
fn key_id(key: &IdentityAnswer) -> String {
use sha2::Digest;
let digest = sha2::Sha256::digest(&key.key_blob);
hex::encode(&digest[..4])
}

View file

@ -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<Packet>,
user_requests: VecDeque<ClientUserRequest>,
is_authenticated: bool,
session_identifier: Option<[u8; 32]>,
}
pub enum ClientUserRequest {
Password,
PrivateKeySign { session_identifier: [u8; 32] },
Banner(Vec<u8>),
}
@ -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") {
// <https://datatracker.ietf.org/doc/html/rfc4252#section-7>
// 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"

View file

@ -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"

View file

@ -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<u8>, server_ident: Vec<u8>) {

59
ssh-transport/src/key.rs Normal file
View file

@ -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<Self> {
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<u8> {
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)
}

View file

@ -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<T, E = SshStatus> = std::result::Result<T, E>;
pub trait SshRng {
fn fill_bytes(&mut self, dest: &mut [u8]);
}

View file

@ -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<usize>,
// The raw data *encrypted*, including the length.
raw_data: Vec<u8>,
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<Option<(usize, Vec<u8>)>> {
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<Option<(usize, RawPacket)>> {
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 {

View file

@ -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<ParseError> for SshStatus {
fn from(err: ParseError) -> Self {
Self::PeerError(err.0)
}
}
pub type Result<T, E = ParseError> = std::result::Result<T, E>;
/// 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<u8> {
let arr = self.array::<1>()?;
Ok(arr[0])
@ -24,7 +45,7 @@ impl<'a> Parser<'a> {
pub fn array<const N: usize>(&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)
}
}

View file

@ -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"

View file

@ -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())?;

View file

@ -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<String>,
}
enum Operation {
PasswordEntered(std::io::Result<String>),
}
#[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::<Operation>(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();
}
}
}
}