Files

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;