Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pageant: Implement NamedPipes-based stream #472

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions pageant/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,41 @@ edition = "2021"
license = "Apache-2.0"
name = "pageant"
repository = "https://github.com/warp-tech/russh"
version = "0.0.2"
version = "0.0.3"
rust-version = "1.75"

[dependencies]
futures.workspace = true
thiserror.workspace = true
rand.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["io-util", "rt"] }
tokio = { workspace = true }
bytes.workspace = true
delegate.workspace = true
sha2 = { workspace = true, optional = true }

[features]
default = ["wmmessage"]

wmmessage = [
"tokio/rt",
"tokio/io-util",
"windows/Win32_UI_WindowsAndMessaging",
"windows/Win32_System_Memory",
"windows/Win32_System_Threading",
"windows/Win32_System_DataExchange",
]

# Since PuTTY 0.75 Pageant switched to a more robust named pipes bases communication
namedpipes = [
"tokio/net",
"tokio/time",
"dep:sha2",
"dep:windows-strings",
"windows/Win32_Security_Authentication_Identity",
"windows/Win32_Security_Cryptography",
]

[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_System_Memory",
"Win32_Security",
"Win32_System_Threading",
"Win32_System_DataExchange",
] }
windows = { version = "0.59", features = ["Win32_Security"] }
windows-strings = { version = "0.3", optional = true }
10 changes: 8 additions & 2 deletions pageant/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@
clippy::panic
)]

#[cfg(windows)]
#[cfg(all(windows, feature = "wmmessage", not(feature = "namedpipes")))]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot!

The two implementations should definitely not be mutually exclusive. They could be both exported together, with neither being a "default", e.g.:

#[cfg(all(windows, feature = "wmmessage"))]
pub use pageant_impl::PageantStream as PageantWmMessageStream;

#[cfg(all(windows, feature = "namedpipes"))]
pub use pageant_impl_namedpipes::PageantStream as PageantNamedPipeStream;

I'd really like to see a common trait for both of them since it would be weird to have to choose a specific implementation at compile time unless you know exactly which PuTTY version to expect in advance

Copy link

@Rondom Rondom Mar 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a cost to maintaining both and why would one want to run an old pre-2020 Putty Pageant with potential security bugs in 2025? I think a compile time decision is good enough, and I would even evaluate to eventually remove the WM_COPYDATA method to simplify maintenance.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because in reality, when you're writing an SSH client, you don't control what version of Putty the user might have installed.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P.S. I don't mind doing it myself, it will just take a bit until I have time to work on it.

mod pageant_impl;

#[cfg(windows)]
#[cfg(all(windows, feature = "wmmessage", not(feature = "namedpipes")))]
pub use pageant_impl::*;

#[cfg(all(windows, feature = "namedpipes"))]
mod pageant_impl_namedpipes;

#[cfg(all(windows, feature = "namedpipes"))]
pub use pageant_impl_namedpipes::*;
46 changes: 23 additions & 23 deletions pageant/src/pageant_impl.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::ffi::CString;
use std::io::IoSlice;
use std::mem::size_of;
use std::pin::Pin;
use std::task::{Context, Poll};

