Skip to content

Commit 97dc08b

Browse files
authored
Support Pageant as agent (#326)
1 parent ca5129f commit 97dc08b

File tree

7 files changed

+341
-5
lines changed

7 files changed

+341
-5
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace]
2-
members = ["russh-keys", "russh", "russh-config", "cryptovec"]
2+
members = ["russh-keys", "russh", "russh-config", "cryptovec", "pageant"]
33

44
[patch.crates-io]
55
russh = { path = "russh" }
@@ -23,3 +23,4 @@ ssh-encoding = "0.2"
2323
ssh-key = { version = "0.6", features = ["ed25519", "rsa", "encryption"] }
2424
thiserror = "1.0"
2525
tokio = { version = "1.17.0" }
26+
tokio-stream = { version = "0.1", features = ["net", "sync"] }

pageant/Cargo.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
authors = ["Eugene <inbox@null.page>"]
3+
description = "Pageant SSH agent transport client."
4+
documentation = "https://docs.rs/pageant"
5+
edition = "2018"
6+
include = ["Cargo.toml", "src/lib.rs"]
7+
license = "Apache-2.0"
8+
name = "pageant"
9+
repository = "https://github.com/warp-tech/russh"
10+
version = "0.0.1-beta.1"
11+
rust-version = "1.65"
12+
13+
[dependencies]
14+
futures = { workspace = true }
15+
thiserror = { workspace = true }
16+
rand = { workspace = true }
17+
tokio = { workspace = true, features = ["io-util", "rt"] }
18+
bytes = "1.7"
19+
delegate = "0.12"
20+
21+
[target.'cfg(windows)'.dependencies]
22+
windows = { version = "0.58", features = [
23+
"Win32_UI_WindowsAndMessaging",
24+
"Win32_System_Memory",
25+
"Win32_Security",
26+
"Win32_System_Threading",
27+
"Win32_System_DataExchange",
28+
] }

pageant/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//! # Pageant SSH agent transport protocol implementation
2+
//!
3+
//! This crate provides a [PageantStream] type that implements [AsyncRead] and [AsyncWrite] traits and can be used to talk to a running Pageant instance.
4+
//!
5+
//! This crate only implements the transport, not the actual SSH agent protocol.
6+
7+
#[cfg(target_os = "windows")]
8+
mod pageant_impl;
9+
10+
#[cfg(target_os = "windows")]
11+
pub use pageant_impl::*;

