use std::ffi::OsString;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::{Error, Result};
use super::protocol::{
IdentityEntry, MAX_REPLY_LEN, SSH_AGENT_CONSTRAIN_CONFIRM, SSH_AGENT_CONSTRAIN_EXTENSION,
SSH_AGENT_CONSTRAIN_LIFETIME, SSH_AGENT_EXTENSION_RESPONSE, SSH_AGENT_FAILURE,
SSH_AGENT_IDENTITIES_ANSWER, SSH_AGENT_SIGN_RESPONSE, SSH_AGENT_SUCCESS,
SSH_AGENTC_ADD_ID_CONSTRAINED, SSH_AGENTC_ADD_IDENTITY, SSH_AGENTC_EXTENSION, SSH_AGENTC_LOCK,
SSH_AGENTC_REMOVE_ALL_IDENTITIES, SSH_AGENTC_REMOVE_IDENTITY, SSH_AGENTC_UNLOCK,
decode_identities_answer, decode_sign_response, encode_message, encode_request_identities,
encode_sign_request,
};
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct AddConstraints {
pub lifetime: Option<u32>,
pub confirm: bool,
pub destinations: Vec<DestinationConstraint>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DestinationHop {
pub user: Option<String>,
pub hostname: Option<String>,
pub keys: Vec<(Vec<u8>, bool)>,
}
impl DestinationHop {
pub fn new(user: Option<String>, hostname: Option<String>, keys: Vec<(Vec<u8>, bool)>) -> Self {
Self {
user,
hostname,
keys,
}
}
#[cfg(test)]
pub(crate) fn encode_for_test(&self) -> Vec<u8> {
self.encode()
}
fn encode(&self) -> Vec<u8> {
let mut w = crate::format::Writer::new();
w.write_string(self.user.as_deref().unwrap_or("").as_bytes());
w.write_string(self.hostname.as_deref().unwrap_or("").as_bytes());
w.write_string(b"");
for (k, is_ca) in &self.keys {
w.write_string(k);
w.write_u8(u8::from(*is_ca));
}
w.into_vec()
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DestinationConstraint {
pub from: DestinationHop,
pub to: DestinationHop,
}
impl DestinationConstraint {
pub fn new(from: DestinationHop, to: DestinationHop) -> Self {
Self { from, to }
}
}
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub comment: String,
}
impl AgentIdentity {
pub fn algorithm(&self) -> String {
crate::format::Reader::new(&self.key_blob)
.read_string()
.map(|s| String::from_utf8_lossy(s).into_owned())
.unwrap_or_default()
}
pub fn comment(&self) -> &str {
&self.comment
}
pub fn key_blob(&self) -> &[u8] {
&self.key_blob
}
}
pub struct Agent {
stream: UnixStream,
}
impl Agent {
pub fn connect(path: impl AsRef<Path>) -> Result<Self> {
let stream = UnixStream::connect(path.as_ref()).map_err(Error::from)?;
stream
.set_read_timeout(Some(DEFAULT_TIMEOUT))
.map_err(Error::from)?;
stream
.set_write_timeout(Some(DEFAULT_TIMEOUT))
.map_err(Error::from)?;
Ok(Self { stream })
}
pub fn connect_env() -> Result<Option<Self>> {
let raw: OsString = match std::env::var_os("SSH_AUTH_SOCK") {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let path = PathBuf::from(raw);
if let Err(why) = validate_auth_sock(&path) {
return Err(Error::Io(std::io::Error::other(why)));
}
Self::connect(path).map(Some)
}
pub fn identities(&mut self) -> Result<Vec<AgentIdentity>> {
self.write_frame(&encode_request_identities())?;
let (msg_type, body) = self.read_frame()?;
if msg_type == SSH_AGENT_FAILURE {
return Err(Error::Protocol("agent: failure on identities request"));
}
if msg_type != SSH_AGENT_IDENTITIES_ANSWER {
return Err(Error::Protocol("agent: unexpected identities-reply type"));
}
let raw = decode_identities_answer(&body)?;
Ok(raw
.into_iter()
.map(|IdentityEntry { key_blob, comment }| AgentIdentity { key_blob, comment })
.collect())
}
pub fn sign(&mut self, key_blob: &[u8], data: &[u8], flags: u32) -> Result<Vec<u8>> {
self.write_frame(&encode_sign_request(key_blob, data, flags))?;
let (msg_type, body) = self.read_frame()?;
if msg_type == SSH_AGENT_FAILURE {
return Err(Error::Protocol("agent: failure on sign request"));
}
if msg_type != SSH_AGENT_SIGN_RESPONSE {
return Err(Error::Protocol("agent: unexpected sign-reply type"));
}
decode_sign_response(&body)
}
pub fn add_identity(&mut self, key: &crate::key::PrivateKey) -> Result<()> {
let body = purecrypto::zeroize::Zeroizing::new(key.to_agent_add_body());
let frame =
purecrypto::zeroize::Zeroizing::new(encode_message(SSH_AGENTC_ADD_IDENTITY, &body));
self.write_frame(&frame)?;
let (msg_type, _) = self.read_frame()?;
match msg_type {
SSH_AGENT_SUCCESS => Ok(()),
SSH_AGENT_FAILURE => Err(Error::Protocol("agent: failure adding identity")),
_ => Err(Error::Protocol("agent: unexpected reply to add-identity")),
}
}
pub fn add_identity_with_lifetime(
&mut self,
key: &crate::key::PrivateKey,
lifetime: u32,
) -> Result<()> {
let mut body = purecrypto::zeroize::Zeroizing::new(key.to_agent_add_body());
body.push(SSH_AGENT_CONSTRAIN_LIFETIME);
body.extend_from_slice(&lifetime.to_be_bytes());
let frame = purecrypto::zeroize::Zeroizing::new(encode_message(
SSH_AGENTC_ADD_ID_CONSTRAINED,
&body,
));
self.simple_request(&frame, "add-identity")
}
pub fn add_certificate(
&mut self,
key: &crate::key::PrivateKey,
cert_blob: &[u8],
lifetime: Option<u32>,
) -> Result<()> {
let c = AddConstraints {
lifetime,
..AddConstraints::default()
};
self.add_constrained(key, Some(cert_blob), &c)
}
pub fn add_constrained(
&mut self,
key: &crate::key::PrivateKey,
cert_blob: Option<&[u8]>,
c: &AddConstraints,
) -> Result<()> {
let mut body = purecrypto::zeroize::Zeroizing::new(match cert_blob {
Some(cert) => key.to_agent_add_cert_body(cert),
None => key.to_agent_add_body(),
});
let mut constrained = false;
if let Some(t) = c.lifetime {
body.push(SSH_AGENT_CONSTRAIN_LIFETIME);
body.extend_from_slice(&t.to_be_bytes());
constrained = true;
}
if c.confirm {
body.push(SSH_AGENT_CONSTRAIN_CONFIRM);
constrained = true;
}
if !c.destinations.is_empty() {
let mut list = crate::format::Writer::new();
for d in &c.destinations {
let mut w = crate::format::Writer::new();
w.write_string(&d.from.encode());
w.write_string(&d.to.encode());
w.write_string(b"");
list.write_string(w.as_slice());
}
let mut w = crate::format::Writer::new();
w.write_u8(SSH_AGENT_CONSTRAIN_EXTENSION);
w.write_string(b"restrict-destination-v00@openssh.com");
w.write_string(list.as_slice());
body.extend_from_slice(w.as_slice());
constrained = true;
}
let msg_type = if constrained {
SSH_AGENTC_ADD_ID_CONSTRAINED
} else {
SSH_AGENTC_ADD_IDENTITY
};
let frame = purecrypto::zeroize::Zeroizing::new(encode_message(msg_type, &body));
self.simple_request(&frame, "add-identity")
}
pub fn query_extensions(&mut self) -> Result<Vec<String>> {
let mut w = crate::format::Writer::new();
w.write_string(b"query");
self.write_frame(&encode_message(SSH_AGENTC_EXTENSION, w.as_slice()))?;
let (msg_type, body) = self.read_frame()?;
if msg_type != SSH_AGENT_EXTENSION_RESPONSE {
return Err(Error::Protocol("agent: query extension not supported"));
}
let mut r = crate::format::Reader::new(&body);
if r.read_string()? != b"query" {
return Err(Error::Protocol("agent: unexpected query reply"));
}
let mut exts = Vec::new();
while !r.is_empty() {
exts.push(String::from_utf8_lossy(r.read_string()?).into_owned());
}
Ok(exts)
}
pub fn remove_identity(&mut self, key_blob: &[u8]) -> Result<()> {
let mut w = crate::format::Writer::new();
w.write_string(key_blob);
let frame = encode_message(SSH_AGENTC_REMOVE_IDENTITY, w.as_slice());
self.simple_request(&frame, "remove-identity")
}
pub fn remove_all_identities(&mut self) -> Result<()> {
let frame = encode_message(SSH_AGENTC_REMOVE_ALL_IDENTITIES, &[]);
self.simple_request(&frame, "remove-all-identities")
}
pub fn lock(&mut self, passphrase: &[u8]) -> Result<()> {
let mut w = crate::format::Writer::new();
w.write_string(passphrase);
let frame =
purecrypto::zeroize::Zeroizing::new(encode_message(SSH_AGENTC_LOCK, w.as_slice()));
self.simple_request(&frame, "lock")
}
pub fn unlock(&mut self, passphrase: &[u8]) -> Result<()> {
let mut w = crate::format::Writer::new();
w.write_string(passphrase);
let frame =
purecrypto::zeroize::Zeroizing::new(encode_message(SSH_AGENTC_UNLOCK, w.as_slice()));
self.simple_request(&frame, "unlock")
}
pub fn bind_session(
&mut self,
host_key: &[u8],
session_id: &[u8],
signature: &[u8],
forwarding: bool,
) -> Result<bool> {
let mut w = crate::format::Writer::new();
w.write_string(b"session-bind@openssh.com");
w.write_string(host_key);
w.write_string(session_id);
w.write_string(signature);
w.write_u8(u8::from(forwarding));
self.write_frame(&encode_message(
super::protocol::SSH_AGENTC_EXTENSION,
w.as_slice(),
))?;
let (msg_type, _) = self.read_frame()?;
Ok(msg_type == SSH_AGENT_SUCCESS)
}
pub fn into_stream(self) -> UnixStream {
self.stream
}
fn simple_request(&mut self, frame: &[u8], what: &'static str) -> Result<()> {
self.write_frame(frame)?;
let (msg_type, _) = self.read_frame()?;
match msg_type {
SSH_AGENT_SUCCESS => Ok(()),
SSH_AGENT_FAILURE => Err(Error::Protocol(match what {
"lock" => "agent: lock refused (already locked?)",
"unlock" => "agent: unlock refused (wrong passphrase?)",
"remove-identity" => "agent: no such identity",
_ => "agent: request refused",
})),
_ => Err(Error::Protocol("agent: unexpected reply")),
}
}
fn write_frame(&mut self, frame: &[u8]) -> Result<()> {
self.stream.write_all(frame).map_err(Error::from)?;
Ok(())
}
fn read_frame(&mut self) -> Result<(u8, Vec<u8>)> {
let mut len_buf = [0u8; 4];
self.stream.read_exact(&mut len_buf).map_err(Error::from)?;
let len = u32::from_be_bytes(len_buf) as usize;
if len == 0 {
return Err(Error::Format("agent: zero-length frame"));
}
if len > MAX_REPLY_LEN {
return Err(Error::Format("agent: reply exceeds MAX_REPLY_LEN"));
}
let mut buf = vec![0u8; len];
self.stream.read_exact(&mut buf).map_err(Error::from)?;
let msg_type = buf[0];
let body = buf.split_off(1);
Ok((msg_type, body))
}
}
pub fn validate_auth_sock(path: &Path) -> core::result::Result<(), String> {
use std::os::unix::fs::FileTypeExt;
let md = std::fs::metadata(path).map_err(|e| {
let s = e.to_string();
s.split(" (os error").next().unwrap_or(&s).to_string()
})?;
if !md.file_type().is_socket() {
return Err("not a Unix-domain socket".into());
}
Ok(())
}
#[cfg(test)]
mod auth_sock_tests {
use super::*;
use std::os::unix::net::UnixListener;
fn make_socket() -> (PathBuf, UnixListener) {
let dir = std::env::temp_dir();
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let unique = format!(
"puressh-auth-sock-test-{}-{}-{}",
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0),
);
let p = dir.join(unique);
let _ = std::fs::remove_file(&p);
let l = UnixListener::bind(&p).expect("bind unix listener");
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
(p, l)
}
#[test]
fn valid_owned_socket_is_accepted() {
let (p, _l) = make_socket();
let r = validate_auth_sock(&p);
std::fs::remove_file(&p).ok();
assert!(r.is_ok(), "expected Ok, got {r:?}");
}
#[test]
fn nonexistent_is_rejected() {
let p = std::env::temp_dir().join(format!(
"puressh-noent-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0),
));
assert!(validate_auth_sock(&p).is_err());
}
#[test]
fn regular_file_is_rejected() {
let p = std::env::temp_dir().join(format!(
"puressh-regular-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0),
));
std::fs::write(&p, b"hello").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
let r = validate_auth_sock(&p);
std::fs::remove_file(&p).ok();
let msg = r.unwrap_err();
assert!(msg.contains("not a Unix-domain socket"), "got: {msg}");
}
#[test]
fn symlink_to_a_socket_is_accepted() {
let (target, _l) = make_socket();
let link = std::env::temp_dir().join(format!(
"puressh-symlink-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0),
));
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&target, &link).unwrap();
let r = validate_auth_sock(&link);
std::fs::remove_file(&link).ok();
std::fs::remove_file(&target).ok();
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn group_accessible_socket_is_accepted() {
let (p, _l) = make_socket();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o775)).unwrap();
let r = validate_auth_sock(&p);
std::fs::remove_file(&p).ok();
assert!(r.is_ok(), "{r:?}");
}
}