//! Narrow host capability for a protected manager. No command, executable, path, //! shell text or environment supplied by the manager is ever executed as root. //! The supervisor supplies an already-connected endpoint and pins both peers. //! This first operation replaces the wallet's privileged macaroon read; further //! host operations must have their own typed validation before being admitted. use crate::container::lnd::protected_config as lnd_config; use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use std::fs::File; #[cfg(test)] use std::fs::OpenOptions; use std::io::{Read, Write}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::MetadataExt; #[cfg(test)] use std::os::unix::fs::OpenOptionsExt; #[cfg(test)] use std::path::Path; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, OnceLock, }; use std::time::Duration; const VERSION: u32 = 1; const OWNER_GRANT_VERSION: u32 = 2; const MAX_PACKET: usize = 65536; const MAX_SECRET: usize = 8192; const WAIT: Duration = Duration::from_secs(2); #[path = "host_capabilities.rs"] pub(crate) mod capabilities; #[path = "host_login.rs"] pub(crate) mod login; #[path = "host_rootless.rs"] pub(crate) mod rootless; static CLIENT: OnceLock> = OnceLock::new(); static READ_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); #[derive(Clone, Copy, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Peer { pub pid: i32, pub uid: u32, pub gid: u32, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Request { version: u32, sequence: u64, operation: Operation, } #[derive(Clone, Serialize, Deserialize)] enum Operation { SetLoginPassword(login::PasswordHash), FipsWg(crate::fips::config::protected_owner::Operation), LndConfig(lnd_config::Operation), ReadLndAdminMacaroon, ReadLndTlsCertificate, ReadLndOnionAddress, ReadTorHostname(capabilities::OnionService), Host(capabilities::Operation), } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Response { version: u32, sequence: u64, result: Reply, } #[derive(Serialize, Deserialize)] enum Reply { Bytes(SecretBytes), Unavailable, } #[derive(Serialize)] #[serde(transparent)] struct SecretBytes(Vec); impl Drop for SecretBytes { fn drop(&mut self) { zeroize::Zeroize::zeroize(&mut self.0); } } impl<'de> Deserialize<'de> for SecretBytes { fn deserialize>( deserializer: D, ) -> std::result::Result { struct Bounded; impl<'de> serde::de::Visitor<'de> for Bounded { type Value = SecretBytes; fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("bounded wallet credential bytes") } fn visit_seq>( self, mut seq: A, ) -> std::result::Result { let mut bytes = SecretBytes(Vec::with_capacity(MAX_SECRET)); while let Some(byte) = seq.next_element::()? { if bytes.0.len() == MAX_SECRET { return Err(serde::de::Error::custom("host secret exceeds bound")); } bytes.0.push(byte); } Ok(bytes) } } deserializer.deserialize_seq(Bounded) } } struct PacketBuffer(zeroize::Zeroizing>); impl Write for PacketBuffer { fn write(&mut self, bytes: &[u8]) -> std::io::Result { if bytes.len() > MAX_PACKET - self.0.len() { return Err(std::io::Error::other("host encoded packet exceeds bound")); } self.0.extend_from_slice(bytes); Ok(bytes.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } fn encode(value: &impl Serialize) -> Result>> { let mut output = PacketBuffer(zeroize::Zeroizing::new(Vec::with_capacity(MAX_PACKET))); serde_json::to_writer(&mut output, value)?; Ok(output.0) } struct CancelOnDrop(Arc); impl Drop for CancelOnDrop { fn drop(&mut self) { self.0.store(true, Ordering::Release); } } struct Client { fd: OwnedFd, parent: Peer, sequence: u64, frozen: bool, } pub(crate) fn installed() -> bool { CLIENT.get().is_some() } pub(crate) fn install(fd: OwnedFd) -> Result<()> { let parent = Peer { pid: unsafe { libc::getppid() }, uid: 0, gid: 0, }; ensure!( unsafe { libc::geteuid() } != 0 && parent.pid > 1, "host capability requires independent root parent" ); check_peer(&fd, parent)?; close_on_exec(&fd)?; CLIENT .set(Mutex::new(Client { fd, parent, sequence: 0, frozen: false, })) .map_err(|_| anyhow::anyhow!("host capability already installed"))?; lnd_config::install_transport(|operation| { Box::pin(read_typed(Operation::LndConfig(operation))) })?; crate::fips::config::protected_owner::install_transport(|operation| { Box::pin(read_typed(Operation::FipsWg(operation))) }) } pub(crate) async fn read_lnd_admin_macaroon() -> Result> { read_typed(Operation::ReadLndAdminMacaroon).await } pub(crate) async fn read_lnd_tls_certificate() -> Result> { read_typed(Operation::ReadLndTlsCertificate).await } pub(crate) async fn read_lnd_onion_address() -> Result> { read_typed(Operation::ReadLndOnionAddress).await } pub(crate) async fn execute(operation: capabilities::Operation) -> Result> { read_typed(Operation::Host(operation)).await } pub(crate) async fn set_login_password(hash: login::PasswordHash) -> Result<()> { read_typed(Operation::SetLoginPassword(hash)).await?; Ok(()) } pub(crate) async fn read_tor_hostname(service: capabilities::OnionService) -> Result> { read_typed(Operation::ReadTorHostname(service)).await } async fn read_typed(operation: Operation) -> Result> { ensure!(installed(), "protected host capability unavailable"); let end = now()?.checked_add(WAIT).context("host deadline overflow")?; let slot = READ_SLOT .try_acquire() .context("protected host capability busy")?; let cancelled = Arc::new(AtomicBool::new(false)); let _cancel = CancelOnDrop(cancelled.clone()); let mut worker = tokio::task::spawn_blocking(move || { let _slot = slot; ensure!( !cancelled.load(Ordering::Acquire) && now()? < end, "queued host read expired/cancelled" ); let mut client = CLIENT .get() .context("protected host capability unavailable")? .try_lock() .map_err(|_| anyhow::anyhow!("protected host capability busy or poisoned"))?; client.read(operation, end, &cancelled) }); loop { // An awake-time timeout alone would exclude system suspend. Recheck // BOOTTIME on every wake and before handing any result to the caller. ensure!(now()? < end, "host operation timed out"); tokio::select! { result = &mut worker => { let mut bytes = result.context("protected host reader stopped")??; ensure!(now()? < end, "host result arrived after deadline"); return Ok(std::mem::take(&mut *bytes)); } _ = tokio::time::sleep(Duration::from_millis(20)) => {} } } } impl Client { fn read( &mut self, operation: Operation, end: Duration, cancelled: &AtomicBool, ) -> Result>> { ensure!(!self.frozen, "protected host capability frozen"); ensure!( !cancelled.load(Ordering::Acquire) && now()? < end, "queued host read expired/cancelled" ); self.frozen = true; ensure!( unsafe { libc::getppid() } == self.parent.pid, "host supervisor parent changed" ); check_peer(&self.fd, self.parent)?; let sequence = self .sequence .checked_add(1) .context("host sequence exhausted")?; send( &self.fd, &encode(&Request { version: VERSION, sequence, operation, })?, end, )?; let bytes = receive(&self.fd, end)?; let response: Response = serde_json::from_slice(&bytes)?; ensure!( response.version == VERSION && response.sequence == sequence, "stale/foreign host response" ); #[cfg(test)] tests::before_acceptance(); ensure!( now()? < end && unsafe { libc::getppid() } == self.parent.pid, "host response expired or supervisor changed" ); let result = match response.result { Reply::Bytes(mut value) => { ensure!( !value.0.is_empty() && value.0.len() <= MAX_SECRET, "invalid host secret size" ); Ok(zeroize::Zeroizing::new(std::mem::take(&mut value.0))) } Reply::Unavailable => Err(anyhow::anyhow!( "typed host operation unavailable; no success established" )), }; // An authenticated missing/locked wallet is not a transport fault. self.sequence = sequence; self.frozen = false; result } } fn close_on_exec(fd: &OwnedFd) -> Result<()> { let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFD) }; ensure!( flags >= 0 && unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) } == 0, "host capability descriptor flags unavailable" ); Ok(()) } fn check_peer(fd: &OwnedFd, peer: Peer) -> Result<()> { crate::federation::platform_clock::independent_driver::check_peer( fd, crate::federation::platform_clock::independent_driver::PeerIdentity { pid: peer.pid, uid: peer.uid, gid: peer.gid, }, ) } fn now() -> Result { let mut value: libc::timespec = unsafe { std::mem::zeroed() }; ensure!( unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &mut value) } == 0 && value.tv_sec >= 0 && (0..1_000_000_000).contains(&value.tv_nsec), "host monotonic clock unavailable" ); Ok(Duration::new(value.tv_sec as u64, value.tv_nsec as u32)) } fn wait(fd: &OwnedFd, events: i16, end: Duration) -> Result<()> { let mut interruptions = 0; loop { let remaining = end .checked_sub(now()?) .context("host operation timed out")?; ensure!(!remaining.is_zero(), "host operation timed out"); let millis = remaining.as_millis().saturating_add(1).min(50) as i32; let mut poll = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0, }; let ready = unsafe { libc::poll(&mut poll, 1, millis) }; ensure!(now()? < end, "host operation timed out"); if ready < 0 { ensure!( std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted, "host operation polling failed" ); interruptions += 1; ensure!( interruptions <= 64, "host operation interruption budget exhausted" ); continue; } if ready == 0 { continue; } ensure!( poll.revents & events != 0 && poll.revents & (libc::POLLERR | libc::POLLNVAL) == 0, "host operation disconnected" ); return Ok(()); } } fn send(fd: &OwnedFd, bytes: &[u8], end: Duration) -> Result<()> { ensure!( !bytes.is_empty() && bytes.len() <= MAX_PACKET, "host request size" ); wait(fd, libc::POLLOUT, end)?; let sent = unsafe { libc::send( fd.as_raw_fd(), bytes.as_ptr().cast(), bytes.len(), libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL, ) }; ensure!( sent == bytes.len() as isize && now()? < end, "host send uncertain" ); Ok(()) } fn receive(fd: &OwnedFd, end: Duration) -> Result>> { wait(fd, libc::POLLIN, end)?; let mut bytes = zeroize::Zeroizing::new(vec![0; MAX_PACKET]); let mut io = libc::iovec { iov_base: bytes.as_mut_ptr().cast(), iov_len: bytes.len(), }; let mut message: libc::msghdr = unsafe { std::mem::zeroed() }; message.msg_iov = &mut io; message.msg_iovlen = 1; let count = unsafe { libc::recvmsg( fd.as_raw_fd(), &mut message, libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC, ) }; // No descriptor or credential ancillary data is accepted by this protocol. ensure!( count > 0 && count as usize <= MAX_PACKET && message.msg_flags & !libc::MSG_CMSG_CLOEXEC == 0 && now()? < end, "host packet missing/truncated/ancillary/late" ); bytes.truncate(count as usize); Ok(bytes) } /// Independently privileged owner. The root supervisor passes an accepted Unix /// connection created under the actual child's credentials, NEVER a root-created /// socketpair whose peer is still root. It owns lifetime/executable/IPC isolation. pub(crate) struct HostOwner { fd: OwnedFd, app: Peer, data: File, sequence: u64, frozen: bool, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct OwnerGrant { version: u32, process_id: u32, application: Peer, data_device: u64, data_inode: u64, } fn inherited(number: i32) -> Result { let raw = unsafe { libc::fcntl(number, libc::F_DUPFD_CLOEXEC, 10) }; ensure!(raw >= 0, "host owner inherited capability missing"); unsafe { libc::close(number); } Ok(unsafe { OwnedFd::from_raw_fd(raw) }) } fn progress( fd: &OwnedFd, event: u32, sequence: u64, deadline: Duration, send_end: Duration, ) -> Result<()> { // Public lifecycle metadata only, never credential bytes. The receiving root // supervisor authenticates the actual sender using SCM_CREDENTIALS. let mut packet = [0u8; 24]; packet[..4].copy_from_slice(&1u32.to_be_bytes()); packet[4..8].copy_from_slice(&event.to_be_bytes()); packet[8..16].copy_from_slice(&sequence.to_be_bytes()); let nanos: u64 = deadline .as_nanos() .try_into() .context("host progress deadline overflow")?; packet[16..].copy_from_slice(&nanos.to_be_bytes()); send(fd, &packet, send_end) } /// Separate root credential-I/O process, invoked only by the trusted root /// supervisor with inherited capabilities. It never parses environment paths /// or initializes application state. The supervisor retains this process until /// reaped, and must not accumulate replacements if filesystem I/O is stuck. pub(crate) fn run_owner() -> Result<()> { ensure!( std::env::args_os().len() == 2, "host owner accepts no path or command arguments" ); let (mut real, mut effective, mut saved) = (0, 0, 0); ensure!( unsafe { libc::getresuid(&mut real, &mut effective, &mut saved) } == 0 && (real, effective, saved) == (0, 0, 0), "host owner requires root credentials" ); ensure!( unsafe { libc::getresgid(&mut real, &mut effective, &mut saved) } == 0 && (real, effective, saved) == (0, 0, 0), "host owner requires root group credentials" ); ensure!( unsafe { libc::prctl(libc::PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) } == 1, "host owner requires no-new-privileges" ); ensure!( unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) } == 0, "host owner inspection exclusion failed" ); let parent = Peer { pid: unsafe { libc::getppid() }, uid: 0, gid: 0, }; ensure!(parent.pid > 1, "host owner root supervisor missing"); let grant_fd = inherited(3)?; check_peer(&grant_fd, parent)?; // This spawning thread must be retained by the supervisor: Linux parent // death signals track that thread, not merely an arbitrary live PID. ensure!( unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0) } == 0 && unsafe { libc::getppid() } == parent.pid, "host supervisor lifetime changed" ); let admission_end = now()?.checked_add(WAIT).context("host deadline overflow")?; let raw = receive(&grant_fd, admission_end)?; ensure!(raw.len() <= 8192, "host owner grant oversized"); let grant: OwnerGrant = serde_json::from_slice(&raw)?; ensure!( grant.version == OWNER_GRANT_VERSION && grant.process_id == std::process::id(), "host owner grant binding mismatch" ); let channel = inherited(4)?; let data = File::from(inherited(5)?); let metadata = data.metadata()?; ensure!( metadata.is_dir() && metadata.dev() == grant.data_device && metadata.ino() == grant.data_inode, "host owner data capability mismatch" ); let mut owner = HostOwner::from_directory(channel, grant.application, data)?; drop(raw); // Retain FD3 as the private progress channel. An older launcher that does // not supervise transaction deadlines cannot supply a version2 owner grant. progress(&grant_fd, 1, 0, Duration::ZERO, admission_end)?; loop { let mut poll = libc::pollfd { fd: owner.fd.as_raw_fd(), events: libc::POLLIN, revents: 0, }; let ready = unsafe { libc::poll(&mut poll, 1, -1) }; if ready < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { continue; } ensure!( ready == 1 && poll.revents & libc::POLLIN != 0, "host application disconnected" ); owner.serve_one_with_progress(|event, sequence, end| { progress(&grant_fd, event, sequence, end, end) })?; } } impl HostOwner { #[cfg(test)] pub(crate) fn new(fd: OwnedFd, app: Peer, data: &Path) -> Result { let data = OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(data)?; Self::from_directory(fd, app, data) } fn from_directory(fd: OwnedFd, app: Peer, data: File) -> Result { ensure!( unsafe { libc::geteuid() } == 0 && app.uid != 0 && app.gid != 0 && app.pid > 1, "host owner requires root and a nonroot application" ); check_peer(&fd, app)?; close_on_exec(&fd)?; ensure!( data.metadata()?.is_dir(), "host owner requires a directory capability" ); // Canonical location and safe ancestry belong to root supervisor inputs; // retain the directory inode so replacing its pathname cannot redirect us. Ok(Self { fd, app, data, sequence: 0, frozen: false, }) } #[cfg(test)] pub(crate) fn serve_one(&mut self) -> Result<()> { self.serve_one_with_progress(|_, _, _| Ok(())) } fn serve_one_with_progress( &mut self, mut report: impl FnMut(u32, u64, Duration) -> Result<()>, ) -> Result<()> { ensure!(!self.frozen, "host owner connection frozen"); self.frozen = true; check_peer(&self.fd, self.app)?; let end = now()?.checked_add(WAIT).context("host deadline overflow")?; let request: Request = serde_json::from_slice(&receive(&self.fd, end)?)?; ensure!( request.version == VERSION && self.sequence.checked_add(1) == Some(request.sequence), "host request version/replay refused" ); // The observer must know the immutable deadline BEFORE filesystem I/O // can stall. Failure to notify refuses the operation, without reading. report(2, request.sequence, end)?; ensure!( now()? < end, "host request expired before filesystem access" ); // Mutation failures are uncertain, never a retryable missing credential. // Keep the connection frozen and retire the owner on any such failure. let result = match request.operation { Operation::SetLoginPassword(hash) => { Reply::Bytes(SecretBytes(capabilities::set_login_password(&hash, end)?)) } Operation::FipsWg(operation) => { let root = File::open("/")?; let result = crate::fips::config::protected_owner::execute( &root, operation, |probe, pin| capabilities::fips_wg_probe(probe, pin, end), ); ensure!(now()? < end, "FIPS/WG admission expired"); match result { Ok(bytes) => Reply::Bytes(SecretBytes(bytes)), Err(error) => { // Validation errors contain fixed custody/state reasons, // never the private key bytes. Keep the refusal intact. eprintln!("C01_FIPS_WG_REFUSED operation={operation:?} reason={error}"); Reply::Unavailable } } } Operation::LndConfig(operation) => { let bytes = lnd_config::execute(&self.data, operation)?; ensure!(now()? < end, "LND config result arrived after deadline"); Reply::Bytes(SecretBytes(bytes)) } Operation::Host(operation) => match capabilities::execute(operation, end) { Ok(bytes) => Reply::Bytes(SecretBytes(bytes)), Err(error) if error.is::() => Reply::Unavailable, Err(error) => return Err(error), }, operation => match read_operation(&self.data, operation, end) { Ok(bytes) => Reply::Bytes(bytes), Err(_) => Reply::Unavailable, // Never return file paths or secret diagnostics. }, }; let response = Response { version: VERSION, sequence: request.sequence, result, }; let encoded = encode(&response)?; send(&self.fd, &encoded, end)?; report(3, request.sequence, end)?; self.sequence = request.sequence; self.frozen = false; Ok(()) } } #[cfg(test)] fn read_macaroon(data: &File, end: Duration) -> Result { read_operation(data, Operation::ReadLndAdminMacaroon, end) } fn read_operation(data: &File, operation: Operation, end: Duration) -> Result { ensure!(now()? < end, "host filesystem read expired"); let mut directory = data.try_clone()?; // Only these compiled, capability-relative paths exist in the protocol. // In particular, no manager-supplied Tor/system path or argv reaches root. let (parents, leaf): (&[&str], &str) = match operation { Operation::ReadLndAdminMacaroon => ( &["lnd", "data", "chain", "bitcoin", "mainnet"], "admin.macaroon", ), Operation::ReadLndTlsCertificate => (&["lnd"], "tls.cert"), Operation::ReadLndOnionAddress => (&["tor-hostnames"], "lnd"), Operation::ReadTorHostname(service) => (&["tor-hostnames"], service.filename()), Operation::Host(_) | Operation::LndConfig(_) | Operation::FipsWg(_) | Operation::SetLoginPassword(_) => { anyhow::bail!("not a credential read") } }; for component in parents { directory = open_at(&directory, component, true)?; ensure!(now()? < end, "host filesystem read expired"); } let file = open_at(&directory, leaf, false)?; let before = file.metadata()?; ensure!(now()? < end, "host filesystem read expired"); ensure!( before.is_file() && before.nlink() == 1 && before.len() > 0 && before.len() <= MAX_SECRET as u64, "invalid wallet credential object" ); // Reserve the complete read limit before secret bytes enter the allocation. let mut bytes = zeroize::Zeroizing::new(Vec::with_capacity(MAX_SECRET + 1)); (&file) .take(MAX_SECRET as u64 + 1) .read_to_end(&mut bytes)?; let after = file.metadata()?; ensure!(now()? < end, "host filesystem read expired"); ensure!( bytes.len() as u64 == before.len() && before.len() == after.len() && after.nlink() == 1 && before.mtime() == after.mtime() && before.mtime_nsec() == after.mtime_nsec() && before.ctime() == after.ctime() && before.ctime_nsec() == after.ctime_nsec(), "wallet credential changed during read" ); Ok(SecretBytes(std::mem::take(&mut *bytes))) } fn open_at(directory: &File, name: &str, is_directory: bool) -> Result { let name = std::ffi::CString::new(name)?; let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK | if is_directory { libc::O_DIRECTORY } else { 0 }; let raw = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) }; ensure!(raw >= 0, "wallet credential open refused"); Ok(unsafe { File::from_raw_fd(raw) }) } #[cfg(test)] #[path = "host_access_tests.rs"] mod tests;