pageant/src/pageant_impl.rs

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
use std::io::IoSlice;
2+
use std::mem::size_of;
3+
use std::pin::Pin;
4+
use std::task::{Context, Poll};
5+
6+
use bytes::BytesMut;
7+
use delegate::delegate;
8+
use thiserror::Error;
9+
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf};
10+
use windows::core::HSTRING;
11+
use windows::Win32::Foundation::{CloseHandle, HANDLE, HWND, INVALID_HANDLE_VALUE, LPARAM, WPARAM};
12+
use windows::Win32::Security::{
13+
GetTokenInformation, InitializeSecurityDescriptor, SetSecurityDescriptorOwner, TokenUser,
14+
PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, TOKEN_QUERY, TOKEN_USER,
15+
};
16+
use windows::Win32::System::DataExchange::COPYDATASTRUCT;
17+
use windows::Win32::System::Memory::{
18+
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS,
19+
PAGE_READWRITE,
20+
};
21+
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
22+
use windows::Win32::UI::WindowsAndMessaging::{FindWindowW, SendMessageA, WM_COPYDATA};
23+
24+
#[derive(Error, Debug)]
25+
pub enum Error {
26+
#[error("Pageant not found")]
27+
NotFound,
28+
29+
#[error("Buffer overflow")]
30+
Overflow,
31+
32+
#[error("No response from Pageant")]
33+
NoResponse,
34+
35+
#[error(transparent)]
36+
WindowsError(#[from] windows::core::Error),
37+
}
38+
39+
impl Error {
40+
fn from_win32() -> Self {
41+
Self::WindowsError(windows::core::Error::from_win32())
42+
}
43+
}
44+
45+
/// Pageant transport stream. Implements [AsyncRead] and [AsyncWrite].
46+
///
47+
/// The stream has a unique cookie and requests made in the same stream are considered the same "session".
48+
pub struct PageantStream {
49+
stream: DuplexStream,
50+
}
51+
52+
impl PageantStream {
53+
pub fn new() -> Self {
54+
let (one, mut two) = tokio::io::duplex(_AGENT_MAX_MSGLEN * 100);
55+
56+
let cookie = rand::random::<u64>().to_string();
57+
tokio::spawn(async move {
58+
let mut buf = BytesMut::new();
59+
while let Ok(n) = two.read_buf(&mut buf).await {
60+
if n == 0 {
61+
break;
62+
}
63+
let msg = buf.split().freeze();
64+
let response = query_pageant_direct(cookie.clone(), &msg).unwrap();
65+
two.write_all(&response).await?
66+
}
67+
std::io::Result::Ok(())
68+
});
69+
70+
Self { stream: one }
71+
}
72+
}
73+
74+
impl Default for PageantStream {
75+
fn default() -> Self {
76+
Self::new()
77+
}
78+
}
79+
80+
impl AsyncRead for PageantStream {
81+
delegate! {
82+
to Pin::new(&mut self.stream) {
83+
fn poll_read(
84+
mut self: Pin<&mut Self>,
85+
cx: &mut Context<'_>,
86+
buf: &mut ReadBuf<'_>,
87+
) -> Poll<Result<(), std::io::Error>>;
88+
89+
}
90+
}
91+
}
92+
93+
impl AsyncWrite for PageantStream {
94+
delegate! {
95+
to Pin::new(&mut self.stream) {
96+
fn poll_write(
97+
mut self: Pin<&mut Self>,
98+
cx: &mut Context<'_>,
99+
buf: &[u8],
100+
) -> Poll<Result<usize, std::io::Error>>;
101+
102+
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>>;
103+
104+
fn poll_write_vectored(
105+
mut self: Pin<&mut Self>,
106+
cx: &mut Context<'_>,
107+
bufs: &[IoSlice<'_>],
108+
) -> Poll<Result<usize, std::io::Error>>;
109+
110+
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>>;
111+
}
112+
113+
to Pin::new(&self.stream) {
114+
fn is_write_vectored(&self) -> bool;
115+
}
116+
}
117+
}
118+
119+
struct MemoryMap {
120+
filemap: HANDLE,
121+
view: MEMORY_MAPPED_VIEW_ADDRESS,
122+
length: usize,
123+
pos: usize,
124+
}
125+
126+
impl MemoryMap {
127+
fn new(
128+
name: String,
129+
length: usize,
130+
security_attributes: Option<SECURITY_ATTRIBUTES>,
131+
) -> Result<Self, Error> {
132+
let filemap = unsafe {
133+
CreateFileMappingW(
134+
INVALID_HANDLE_VALUE,
135+
security_attributes.map(|sa| &sa as *const _),
136+
PAGE_READWRITE,
137+
0,
138+
length as u32,
139+
&HSTRING::from(name.clone()),
140+
)
141+
}?;
142+
if filemap.is_invalid() {
143+
return Err(Error::from_win32());
144+
}
145+
let view = unsafe { MapViewOfFile(filemap, FILE_MAP_WRITE, 0, 0, 0) };
146+
Ok(Self {
147+
filemap,
148+
view,
149+
length,
150+
pos: 0,
151+
})
152+
}
153+
154+
fn seek(&mut self, pos: usize) {
155+
self.pos = pos;
156+
}
157+
158+
fn write(&mut self, data: &[u8]) -> Result<(), Error> {
159+
if self.pos + data.len() > self.length {
160+
return Err(Error::Overflow);
161+
}
162+
163+
unsafe {
164+
std::ptr::copy_nonoverlapping(
165+
&data[0] as *const u8,
166+
self.view.Value.add(self.pos) as *mut u8,
167+
data.len(),
168+
);
169+
}
170+
self.pos += data.len();
171+
Ok(())
172+
}
173+
174+
fn read(&mut self, n: usize) -> Vec<u8> {
175+
let out = vec![0; n];
176+
unsafe {
177+
std::ptr::copy_nonoverlapping(
178+
self.view.Value.add(self.pos) as *const u8,
179+
out.as_ptr() as *mut u8,
180+
n,
181+
);
182+
}
183+
self.pos += n;
184+
out
185+
}
186+
}
187+
188+
impl Drop for MemoryMap {
189+
fn drop(&mut self) {
190+
unsafe {
191+
let _ = UnmapViewOfFile(self.view);
192+
let _ = CloseHandle(self.filemap);
193+
}
194+
}
195+
}
196+
197+
fn find_pageant_window() -> Result<HWND, Error> {
198+
let w = unsafe { FindWindowW(&HSTRING::from("Pageant"), &HSTRING::from("Pageant")) }?;
199+
if w.is_invalid() {
200+
return Err(Error::NotFound);
201+
}
202+
Ok(w)
203+
}
204+
205+
const _AGENT_COPYDATA_ID: u64 = 0x804E50BA;
206+
const _AGENT_MAX_MSGLEN: usize = 8192;
207+
208+
/// Send a one-off query to Pageant and return a response.
209+
pub fn query_pageant_direct(cookie: String, msg: &[u8]) -> Result<Vec<u8>, Error> {
210+
let hwnd = find_pageant_window()?;
211+
let map_name = format!("PageantRequest{cookie}");
212+
213+
let user = unsafe {
214+
let mut process_token = HANDLE::default();
215+
OpenProcessToken(
216+
GetCurrentProcess(),
217+
TOKEN_QUERY,
218+
&mut process_token as *mut _,
219+
)?;
220+
221+
let mut info_size = 0;
222+
let _ = GetTokenInformation(process_token, TokenUser, None, 0, &mut info_size);
223+
224+
let mut buffer = vec![0; info_size as usize];
225+
GetTokenInformation(
226+
process_token,
227+
TokenUser,
228+
Some(buffer.as_mut_ptr() as *mut _),
229+
buffer.len() as u32,
230+
&mut info_size,
231+
)?;
232+
let user: TOKEN_USER = *(buffer.as_ptr() as *const _);
233+
let _ = CloseHandle(process_token);
234+
user
235+
};
236+
237+
let mut sd = SECURITY_DESCRIPTOR::default();
238+
let sa = SECURITY_ATTRIBUTES {
239+
lpSecurityDescriptor: &mut sd as *mut _ as *mut _,
240+
bInheritHandle: true.into(),
241+
..Default::default()
242+
};
243+
244+
let psd = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _);
245+
246+
unsafe {
247+
InitializeSecurityDescriptor(psd, 1)?;
248+
SetSecurityDescriptorOwner(psd, user.User.Sid, false)?;
249+
}
250+
251+
let mut map: MemoryMap = MemoryMap::new(map_name.clone(), _AGENT_MAX_MSGLEN, Some(sa))?;
252+
map.write(msg)?;
253+
254+
let mut char_buffer = map_name.as_bytes().to_vec();
255+
char_buffer.push(0);
256+
let cds = COPYDATASTRUCT {
257+
dwData: _AGENT_COPYDATA_ID as usize,
258+
cbData: char_buffer.len() as u32,
259+
lpData: char_buffer.as_ptr() as *mut _,
260+
};
261+
262+
let response = unsafe {
263+
SendMessageA(
264+
hwnd,
265+
WM_COPYDATA,
266+
WPARAM(size_of::<COPYDATASTRUCT>()),
267+
LPARAM(&cds as *const _ as isize),
268+
)
269+
};
270+
271+
if response.0 == 0 {
272+
return Err(Error::NoResponse);
273+
}
274+
275+
map.seek(0);
276+
let mut buf = map.read(4);
277+
let size = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
278+
buf.extend(map.read(size));
279+
280+
Ok(buf)
281+
}

russh-keys/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ tokio = { workspace = true, features = [
5858
"time",
5959
"net",
6060
] }
61-
tokio-stream = { version = "0.1", features = ["net"] }
61+
tokio-stream = { workspace = true }
6262
typenum = "1.17"
6363
yasna = { version = "0.5.0", features = ["bit-vec", "num-bigint"], optional = true }
6464
zeroize = "1.7"
@@ -67,6 +67,9 @@ zeroize = "1.7"
6767
vendored-openssl = ["openssl", "openssl/vendored"]
6868
legacy-ed25519-pkcs8-parser = ["yasna"]
6969

70+
[target.'cfg(windows)'.dependencies]
71+
pageant = { version = "0.0.1-beta.1", path = "../pageant" }
72+
7073
[dev-dependencies]
7174
env_logger = "0.10"
7275
tempdir = "0.3"

russh-keys/src/agent/client.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ impl<S: AsyncRead + AsyncWrite + Unpin> AgentClient<S> {
3131

3232
#[cfg(unix)]
3333
impl AgentClient<tokio::net::UnixStream> {
34-
/// Build a future that connects to an SSH agent via the provided
34+
/// Connect to an SSH agent via the provided
3535
/// stream (on Unix, usually a Unix-domain socket).
3636
pub async fn connect_uds<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
3737
let stream = tokio::net::UnixStream::connect(path).await?;
@@ -41,8 +41,8 @@ impl AgentClient<tokio::net::UnixStream> {
4141
})
4242
}
4343

44-
/// Build a future that connects to an SSH agent via the provided
45-
/// stream (on Unix, usually a Unix-domain socket).
44+
/// Connect to an SSH agent specified by the SSH_AUTH_SOCK
45+
/// environment variable.
4646
pub async fn connect_env() -> Result<Self, Error> {
4747
let var = if let Ok(var) = std::env::var("SSH_AUTH_SOCK") {
4848
var
@@ -58,6 +58,14 @@ impl AgentClient<tokio::net::UnixStream> {
5858
}
5959
}
6060

61+
#[cfg(target_os = "windows")]
62+
impl AgentClient<pageant::PageantStream> {
63+
/// Connect to a running Pageant instance
64+
pub async fn connect_pageant() -> Self {
65+
Self::connect(pageant::PageantStream::new())
66+
}
67+
}
68+
6169
#[cfg(not(unix))]
6270
impl AgentClient<tokio::net::TcpStream> {
6371
/// Build a future that connects to an SSH agent via the provided

russh-keys/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ pub enum Error {
190190
#[error("ASN1 decoding error: {0}")]
191191
#[cfg(feature = "legacy-ed25519-pkcs8-parser")]
192192
LegacyASN1(::yasna::ASN1Error),
193+
194+
#[cfg(target_os = "windows")]
195+
#[error("Pageant: {0}")]
196+
Pageant(#[from] pageant::Error),
193197
}
194198

195199
#[cfg(feature = "legacy-ed25519-pkcs8-parser")]

0 commit comments

Comments
 (0)