initial privilege separation

This commit is contained in:
nora 2024-08-28 18:57:09 +02:00
parent 46f77b7f58
commit 543b1b6e76
15 changed files with 887 additions and 108 deletions

View file

@ -19,6 +19,8 @@ thiserror = "1.0.63"
cluelessh-keys = { version = "0.1.0", path = "../../lib/cluelessh-keys" }
serde = { version = "1.0.209", features = ["derive"] }
toml = "0.8.19"
clap = { version = "4.5.16", features = ["derive"] }
postcard = { version = "1.0.10", features = ["alloc"] }
[lints]
workspace = true

View file

@ -1,11 +1,13 @@
use eyre::{Context, Result};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::{
net::{IpAddr, Ipv4Addr},
path::PathBuf,
};
#[derive(Deserialize)]
use crate::Args;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_info")]
@ -14,7 +16,7 @@ pub struct Config {
pub auth: AuthConfig,
}
#[derive(Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetConfig {
#[serde(default = "addr_default")]
@ -23,7 +25,7 @@ pub struct NetConfig {
pub port: u16,
}
#[derive(Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
pub host_keys: Vec<PathBuf>,
@ -33,15 +35,18 @@ pub struct AuthConfig {
}
impl Config {
pub fn find() -> Result<Self> {
let path =
std::env::var("CLUELESSHD_CONFIG").unwrap_or_else(|_| "cluelesshd.toml".to_owned());
pub fn find(args: &Args) -> Result<Self> {
let path = std::env::var("CLUELESSHD_CONFIG")
.map(PathBuf::from)
.or(args.config.clone().ok_or(std::env::VarError::NotPresent))
.unwrap_or_else(|_| PathBuf::from("cluelesshd.toml"));
let content = std::fs::read_to_string(&path).wrap_err_with(|| {
format!("failed to open config file '{path}', refusing to start. you can change the config file path with the CLUELESSHD_CONFIG environment variable")
format!("failed to open config file '{}', refusing to start. you can change the config file path with the --config arg or the CLUELESSHD_CONFIG environment variable", path.display())
})?;
toml::from_str(&content).wrap_err_with(|| format!("invalid config file '{path}'"))
toml::from_str(&content)
.wrap_err_with(|| format!("invalid config file '{}'", path.display()))
}
}

View file

@ -1,22 +1,30 @@
mod auth;
mod config;
mod pty;
mod rpc;
use std::{
io,
io::{self, Read, Seek, SeekFrom},
marker::PhantomData,
net::SocketAddr,
os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd},
path::PathBuf,
pin::Pin,
process::{ExitStatus, Stdio},
sync::Arc,
};
use cluelessh_keys::{host_keys::HostKeySet, private::EncryptedPrivateKeys};
use cluelessh_tokio::{server::ServerAuthVerify, Channel};
use cluelessh_transport::server::ServerConfig;
use clap::Parser;
use cluelessh_keys::{host_keys::HostKeySet, private::EncryptedPrivateKeys, public::PublicKey};
use cluelessh_tokio::{
server::{ServerAuth, ServerConnection, SignWithHostKey},
Channel,
};
use config::Config;
use eyre::{bail, Context, OptionExt, Result};
use pty::Pty;
use rustix::termios::Winsize;
use rustix::{fs::MemfdFlags, termios::Winsize};
use serde::{Deserialize, Serialize};
use tokio::{
fs::File,
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
@ -27,66 +35,324 @@ use tokio::{
use tracing::{debug, error, info, info_span, warn, Instrument};
use cluelessh_protocol::{
auth::{CheckPubkey, VerifySignature},
connection::{ChannelKind, ChannelOperationKind, ChannelRequest},
ChannelUpdateKind, SshStatus,
};
use tracing_subscriber::EnvFilter;
use users::os::unix::UserExt;
#[derive(clap::Parser)]
struct Args {
/// The path to the config file
#[arg(long)]
config: Option<PathBuf>,
}
struct MemFd<T> {
fd: std::fs::File,
_data: PhantomData<T>,
}
impl<T: serde::Serialize + serde::de::DeserializeOwned> MemFd<T> {
fn new(data: &T) -> Result<Self> {
let fd = rustix::fs::memfd_create("cluelesshd.toml", MemfdFlags::empty())
.wrap_err("failed to memfd memfd")?;
let mut fd: std::fs::File = std::fs::File::from(fd);
std::io::Write::write_all(&mut fd, &postcard::to_allocvec(data)?)
.wrap_err("failed to write config")?;
Ok(Self {
fd,
_data: PhantomData,
})
}
unsafe fn from_raw_fd(fd: RawFd) -> Result<Self> {
let fd = unsafe { std::fs::File::from_raw_fd(fd) };
Ok(Self {
fd,
_data: PhantomData,
})
}
fn read(&mut self) -> Result<T> {
self.fd.seek(SeekFrom::Start(0))?;
let mut data = Vec::new();
self.fd.read_to_end(&mut data).wrap_err("reading data")?;
postcard::from_bytes(&data).wrap_err("failed to deserialize")
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> eyre::Result<()> {
let config = config::Config::find()?;
match std::env::var("CLUELESSH_PRIVSEP_PROCESS") {
Ok(privsep_process) => match privsep_process.as_str() {
"connection" => connnection().await,
_ => bail!("unknown CLUELESSH_PRIVSEP_PROCESS: {privsep_process}"),
},
Err(_) => {
// Initial setup
let args = Args::parse();
let config = config::Config::find(&args)?;
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.log_level));
tracing_subscriber::fmt().with_env_filter(env_filter).init();
let addr: SocketAddr = SocketAddr::new(config.net.ip, config.net.port);
info!(%addr, "Starting server");
let listener = TcpListener::bind(addr)
.await
.wrap_err_with(|| format!("trying to listen on {addr}"))?;
main_process(config, listener).await
}
}
}
const PRIVSEP_CONNECTION_STATE_FD: RawFd = 3;
/// The connection state passed to the child in the STATE_FD
#[derive(Serialize, Deserialize)]
struct SerializedConnectionState {
stream_fd: RawFd,
peer_addr: SocketAddr,
pub_host_keys: Vec<PublicKey>,
config: Config,
rpc_client_fd: RawFd,
}
async fn connnection() -> Result<()> {
rustix::fs::fcntl_getfd(unsafe { BorrowedFd::borrow_raw(PRIVSEP_CONNECTION_STATE_FD) })
.unwrap();
let mut memfd =
unsafe { MemFd::<SerializedConnectionState>::from_raw_fd(PRIVSEP_CONNECTION_STATE_FD) }
.wrap_err("failed to open memfd")?;
let state = memfd.read().wrap_err("failed to read state")?;
let config = state.config;
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log_level));
tracing_subscriber::fmt().with_env_filter(env_filter).init();
let addr = SocketAddr::new(config.net.ip, config.net.port);
info!(%addr, "Starting server");
let span = info_span!("connection", addr = %state.peer_addr);
let listener = TcpListener::bind(addr)
.await
.wrap_err_with(|| format!("trying to listen on {addr}"))?;
let stream = unsafe { std::net::TcpStream::from_raw_fd(state.stream_fd) };
let stream = TcpStream::from_std(stream)?;
let auth_verify = ServerAuthVerify {
let host_keys = state.pub_host_keys;
let transport_config = cluelessh_transport::server::ServerConfig { host_keys };
let rpc_client = unsafe { OwnedFd::from_raw_fd(state.rpc_client_fd) };
let rpc_client1 = Arc::new(rpc::Client::from_fd(rpc_client)?);
let rpc_client2 = rpc_client1.clone();
let rpc_client3 = rpc_client1.clone();
let auth_verify = ServerAuth {
verify_password: config.auth.password_login.then(|| todo!("password login")),
verify_signature: Some(Arc::new(|auth| Box::pin(auth::verify_signature(auth)))),
check_pubkey: Some(Arc::new(|auth| Box::pin(auth::check_pubkey(auth)))),
verify_signature: Some(Arc::new(move |msg: VerifySignature| {
let rpc_client = rpc_client1.clone();
Box::pin(async move {
rpc_client
.verify_signature(
msg.user,
msg.session_identifier,
msg.pubkey_alg_name,
msg.pubkey,
msg.signature,
)
.await
})
})),
check_pubkey: Some(Arc::new(move |msg: CheckPubkey| {
let rpc_client = rpc_client2.clone();
Box::pin(async move {
rpc_client
.check_pubkey(
msg.user,
msg.session_identifier,
msg.pubkey_alg_name,
msg.pubkey,
)
.await
})
})),
auth_banner: config.auth.banner,
sign_with_hostkey: Arc::new(move |msg: SignWithHostKey| {
let rpc_client = rpc_client3.clone();
Box::pin(async move { rpc_client.sign(msg.hash, msg.public_key).await })
}),
};
let server_conn = ServerConnection::new(stream, state.peer_addr, auth_verify, transport_config);
connection_inner(server_conn).instrument(span).await;
Ok(())
}
async fn connection_inner(server_conn: ServerConnection<TcpStream>) {
if let Err(err) = handle_connection(server_conn).await {
if let Some(err) = err.downcast_ref::<std::io::Error>() {
if err.kind() == std::io::ErrorKind::ConnectionReset {
return;
}
}
error!(?err, "error handling connection");
}
info!("Finished connection");
}
async fn main_process(config: Config, listener: TcpListener) -> Result<()> {
let host_keys = load_host_keys(&config.auth.host_keys).await?.into_keys();
if host_keys.is_empty() {
bail!("no host keys found");
}
let config = ServerConfig { host_keys };
let pub_host_keys = host_keys
.iter()
.map(|key| key.private_key.public_key())
.collect::<Vec<_>>();
let mut listener = cluelessh_tokio::server::ServerListener::new(listener, auth_verify, config);
let auth_operations = ServerAuth {
verify_password: config
.auth
.clone()
.password_login
.then(|| todo!("password login")),
verify_signature: Some(Arc::new(|auth| Box::pin(auth::verify_signature(auth)))),
check_pubkey: Some(Arc::new(|auth| Box::pin(auth::check_pubkey(auth)))),
auth_banner: config.auth.clone().banner,
sign_with_hostkey: Arc::new(move |msg: SignWithHostKey| {
let host_keys = host_keys.clone();
Box::pin(async move {
let private = host_keys
.iter()
.find(|privkey| privkey.private_key.public_key() == msg.public_key)
.ok_or_eyre("missing private key")?;
Ok(private.private_key.sign(&msg.hash))
})
}),
};
// let server_config = ServerConfig {
// host_keys: pub_host_keys,
// };
loop {
let next = listener.accept().await?;
let span = info_span!("connection", addr = %next.peer_addr());
tokio::spawn(
async move {
if let Err(err) = handle_connection(next).await {
if let Some(err) = err.downcast_ref::<std::io::Error>() {
if err.kind() == std::io::ErrorKind::ConnectionReset {
return;
}
}
let (next_stream, peer_addr) = listener.accept().await?;
error!(?err, "error handling connection");
}
info!("Finished connection");
// let server_conn = cluelessh_tokio::server::ServerConnection::new(
// next_stream,
// peer_addr,
// auth_verify.clone(),
// server_config.clone(),
// );
let config = config.clone();
let pub_host_keys = pub_host_keys.clone();
let auth_operations = auth_operations.clone();
tokio::spawn(async move {
let err = spawn_connection_child(
next_stream,
peer_addr,
pub_host_keys,
config,
auth_operations,
)
.await;
if let Err(err) = err {
error!(?err, "child failed");
}
.instrument(span),
);
});
//tokio::spawn(
// async move {
// if let Err(err) = handle_connection(server_conn).await {
// if let Some(err) = err.downcast_ref::<std::io::Error>() {
// if err.kind() == std::io::ErrorKind::ConnectionReset {
// return;
// }
// }
//
// error!(?err, "error handling connection");
// }
// info!("Finished connection");
// }
// .instrument(span),
//);
}
}
async fn spawn_connection_child(
stream: TcpStream,
peer_addr: SocketAddr,
pub_host_keys: Vec<PublicKey>,
config: Config,
auth_operations: ServerAuth,
) -> Result<()> {
let stream_fd = stream.as_fd();
let rpc_server = rpc::Server::new(auth_operations).wrap_err("creating RPC server")?;
// dup to avoid cloexec
// TODO: we should probably do this in the child? not that it matters that much.
let stream_fd = rustix::io::dup(stream_fd).wrap_err("duping tcp stream")?;
let rpc_client_fd = rustix::io::dup(rpc_server.client_fd()).wrap_err("duping tcp stream")?;
let config_fd = MemFd::new(&SerializedConnectionState {
stream_fd: stream_fd.as_raw_fd(),
peer_addr,
pub_host_keys,
config,
rpc_client_fd: rpc_client_fd.as_raw_fd(),
})?;
let exe = std::env::current_exe().wrap_err("failed to get current executable path")?;
let mut cmd = tokio::process::Command::new(exe);
cmd.env("CLUELESSH_PRIVSEP_PROCESS", "connection")
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
unsafe {
let fd = config_fd.fd.as_raw_fd();
cmd.pre_exec(move || {
let mut state_fd = OwnedFd::from_raw_fd(PRIVSEP_CONNECTION_STATE_FD);
rustix::io::dup2(BorrowedFd::borrow_raw(fd), &mut state_fd)?;
// Ensure that it stays open in the child.
std::mem::forget(state_fd);
Ok(())
});
}
let mut listen_child = cmd.spawn().wrap_err("failed to spawn listener process")?;
loop {
tokio::select! {
server_err = rpc_server.process() => {
error!(err = ?server_err, "RPC server error");
}
status = listen_child.wait() => {
let status = status?;
if !status.success() {
bail!("connection child process failed: {}", status);
}
break;
}
}
}
Ok(())
}
async fn load_host_keys(keys: &[PathBuf]) -> Result<HostKeySet> {
let mut host_keys = HostKeySet::new();

239
bin/cluelesshd/src/rpc.rs Normal file
View file

@ -0,0 +1,239 @@
//! [`postcard`]-based RPC between the different processes.
use std::os::fd::AsFd;
use std::os::fd::BorrowedFd;
use std::os::fd::OwnedFd;
use cluelessh_keys::public::PublicKey;
use cluelessh_keys::signature::Signature;
use cluelessh_protocol::auth::CheckPubkey;
use cluelessh_protocol::auth::VerifySignature;
use cluelessh_tokio::server::ServerAuth;
use cluelessh_tokio::server::SignWithHostKey;
use eyre::eyre;
use eyre::Context;
use eyre::Result;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tokio::net::UnixDatagram;
#[derive(Serialize, Deserialize)]
enum Request {
Sign {
hash: [u8; 32],
public_key: PublicKey,
},
VerifySignature {
user: String,
session_identifier: [u8; 32],
pubkey_alg_name: String,
pubkey: Vec<u8>,
signature: Vec<u8>,
},
CheckPubkey {
user: String,
session_identifier: [u8; 32],
pubkey_alg_name: String,
pubkey: Vec<u8>,
},
}
#[derive(Serialize, Deserialize)]
struct SignResponse {
signature: Result<Signature, String>,
}
#[derive(Serialize, Deserialize)]
struct VerifySignatureResponse {
is_ok: Result<bool, String>,
}
#[derive(Serialize, Deserialize)]
struct CheckPubkeyResponse {
is_ok: Result<bool, String>,
}
pub struct Client {
socket: UnixDatagram,
}
pub struct Server {
server: UnixDatagram,
client: UnixDatagram,
auth_operations: ServerAuth,
}
impl Server {
pub fn new(auth_operations: ServerAuth) -> Result<Self> {
let (server, client) = UnixDatagram::pair().wrap_err("creating socketpair")?;
Ok(Self {
server,
client,
auth_operations,
})
}
pub fn client_fd(&self) -> BorrowedFd<'_> {
self.client.as_fd()
}
pub async fn process(&self) -> Result<()> {
let mut req = [0; 1024];
loop {
let read = self
.server
.recv(&mut req)
.await
.wrap_err("receiving response")?;
let req = postcard::from_bytes::<Request>(&req[..read]).wrap_err("invalid request")?;
match req {
Request::Sign { hash, public_key } => {
let signature = (self.auth_operations.sign_with_hostkey)(SignWithHostKey {
hash,
public_key,
})
.await
.map_err(|err| err.to_string());
self.respond(SignResponse { signature }).await?;
}
Request::VerifySignature {
user,
session_identifier,
pubkey_alg_name,
pubkey,
signature,
} => {
let Some(verify_signature) = &self.auth_operations.verify_signature else {
self.respond(VerifySignatureResponse {
is_ok: Err("public key login not supported".into()),
})
.await?;
continue;
};
let is_ok = verify_signature(VerifySignature {
user,
session_identifier,
pubkey_alg_name,
pubkey,
signature,
})
.await
.map_err(|err| err.to_string());
self.respond(VerifySignatureResponse { is_ok }).await?;
}
Request::CheckPubkey {
user,
session_identifier,
pubkey_alg_name,
pubkey,
} => {
let Some(check_pubkey) = &self.auth_operations.check_pubkey else {
self.respond(VerifySignatureResponse {
is_ok: Err("public key login not supported".into()),
})
.await?;
continue;
};
let is_ok = check_pubkey(CheckPubkey {
user,
session_identifier,
pubkey_alg_name,
pubkey,
})
.await
.map_err(|err| err.to_string());
self.respond(CheckPubkeyResponse { is_ok }).await?;
}
}
}
}
async fn respond(&self, resp: impl Serialize) -> Result<()> {
self.server
.send(&postcard::to_allocvec(&resp)?)
.await
.wrap_err("sending response")?;
Ok(())
}
}
impl Client {
pub fn from_fd(fd: OwnedFd) -> Result<Self> {
let socket = UnixDatagram::from_std(std::os::unix::net::UnixDatagram::from(fd))?;
Ok(Self { socket })
}
pub async fn sign(&self, hash: [u8; 32], public_key: PublicKey) -> Result<Signature> {
let resp = self
.request_response::<SignResponse>(&Request::Sign { hash, public_key })
.await?;
resp.signature.map_err(|err| eyre!(err))
}
pub async fn check_pubkey(
&self,
user: String,
session_identifier: [u8; 32],
pubkey_alg_name: String,
pubkey: Vec<u8>,
) -> Result<bool> {
let resp = self
.request_response::<CheckPubkeyResponse>(&Request::CheckPubkey {
user,
session_identifier,
pubkey_alg_name,
pubkey,
})
.await?;
resp.is_ok.map_err(|err| eyre!(err))
}
pub async fn verify_signature(
&self,
user: String,
session_identifier: [u8; 32],
pubkey_alg_name: String,
pubkey: Vec<u8>,
signature: Vec<u8>,
) -> Result<bool> {
let resp = self
.request_response::<VerifySignatureResponse>(&Request::VerifySignature {
user,
session_identifier,
pubkey_alg_name,
pubkey,
signature,
})
.await?;
resp.is_ok.map_err(|err| eyre!(err))
}
async fn request_response<Resp: DeserializeOwned>(&self, req: &Request) -> Result<Resp> {
self.socket
.send(&postcard::to_allocvec(&req)?)
.await
.wrap_err("sending request")?;
let mut resp = [0; 1024];
let read = self
.socket
.recv(&mut resp)
.await
.wrap_err("receiving response")?;
let resp =
postcard::from_bytes::<Resp>(&resp[..read]).wrap_err("invalid signature response")?;
Ok(resp)
}
}