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
+116 -4
View File
@@ -39,11 +39,17 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
use tokio::net::{TcpListener, TcpStream, UdpSocket, UnixStream};
use tokio::sync::mpsc;
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// Sentinel `target_host` the server uses on a server-initiated `StreamOpen` that
/// represents an SSH-agent connection (rather than a TCP target). The client
/// splices these into its local `SSH_AUTH_SOCK` instead of dialing TCP. The
/// `:0` port and this reserved name are never valid TCP forward targets.
const AGENT_STREAM_SENTINEL: &str = "@dosh-agent";
/// Current terminal size, with a sane fallback.
///
/// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it
@@ -89,6 +95,10 @@ struct Args {
remote_forward: Vec<String>,
#[arg(short = 'D', long = "dynamic-forward")]
dynamic_forward: Vec<String>,
/// Forward the local ssh-agent (SSH_AUTH_SOCK) to the remote session.
/// SECURITY: opt-in only; the server must also set allow_agent_forwarding.
#[arg(short = 'A', long = "forward-agent")]
forward_agent: bool,
#[arg(short = 'N', long)]
forward_only: bool,
#[arg(short = 'f', long)]
@@ -200,8 +210,28 @@ async fn main() -> Result<()> {
let local_forwards = parse_local_forwards(&args.local_forward)?;
let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?;
let forwarding_requested =
!local_forwards.is_empty() || !remote_forwards.is_empty() || !dynamic_forwards.is_empty();
// SSH-agent forwarding (opt-in via -A / forward_agent). SECURITY: this exposes
// your local agent to the remote host for the session's lifetime; only ever
// active on explicit opt-in AND when the server's allow_agent_forwarding is
// set. We capture the LOCAL agent socket path now so the run loop can splice
// each server-initiated agent stream into it.
let forward_agent = args.forward_agent || config.forward_agent;
let agent_sock = if forward_agent {
match std::env::var_os("SSH_AUTH_SOCK") {
Some(path) if !path.is_empty() => Some(PathBuf::from(path)),
_ => {
return Err(anyhow!(
"agent forwarding requested but SSH_AUTH_SOCK is not set"
));
}
}
} else {
None
};
let forwarding_requested = !local_forwards.is_empty()
|| !remote_forwards.is_empty()
|| !dynamic_forwards.is_empty()
|| forward_agent;
let predict = args.predict || host.predict.unwrap_or(config.predict);
let session = select_session(args.session.as_deref(), args.new);
let mode = if args.forward_only {
@@ -317,6 +347,7 @@ async fn main() -> Result<()> {
Vec::new(),
Vec::new(),
false,
None,
)
.await;
}
@@ -352,6 +383,7 @@ async fn main() -> Result<()> {
Vec::new(),
Vec::new(),
false,
None,
)
.await;
}
@@ -385,7 +417,12 @@ async fn main() -> Result<()> {
&mode,
cols,
rows,
forwarding_requests(&local_forwards, &remote_forwards, &dynamic_forwards),
forwarding_requests(
&local_forwards,
&remote_forwards,
&dynamic_forwards,
forward_agent,
),
resolved_ssh_config.as_ref(),
cold_requested_env,
)
@@ -412,6 +449,7 @@ async fn main() -> Result<()> {
local_forwards,
dynamic_forwards,
args.forward_only,
agent_sock,
)
.await;
}
@@ -487,6 +525,7 @@ async fn main() -> Result<()> {
Vec::new(),
Vec::new(),
false,
None,
)
.await
}
@@ -897,6 +936,7 @@ fn forwarding_requests(
local_forwards: &[LocalForward],
remote_forwards: &[RemoteForward],
dynamic_forwards: &[DynamicForward],
forward_agent: bool,
) -> Vec<ForwardingRequest> {
let mut requests = local_forwards
.iter()
@@ -922,6 +962,15 @@ fn forwarding_requests(
target_host: None,
target_port: None,
}));
if forward_agent {
requests.push(ForwardingRequest {
kind: ForwardingKind::Agent,
bind_host: None,
listen_port: 0,
target_host: None,
target_port: None,
});
}
requests
}
@@ -2075,6 +2124,7 @@ async fn run_terminal(
local_forwards: Vec<LocalForward>,
dynamic_forwards: Vec<DynamicForward>,
forward_only: bool,
agent_sock: Option<PathBuf>,
) -> Result<()> {
let _raw = if forward_only {
None
@@ -2112,6 +2162,10 @@ async fn run_terminal(
start_dynamic_forwards(&dynamic_forwards, forward_tx.clone(), stream_ids).await?;
signal_background_ready();
let mut stream_writers: HashMap<u64, tokio::net::tcp::OwnedWriteHalf> = HashMap::new();
// Write halves for server-initiated SSH-agent streams, spliced into the local
// unix agent socket. Kept separate from the TCP `stream_writers` so the
// existing TCP forwarding paths are untouched.
let mut agent_writers: HashMap<u64, tokio::net::unix::OwnedWriteHalf> = HashMap::new();
let mut pending_socks_replies: HashSet<u64> = HashSet::new();
let mut opened_streams: HashSet<u64> = HashSet::new();
let mut stream_send_credit: HashMap<u64, usize> = HashMap::new();
@@ -2323,6 +2377,60 @@ async fn run_terminal(
continue;
};
last_packet_at = Instant::now();
// SSH-agent stream: the server is asking us to splice this
// stream into our LOCAL ssh-agent. Only honor it when we
// actually opted in (agent_sock is Some); otherwise reject,
// so a server cannot reach our agent without consent.
if open.target_host == AGENT_STREAM_SENTINEL {
let Some(sock_path) = agent_sock.clone() else {
send_stream_open_reject(
&socket, addr, &cred, &mut send_seq, open.stream_id,
"agent forwarding not enabled by client".to_string(),
).await?;
continue;
};
match UnixStream::connect(&sock_path).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
agent_writers.insert(open.stream_id, writer);
opened_streams.insert(open.stream_id);
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
let forward_tx = remote_forward_tx.clone();
tokio::spawn(async move {
let mut buf = [0u8; 16 * 1024];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if forward_tx
.send(ForwardEvent::Data {
stream_id: open.stream_id,
bytes: buf[..n].to_vec(),
})
.await
.is_err()
{
return;
}
}
Err(_) => break,
}
}
let _ = forward_tx.send(ForwardEvent::Close {
stream_id: open.stream_id,
}).await;
});
}
Err(err) => {
send_stream_open_reject(
&socket, addr, &cred, &mut send_seq, open.stream_id,
format!("connect local agent: {err}"),
).await?;
}
}
continue;
}
match TcpStream::connect((open.target_host.as_str(), open.target_port)).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
@@ -2423,6 +2531,8 @@ async fn run_terminal(
let len = data.bytes.len();
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).await;
} else if let Some(writer) = agent_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).await;
}
// Always return flow-control credit so a stream whose local
// writer is already gone cannot wedge the server's send window.
@@ -2467,6 +2577,7 @@ async fn run_terminal(
stream_send_credit.remove(&close.stream_id);
stream_pending_data.remove(&close.stream_id);
stream_writers.remove(&close.stream_id);
agent_writers.remove(&close.stream_id);
}
_ => {}
}
@@ -2509,6 +2620,7 @@ async fn run_terminal(
stream_send_credit.remove(&stream_id);
stream_pending_data.remove(&stream_id);
stream_writers.remove(&stream_id);
agent_writers.remove(&stream_id);
send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?;
}
None if forward_only => break,