#[cfg(not(target_os = "windows"))]
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(not(target_os = "windows"))]
use std::sync::Mutex;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[cfg(not(target_os = "windows"))]
use tsafe_core::agent::{cellos_socket_path, CellRecord, CellState, CellosRequest, CellosResponse};
use tsafe_core::agent::{
clear_agent_sock, pipe_name, write_agent_sock, AgentRequest, AgentResponse, AgentSession,
};
#[cfg(not(target_os = "windows"))]
use tsafe_core::audit::{AuditCellosContext, AuditContext, AuditEntry, AuditLog};
use tsafe_core::profile;
use tsafe_core::{keyring_store, vault::Vault};
#[derive(Zeroize, ZeroizeOnDrop)]
struct Password(String);
#[cfg(not(target_os = "windows"))]
type CellCache = Arc<Mutex<HashMap<String, CellState>>>;
#[cfg(not(target_os = "windows"))]
fn lock_cell_cache(
cell_cache: &CellCache,
) -> std::sync::MutexGuard<'_, HashMap<String, CellState>> {
match cell_cache.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(not(target_os = "windows"))]
fn install_signal_handlers(stop: Arc<AtomicBool>) -> std::io::Result<()> {
use signal_hook::consts::{SIGINT, SIGTERM};
use signal_hook::flag;
flag::register(SIGTERM, Arc::clone(&stop))?;
flag::register(SIGINT, Arc::clone(&stop))?;
unsafe {
libc::signal(libc::SIGHUP, libc::SIG_IGN);
}
Ok(())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() != 6 {
eprintln!(
"usage: tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>"
);
std::process::exit(1);
}
let profile_name = &args[1];
let session_token = &args[2];
let requesting_pid: u32 = args[3].parse().context("invalid requesting_pid")?;
let idle_ttl_secs: u64 = args[4].parse().context("invalid idle_ttl_secs")?;
let absolute_ttl_secs: u64 = args[5].parse().context("invalid absolute_ttl_secs")?;
#[cfg(not(target_os = "windows"))]
let raw_password = acquire_password(profile_name)?;
#[cfg(not(target_os = "windows"))]
let pw = Password(raw_password.clone());
#[cfg(target_os = "windows")]
let pw = Password(acquire_password(profile_name)?);
{
let path = profile::vault_path(profile_name);
Vault::open(&path, pw.0.as_bytes()).context("wrong password — agent will not start")?;
}
let agent_pid = std::process::id();
let pipe = pipe_name(agent_pid);
println!("TSAFE_AGENT_SOCK={pipe}::{session_token}");
let _ = std::io::stdout().flush();
write_agent_sock(&format!("{pipe}::{session_token}"));
let stop = Arc::new(AtomicBool::new(false));
#[cfg(not(target_os = "windows"))]
install_signal_handlers(Arc::clone(&stop))
.context("failed to install SIGTERM/SIGINT handlers")?;
let absolute_deadline = Instant::now() + Duration::from_secs(absolute_ttl_secs);
let mut session =
AgentSession::new(session_token.to_string(), idle_ttl_secs, absolute_deadline);
spawn_expiry_watchdog(pipe.clone(), absolute_deadline, Arc::clone(&stop));
#[cfg(not(target_os = "windows"))]
{
let cell_cache: CellCache = Arc::new(Mutex::new(HashMap::new()));
let profile = profile_name.clone();
let shared_pw = Arc::new(raw_password);
let cache = Arc::clone(&cell_cache);
let stop_clone = Arc::clone(&stop);
std::thread::spawn(move || {
if let Err(e) = serve_cellos(&profile, shared_pw, cache, stop_clone) {
eprintln!("tsafe-agent: CellOS socket error: {e:#}");
}
});
}
serve(
&pipe,
&pw,
&mut session,
requesting_pid,
absolute_deadline,
stop,
)?;
clear_agent_sock();
Ok(())
}
fn acquire_password(profile: &str) -> Result<String> {
match keyring_store::retrieve_password(profile) {
Ok(Some(pw)) => return Ok(pw),
Ok(None) => {}
Err(e) => eprintln!("tsafe-agent: keychain lookup failed: {e}; falling back"),
}
if let Ok(env_pw) = std::env::var("TSAFE_VAULT_PASSWORD") {
eprintln!(
"tsafe-agent: WARNING — using TSAFE_VAULT_PASSWORD from environment. \
This value is visible in /proc/self/environ, `docker inspect`, and shell \
history. Use `tsafe biometric enable` to store the password in the OS \
keychain instead."
);
return Ok(env_pw);
}
rpassword_read()
}
#[cfg(not(target_os = "windows"))]
fn serve_cellos(
profile: &str,
password: Arc<String>,
cell_cache: CellCache,
stop: Arc<AtomicBool>,
) -> Result<()> {
use std::os::unix::net::UnixListener;
let sock_path = cellos_socket_path();
if let Some(parent) = sock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let _ = std::fs::remove_file(&sock_path);
let listener = UnixListener::bind(&sock_path)
.with_context(|| format!("CellOS: failed to bind {}", sock_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600));
}
let _cleanup = SocketCleanup(sock_path.to_string_lossy().into_owned());
listener.set_nonblocking(true)?;
let daemon_uid = unsafe { libc::getuid() };
let vault_path = profile::vault_path(profile);
let audit = AuditLog::new(&profile::audit_log_path(profile));
loop {
if stop.load(Ordering::Relaxed) {
break;
}
match listener.accept() {
Ok((stream, _)) => {
stream.set_nonblocking(false)?;
let cred = match unix_peer_credential(&stream) {
Ok(c) => c,
Err(e) => {
eprintln!("tsafe-agent: CellOS: peer credential failed: {e}");
continue;
}
};
if cred.uid != daemon_uid {
let resp = CellosResponse::Err {
error: "uid mismatch".to_string(),
};
let mut w = &stream;
let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
continue;
}
handle_cellos_connection(
&stream,
cred.pid,
&vault_path,
&password,
profile,
&cell_cache,
&audit,
);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(200));
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn handle_cellos_connection(
stream: &std::os::unix::net::UnixStream,
peer_pid: u32,
vault_path: &std::path::Path,
password: &str,
profile: &str,
cell_cache: &CellCache,
audit: &AuditLog,
) {
use std::io::BufRead;
let mut reader = BufReader::new(stream);
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return;
}
let req: CellosRequest = match serde_json::from_str(line.trim()) {
Ok(r) => r,
Err(e) => {
let resp = CellosResponse::Err {
error: format!("bad request: {e}"),
};
let mut w = stream;
let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
return;
}
};
let resp = dispatch_cellos(
req, peer_pid, vault_path, password, profile, cell_cache, audit,
);
let mut w = stream;
let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
}
#[cfg(not(target_os = "windows"))]
fn dispatch_cellos(
req: CellosRequest,
peer_pid: u32,
vault_path: &std::path::Path,
password: &str,
profile: &str,
cell_cache: &CellCache,
audit: &AuditLog,
) -> CellosResponse {
match req {
CellosRequest::Resolve {
key,
cell_id,
ttl_seconds: _,
cell_token,
} => {
{
let mut cache = lock_cell_cache(cell_cache);
match cache.get(&cell_id) {
Some(CellState::Revoked) => {
return CellosResponse::Err {
error: "cell revoked".to_string(),
};
}
Some(CellState::Active(record)) => {
if record.token != cell_token {
return CellosResponse::Err {
error: "cell_token mismatch".to_string(),
};
}
}
None => {
cache.insert(
cell_id.clone(),
CellState::Active(CellRecord {
pid: peer_pid,
token: cell_token.clone(),
}),
);
}
}
}
let value = match Vault::open_read_only(vault_path, password.as_bytes()) {
Ok(v) => match v.get(&key) {
Ok(s) => s.to_string(),
Err(_) => {
return CellosResponse::Err {
error: format!("key not found: {key}"),
};
}
},
Err(e) => {
return CellosResponse::Err {
error: format!("vault error: {e}"),
};
}
};
audit
.append(
&AuditEntry::success(profile, "cellos-resolve", Some(&key)).with_context(
AuditContext::from_cellos(AuditCellosContext {
cellos_cell_id: cell_id,
cell_token: Some(cell_token),
}),
),
)
.ok();
CellosResponse::Value { value }
}
CellosRequest::RevokeForCell { cell_id } => {
{
let mut cache = lock_cell_cache(cell_cache);
cache.insert(cell_id.clone(), CellState::Revoked);
}
audit
.append(
&AuditEntry::success(profile, "cellos-revoke", None).with_context(
AuditContext::from_cellos(AuditCellosContext {
cellos_cell_id: cell_id,
cell_token: None,
}),
),
)
.ok();
CellosResponse::Ok
}
}
}
#[cfg(target_os = "windows")]
fn serve(
pipe: &str,
pw: &Password,
session: &mut AgentSession,
_requesting_pid: u32,
deadline: Instant,
stop: Arc<AtomicBool>,
) -> Result<()> {
use std::fs::File;
use std::os::windows::io::FromRawHandle;
let pipe_wide: Vec<u16> = pipe.encode_utf16().chain(std::iter::once(0)).collect();
loop {
if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
break;
}
let handle = unsafe { windows_create_named_pipe(&pipe_wide)? };
let connected = unsafe { windows_connect_with_timeout(handle, 5_000) };
if !connected {
unsafe { windows_close_handle(handle) };
continue;
}
let client_file = unsafe { File::from_raw_handle(handle as _) };
let mut reader = BufReader::new(&client_file);
let mut writer = &client_file;
handle_connection(
&mut reader,
&mut writer,
pw,
session,
Some(unsafe { windows_get_named_pipe_client_process_id(handle)? }),
&stop,
)?;
}
Ok(())
}
fn handle_connection(
reader: &mut impl BufRead,
writer: &mut impl Write,
pw: &Password,
session: &mut AgentSession,
peer_pid: Option<u32>,
stop: &Arc<AtomicBool>,
) -> Result<()> {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return Ok(());
}
let req: AgentRequest = match serde_json::from_str(line.trim()) {
Ok(r) => r,
Err(e) => {
let resp = AgentResponse::Err {
reason: format!("bad request: {e}"),
};
let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
return Ok(());
}
};
let outcome = session.handle_request(&req, peer_pid, &pw.0, Instant::now());
if outcome.stop {
stop.store(true, Ordering::Relaxed);
}
let resp = outcome.response;
let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
Ok(())
}
fn spawn_expiry_watchdog(pipe: String, deadline: Instant, stop: Arc<AtomicBool>) {
std::thread::spawn(move || loop {
if stop.load(Ordering::Relaxed) {
return;
}
let now = Instant::now();
if now >= deadline {
stop.store(true, Ordering::Relaxed);
wake_listener(&pipe);
return;
}
std::thread::sleep((deadline - now).min(Duration::from_millis(200)));
});
}
#[cfg(target_os = "windows")]
fn wake_listener(pipe: &str) {
let _ = windows_connect_pipe_client(pipe);
}
#[cfg(not(target_os = "windows"))]
fn wake_listener(pipe: &str) {
let _ = std::os::unix::net::UnixStream::connect(pipe);
}
fn rpassword_read() -> Result<String> {
rpassword::prompt_password("Vault password: ").context("failed to read password")
}
#[cfg(target_os = "windows")]
mod ffi {
use std::ffi::c_void;
extern "system" {
pub fn CreateNamedPipeW(
name: *const u16,
open_mode: u32,
pipe_mode: u32,
max_instances: u32,
out_buf: u32,
in_buf: u32,
default_timeout: u32,
security: *mut c_void,
) -> *mut c_void;
pub fn CreateFileW(
name: *const u16,
access: u32,
share: u32,
security: *mut c_void,
creation: u32,
flags: u32,
template: *mut c_void,
) -> *mut c_void;
pub fn ConnectNamedPipe(pipe: *mut c_void, overlapped: *mut c_void) -> i32;
pub fn CloseHandle(handle: *mut c_void) -> i32;
pub fn GetNamedPipeClientProcessId(pipe: *mut c_void, client_process_id: *mut u32) -> i32;
#[allow(dead_code)]
pub fn WaitForSingleObject(handle: *mut c_void, ms: u32) -> u32;
}
}
#[cfg(target_os = "windows")]
unsafe fn windows_create_named_pipe(pipe_wide: &[u16]) -> Result<*mut std::ffi::c_void> {
let h = ffi::CreateNamedPipeW(
pipe_wide.as_ptr(),
3, 0x00, 1, 4096,
4096,
0,
std::ptr::null_mut(),
);
if h as isize == -1 || h.is_null() {
anyhow::bail!("CreateNamedPipeW failed");
}
Ok(h)
}
#[cfg(target_os = "windows")]
unsafe fn windows_connect_with_timeout(handle: *mut std::ffi::c_void, _ms: u32) -> bool {
ffi::ConnectNamedPipe(handle, std::ptr::null_mut()) != 0
}
#[cfg(target_os = "windows")]
unsafe fn windows_close_handle(handle: *mut std::ffi::c_void) {
ffi::CloseHandle(handle);
}
#[cfg(target_os = "windows")]
unsafe fn windows_get_named_pipe_client_process_id(handle: *mut std::ffi::c_void) -> Result<u32> {
let mut pid = 0u32;
if ffi::GetNamedPipeClientProcessId(handle, &mut pid) == 0 {
anyhow::bail!("GetNamedPipeClientProcessId failed");
}
Ok(pid)
}
#[cfg(target_os = "windows")]
fn windows_connect_pipe_client(pipe: &str) -> Result<std::fs::File> {
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::FromRawHandle;
let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let handle = unsafe {
ffi::CreateFileW(
wide.as_ptr(),
0xC000_0000, 0,
std::ptr::null_mut(),
3, 128, std::ptr::null_mut(),
)
};
if handle.is_null() || handle as isize == -1 {
anyhow::bail!("CreateFileW failed");
}
Ok(unsafe { std::fs::File::from_raw_handle(handle as _) })
}
#[cfg(not(target_os = "windows"))]
fn serve(
pipe: &str,
pw: &Password,
session: &mut AgentSession,
_requesting_pid: u32,
deadline: Instant,
stop: Arc<AtomicBool>,
) -> Result<()> {
use std::os::unix::net::UnixListener;
let _ = std::fs::remove_file(pipe);
let listener =
UnixListener::bind(pipe).with_context(|| format!("failed to bind Unix socket: {pipe}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(pipe, std::fs::Permissions::from_mode(0o600));
}
listener.set_nonblocking(true)?;
let _cleanup = SocketCleanup(pipe.to_string());
loop {
if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
break;
}
match listener.accept() {
Ok((stream, _)) => {
stream.set_nonblocking(false)?;
let mut reader = BufReader::new(&stream);
let mut writer = &stream;
handle_connection(
&mut reader,
&mut writer,
pw,
session,
Some(unix_peer_pid(&stream)?),
&stop,
)?;
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(200));
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
struct SocketCleanup(String);
#[cfg(not(target_os = "windows"))]
impl Drop for SocketCleanup {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[cfg(unix)]
struct PeerCredential {
pid: u32,
uid: u32,
}
#[cfg(target_os = "linux")]
fn unix_peer_credential(
stream: &std::os::unix::net::UnixStream,
) -> std::io::Result<PeerCredential> {
use std::mem::size_of;
use std::os::fd::AsRawFd;
let fd = stream.as_raw_fd();
let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
let mut len = size_of::<libc::ucred>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut libc::c_void,
&mut len,
)
};
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(PeerCredential {
pid: cred.pid as u32,
uid: cred.uid,
})
}
#[cfg(target_os = "macos")]
fn unix_peer_credential(
stream: &std::os::unix::net::UnixStream,
) -> std::io::Result<PeerCredential> {
use std::mem::size_of;
use std::os::fd::AsRawFd;
let fd = stream.as_raw_fd();
let mut uid: libc::uid_t = 0;
let mut gid: libc::gid_t = 0;
let mut pid: libc::pid_t = 0;
let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
let rc_uid = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
let rc_pid = unsafe {
libc::getsockopt(
fd,
libc::SOL_LOCAL,
libc::LOCAL_PEERPID,
&mut pid as *mut _ as *mut libc::c_void,
&mut len,
)
};
if rc_uid != 0 {
return Err(std::io::Error::last_os_error());
}
if rc_pid != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(PeerCredential {
pid: pid as u32,
uid: uid as u32,
})
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
fn unix_peer_credential(
_stream: &std::os::unix::net::UnixStream,
) -> std::io::Result<PeerCredential> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"peer credentials unsupported on this platform",
))
}
#[cfg(unix)]
fn unix_peer_pid(stream: &std::os::unix::net::UnixStream) -> Result<u32> {
unix_peer_credential(stream)
.map(|c| c.pid)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
fn run_request(
req: AgentRequest,
peer_pid: Option<u32>,
absolute_deadline: Instant,
) -> (AgentResponse, bool) {
let stop = Arc::new(AtomicBool::new(false));
let mut input = std::io::Cursor::new(format!("{}\n", serde_json::to_string(&req).unwrap()));
let mut output = Vec::new();
let password = Password("secret".to_string());
let idle_secs = if absolute_deadline > Instant::now() {
(absolute_deadline - Instant::now()).as_secs().max(1)
} else {
1
};
let mut session = AgentSession::new("token-123", idle_secs, absolute_deadline);
handle_connection(
&mut input,
&mut output,
&password,
&mut session,
peer_pid,
&stop,
)
.unwrap();
let response: AgentResponse = serde_json::from_slice(&output).unwrap();
(response, stop.load(Ordering::Relaxed))
}
#[test]
fn open_vault_allows_matching_peer_pid() {
let (response, stop) = run_request(
AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(4242),
Instant::now() + Duration::from_secs(60),
);
assert!(!stop);
match response {
AgentResponse::Password { password } => assert_eq!(password, "secret"),
other => panic!("expected password response, got {other:?}"),
}
}
#[test]
fn open_vault_rejects_pid_mismatch() {
let (response, stop) = run_request(
AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(9001),
Instant::now() + Duration::from_secs(60),
);
assert!(!stop);
match response {
AgentResponse::Err { reason } => {
assert!(reason.contains("does not match the connecting process"));
}
other => panic!("expected authorization error, got {other:?}"),
}
}
#[test]
fn expired_session_rejects_requests_and_stops() {
let (response, stop) = run_request(
AgentRequest::Ping,
Some(4242),
Instant::now() - Duration::from_secs(1),
);
assert!(stop);
match response {
AgentResponse::Err { reason } => assert!(
reason.contains("agent session expired"),
"unexpected: {reason}"
),
other => panic!("expected expiry error, got {other:?}"),
}
}
#[test]
fn lock_request_transitions_session_and_stops() {
let (response, stop) = run_request(
AgentRequest::Lock {
session_token: "token-123".into(),
},
Some(4242),
Instant::now() + Duration::from_secs(60),
);
assert!(stop);
assert!(matches!(response, AgentResponse::Ok));
}
}