742 lines
26 KiB
Rust
742 lines
26 KiB
Rust
//! 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<Mutex<Client>> = 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<u8>);
|
|
impl Drop for SecretBytes {
|
|
fn drop(&mut self) {
|
|
zeroize::Zeroize::zeroize(&mut self.0);
|
|
}
|
|
}
|
|
impl<'de> Deserialize<'de> for SecretBytes {
|
|
fn deserialize<D: serde::Deserializer<'de>>(
|
|
deserializer: D,
|
|
) -> std::result::Result<Self, D::Error> {
|
|
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<A: serde::de::SeqAccess<'de>>(
|
|
self,
|
|
mut seq: A,
|
|
) -> std::result::Result<Self::Value, A::Error> {
|
|
let mut bytes = SecretBytes(Vec::with_capacity(MAX_SECRET));
|
|
while let Some(byte) = seq.next_element::<u8>()? {
|
|
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<Vec<u8>>);
|
|
impl Write for PacketBuffer {
|
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
|
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<zeroize::Zeroizing<Vec<u8>>> {
|
|
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<AtomicBool>);
|
|
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<Vec<u8>> {
|
|
read_typed(Operation::ReadLndAdminMacaroon).await
|
|
}
|
|
|
|
pub(crate) async fn read_lnd_tls_certificate() -> Result<Vec<u8>> {
|
|
read_typed(Operation::ReadLndTlsCertificate).await
|
|
}
|
|
|
|
pub(crate) async fn read_lnd_onion_address() -> Result<Vec<u8>> {
|
|
read_typed(Operation::ReadLndOnionAddress).await
|
|
}
|
|
|
|
pub(crate) async fn execute(operation: capabilities::Operation) -> Result<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
read_typed(Operation::ReadTorHostname(service)).await
|
|
}
|
|
|
|
async fn read_typed(operation: Operation) -> Result<Vec<u8>> {
|
|
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<zeroize::Zeroizing<Vec<u8>>> {
|
|
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<Duration> {
|
|
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<zeroize::Zeroizing<Vec<u8>>> {
|
|
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<OwnedFd> {
|
|
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<Self> {
|
|
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<Self> {
|
|
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::<capabilities::Unavailable>() => 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<SecretBytes> {
|
|
read_operation(data, Operation::ReadLndAdminMacaroon, end)
|
|
}
|
|
|
|
fn read_operation(data: &File, operation: Operation, end: Duration) -> Result<SecretBytes> {
|
|
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<File> {
|
|
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;
|