diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 5ca2447..35d0e6d 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -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 { + 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>, diff --git a/src/lib.rs b/src/lib.rs index 7a8ab53..abe852d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod crypto; pub mod native; pub mod protocol; pub mod pty; +pub mod ssh_agent; diff --git a/src/native.rs b/src/native.rs index ea4ed04..227898f 100644 --- a/src/native.rs +++ b/src/native.rs @@ -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 { + 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, 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={}", diff --git a/src/ssh_agent.rs b/src/ssh_agent.rs new file mode 100644 index 0000000..2275fa2 --- /dev/null +++ b/src/ssh_agent.rs @@ -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, + pub public_key: [u8; 32], + pub comment: String, +} + +pub fn sign_user_auth_with_agent( + client: &NativeClientHello, + server: &NativeServerHello, + requested_forwardings: Vec, +) -> Result { + 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, + client: &NativeClientHello, + server: &NativeServerHello, + requested_forwardings: Vec, +) -> Result { + 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> { + 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> { + 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> { + 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 { + anyhow::ensure!(!cursor.is_empty(), "truncated u8"); + let value = cursor[0]; + *cursor = &cursor[1..]; + Ok(value) +} + +fn read_u32(cursor: &mut &[u8]) -> Result { + 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, 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, 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(), + } + } +} diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index c2141ac..8b6c045 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -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, 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> { + 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, 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();