Add opt-in, security-gated SSH-agent forwarding

Item 3: SSH-agent forwarding, double-gated (client -A/forward_agent AND server
allow_agent_forwarding) and off by default on both ends.

Wire: new ForwardingKind::Agent (appended, existing bincode discriminants
unchanged). Bumped protocol VERSION 2 -> 3 so a pre-agent peer answers with a
clear version-mismatch reject instead of a deserialize error.

Client: -A / --forward-agent flag; requires local SSH_AUTH_SOCK. Requests an
Agent forwarding during native auth (agent forwarding requires native auth) and,
on a server-initiated StreamOpen carrying the reserved @dosh-agent sentinel,
splices the stream into the local agent unix socket (separate agent_writers map,
TCP forwarding paths untouched). Rejects agent StreamOpens unless it opted in, so
a server cannot reach the agent without consent.

Server: when the client opted in and allow_agent_forwarding is set, binds a
per-session proxy unix socket (dir 0700, socket 0600) before the shell spawns,
exports its path as the session's SSH_AUTH_SOCK, and tunnels each connection back
to the client over the agent sentinel stream. Applies only to freshly spawned
shells (documented). Socket cleaned up on auth/forward failure and when the
accept loop ends. SECURITY rationale documented in comments.

Tests: 2 end-to-end integration tests (full chain identities round-trip via a
PTY-hosted client + looping fake agent; and "no -A => no proxy socket"). Docs
updated (README forwarding + disconnect-status note, spec §12.1). fmt + full test
suite green (122 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DuProcess
2026-06-14 11:10:44 -04:00
parent 6b2933eb05
commit 9a52d65beb
7 changed files with 585 additions and 16 deletions
+178 -8
View File
@@ -21,13 +21,24 @@ use dosh::protocol::{
use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session};
use std::collections::{HashMap, HashSet, VecDeque};
use std::net::{IpAddr, SocketAddr};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
use tokio::net::{TcpListener, TcpStream, UdpSocket, UnixListener};
use tokio::sync::mpsc;
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// Sentinel `target_host` sent to the client on a server-initiated `StreamOpen`
/// that carries an SSH-agent connection (the client splices it into its local
/// `SSH_AUTH_SOCK` rather than dialing TCP). Must match the client's constant.
const AGENT_STREAM_SENTINEL: &str = "@dosh-agent";
/// Monotonic counter for unique agent proxy socket filenames within this process.
static AGENT_SOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
/// How long a pending native handshake (ClientHello awaiting UserAuth) is kept
/// before it is swept by the cleanup task.
const NATIVE_HANDSHAKE_TTL_SECS: u64 = 30;
@@ -765,6 +776,41 @@ async fn handle_native_user_auth(
return Ok(());
}
// SSH-agent forwarding (opt-in + policy-gated). When the client requested it
// AND the server allows it, bind a per-session proxy unix socket now so its
// path can be exported as the remote shell's SSH_AUTH_SOCK before the PTY is
// spawned. The accept loop is started after the client_id exists below.
// SECURITY: only ever reached on explicit client opt-in and with
// `allow_agent_forwarding` enabled; the socket is private to this user.
let agent_allowed = {
let locked = state.lock().expect("server state poisoned");
locked.config.allow_agent_forwarding
};
let agent_listener =
if agent_allowed && agent_forwarding_requested(&req.auth.requested_forwardings) {
match bind_agent_proxy_socket() {
Ok(pair) => Some(pair),
Err(err) => {
eprintln!("agent forwarding setup failed, continuing without it: {err:#}");
None
}
}
} else {
None
};
// Env handed to ensure_session: base requested env plus SSH_AUTH_SOCK when
// agent forwarding is active. Note: only applies to a freshly spawned shell;
// an already-running (prewarmed/attached) session keeps its existing env, the
// same constraint ssh/mosh have.
let mut session_env = pending.client.requested_env.clone();
if let Some((_, ref path)) = agent_listener {
session_env.retain(|e| e.name != "SSH_AUTH_SOCK");
session_env.push(EnvVar {
name: "SSH_AUTH_SOCK".to_string(),
value: path.to_string_lossy().to_string(),
});
}
let attached: Result<(
[u8; 16],
[u8; 16],
@@ -793,13 +839,7 @@ async fn handle_native_user_auth(
if !locked.sessions.contains_key(&session_name) && !locked.config.create_on_attach {
return Err(anyhow!("session does not exist"));
}
locked.ensure_session(
&session_name,
cols,
rows,
&mode,
&pending.client.requested_env,
)?;
locked.ensure_session(&session_name, cols, rows, &mode, &session_env)?;
let session_key = pending.session_key;
let key_id = protocol::session_key_id(&session_key);
let attach_ticket_psk = crypto::random_32();
@@ -877,6 +917,10 @@ async fn handle_native_user_auth(
) = match attached {
Ok(attached) => attached,
Err(err) => {
// Auth/session setup failed: clean up the agent socket we bound early.
if let Some((_, path)) = agent_listener {
let _ = std::fs::remove_file(&path);
}
return send_reject_to_client(socket, peer, packet.header.conn_id, &format!("{err:#}"))
.await;
}
@@ -890,9 +934,16 @@ async fn handle_native_user_auth(
)
.await
{
if let Some((_, path)) = agent_listener {
let _ = std::fs::remove_file(&path);
}
remove_client(state, client_id);
return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string()).await;
}
// Now that the client exists, start accepting forwarded agent connections.
if let Some((listener, path)) = agent_listener {
spawn_agent_forward(state, socket, client_id, listener, path);
}
let ok = NativeAuthOk {
client_id,
@@ -1793,6 +1844,123 @@ fn allocate_server_stream_id(state: &Arc<Mutex<ServerState>>) -> u64 {
stream_id
}
/// Whether the client requested SSH-agent forwarding during auth.
fn agent_forwarding_requested(forwardings: &[ForwardingRequest]) -> bool {
forwardings.iter().any(|f| f.kind == ForwardingKind::Agent)
}
/// Bind the per-session SSH-agent proxy unix socket, returning its `UnixListener`
/// and path. SECURITY: the socket lives in a process-private directory created
/// 0700 and the socket itself is chmod 0600, so only this user can connect — the
/// forwarded agent is never world-reachable. Called only when the client opted in
/// AND `allow_agent_forwarding` is set; the path is then exported as the remote
/// session's `SSH_AUTH_SOCK`.
fn bind_agent_proxy_socket() -> Result<(UnixListener, PathBuf)> {
let dir = std::env::temp_dir().join(format!("dosh-agent-{}", std::process::id()));
std::fs::create_dir_all(&dir).with_context(|| format!("create agent dir {}", dir.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod agent dir {}", dir.display()))?;
let n = AGENT_SOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("agent.{}.{n}.sock", std::process::id()));
// Stale socket from a crashed prior run with the same name: remove first.
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path)
.with_context(|| format!("bind agent socket {}", path.display()))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod agent socket {}", path.display()))?;
Ok((listener, path))
}
/// Run the accept loop for a forwarded SSH-agent: each connection from a remote
/// process to the proxy socket is tunneled to the client (which splices it into
/// the user's real ssh-agent) over a Dosh stream using the agent sentinel target.
/// The socket file is removed when the loop ends (client gone / session closed).
fn spawn_agent_forward(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
client_id: [u8; 16],
listener: UnixListener,
socket_path: PathBuf,
) {
let state = Arc::clone(state);
let socket = Arc::clone(socket);
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
// Stop accepting once the client has gone away.
{
let locked = state.lock().expect("server state poisoned");
if locked.lookup_client(&client_id).is_none() {
break;
}
}
let stream_id = allocate_server_stream_id(&state);
let (mut reader, mut writer) = stream.into_split();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(1024);
{
let mut locked = state.lock().expect("server state poisoned");
if let Some(client) = locked.client_mut(&client_id) {
client.stream_writers.insert(stream_id, writer_tx);
client
.stream_send_credit
.insert(stream_id, STREAM_INITIAL_WINDOW);
} else {
break;
}
}
tokio::spawn(async move {
while let Some(bytes) = writer_rx.recv().await {
if writer.write_all(&bytes).await.is_err() {
break;
}
}
});
if let Err(err) = send_stream_open_to_client(
&state,
&socket,
client_id,
stream_id,
AGENT_STREAM_SENTINEL.to_string(),
0,
)
.await
{
eprintln!("agent forward open error: {err:#}");
break;
}
let state = Arc::clone(&state);
let socket = Arc::clone(&socket);
tokio::spawn(async move {
let mut buf = [0u8; 16 * 1024];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if let Err(err) = send_stream_data_to_client(
&state,
&socket,
client_id,
stream_id,
buf[..n].to_vec(),
)
.await
{
eprintln!("agent forward data error: {err:#}");
break;
}
}
Err(_) => break,
}
}
let _ = send_stream_close_to_client(&state, &socket, client_id, stream_id).await;
});
}
let _ = std::fs::remove_file(&socket_path);
});
}
fn is_loopback_bind(host: &str) -> bool {
matches!(host, "127.0.0.1" | "localhost" | "::1")
}
@@ -1823,6 +1991,8 @@ fn stream_open_allowed(
}
ForwardingKind::Dynamic => true,
ForwardingKind::Remote => false,
// Agent forwarding never authorizes a client-initiated TCP open.
ForwardingKind::Agent => false,
});
anyhow::ensure!(
allowed,