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:
@@ -44,6 +44,17 @@ fn write_server_config_with_remote_forwarding(
|
||||
config
|
||||
}
|
||||
|
||||
fn write_server_config_with_agent_forwarding(
|
||||
dir: &tempfile::TempDir,
|
||||
port: u16,
|
||||
) -> std::path::PathBuf {
|
||||
let config = write_server_config(dir, port);
|
||||
let mut raw = fs::read_to_string(&config).unwrap();
|
||||
raw.push_str("allow_agent_forwarding = true\n");
|
||||
fs::write(&config, raw).unwrap();
|
||||
config
|
||||
}
|
||||
|
||||
fn write_server_config_with_timeout(
|
||||
dir: &tempfile::TempDir,
|
||||
port: u16,
|
||||
@@ -326,6 +337,40 @@ fn fake_ssh_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey
|
||||
write_agent_packet(&mut stream, &response).unwrap();
|
||||
}
|
||||
|
||||
/// A fake ssh-agent that serves repeated connections and only answers
|
||||
/// REQUEST_IDENTITIES (enough to prove the forwarded socket reaches the real
|
||||
/// local agent). Returns the comment string it advertises so the test can match.
|
||||
fn start_fake_ssh_agent_looping(socket_path: &Path, comment: &'static str) {
|
||||
let signing = SigningKey::from_bytes(&[77u8; 32]);
|
||||
let public = VerifyingKey::from(&signing).to_bytes();
|
||||
let key_blob = ssh_ed25519_public_blob(&public);
|
||||
let listener = UnixListener::bind(socket_path).unwrap();
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { break };
|
||||
let key_blob = key_blob.clone();
|
||||
thread::spawn(move || {
|
||||
while let Ok(request) = read_agent_packet(&mut stream) {
|
||||
// Only the identities request (11) is exercised.
|
||||
if request.first() == Some(&11) {
|
||||
let mut response = Vec::new();
|
||||
response.push(12); // IDENTITIES_ANSWER
|
||||
response.extend_from_slice(&1u32.to_be_bytes());
|
||||
write_ssh_string(&mut response, &key_blob);
|
||||
write_ssh_string(&mut response, comment.as_bytes());
|
||||
if write_agent_packet(&mut stream, &response).is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Unknown request: reply AGENT_FAILURE (5).
|
||||
let _ = write_agent_packet(&mut stream, &[5]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
|
||||
let mut len = [0u8; 4];
|
||||
stream.read_exact(&mut len)?;
|
||||
@@ -1557,3 +1602,201 @@ fn native_hello_with_mismatched_protocol_version_gets_named_reject_not_hang() {
|
||||
reject.reason
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end SSH-agent forwarding: with -A opt-in AND allow_agent_forwarding on,
|
||||
/// the server exports a proxy SSH_AUTH_SOCK into the session and tunnels a
|
||||
/// connection from a "remote process" (here, the test connecting to the proxy
|
||||
/// socket) all the way back to the client's local ssh-agent. We drive an
|
||||
/// identities request through the chain and verify the real agent's answer comes
|
||||
/// back, proving server-proxy -> Dosh stream -> client unix splice -> local agent.
|
||||
#[test]
|
||||
fn native_agent_forwarding_round_trip() {
|
||||
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let port = free_udp_port();
|
||||
let config = write_server_config_with_agent_forwarding(&dir, port);
|
||||
write_native_client_auth(&dir, &config);
|
||||
|
||||
// Local fake agent the client will splice forwarded connections into.
|
||||
let agent_sock = dir.path().join("local-agent.sock");
|
||||
start_fake_ssh_agent_looping(&agent_sock, "forwarded-agent-key");
|
||||
|
||||
// Controlled TMPDIR so the server's proxy socket path is deterministic and
|
||||
// isolated to this test (std::env::temp_dir honors TMPDIR).
|
||||
let tmpdir = dir.path().join("tmp");
|
||||
fs::create_dir_all(&tmpdir).unwrap();
|
||||
|
||||
let server_bin = env!("CARGO_BIN_EXE_dosh-server");
|
||||
let mut server = Command::new(server_bin)
|
||||
.arg("serve")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.env("HOME", dir.path())
|
||||
.env("TMPDIR", &tmpdir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Spawn the client inside a real PTY so it can enter raw mode and stay
|
||||
// attached to an interactive session (agent forwarding needs a spawned shell).
|
||||
let pty = NativePtySystem::default();
|
||||
let pair = pty
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
|
||||
let mut cmd = CommandBuilder::new(client_bin);
|
||||
cmd.args([
|
||||
"--auth",
|
||||
"native",
|
||||
"--no-cache",
|
||||
"-A",
|
||||
"--session",
|
||||
"agent-session",
|
||||
"--dosh-host",
|
||||
"127.0.0.1",
|
||||
"--dosh-port",
|
||||
&port.to_string(),
|
||||
"local",
|
||||
]);
|
||||
cmd.env("HOME", dir.path().to_string_lossy().to_string());
|
||||
cmd.env("TMPDIR", tmpdir.to_string_lossy().to_string());
|
||||
cmd.env("SSH_AUTH_SOCK", agent_sock.to_string_lossy().to_string());
|
||||
let mut child = pair.slave.spawn_command(cmd).unwrap();
|
||||
drop(pair.slave);
|
||||
|
||||
// The server's proxy socket is created at session spawn; its path is
|
||||
// deterministic: TMPDIR/dosh-agent-<server_pid>/agent.<server_pid>.0.sock.
|
||||
let server_pid = server.id();
|
||||
let proxy_sock = tmpdir
|
||||
.join(format!("dosh-agent-{server_pid}"))
|
||||
.join(format!("agent.{server_pid}.0.sock"));
|
||||
|
||||
// Wait for the client to authenticate and the server to spawn the shell +
|
||||
// bind the proxy socket, then connect as the "remote process" and run an
|
||||
// identities request through the forwarded chain.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let answer = loop {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = server.kill();
|
||||
let _ = server.wait();
|
||||
panic!("agent proxy socket {proxy_sock:?} never became usable");
|
||||
}
|
||||
if proxy_sock.exists()
|
||||
&& let Ok(mut stream) = UnixStream::connect(&proxy_sock)
|
||||
{
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
if write_agent_packet(&mut stream, &[11]).is_ok()
|
||||
&& let Ok(answer) = read_agent_packet(&mut stream)
|
||||
{
|
||||
break answer;
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = server.kill();
|
||||
let _ = server.wait();
|
||||
|
||||
// The answer must be an IDENTITIES_ANSWER (12) carrying our agent's comment,
|
||||
// proving the bytes traversed the full forwarding chain to the local agent.
|
||||
assert_eq!(
|
||||
answer.first(),
|
||||
Some(&12),
|
||||
"expected IDENTITIES_ANSWER from the forwarded agent, got {:?}",
|
||||
answer.first()
|
||||
);
|
||||
assert!(
|
||||
answer
|
||||
.windows(b"forwarded-agent-key".len())
|
||||
.any(|w| w == b"forwarded-agent-key"),
|
||||
"forwarded agent answer should carry the local agent's key comment"
|
||||
);
|
||||
}
|
||||
|
||||
/// Agent forwarding stays off unless the client opts in: with the server allowing
|
||||
/// it but no -A, no proxy socket is created.
|
||||
#[test]
|
||||
fn native_agent_forwarding_requires_client_opt_in() {
|
||||
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let port = free_udp_port();
|
||||
let config = write_server_config_with_agent_forwarding(&dir, port);
|
||||
write_native_client_auth(&dir, &config);
|
||||
let agent_sock = dir.path().join("local-agent.sock");
|
||||
start_fake_ssh_agent_looping(&agent_sock, "forwarded-agent-key");
|
||||
let tmpdir = dir.path().join("tmp");
|
||||
fs::create_dir_all(&tmpdir).unwrap();
|
||||
|
||||
let server_bin = env!("CARGO_BIN_EXE_dosh-server");
|
||||
let mut server = Command::new(server_bin)
|
||||
.arg("serve")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.env("HOME", dir.path())
|
||||
.env("TMPDIR", &tmpdir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
let pty = NativePtySystem::default();
|
||||
let pair = pty
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
|
||||
let mut cmd = CommandBuilder::new(client_bin);
|
||||
// Note: NO -A flag.
|
||||
cmd.args([
|
||||
"--auth",
|
||||
"native",
|
||||
"--no-cache",
|
||||
"--session",
|
||||
"no-agent-session",
|
||||
"--dosh-host",
|
||||
"127.0.0.1",
|
||||
"--dosh-port",
|
||||
&port.to_string(),
|
||||
"local",
|
||||
]);
|
||||
cmd.env("HOME", dir.path().to_string_lossy().to_string());
|
||||
cmd.env("TMPDIR", tmpdir.to_string_lossy().to_string());
|
||||
cmd.env("SSH_AUTH_SOCK", agent_sock.to_string_lossy().to_string());
|
||||
let mut child = pair.slave.spawn_command(cmd).unwrap();
|
||||
drop(pair.slave);
|
||||
|
||||
// Give the client time to authenticate and the shell to spawn.
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
let server_pid = server.id();
|
||||
let agent_dir = tmpdir.join(format!("dosh-agent-{server_pid}"));
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = server.kill();
|
||||
let _ = server.wait();
|
||||
|
||||
// No client opt-in -> the server must not have created any agent socket dir.
|
||||
assert!(
|
||||
!agent_dir.exists(),
|
||||
"agent proxy dir {agent_dir:?} must not exist without client -A opt-in"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user