Add ssh-agent native auth
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user