use std::path::{Path, PathBuf};
use crate::error::AgentError;
#[cfg(any(unix, windows))]
use crate::session::Session;
#[cfg(unix)]
pub use std::os::unix::net::UnixListener as AgentListener;
#[cfg(not(any(unix, windows)))]
pub use stub_impl::AgentListener;
#[cfg(windows)]
pub use windows_impl::AgentListener;
pub fn ensure_no_existing_agent() -> Result<(), AgentError> {
if let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") {
return Err(AgentError::AuthSockAlreadySet(
sock.to_string_lossy().into_owned(),
));
}
Ok(())
}
#[cfg(not(windows))]
pub fn default_socket_path(root: &Path) -> PathBuf {
root.join("agent.sock")
}
#[cfg(windows)]
pub fn default_socket_path(root: &Path) -> PathBuf {
let fp = kovra_core::fingerprint(root.to_string_lossy().as_bytes());
PathBuf::from(format!(r"\\.\pipe\kovra-ssh-agent-{fp}"))
}
pub struct SessionOwned {
pub keys: Vec<crate::session::KeypairEntry>,
pub scope: kovra_core::AgentScope,
pub confirmer: Box<dyn kovra_core::Confirmer>,
pub audit: Box<dyn kovra_core::AuditSink>,
pub clock: Box<dyn kovra_core::Clock>,
pub confirm_timeout: std::time::Duration,
pub requesting_process: Option<String>,
}
#[cfg(any(unix, windows))]
impl SessionOwned {
fn as_session(&self) -> Session<'_> {
Session {
keys: &self.keys,
scope: &self.scope,
confirmer: self.confirmer.as_ref(),
audit: self.audit.as_ref(),
clock: self.clock.as_ref(),
confirm_timeout: self.confirm_timeout,
requesting_process: self.requesting_process.clone(),
}
}
}
pub fn cleanup(path: &Path) {
let _ = std::fs::remove_file(path);
}
#[cfg(any(unix, windows))]
fn handle_connection<S, F>(mut stream: S, make_session: &mut F) -> Result<(), AgentError>
where
S: std::io::Read + std::io::Write,
F: FnMut() -> Result<SessionOwned, AgentError>,
{
use crate::protocol::{encode_failure, frame, parse_request, read_frame};
loop {
let body = match read_frame(&mut stream)? {
Some(b) => b,
None => return Ok(()), };
let reply_body = match parse_request(&body) {
Ok(request) => {
let owned = make_session()?;
let session = owned.as_session();
session.handle(&request)?
}
Err(_) => encode_failure(),
};
stream.write_all(&frame(&reply_body))?;
stream.flush()?;
}
}
#[cfg(unix)]
pub use unix_impl::{bind, serve};
#[cfg(unix)]
mod unix_impl {
use std::os::unix::net::UnixListener;
use std::path::Path;
use super::{AgentError, SessionOwned, handle_connection};
pub fn bind(path: &Path) -> Result<UnixListener, AgentError> {
if path.exists() {
let is_socket = std::fs::symlink_metadata(path)
.map(|m| {
use std::os::unix::fs::FileTypeExt;
m.file_type().is_socket()
})
.unwrap_or(false);
if is_socket {
let _ = std::fs::remove_file(path);
} else {
return Err(AgentError::Socket(format!(
"{} exists and is not a socket — refusing to overwrite",
path.display()
)));
}
}
let listener = UnixListener::bind(path)
.map_err(|e| AgentError::Socket(format!("bind {}: {e}", path.display())))?;
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| AgentError::Socket(format!("chmod {}: {e}", path.display())))?;
}
Ok(listener)
}
pub fn serve<F>(listener: &UnixListener, mut make_session: F) -> Result<(), AgentError>
where
F: FnMut() -> Result<SessionOwned, AgentError>,
{
for incoming in listener.incoming() {
match incoming {
Ok(stream) => {
if let Err(e) = handle_connection(stream, &mut make_session) {
eprintln!("kovra ssh-agent: connection error: {e}");
}
}
Err(e) => {
eprintln!("kovra ssh-agent: accept error: {e}");
}
}
}
Ok(())
}
}
#[cfg(windows)]
pub use windows_impl::{bind, serve};
#[cfg(windows)]
mod windows_impl {
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::FromRawHandle;
use std::path::Path;
use windows::Win32::Foundation::{
CloseHandle, ERROR_PIPE_CONNECTED, HANDLE, HLOCAL, LocalFree,
};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
SDDL_REVISION_1,
};
use windows::Win32::Security::{
GetTokenInformation, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER,
TokenUser,
};
use windows::Win32::Storage::FileSystem::{FILE_FLAGS_AND_ATTRIBUTES, FlushFileBuffers};
use windows::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE,
PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
use windows::core::{HRESULT, HSTRING, PCWSTR, PWSTR};
use super::{AgentError, SessionOwned, handle_connection};
const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003;
const PIPE_BUF: u32 = 8 * 1024;
fn win(e: impl std::fmt::Display, what: &str) -> AgentError {
AgentError::Socket(format!("{what}: {e}"))
}
pub struct AgentListener {
name: Vec<u16>,
sd: PSECURITY_DESCRIPTOR,
instance: HANDLE,
}
unsafe impl Send for AgentListener {}
impl Drop for AgentListener {
fn drop(&mut self) {
if !self.sd.0.is_null() {
unsafe {
let _ = LocalFree(Some(HLOCAL(self.sd.0.cast())));
}
}
}
}
pub fn bind(path: &Path) -> Result<AgentListener, AgentError> {
let name: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let sd = build_owner_only_sd().map_err(|e| win(e, "pipe security descriptor"))?;
match create_instance(&name, sd) {
Ok(instance) => Ok(AgentListener { name, sd, instance }),
Err(e) => {
unsafe {
if !sd.0.is_null() {
let _ = LocalFree(Some(HLOCAL(sd.0.cast())));
}
}
Err(win(
e,
&format!(
"create named pipe {} (another kovra agent already running?)",
path.display()
),
))
}
}
}
pub fn serve<F>(listener: &AgentListener, mut make_session: F) -> Result<(), AgentError>
where
F: FnMut() -> Result<SessionOwned, AgentError>,
{
let mut current = listener.instance;
loop {
match unsafe { ConnectNamedPipe(current, None) } {
Ok(()) => {}
Err(e) if e.code() == HRESULT::from_win32(ERROR_PIPE_CONNECTED.0) => {}
Err(e) => {
eprintln!("kovra ssh-agent: connect error: {e}");
unsafe {
let _ = CloseHandle(current);
}
current = create_instance(&listener.name, listener.sd)
.map_err(|e| win(e, "re-create pipe instance"))?;
continue;
}
}
let file = unsafe { std::fs::File::from_raw_handle(current.0.cast()) };
if let Err(e) = handle_connection(file, &mut make_session) {
eprintln!("kovra ssh-agent: connection error: {e}");
}
drop_instance(current);
current = create_instance(&listener.name, listener.sd)
.map_err(|e| win(e, "create next pipe instance"))?;
}
}
fn drop_instance(handle: HANDLE) {
unsafe {
let _ = FlushFileBuffers(handle);
let _ = DisconnectNamedPipe(handle);
}
}
fn create_instance(name: &[u16], sd: PSECURITY_DESCRIPTOR) -> windows::core::Result<HANDLE> {
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.0,
bInheritHandle: false.into(),
};
let handle = unsafe {
CreateNamedPipeW(
PCWSTR(name.as_ptr()),
FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_DUPLEX),
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
PIPE_UNLIMITED_INSTANCES,
PIPE_BUF,
PIPE_BUF,
0,
Some(&sa),
)
};
if handle.is_invalid() {
return Err(windows::core::Error::from_thread());
}
Ok(handle)
}
fn build_owner_only_sd() -> windows::core::Result<PSECURITY_DESCRIPTOR> {
let sid = current_user_sid_string()?;
let sddl = HSTRING::from(format!("D:P(A;;GA;;;{sid})"));
let mut psd = PSECURITY_DESCRIPTOR::default();
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
&sddl,
SDDL_REVISION_1,
&mut psd,
None,
)?;
}
Ok(psd)
}
fn current_user_sid_string() -> windows::core::Result<String> {
unsafe {
let mut token = HANDLE::default();
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)?;
let mut len = 0u32;
let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len);
let mut buf = vec![0u8; len as usize];
let info = GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
len,
&mut len,
);
let _ = CloseHandle(token);
info?;
let token_user = &*(buf.as_ptr() as *const TOKEN_USER);
let mut pwstr = PWSTR::null();
ConvertSidToStringSidW(token_user.User.Sid, &mut pwstr)?;
let s = pwstr
.to_string()
.map_err(|_| windows::core::Error::from_thread())?;
let _ = LocalFree(Some(HLOCAL(pwstr.0.cast())));
Ok(s)
}
}
}
#[cfg(not(any(unix, windows)))]
pub use stub_impl::{AgentListener, bind, serve};
#[cfg(not(any(unix, windows)))]
mod stub_impl {
use std::path::Path;
use super::{AgentError, SessionOwned};
const UNSUPPORTED: &str = "the governed ssh-agent is not available on this platform";
#[derive(Debug)]
pub struct AgentListener(());
pub fn bind(_path: &Path) -> Result<AgentListener, AgentError> {
Err(AgentError::Socket(UNSUPPORTED.into()))
}
pub fn serve<F>(_listener: &AgentListener, _make_session: F) -> Result<(), AgentError>
where
F: FnMut() -> Result<SessionOwned, AgentError>,
{
Err(AgentError::Socket(UNSUPPORTED.into()))
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use std::io::{Read, Write};
use std::time::Duration;
use kovra_core::{
AgentScope, ConfirmOutcome, Filter, MockAuditSink, MockClock, MockConfirmer, Operation,
};
use super::{SessionOwned, bind, default_socket_path, serve};
use crate::protocol::{SSH_AGENT_IDENTITIES_ANSWER, SSH_AGENTC_REQUEST_IDENTITIES, frame};
#[test]
fn named_pipe_round_trips_request_identities() {
let root = tempfile::tempdir().unwrap();
let pipe = default_socket_path(root.path());
let listener = bind(&pipe).expect("bind named pipe");
std::thread::spawn(move || {
let _ = serve(&listener, || {
Ok(SessionOwned {
keys: Vec::new(),
scope: AgentScope {
operations: [Operation::Metadata, Operation::Inject]
.into_iter()
.collect(),
projects: Filter::Any,
environments: Filter::Any,
},
confirmer: Box::new(MockConfirmer::always(ConfirmOutcome::Approved)),
audit: Box::new(MockAuditSink::new()),
clock: Box::new(MockClock::default()),
confirm_timeout: Duration::from_secs(1),
requesting_process: None,
})
});
});
let mut client = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&pipe)
.expect("open the agent pipe as a client");
client
.write_all(&frame(&[SSH_AGENTC_REQUEST_IDENTITIES]))
.unwrap();
client.flush().unwrap();
let mut len_buf = [0u8; 4];
client.read_exact(&mut len_buf).unwrap();
let len = u32::from_be_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
client.read_exact(&mut body).unwrap();
assert_eq!(body[0], SSH_AGENT_IDENTITIES_ANSWER, "answer message type");
assert_eq!(&body[1..5], &[0, 0, 0, 0], "zero identities");
}
}