//! The unix socket this daemon listens on, and who is allowed to reach it. //! //! Four of the five guarantees below answer an exposure review that found a //! socket in this project landing world-writable at a predictable path. //! What is behind this one issues credentials, so the directory is `0700` //! and the socket `0600`, both set explicitly on every start and //! re-tightened when found wider. //! //! The fifth is this socket's own. It hands a different answer to each //! caller, so it reads the connecting process's credentials rather than //! believing what the connection says about itself. //! //! # What reaching this socket means, and what it does not //! //! Reaching it **is** the authorization. `plan/cred-delivery.md` publishes //! this path through `CLAUDE_ENV_FILE`, which any shell command the model //! runs can print — so the path is not a secret and nothing here treats it //! as one. This project deleted its last shared operator secret for exactly //! this reason: //! the same caller could read it off the same filesystem, and the socket's //! permissions were doing the real work. Anything later carried in that //! environment file selects which context is calling. It does not decide //! whether the caller is allowed to. //! //! The ceiling is therefore the user account, as `docs/trust-model.md` //! already states: anything running as this user can connect, and this //! design defends against a credential leaving the machine rather than //! against one being misused on it. use std::io; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; use tokio::net::{UnixListener, UnixStream}; use tracing::warn; /// The directory this daemon creates and owns, under the runtime directory. const DIR_NAME: &str = "didbot-agent"; /// The socket file inside it. const SOCKET_NAME: &str = "agent.sock"; /// The credentials of a connected process, as the kernel reports them. /// /// Not as the connection claims them: these come from the socket itself, so /// a caller cannot choose what they say. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Peer { /// The connecting process's effective user. pub uid: u32, /// Its effective group. pub gid: u32, /// Its process id, where the platform reports one. Useful for finding /// which session a caller belongs to, and useless as a check on its own: /// a process id is reused once the process is gone. pub pid: Option, } /// Where the socket lives when a deployment has not said otherwise. /// /// `XDG_RUNTIME_DIR` when it is set — a per-user directory the system clears /// on logout — and the temporary directory otherwise. Either way the socket /// sits inside a subdirectory this process creates and owns, never directly /// at the base: a predictable path stops being a weakness once the directory /// holding it cannot be pre-empted, and that is what [`Listener::bind`] /// enforces. pub fn default_socket_path() -> PathBuf { std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(std::env::temp_dir) .join(DIR_NAME) .join(SOCKET_NAME) } /// A listening socket that only yields connections from this user. #[derive(Debug)] pub struct Listener { inner: UnixListener, owner: u32, path: PathBuf, } impl Listener { /// Binds `path`, hardening the directory and the socket file. /// /// The directory is created at `0700`. One that is already there is /// checked before anything is done to it — a symlink, something that is /// not a directory, a directory shared between users, or one belonging to /// another user is refused and left as it was — and tightened to `0700` /// only once it is this process's own. That refusal is the point. An /// attacker who pre-creates the directory is caught here, and one who /// tries to bind first inside a directory they do not own never gets the /// chance. /// /// The socket file is then set to `0600` explicitly, rather than left to /// whatever `umask` this process happens to be running under. pub fn bind(path: impl Into) -> io::Result { let path = path.into(); if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) { secure_dir(dir)?; } let inner = UnixListener::bind(&path)?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; // The socket this process just created is owned by this process, so // its own metadata answers "who am I" without a second way to ask. let owner = std::fs::metadata(&path)?.uid(); Ok(Self { inner, owner, path }) } /// The user this socket belongs to. Every accepted peer matches it. pub fn owner(&self) -> u32 { self.owner } /// The path bound. pub fn path(&self) -> &Path { &self.path } /// Accepts the next connection from this user, dropping any other. /// /// A peer from another user should not be able to reach a `0600` socket /// in a `0700` directory at all. It is checked anyway, because the cost /// is one syscall and the alternative is that a deployment which relaxes /// either permission by accident relaxes credential issuance with it. pub async fn accept(&self) -> io::Result<(UnixStream, Peer)> { loop { let (stream, _) = self.inner.accept().await?; let peer = match peer_of(&stream) { Ok(peer) => peer, Err(error) => { // A connection whose credentials cannot be read is not // one to serve on the assumption that it is probably // fine. warn!(%error, "dropped a connection whose peer credentials could not be read"); continue; } }; if peer.uid != self.owner { warn!( peer_uid = peer.uid, owner_uid = self.owner, "dropped a connection from another user" ); continue; } return Ok((stream, peer)); } } } /// Reads the connected process's credentials off the socket. pub fn peer_of(stream: &UnixStream) -> io::Result { let cred = stream.peer_cred()?; Ok(Peer { uid: cred.uid(), gid: cred.gid(), pid: cred.pid(), }) } /// Removes `path` when, and only when, nothing is listening on it. /// /// Best effort and silent on any doubt. Skipping a socket that should have /// been removed costs a clear "address in use" from [`Listener::bind`]; /// removing one a live process still holds takes the socket away from a /// running daemon, which then serves a path nothing can reach while a second /// process binds a fresh one and quietly takes over credential issuance. pub fn remove_if_stale(path: &Path) { use std::os::unix::fs::FileTypeExt; // `symlink_metadata`, not `exists`: a dangling symlink here is not a // socket this daemon left behind, and `Path::exists` answers `false` for // any stat error at all, which is the conflation this function exists to // avoid. let Ok(metadata) = std::fs::symlink_metadata(path) else { return; }; if !metadata.file_type().is_socket() { return; } // Only `ECONNREFUSED` means nothing is listening. Every other failure — // `EACCES` for a socket belonging to somebody else, `EAGAIN` or // `ENOBUFS` when a live backlog is full, `EMFILE` when this process is // out of descriptors — means a listener may well be there. match std::os::unix::net::UnixStream::connect(path) { Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { let _ = std::fs::remove_file(path); } _ => {} } } /// Creates `dir` at `0700`, or takes up an existing one it is allowed to. /// /// A directory that was already there is checked before anything is done to /// it, and refused when it is a symlink, when it is not a directory, when its /// sticky bit marks it as shared between users, or when it belongs to another /// user. Ownership is compared rather than inferred from whether a chmod /// succeeded: `chmod(2)` succeeds for a process holding `CAP_FOWNER`, so /// under root an attacker's pre-created directory was being narrowed and used /// instead of refused. /// /// One that passes all of that and is wider than `0700` is tightened to it, /// with a warning naming the directory: whatever umask made it left the node /// key only as private as that guess, and a run that quietly re-permissions a /// directory somebody named on the command line should at least say so. /// /// The state directory holding the node key gets the same treatment, from /// the same function. pub(crate) fn secure_dir(dir: &Path) -> io::Result<()> { use std::os::unix::fs::DirBuilderExt; if let Some(parent) = dir.parent().filter(|parent| !parent.as_os_str().is_empty()) { std::fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(parent)?; } // The last component alone, and not recursive, so `mkdir(2)`'s own // atomicity answers "was this already here": a racing pre-creation // arrives as `AlreadyExists` and goes through the checks below rather // than being silently adopted. match std::fs::DirBuilder::new() .recursive(false) .mode(0o700) .create(dir) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => accept_existing(dir), Err(error) => Err(error), } } /// Takes up a directory that was already there, or says why it will not. fn accept_existing(dir: &Path) -> io::Result<()> { // `symlink_metadata`, not `metadata`: a symlink planted at this path // redirects everything that follows, and `Path::is_dir` would follow it // and answer about the target. `remove_if_stale` takes the same care for // the socket file. let metadata = std::fs::symlink_metadata(dir)?; if metadata.file_type().is_symlink() { return Err(refused(dir, "is a symlink")); } if !metadata.is_dir() { return Err(refused(dir, "is not a directory")); } let mode = metadata.permissions().mode() & 0o7777; // The sticky bit is what marks a directory shared between users -- // `/tmp` is the case -- and tightening one takes it away from every // other process on the host. A restored data directory carries whatever // mode the snapshot had and no sticky bit, so it is tightened below the // way `docs/operations.md` says it is. if mode & 0o1000 != 0 { return Err(refused( dir, &format!( "is mode {mode:04o}: its sticky bit marks a directory shared between users. \ Name a directory of this deployment's own -- making this one private would \ take it away from everything else on this host." ), )); } // Before the chmod, not inferred from it. refuse_unless_owned(dir, creating_uid(dir)?)?; if mode & 0o077 != 0 { warn!( dir = %dir.display(), found = format!("{mode:04o}"), "tightening a directory this process did not create to 0700" ); std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; } Ok(()) } /// Refuses `dir` unless it belongs to `ours`. /// /// Split out from [`accept_existing`] because this is the refusal that /// closes the pre-creation race, and an unprivileged test cannot make a /// directory belong to somebody else to reach it any other way. fn refuse_unless_owned(dir: &Path, ours: u32) -> io::Result<()> { let owner = std::fs::symlink_metadata(dir)?.uid(); if owner != ours { return Err(refused( dir, &format!("belongs to uid {owner}, and this process writes as uid {ours}"), )); } Ok(()) } /// The uid this process creates files as, read off one it creates in `dir`. /// /// The same way [`Listener::bind`] learns it from the socket it just bound: /// a file this process created is owned by this process, so its own metadata /// answers "who am I" without a second way to ask. Doing it inside `dir` /// also settles that this process can write there, which everything holding /// this directory goes on to need. fn creating_uid(dir: &Path) -> io::Result { use std::os::unix::fs::OpenOptionsExt; let probe = dir.join(probe_name()); let file = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&probe) .map_err(|error| refused(dir, &format!("cannot be written to: {error}")))?; let uid = file.metadata()?.uid(); drop(file); let _ = std::fs::remove_file(&probe); Ok(uid) } /// A name for the ownership probe that no other caller is using. /// /// The process id alone is not enough: several threads open the same /// directory at once, and a name they shared would make the second one’s /// `create_new` collide with the first one’s probe. fn probe_name() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static NEXT: AtomicU64 = AtomicU64::new(0); format!( ".didbot-owner-check-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) ) } /// A refusal naming the path and what is wrong with it. fn refused(dir: &Path, why: &str) -> io::Error { io::Error::new( io::ErrorKind::PermissionDenied, format!("{} {why}", dir.display()), ) } #[cfg(test)] mod tests { use super::*; use crate::scratch::Scratch; impl Scratch { fn socket(&self) -> PathBuf { self.0.join(SOCKET_NAME) } } fn mode_of(path: &Path) -> u32 { std::fs::metadata(path).unwrap().permissions().mode() & 0o777 } #[tokio::test] async fn binding_leaves_the_directory_and_the_socket_shut() { let scratch = Scratch::new("shut"); let listener = Listener::bind(scratch.socket()).unwrap(); assert_eq!(mode_of(&scratch.0), 0o700, "directory"); assert_eq!(mode_of(listener.path()), 0o600, "socket"); } /// A readable directory this process owns is tightened, as before. #[tokio::test] async fn binding_tightens_a_readable_directory_it_owns() { let scratch = Scratch::new("tighten"); std::fs::create_dir_all(&scratch.0).unwrap(); std::fs::set_permissions(&scratch.0, std::fs::Permissions::from_mode(0o755)).unwrap(); let _listener = Listener::bind(scratch.socket()).unwrap(); assert_eq!(mode_of(&scratch.0), 0o700); } /// A directory shared between users is not one to tighten. `/tmp` is the /// case: as an ordinary user the chmod failed and startup was refused /// though nothing was wrong with `/tmp`, and under root it succeeded and /// took the sticky world-writable mode off it for the whole host. #[tokio::test] async fn binding_refuses_a_shared_directory_and_changes_nothing() { let scratch = Scratch::new("shared"); std::fs::create_dir_all(&scratch.0).unwrap(); std::fs::set_permissions(&scratch.0, std::fs::Permissions::from_mode(0o1777)).unwrap(); let refusal = Listener::bind(scratch.socket()).expect_err("a shared directory is refused"); assert_eq!(refusal.kind(), io::ErrorKind::PermissionDenied); assert!( refusal.to_string().contains("1777"), "the refusal must name the mode it found: {refusal}" ); assert_eq!( std::fs::metadata(&scratch.0).unwrap().permissions().mode() & 0o7777, 0o1777, "left exactly as it was" ); assert!( !scratch.socket().exists(), "nothing was bound inside a directory this process refused" ); } /// `Path::is_dir` follows a symlink and answers about its target, so a /// link planted at the socket's parent used to redirect both the chmod /// and the bind. `remove_if_stale` already takes this care for the /// socket file; the directory around it got none. #[tokio::test] async fn binding_refuses_a_symlink_at_the_sockets_directory() { let real = Scratch::new("symlink-target"); std::fs::create_dir_all(&real.0).unwrap(); std::fs::set_permissions(&real.0, std::fs::Permissions::from_mode(0o700)).unwrap(); let link = real.0.with_extension("link"); let _ = std::fs::remove_file(&link); std::os::unix::fs::symlink(&real.0, &link).unwrap(); let refusal = Listener::bind(link.join(SOCKET_NAME)).expect_err("a symlink here is refused"); assert!( refusal.to_string().contains("symlink"), "the refusal must say what it found: {refusal}" ); assert!( std::fs::read_dir(&real.0).unwrap().next().is_none(), "nothing was written through the link" ); let _ = std::fs::remove_file(&link); } /// The ownership refusal, driven directly: an unprivileged test cannot /// make a directory belong to somebody else, but it can ask the check /// what it does when the two uids differ. Under root this is the case /// `set_permissions` never failed for, because `chmod(2)` succeeds for a /// process holding `CAP_FOWNER`. #[tokio::test] async fn a_directory_belonging_to_another_user_is_refused() { let scratch = Scratch::new("foreign"); std::fs::create_dir_all(&scratch.0).unwrap(); std::fs::set_permissions(&scratch.0, std::fs::Permissions::from_mode(0o700)).unwrap(); let ours = std::fs::metadata(&scratch.0).unwrap().uid(); let refusal = refuse_unless_owned(&scratch.0, ours.wrapping_add(1)) .expect_err("a directory belonging to another uid is refused"); assert!( refusal.to_string().contains("belongs to uid"), "the refusal must name both uids: {refusal}" ); assert_eq!(mode_of(&scratch.0), 0o700, "left exactly as it was"); } #[tokio::test] async fn a_path_that_is_not_a_directory_is_refused() { let scratch = Scratch::new("notadir"); std::fs::create_dir_all(&scratch.0).unwrap(); let file = scratch.0.join("ordinary"); std::fs::write(&file, b"").unwrap(); let refusal = secure_dir(&file).expect_err("a file is not a directory to bind in"); assert!( refusal.to_string().contains("not a directory"), "the refusal must say what it found: {refusal}" ); } #[tokio::test] async fn an_accepted_peer_is_this_process() { let scratch = Scratch::new("peer"); let listener = Listener::bind(scratch.socket()).unwrap(); let path = listener.path().to_path_buf(); let connect = tokio::spawn(async move { UnixStream::connect(&path).await.unwrap() }); let (_stream, peer) = listener.accept().await.unwrap(); let _client = connect.await.unwrap(); assert_eq!(peer.uid, listener.owner()); assert_eq!( peer.pid, Some(std::process::id() as i32), "the connecting process is this one" ); } #[tokio::test] async fn a_socket_nothing_listens_on_is_removed() { let scratch = Scratch::new("stale"); let socket = scratch.socket(); drop(Listener::bind(&socket).unwrap()); remove_if_stale(&socket); assert!(!socket.exists(), "a dead socket is cleared out of the way"); } #[tokio::test] async fn a_socket_something_listens_on_is_left_alone() { let scratch = Scratch::new("live"); let listener = Listener::bind(scratch.socket()).unwrap(); remove_if_stale(listener.path()); assert!( listener.path().exists(), "a live socket is never taken from under its listener" ); } #[tokio::test] async fn something_that_is_not_a_socket_is_left_alone() { let scratch = Scratch::new("notasocket"); std::fs::create_dir_all(&scratch.0).unwrap(); let file = scratch.0.join("ordinary"); std::fs::write(&file, b"").unwrap(); remove_if_stale(&file); assert!(file.exists()); } }