Add ssh-agent native auth
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 15:21:21 -04:00
parent e7f3679fc1
commit 55a0fc1ab4
5 changed files with 484 additions and 14 deletions
+35 -2
View File
@@ -19,6 +19,7 @@ use dosh::protocol::{
Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
};
use dosh::ssh_agent;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
@@ -899,8 +900,13 @@ async fn try_native_auth(
&hello,
&server_hello.hello,
)?;
let identity = load_first_native_identity(config, cli_identity, &ssh_config)?;
let auth = sign_user_auth(&identity, &hello, &server_hello.hello, Vec::new())?;
let auth = sign_native_user_auth(
config,
cli_identity,
&ssh_config,
&hello,
&server_hello.hello,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
let auth_body = protocol::to_body(&NativeUserAuthBody { auth })?;
@@ -953,6 +959,33 @@ async fn try_native_auth(
Ok((frame, cred))
}
fn sign_native_user_auth(
config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>,
ssh_config: &SshConfig,
hello: &dosh::native::NativeClientHello,
server_hello: &dosh::native::NativeServerHello,
) -> Result<dosh::native::NativeUserAuth> {
let mut errors = Vec::new();
if config.use_ssh_agent {
match ssh_agent::sign_user_auth_with_agent(hello, server_hello, Vec::new()) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
}
}
match load_first_native_identity(config, cli_identity, ssh_config) {
Ok(identity) => sign_user_auth(&identity, hello, server_hello, Vec::new()),
Err(err) => {
errors.push(format!("identity files: {err:#}"));
Err(anyhow!(
"native auth found no usable key: {}",
errors.join("; ")
))
}
}
}
fn load_first_native_identity(
config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>,
+1
View File
@@ -4,3 +4,4 @@ pub mod crypto;
pub mod native;
pub mod protocol;
pub mod pty;
pub mod ssh_agent;
+15 -11
View File
@@ -546,7 +546,7 @@ fn strip_quotes(raw: &str) -> &str {
.unwrap_or(raw)
}
fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-ed25519", "key blob type mismatch");
@@ -558,6 +558,13 @@ fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out)
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519");
write_ssh_string(&mut out, public_key);
out
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -568,21 +575,18 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
Ok(value)
}
#[cfg(test)]
fn ssh_ed25519_authorized_key_line(signing_key: &SigningKey) -> String {
let verifying = VerifyingKey::from(signing_key);
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, &verifying.to_bytes());
format!("ssh-ed25519 {} test-key", STANDARD.encode(blob))
}
#[cfg(test)]
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
#[cfg(test)]
fn ssh_ed25519_authorized_key_line(signing_key: &SigningKey) -> String {
let verifying = VerifyingKey::from(signing_key);
let blob = ssh_ed25519_public_blob(&verifying.to_bytes());
format!("ssh-ed25519 {} test-key", STANDARD.encode(blob))
}
pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String {
format!(
"{} {} {} first-seen={} source={}",
+281
View File
@@ -0,0 +1,281 @@
use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
const SSH_AGENT_FAILURE: u8 = 5;
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub public_key: [u8; 32],
pub comment: String,
}
pub fn sign_user_auth_with_agent(
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
let sock = std::env::var("SSH_AUTH_SOCK").context("SSH_AUTH_SOCK is not set")?;
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
}
pub fn sign_user_auth_with_agent_at(
socket_path: impl AsRef<Path>,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?;
let identity = identities
.first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(),
public_key: identity.public_key.to_vec(),
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = sign_with_agent(&mut agent, identity, &transcript)
.with_context(|| format!("ssh-agent sign with {}", identity.comment))?;
Ok(auth)
}
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
let response = read_u8(&mut cursor)?;
if response == SSH_AGENT_FAILURE {
bail!("ssh-agent refused identity request");
}
anyhow::ensure!(
response == SSH2_AGENT_IDENTITIES_ANSWER,
"unexpected ssh-agent identity response {response}"
);
let count = read_u32(&mut cursor)? as usize;
anyhow::ensure!(count <= 4096, "ssh-agent returned too many identities");
let mut identities = Vec::new();
for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
identities.push(AgentIdentity {
key_blob,
public_key,
comment,
});
}
}
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities)
}
fn sign_with_agent(
agent: &mut UnixStream,
identity: &AgentIdentity,
transcript: &[u8],
) -> Result<Vec<u8>> {
let mut request = Vec::new();
request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes());
write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
let response = read_u8(&mut cursor)?;
if response == SSH_AGENT_FAILURE {
bail!("ssh-agent refused signature request");
}
anyhow::ensure!(
response == SSH2_AGENT_SIGN_RESPONSE,
"unexpected ssh-agent sign response {response}"
);
let signature_blob = read_ssh_string(&mut cursor)?;
anyhow::ensure!(
cursor.is_empty(),
"trailing data in ssh-agent sign response"
);
let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
algorithm == b"ssh-ed25519",
"ssh-agent returned unsupported signature algorithm {}",
String::from_utf8_lossy(algorithm)
);
let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob"
);
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec())
}
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
let mut len = [0u8; 4];
stream
.read_exact(&mut len)
.context("read ssh-agent packet length")?;
let len = u32::from_be_bytes(len) as usize;
anyhow::ensure!(len <= MAX_AGENT_PACKET, "ssh-agent packet too large");
let mut payload = vec![0u8; len];
stream
.read_exact(&mut payload)
.context("read ssh-agent packet body")?;
Ok(payload)
}
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
anyhow::ensure!(
payload.len() <= MAX_AGENT_PACKET,
"ssh-agent request too large"
);
stream.write_all(&(payload.len() as u32).to_be_bytes())?;
stream.write_all(payload)?;
Ok(())
}
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
let value = cursor[0];
*cursor = &cursor[1..];
Ok(value)
}
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
*cursor = &cursor[4..];
Ok(value)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let len = read_u32(cursor)? as usize;
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
let (value, rest) = cursor.split_at(len);
*cursor = rest;
Ok(value)
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::native::{
host_public_key, parse_authorized_keys, sign_server_hello, ssh_ed25519_public_blob,
verify_native_user_auth,
};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
use std::os::unix::net::UnixListener;
use std::thread;
#[test]
fn agent_ed25519_signature_authenticates_native_user() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("agent.sock");
let user_signing = SigningKey::from_bytes(&[51u8; 32]);
let user_public = VerifyingKey::from(&user_signing).to_bytes();
let key_blob = ssh_ed25519_public_blob(&user_public);
let listener = UnixListener::bind(&socket_path).unwrap();
thread::spawn(move || fake_agent(listener, key_blob, user_signing));
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth =
sign_user_auth_with_agent_at(&socket_path, &client, &server, Vec::new()).unwrap();
let authorized_line = format!(
"ssh-ed25519 {} agent-key",
STANDARD.encode(ssh_ed25519_public_blob(&user_public))
);
let authorized = parse_authorized_keys(&authorized_line).unwrap();
verify_native_user_auth(&client, &server, &auth, &authorized).unwrap();
}
fn fake_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey) {
let (mut stream, _) = listener.accept().unwrap();
let payload = read_agent_packet(&mut stream).unwrap();
assert_eq!(payload, vec![SSH2_AGENTC_REQUEST_IDENTITIES]);
let mut response = Vec::new();
response.push(SSH2_AGENT_IDENTITIES_ANSWER);
response.extend_from_slice(&1u32.to_be_bytes());
write_ssh_string(&mut response, &key_blob);
write_ssh_string(&mut response, b"test-agent-key");
write_agent_packet(&mut stream, &response).unwrap();
let payload = read_agent_packet(&mut stream).unwrap();
let mut cursor = payload.as_slice();
assert_eq!(read_u8(&mut cursor).unwrap(), SSH2_AGENTC_SIGN_REQUEST);
assert_eq!(read_ssh_string(&mut cursor).unwrap(), key_blob.as_slice());
let transcript = read_ssh_string(&mut cursor).unwrap();
assert_eq!(read_u32(&mut cursor).unwrap(), 0);
assert!(cursor.is_empty());
let signature = signing.sign(transcript).to_bytes();
let mut signature_blob = Vec::new();
write_ssh_string(&mut signature_blob, b"ssh-ed25519");
write_ssh_string(&mut signature_blob, &signature);
let mut response = Vec::new();
response.push(SSH2_AGENT_SIGN_RESPONSE);
write_ssh_string(&mut response, &signature_blob);
write_agent_packet(&mut stream, &response).unwrap();
}
fn test_client_hello() -> NativeClientHello {
NativeClientHello {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
}
}
fn test_server_hello(signing: &SigningKey) -> NativeServerHello {
NativeServerHello {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
server_random: [4u8; 32],
server_ephemeral_public: [5u8; 32],
host_key: host_public_key(signing),
chosen_aead: "chacha20poly1305".to_string(),
server_key_epoch: 1,
auth_challenge: [6u8; 32],
rate_limit_remaining: Some(30),
host_signature: Vec::new(),
}
}
}
+152 -1
View File
@@ -1,17 +1,23 @@
use std::fs;
use std::io::{Read, Write};
use std::net::UdpSocket;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
use dosh::crypto;
use dosh::native::{host_public_key, load_or_create_host_key, trust_host};
use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT,
};
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
fn free_udp_port() -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
@@ -113,6 +119,30 @@ fn native_attach_once(dir: &tempfile::TempDir, port: u16) -> std::process::Outpu
.unwrap()
}
fn native_attach_once_with_agent(
dir: &tempfile::TempDir,
port: u16,
agent_sock: &Path,
) -> std::process::Output {
let client = env!("CARGO_BIN_EXE_dosh-client");
Command::new(client)
.arg("--auth")
.arg("native")
.arg("--attach-only")
.arg("-v")
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.env("SSH_AUTH_SOCK", agent_sock)
.output()
.unwrap()
}
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
let ssh_dir = dir.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
@@ -142,6 +172,109 @@ fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Pa
.unwrap();
}
fn write_native_agent_auth(
dir: &tempfile::TempDir,
config_path: &std::path::Path,
signing: &SigningKey,
) {
let public = VerifyingKey::from(signing).to_bytes();
fs::write(
dir.path().join("authorized_keys"),
format!(
"ssh-ed25519 {} agent-key\n",
STANDARD.encode(ssh_ed25519_public_blob(&public))
),
)
.unwrap();
let config = load_server_config(Some(config_path.to_path_buf())).unwrap();
let host_signing = load_or_create_host_key(&config).unwrap();
let known_hosts = dir.path().join(".config/dosh/known_hosts");
trust_host(
&known_hosts,
"local",
&host_public_key(&host_signing),
"test",
false,
)
.unwrap();
}
fn start_fake_ssh_agent(dir: &tempfile::TempDir, signing: SigningKey) -> PathBuf {
let socket_path = dir.path().join("agent.sock");
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 || fake_ssh_agent(listener, key_blob, signing));
socket_path
}
fn fake_ssh_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey) {
let (mut stream, _) = listener.accept().unwrap();
let request = read_agent_packet(&mut stream).unwrap();
assert_eq!(request, vec![11]);
let mut response = Vec::new();
response.push(12);
response.extend_from_slice(&1u32.to_be_bytes());
write_ssh_string(&mut response, &key_blob);
write_ssh_string(&mut response, b"integration-agent-key");
write_agent_packet(&mut stream, &response).unwrap();
let request = read_agent_packet(&mut stream).unwrap();
let mut cursor = request.as_slice();
assert_eq!(read_u8(&mut cursor), 13);
assert_eq!(read_ssh_string(&mut cursor), key_blob.as_slice());
let transcript = read_ssh_string(&mut cursor);
assert_eq!(read_u32(&mut cursor), 0);
assert!(cursor.is_empty());
let signature = signing.sign(transcript).to_bytes();
let mut signature_blob = Vec::new();
write_ssh_string(&mut signature_blob, b"ssh-ed25519");
write_ssh_string(&mut signature_blob, &signature);
let mut response = Vec::new();
response.push(14);
write_ssh_string(&mut response, &signature_blob);
write_agent_packet(&mut stream, &response).unwrap();
}
fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 4];
stream.read_exact(&mut len)?;
let len = u32::from_be_bytes(len) as usize;
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload)?;
Ok(payload)
}
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> {
stream.write_all(&(payload.len() as u32).to_be_bytes())?;
stream.write_all(payload)
}
fn read_u8(cursor: &mut &[u8]) -> u8 {
let value = cursor[0];
*cursor = &cursor[1..];
value
}
fn read_u32(cursor: &mut &[u8]) -> u32 {
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
*cursor = &cursor[4..];
value
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> &'a [u8] {
let len = read_u32(cursor) as usize;
let (value, rest) = cursor.split_at(len);
*cursor = rest;
value
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn direct_attach(
config: &std::path::Path,
port: u16,
@@ -344,6 +477,24 @@ fn native_attach_only_smoke() {
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
}
#[test]
fn native_attach_only_smoke_uses_ssh_agent() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing = SigningKey::from_bytes(&[53u8; 32]);
write_native_agent_auth(&dir, &config, &signing);
let agent_sock = start_fake_ssh_agent(&dir, signing);
let mut server = start_server(&dir, &config);
let output = native_attach_once_with_agent(&dir, port, &agent_sock);
let _ = server.kill();
let _ = server.wait();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "stderr={stderr}");
assert!(stderr.contains("native_auth"), "stderr={stderr}");
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
}
#[test]
fn ticket_attach_after_server_restart_smoke() {
let dir = tempfile::tempdir().unwrap();