Expand Down Expand Up @@ -33,6 +33,9 @@ pub enum Error {
#[error("No response from Pageant")]
NoResponse,

#[error("Invalid Cookie")]
InvalidCookie,

#[error(transparent)]
WindowsError(#[from] windows::core::Error),
}
Expand All @@ -51,7 +54,7 @@ pub struct PageantStream {
}

impl PageantStream {
pub fn new() -> Self {
pub async fn new() -> Result<Self, Error> {
let (one, mut two) = tokio::io::duplex(_AGENT_MAX_MSGLEN * 100);

let cookie = rand::random::<u64>().to_string();
Expand All @@ -73,13 +76,7 @@ impl PageantStream {
std::io::Result::Ok(())
});

Self { stream: one }
}
}

impl Default for PageantStream {
fn default() -> Self {
Self::new()
Ok(Self { stream: one })
}
}

Expand Down Expand Up @@ -220,12 +217,8 @@ pub fn is_pageant_running() -> bool {
find_pageant_window().is_ok()
}

/// Send a one-off query to Pageant and return a response.
pub fn query_pageant_direct(cookie: String, msg: &[u8]) -> Result<Vec<u8>, Error> {
let hwnd = find_pageant_window()?;
let map_name = format!("PageantRequest{cookie}");

let user = unsafe {
fn get_current_process_user() -> Result<TOKEN_USER, Error> {
unsafe {
let mut process_token = HANDLE::default();
OpenProcessToken(
GetCurrentProcess(),
Expand All @@ -246,8 +239,16 @@ pub fn query_pageant_direct(cookie: String, msg: &[u8]) -> Result<Vec<u8>, Error
)?;
let user: TOKEN_USER = *(buffer.as_ptr() as *const _);
let _ = CloseHandle(process_token);
user
};
Ok(user)
}
}

/// Send a one-off query to Pageant and return a response.
pub fn query_pageant_direct(cookie: String, msg: &[u8]) -> Result<Vec<u8>, Error> {
let hwnd = find_pageant_window()?;
let map_name = format!("PageantRequest{cookie}");

let user = get_current_process_user()?;

let mut sd = SECURITY_DESCRIPTOR::default();
let sa = SECURITY_ATTRIBUTES {
Expand All @@ -260,25 +261,24 @@ pub fn query_pageant_direct(cookie: String, msg: &[u8]) -> Result<Vec<u8>, Error

unsafe {
InitializeSecurityDescriptor(psd, 1)?;
SetSecurityDescriptorOwner(psd, user.User.Sid, false)?;
SetSecurityDescriptorOwner(psd, Some(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 char_buffer = CString::new(map_name.as_bytes()).map_err(|_| Error::InvalidCookie)?;
let cds = COPYDATASTRUCT {
dwData: _AGENT_COPYDATA_ID as usize,
cbData: char_buffer.len() as u32,
lpData: char_buffer.as_ptr() as *mut _,
cbData: char_buffer.as_bytes().len() as u32,
lpData: char_buffer.as_bytes().as_ptr() as *mut _,
};

let response = unsafe {
SendMessageA(
hwnd,
WM_COPYDATA,
WPARAM(size_of::<COPYDATASTRUCT>()),
WPARAM(0), // Should be window handle to requesting app, which we don't have
LPARAM(&cds as *const _ as isize),
)
};
Expand Down
174 changes: 174 additions & 0 deletions pageant/src/pageant_impl_namedpipes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
use std::io::IoSlice;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;

use delegate::delegate;
use log::debug;
use sha2::{Digest, Sha256};
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient};
use windows::Win32::Foundation::ERROR_PIPE_BUSY;
use windows::Win32::Security::Authentication::Identity::{GetUserNameExA, NameUserPrincipal};
use windows::Win32::Security::Cryptography::{
CryptProtectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, CRYPTPROTECTMEMORY_CROSS_PROCESS,
};
use windows_strings::PSTR;

#[derive(Error, Debug)]
pub enum Error {
#[error("Pageant not found")]
NotFound,

#[error("Buffer overflow")]
Overflow,

#[error("No response from Pageant")]
NoResponse,

#[error("Invalid Username")]
InvalidUsername,

#[error(transparent)]
WindowsError(#[from] windows::core::Error),

#[error(transparent)]
IoError(#[from] std::io::Error),
}

impl Error {
fn from_win32() -> Self {
Self::WindowsError(windows::core::Error::from_win32())
}
}

/// Pageant transport stream. Implements [AsyncRead] and [AsyncWrite].
pub struct PageantStream {
stream: NamedPipeClient,
}

impl PageantStream {
pub async fn new() -> Result<Self, Error> {
let pipe_name = Self::determine_pipe_name()?;
debug!("Opening pipe '{}'", pipe_name);
let stream = loop {
match ClientOptions::new().open(&pipe_name) {
Ok(client) => break client,
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => (),
Err(e) => return Err(e.into()),
}

tokio::time::sleep(Duration::from_millis(50)).await;
};

Ok(Self { stream })
}

fn determine_pipe_name() -> Result<String, Error> {
let username = Self::get_username()?;
let suffix = Self::capi_obfuscate_string("Pageant")?;
Ok(format!("\\\\.\\pipe\\pageant.{username}.{suffix}"))
}

fn get_username() -> Result<String, Error> {
unsafe {
let mut name_length = 0;

// don't check result on this, always returns ERROR_MORE_DATA
GetUserNameExA(NameUserPrincipal, None, &mut name_length);

let mut name_buf = vec![0u8; name_length as usize];

if !GetUserNameExA(
NameUserPrincipal,
Some(PSTR(name_buf.as_mut_ptr())),
&mut name_length,
) {
// Pageant falls back to GetUserNameA here,
// but as far as I can tell, all Versions of Windows supported by Rust today
// should be able to answer the UserNameEx request - the comments in Pageant source
// point to Windows XP and earlier compatibility...
return Err(Error::from_win32());
}

//remove terminating null
if let Some(0) = name_buf.pop() {
let mut name = String::from_utf8(name_buf).map_err(|_| Error::InvalidUsername)?;
if let Some(at_index) = name.find('@') {
name.drain(at_index..);
}
Ok(name)
} else {
Err(Error::InvalidUsername)
}
}
}

fn capi_obfuscate_string(input: &str) -> Result<String, Error> {
let mut cryptlen = input.len() + 1;
cryptlen = cryptlen.next_multiple_of(CRYPTPROTECTMEMORY_BLOCK_SIZE as usize);
let mut cryptdata = vec![0u8; cryptlen];

// copy cleartext into crypt buffer:
cryptdata
.iter_mut()
.zip(input.as_bytes())
.for_each(|(c, i)| *c = *i);
// (since the buffer is initialized to 0 and always at least 1 longer than the input,
// we don't need to worry about terminating the string)

unsafe {
// Errors are explicitly ignored:
let _ = CryptProtectMemory(
cryptdata.as_mut_ptr() as *mut _,
cryptlen as u32,
CRYPTPROTECTMEMORY_CROSS_PROCESS,
);
}

let mut hasher = Sha256::new();
hasher.update((cryptdata.len() as u32).to_be_bytes());
hasher.update(&cryptdata);
Ok(format!("{:x}", hasher.finalize()))
}
}

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<Result<(), std::io::Error>>;

}
}
}

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<Result<usize, std::io::Error>>;

fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>>;

fn poll_write_vectored(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<Result<usize, std::io::Error>>;

fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>>;
}

to Pin::new(&self.stream) {
fn is_write_vectored(&self) -> bool;
}
}
}
20 changes: 10 additions & 10 deletions russh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ thiserror.workspace = true
tokio = { workspace = true, features = ["io-util", "sync", "time"] }
typenum = "1.17"
yasna = { version = "0.5.0", features = [
"bit-vec",
"num-bigint",
"bit-vec",
"num-bigint",
], optional = true }
zeroize = "1.7"

Expand All @@ -95,20 +95,20 @@ tokio-stream.workspace = true
home.workspace = true

[target.'cfg(windows)'.dependencies]
pageant = { version = "0.0.2", path = "../pageant" }
pageant = { version = "0.0.3", path = "../pageant" }

[dev-dependencies]
anyhow = "1.0.4"
env_logger.workspace = true
clap = { version = "3.2.3", features = ["derive"] }
tokio = { workspace = true, features = [
"io-std",
"io-util",
"rt-multi-thread",
"time",
"net",
"sync",
"macros",
"io-std",
"io-util",
"rt-multi-thread",
"time",
"net",
"sync",
"macros",
] }
rand = "0.8.5"
shell-escape = "0.1"
Expand Down
4 changes: 2 additions & 2 deletions russh/src/keys/agent/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ const ERROR_PIPE_BUSY: u32 = 231u32;
#[cfg(windows)]
impl AgentClient<pageant::PageantStream> {
/// Connect to a running Pageant instance
pub async fn connect_pageant() -> Self {
Self::connect(pageant::PageantStream::new())
pub async fn connect_pageant() -> Result<Self, Error> {
Ok(Self::connect(pageant::PageantStream::new().await?))
}
}

Expand Down