diff --git a/Cargo.toml b/Cargo.toml index e0d2827..8ba84e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["russh-keys", "russh", "russh-config", "cryptovec"] +members = ["russh-keys", "russh", "russh-config", "cryptovec", "pageant"] [patch.crates-io] russh = { path = "russh" } @@ -23,3 +23,4 @@ ssh-encoding = "0.2" ssh-key = { version = "0.6", features = ["ed25519", "rsa", "encryption"] } thiserror = "1.0" tokio = { version = "1.17.0" } +tokio-stream = { version = "0.1", features = ["net", "sync"] } diff --git a/pageant/Cargo.toml b/pageant/Cargo.toml new file mode 100644 index 0000000..b682f1f --- /dev/null +++ b/pageant/Cargo.toml @@ -0,0 +1,28 @@ +[package] +authors = ["Eugene "] +description = "Pageant SSH agent transport client." +documentation = "https://docs.rs/pageant" +edition = "2018" +include = ["Cargo.toml", "src/lib.rs"] +license = "Apache-2.0" +name = "pageant" +repository = "https://github.com/warp-tech/russh" +version = "0.0.1-beta.1" +rust-version = "1.65" + +[dependencies] +futures = { workspace = true } +thiserror = { workspace = true } +rand = { workspace = true } +tokio = { workspace = true, features = ["io-util", "rt"] } +bytes = "1.7" +delegate = "0.12" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_UI_WindowsAndMessaging", + "Win32_System_Memory", + "Win32_Security", + "Win32_System_Threading", + "Win32_System_DataExchange", +] } diff --git a/pageant/src/lib.rs b/pageant/src/lib.rs new file mode 100644 index 0000000..4a8d9be --- /dev/null +++ b/pageant/src/lib.rs @@ -0,0 +1,11 @@ +//! # Pageant SSH agent transport protocol implementation +//! +//! This crate provides a [PageantStream] type that implements [AsyncRead] and [AsyncWrite] traits and can be used to talk to a running Pageant instance. +//! +//! This crate only implements the transport, not the actual SSH agent protocol. + +#[cfg(target_os = "windows")] +mod pageant_impl; + +#[cfg(target_os = "windows")] +pub use pageant_impl::*; diff --git a/pageant/src/pageant_impl.rs b/pageant/src/pageant_impl.rs new file mode 100644 index 0000000..27e9fc7 --- /dev/null +++ b/pageant/src/pageant_impl.rs @@ -0,0 +1,281 @@ +use std::io::IoSlice; +use std::mem::size_of; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::BytesMut; +use delegate::delegate; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf}; +use windows::core::HSTRING; +use windows::Win32::Foundation::{CloseHandle, HANDLE, HWND, INVALID_HANDLE_VALUE, LPARAM, WPARAM}; +use windows::Win32::Security::{ + GetTokenInformation, InitializeSecurityDescriptor, SetSecurityDescriptorOwner, TokenUser, + PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, TOKEN_QUERY, TOKEN_USER, +}; +use windows::Win32::System::DataExchange::COPYDATASTRUCT; +use windows::Win32::System::Memory::{ + CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, + PAGE_READWRITE, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows::Win32::UI::WindowsAndMessaging::{FindWindowW, SendMessageA, WM_COPYDATA}; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Pageant not found")] + NotFound, + + #[error("Buffer overflow")] + Overflow, + + #[error("No response from Pageant")] + NoResponse, + + #[error(transparent)] + WindowsError(#[from] windows::core::Error), +} + +impl Error { + fn from_win32() -> Self { + Self::WindowsError(windows::core::Error::from_win32()) + } +} + +/// Pageant transport stream. Implements [AsyncRead] and [AsyncWrite]. +/// +/// The stream has a unique cookie and requests made in the same stream are considered the same "session". +pub struct PageantStream { + stream: DuplexStream, +} + +impl PageantStream { + pub fn new() -> Self { + let (one, mut two) = tokio::io::duplex(_AGENT_MAX_MSGLEN * 100); + + let cookie = rand::random::().to_string(); + tokio::spawn(async move { + let mut buf = BytesMut::new(); + while let Ok(n) = two.read_buf(&mut buf).await { + if n == 0 { + break; + } + let msg = buf.split().freeze(); + let response = query_pageant_direct(cookie.clone(), &msg).unwrap(); + two.write_all(&response).await? + } + std::io::Result::Ok(()) + }); + + Self { stream: one } + } +} + +impl Default for PageantStream { + fn default() -> Self { + Self::new() + } +} + +impl AsyncRead for PageantStream { + delegate! { + to Pin::new(&mut self.stream) { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll>; + + } + } +} + +impl AsyncWrite for PageantStream { + delegate! { + to Pin::new(&mut self.stream) { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll>; + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; + + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[IoSlice<'_>], + ) -> Poll>; + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; + } + + to Pin::new(&self.stream) { + fn is_write_vectored(&self) -> bool; + } + } +} + +struct MemoryMap { + filemap: HANDLE, + view: MEMORY_MAPPED_VIEW_ADDRESS, + length: usize, + pos: usize, +} + +impl MemoryMap { + fn new( + name: String, + length: usize, + security_attributes: Option, + ) -> Result { + let filemap = unsafe { + CreateFileMappingW( + INVALID_HANDLE_VALUE, + security_attributes.map(|sa| &sa as *const _), + PAGE_READWRITE, + 0, + length as u32, + &HSTRING::from(name.clone()), + ) + }?; + if filemap.is_invalid() { + return Err(Error::from_win32()); + } + let view = unsafe { MapViewOfFile(filemap, FILE_MAP_WRITE, 0, 0, 0) }; + Ok(Self { + filemap, + view, + length, + pos: 0, + }) + } + + fn seek(&mut self, pos: usize) { + self.pos = pos; + } + + fn write(&mut self, data: &[u8]) -> Result<(), Error> { + if self.pos + data.len() > self.length { + return Err(Error::Overflow); + } + + unsafe { + std::ptr::copy_nonoverlapping( + &data[0] as *const u8, + self.view.Value.add(self.pos) as *mut u8, + data.len(), + ); + } + self.pos += data.len(); + Ok(()) + } + + fn read(&mut self, n: usize) -> Vec { + let out = vec![0; n]; + unsafe { + std::ptr::copy_nonoverlapping( + self.view.Value.add(self.pos) as *const u8, + out.as_ptr() as *mut u8, + n, + ); + } + self.pos += n; + out + } +} + +impl Drop for MemoryMap { + fn drop(&mut self) { + unsafe { + let _ = UnmapViewOfFile(self.view); + let _ = CloseHandle(self.filemap); + } + } +} + +fn find_pageant_window() -> Result { + let w = unsafe { FindWindowW(&HSTRING::from("Pageant"), &HSTRING::from("Pageant")) }?; + if w.is_invalid() { + return Err(Error::NotFound); + } + Ok(w) +} + +const _AGENT_COPYDATA_ID: u64 = 0x804E50BA; +const _AGENT_MAX_MSGLEN: usize = 8192; + +/// Send a one-off query to Pageant and return a response. +pub fn query_pageant_direct(cookie: String, msg: &[u8]) -> Result, Error> { + let hwnd = find_pageant_window()?; + let map_name = format!("PageantRequest{cookie}"); + + let user = unsafe { + let mut process_token = HANDLE::default(); + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY, + &mut process_token as *mut _, + )?; + + let mut info_size = 0; + let _ = GetTokenInformation(process_token, TokenUser, None, 0, &mut info_size); + + let mut buffer = vec![0; info_size as usize]; + GetTokenInformation( + process_token, + TokenUser, + Some(buffer.as_mut_ptr() as *mut _), + buffer.len() as u32, + &mut info_size, + )?; + let user: TOKEN_USER = *(buffer.as_ptr() as *const _); + let _ = CloseHandle(process_token); + user + }; + + let mut sd = SECURITY_DESCRIPTOR::default(); + let sa = SECURITY_ATTRIBUTES { + lpSecurityDescriptor: &mut sd as *mut _ as *mut _, + bInheritHandle: true.into(), + ..Default::default() + }; + + let psd = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _); + + unsafe { + InitializeSecurityDescriptor(psd, 1)?; + SetSecurityDescriptorOwner(psd, user.User.Sid, false)?; + } + + let mut map: MemoryMap = MemoryMap::new(map_name.clone(), _AGENT_MAX_MSGLEN, Some(sa))?; + map.write(msg)?; + + let mut char_buffer = map_name.as_bytes().to_vec(); + char_buffer.push(0); + let cds = COPYDATASTRUCT { + dwData: _AGENT_COPYDATA_ID as usize, + cbData: char_buffer.len() as u32, + lpData: char_buffer.as_ptr() as *mut _, + }; + + let response = unsafe { + SendMessageA( + hwnd, + WM_COPYDATA, + WPARAM(size_of::()), + LPARAM(&cds as *const _ as isize), + ) + }; + + if response.0 == 0 { + return Err(Error::NoResponse); + } + + map.seek(0); + let mut buf = map.read(4); + let size = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + buf.extend(map.read(size)); + + Ok(buf) +} diff --git a/russh-keys/Cargo.toml b/russh-keys/Cargo.toml index 6958202..53cdc4d 100644 --- a/russh-keys/Cargo.toml +++ b/russh-keys/Cargo.toml @@ -58,7 +58,7 @@ tokio = { workspace = true, features = [ "time", "net", ] } -tokio-stream = { version = "0.1", features = ["net"] } +tokio-stream = { workspace = true } typenum = "1.17" yasna = { version = "0.5.0", features = ["bit-vec", "num-bigint"], optional = true } zeroize = "1.7" @@ -67,6 +67,9 @@ zeroize = "1.7" vendored-openssl = ["openssl", "openssl/vendored"] legacy-ed25519-pkcs8-parser = ["yasna"] +[target.'cfg(windows)'.dependencies] +pageant = { version = "0.0.1-beta.1", path = "../pageant" } + [dev-dependencies] env_logger = "0.10" tempdir = "0.3" diff --git a/russh-keys/src/agent/client.rs b/russh-keys/src/agent/client.rs index 582d27d..6a1acf0 100644 --- a/russh-keys/src/agent/client.rs +++ b/russh-keys/src/agent/client.rs @@ -31,7 +31,7 @@ impl AgentClient { #[cfg(unix)] impl AgentClient { - /// Build a future that connects to an SSH agent via the provided + /// Connect to an SSH agent via the provided /// stream (on Unix, usually a Unix-domain socket). pub async fn connect_uds>(path: P) -> Result { let stream = tokio::net::UnixStream::connect(path).await?; @@ -41,8 +41,8 @@ impl AgentClient { }) } - /// Build a future that connects to an SSH agent via the provided - /// stream (on Unix, usually a Unix-domain socket). + /// Connect to an SSH agent specified by the SSH_AUTH_SOCK + /// environment variable. pub async fn connect_env() -> Result { let var = if let Ok(var) = std::env::var("SSH_AUTH_SOCK") { var @@ -58,6 +58,14 @@ impl AgentClient { } } +#[cfg(target_os = "windows")] +impl AgentClient { + /// Connect to a running Pageant instance + pub async fn connect_pageant() -> Self { + Self::connect(pageant::PageantStream::new()) + } +} + #[cfg(not(unix))] impl AgentClient { /// Build a future that connects to an SSH agent via the provided diff --git a/russh-keys/src/lib.rs b/russh-keys/src/lib.rs index cc895ed..b21c11a 100644 --- a/russh-keys/src/lib.rs +++ b/russh-keys/src/lib.rs @@ -190,6 +190,10 @@ pub enum Error { #[error("ASN1 decoding error: {0}")] #[cfg(feature = "legacy-ed25519-pkcs8-parser")] LegacyASN1(::yasna::ASN1Error), + + #[cfg(target_os = "windows")] + #[error("Pageant: {0}")] + Pageant(#[from] pageant::Error), } #[cfg(feature = "legacy-ed25519-pkcs8-parser")]