1#[cfg(not(target_os = "windows"))]
13use std::collections::HashMap;
14use std::io::{BufRead, BufReader, Write};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17#[cfg(not(target_os = "windows"))]
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use anyhow::{Context, Result};
22use zeroize::{Zeroize, ZeroizeOnDrop};
23
24#[cfg(not(target_os = "windows"))]
25use tsafe_core::agent::{cellos_socket_path, CellRecord, CellState, CellosRequest, CellosResponse};
26use tsafe_core::agent::{
27 clear_agent_sock, pipe_name, write_agent_sock, AgentRequest, AgentResponse, AgentSession,
28};
29#[cfg(not(target_os = "windows"))]
30use tsafe_core::audit::{AuditCellosContext, AuditContext, AuditEntry, AuditLog};
31use tsafe_core::profile;
32use tsafe_core::{keyring_store, vault::Vault};
33
34const HELP: &str = "\
35tsafe-agent — local session agent for tsafe
36
37Usage:
38 tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>
39
40This companion runtime is normally launched by `tsafe agent unlock`.
41It holds one profile's unlock state in memory for the configured TTL and serves
42same-user callers over a local named pipe or Unix socket. It does not send telemetry
43or analytics. It also does not send crash reports, update pings, secret names,
44or secret values to a vendor service.
45
46Local surfaces:
47 - named pipe or Unix socket IPC
48 - in-memory unlock state until idle or absolute TTL expiry
49 - per-profile local audit receipts written by the calling tsafe workflow
50
51Revoke:
52 tsafe agent lock
53";
54
55#[derive(Zeroize, ZeroizeOnDrop)]
58struct Password(String);
59
60#[cfg(not(target_os = "windows"))]
63type CellCache = Arc<Mutex<HashMap<String, CellState>>>;
64
65#[cfg(not(target_os = "windows"))]
74fn lock_cell_cache(
75 cell_cache: &CellCache,
76) -> std::sync::MutexGuard<'_, HashMap<String, CellState>> {
77 match cell_cache.lock() {
78 Ok(g) => g,
79 Err(poisoned) => poisoned.into_inner(),
80 }
81}
82
83#[cfg(not(target_os = "windows"))]
96fn install_signal_handlers(stop: Arc<AtomicBool>) -> std::io::Result<()> {
97 use signal_hook::consts::{SIGINT, SIGTERM};
98 use signal_hook::flag;
99 flag::register(SIGTERM, Arc::clone(&stop))?;
100 flag::register(SIGINT, Arc::clone(&stop))?;
101 unsafe {
105 libc::signal(libc::SIGHUP, libc::SIG_IGN);
106 }
107 Ok(())
108}
109
110fn daemon_main() -> Result<()> {
113 let args: Vec<String> = std::env::args().collect();
114 if args
115 .iter()
116 .skip(1)
117 .any(|arg| arg == "--help" || arg == "-h")
118 {
119 print!("{HELP}");
120 return Ok(());
121 }
122 if args.len() != 6 {
123 eprintln!(
124 "usage: tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>"
125 );
126 std::process::exit(1);
127 }
128
129 let profile_name = &args[1];
130 let session_token = &args[2];
131 let requesting_pid: u32 = args[3].parse().context("invalid requesting_pid")?;
132 let idle_ttl_secs: u64 = args[4].parse().context("invalid idle_ttl_secs")?;
133 let absolute_ttl_secs: u64 = args[5].parse().context("invalid absolute_ttl_secs")?;
134
135 #[cfg(not(target_os = "windows"))]
137 let raw_password = acquire_password(profile_name)?;
138 #[cfg(not(target_os = "windows"))]
139 let pw = Password(raw_password.clone());
140 #[cfg(target_os = "windows")]
141 let pw = Password(acquire_password(profile_name)?);
142
143 {
145 let path = profile::vault_path(profile_name);
146 Vault::open(&path, pw.0.as_bytes()).context("wrong password — agent will not start")?;
147 }
148
149 let agent_pid = std::process::id();
150 let pipe = pipe_name(agent_pid);
151
152 println!("TSAFE_AGENT_SOCK={pipe}::{session_token}");
160 let _ = std::io::stdout().flush();
161
162 write_agent_sock(&format!("{pipe}::{session_token}"));
165
166 let stop = Arc::new(AtomicBool::new(false));
167 #[cfg(not(target_os = "windows"))]
171 install_signal_handlers(Arc::clone(&stop))
172 .context("failed to install SIGTERM/SIGINT handlers")?;
173 let absolute_deadline = Instant::now() + Duration::from_secs(absolute_ttl_secs);
174 let mut session =
175 AgentSession::new(session_token.to_string(), idle_ttl_secs, absolute_deadline);
176 spawn_expiry_watchdog(pipe.clone(), absolute_deadline, Arc::clone(&stop));
177
178 #[cfg(not(target_os = "windows"))]
181 {
182 let cell_cache: CellCache = Arc::new(Mutex::new(HashMap::new()));
183 let profile = profile_name.clone();
184 let shared_pw = Arc::new(raw_password);
185 let cache = Arc::clone(&cell_cache);
186 let stop_clone = Arc::clone(&stop);
187 std::thread::spawn(move || {
188 if let Err(e) = serve_cellos(&profile, shared_pw, cache, stop_clone) {
189 eprintln!("tsafe-agent: CellOS socket error: {e:#}");
190 }
191 });
192 }
193
194 serve(
195 &pipe,
196 &pw,
197 &mut session,
198 requesting_pid,
199 absolute_deadline,
200 stop,
201 )?;
202
203 clear_agent_sock();
205
206 Ok(())
207}
208
209pub fn run() {
215 if let Err(e) = daemon_main() {
216 eprintln!("tsafe-agent: {e:#}");
217 std::process::exit(1);
218 }
219}
220
221fn acquire_password(profile: &str) -> Result<String> {
228 match keyring_store::retrieve_password(profile) {
237 Ok(Some(pw)) => return Ok(pw),
238 Ok(None) => {}
239 Err(e) => eprintln!("tsafe-agent: keychain lookup failed: {e}; falling back"),
240 }
241
242 if let Ok(env_pw) = std::env::var("TSAFE_VAULT_PASSWORD") {
244 eprintln!(
245 "tsafe-agent: WARNING — using TSAFE_VAULT_PASSWORD from environment. \
246 This value is visible in /proc/self/environ, `docker inspect`, and shell \
247 history. Use `tsafe biometric enable` to store the password in the OS \
248 keychain instead."
249 );
250 return Ok(env_pw);
251 }
252
253 rpassword_read()
255}
256
257#[cfg(not(target_os = "windows"))]
264fn serve_cellos(
265 profile: &str,
266 password: Arc<String>,
267 cell_cache: CellCache,
268 stop: Arc<AtomicBool>,
269) -> Result<()> {
270 use std::os::unix::net::UnixListener;
271
272 let sock_path = cellos_socket_path();
273 if let Some(parent) = sock_path.parent() {
274 std::fs::create_dir_all(parent)?;
275 }
276 let _ = std::fs::remove_file(&sock_path);
277 let listener = UnixListener::bind(&sock_path)
278 .with_context(|| format!("CellOS: failed to bind {}", sock_path.display()))?;
279 #[cfg(unix)]
280 {
281 use std::os::unix::fs::PermissionsExt;
282 let _ = std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600));
283 }
284 let _cleanup = SocketCleanup(sock_path.to_string_lossy().into_owned());
285
286 listener.set_nonblocking(true)?;
287 let daemon_uid = unsafe { libc::getuid() };
291 let vault_path = profile::vault_path(profile);
292 let audit = AuditLog::new(&profile::audit_log_path(profile));
293
294 loop {
295 if stop.load(Ordering::Relaxed) {
296 break;
297 }
298 match listener.accept() {
299 Ok((stream, _)) => {
300 stream.set_nonblocking(false)?;
301 let cred = match unix_peer_credential(&stream) {
302 Ok(c) => c,
303 Err(e) => {
304 eprintln!("tsafe-agent: CellOS: peer credential failed: {e}");
305 continue;
306 }
307 };
308 if cred.uid != daemon_uid {
309 let resp = CellosResponse::Err {
310 error: "uid mismatch".to_string(),
311 };
312 let mut w = &stream;
313 let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
314 continue;
315 }
316 handle_cellos_connection(
317 &stream,
318 cred.pid,
319 &vault_path,
320 &password,
321 profile,
322 &cell_cache,
323 &audit,
324 );
325 }
326 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
327 std::thread::sleep(Duration::from_millis(200));
328 }
329 Err(e) => return Err(e.into()),
330 }
331 }
332 Ok(())
333}
334
335#[cfg(not(target_os = "windows"))]
336fn handle_cellos_connection(
337 stream: &std::os::unix::net::UnixStream,
338 peer_pid: u32,
339 vault_path: &std::path::Path,
340 password: &str,
341 profile: &str,
342 cell_cache: &CellCache,
343 audit: &AuditLog,
344) {
345 use std::io::BufRead;
346
347 let mut reader = BufReader::new(stream);
348 let mut line = String::new();
349 if reader.read_line(&mut line).unwrap_or(0) == 0 {
350 return;
351 }
352
353 let req: CellosRequest = match serde_json::from_str(line.trim()) {
354 Ok(r) => r,
355 Err(e) => {
356 let resp = CellosResponse::Err {
357 error: format!("bad request: {e}"),
358 };
359 let mut w = stream;
360 let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
361 return;
362 }
363 };
364
365 let resp = dispatch_cellos(
366 req, peer_pid, vault_path, password, profile, cell_cache, audit,
367 );
368 let mut w = stream;
369 let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
370}
371
372#[cfg(not(target_os = "windows"))]
373fn dispatch_cellos(
374 req: CellosRequest,
375 peer_pid: u32,
376 vault_path: &std::path::Path,
377 password: &str,
378 profile: &str,
379 cell_cache: &CellCache,
380 audit: &AuditLog,
381) -> CellosResponse {
382 match req {
383 CellosRequest::Resolve {
384 key,
385 cell_id,
386 ttl_seconds: _,
387 cell_token,
388 } => {
389 {
391 let mut cache = lock_cell_cache(cell_cache);
392 match cache.get(&cell_id) {
393 Some(CellState::Revoked) => {
394 return CellosResponse::Err {
395 error: "cell revoked".to_string(),
396 };
397 }
398 Some(CellState::Active(record)) => {
399 if !tsafe_core::crypto::ct_eq_str(&record.token, &cell_token) {
400 return CellosResponse::Err {
401 error: "cell_token mismatch".to_string(),
402 };
403 }
404 }
405 None => {
406 cache.insert(
408 cell_id.clone(),
409 CellState::Active(CellRecord {
410 pid: peer_pid,
411 token: cell_token.clone(),
412 }),
413 );
414 }
415 }
416 }
417
418 let value = match Vault::open_read_only(vault_path, password.as_bytes()) {
420 Ok(v) => match v.get(&key) {
421 Ok(s) => s.to_string(),
422 Err(_) => {
423 return CellosResponse::Err {
424 error: format!("key not found: {key}"),
425 };
426 }
427 },
428 Err(e) => {
429 return CellosResponse::Err {
430 error: format!("vault error: {e}"),
431 };
432 }
433 };
434
435 audit
437 .append(
438 &AuditEntry::success(profile, "cellos-resolve", Some(&key)).with_context(
439 AuditContext::from_cellos(AuditCellosContext {
440 cellos_cell_id: cell_id,
441 cell_token: Some(cell_token),
442 }),
443 ),
444 )
445 .ok();
446
447 CellosResponse::Value { value }
448 }
449
450 CellosRequest::RevokeForCell { cell_id } => {
451 {
452 let mut cache = lock_cell_cache(cell_cache);
453 cache.insert(cell_id.clone(), CellState::Revoked);
454 }
455
456 audit
457 .append(
458 &AuditEntry::success(profile, "cellos-revoke", None).with_context(
459 AuditContext::from_cellos(AuditCellosContext {
460 cellos_cell_id: cell_id,
461 cell_token: None,
462 }),
463 ),
464 )
465 .ok();
466
467 CellosResponse::Ok
468 }
469 }
470}
471
472#[cfg(target_os = "windows")]
475fn serve(
476 pipe: &str,
477 pw: &Password,
478 session: &mut AgentSession,
479 _requesting_pid: u32,
480 deadline: Instant,
481 stop: Arc<AtomicBool>,
482) -> Result<()> {
483 use std::fs::File;
484 use std::os::windows::io::FromRawHandle;
485
486 let pipe_wide: Vec<u16> = pipe.encode_utf16().chain(std::iter::once(0)).collect();
488
489 loop {
490 if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
491 break;
492 }
493 let handle = unsafe { windows_create_named_pipe(&pipe_wide)? };
494
495 let connected = unsafe { windows_connect_with_timeout(handle, 5_000) };
497 if !connected {
498 unsafe { windows_close_handle(handle) };
499 continue;
500 }
501
502 let client_file = unsafe { File::from_raw_handle(handle as _) };
503 let mut reader = BufReader::new(&client_file);
504 let mut writer = &client_file;
505 handle_connection(
506 &mut reader,
507 &mut writer,
508 pw,
509 session,
510 Some(unsafe { windows_get_named_pipe_client_process_id(handle)? }),
511 &stop,
512 )?;
513 }
514
515 Ok(())
516}
517
518pub(crate) fn handle_connection(
519 reader: &mut impl BufRead,
520 writer: &mut impl Write,
521 pw: &Password,
522 session: &mut AgentSession,
523 peer_pid: Option<u32>,
524 stop: &Arc<AtomicBool>,
525) -> Result<()> {
526 let mut line = String::new();
527 if reader.read_line(&mut line).unwrap_or(0) == 0 {
528 return Ok(());
529 }
530
531 let req: AgentRequest = match serde_json::from_str(line.trim()) {
532 Ok(r) => r,
533 Err(e) => {
534 let resp = AgentResponse::Err {
535 reason: format!("bad request: {e}"),
536 };
537 let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
538 return Ok(());
539 }
540 };
541
542 let outcome = session.handle_request(&req, peer_pid, &pw.0, Instant::now());
543 if outcome.stop {
544 stop.store(true, Ordering::Relaxed);
545 }
546 let resp = outcome.response;
547
548 let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
549 Ok(())
550}
551
552pub(crate) fn spawn_expiry_watchdog(pipe: String, deadline: Instant, stop: Arc<AtomicBool>) {
553 std::thread::spawn(move || loop {
554 if stop.load(Ordering::Relaxed) {
555 return;
556 }
557
558 let now = Instant::now();
559 if now >= deadline {
560 stop.store(true, Ordering::Relaxed);
561 wake_listener(&pipe);
562 return;
563 }
564
565 std::thread::sleep((deadline - now).min(Duration::from_millis(200)));
566 });
567}
568
569#[cfg(target_os = "windows")]
570fn wake_listener(pipe: &str) {
571 let _ = windows_connect_pipe_client(pipe);
572}
573
574#[cfg(not(target_os = "windows"))]
575fn wake_listener(pipe: &str) {
576 let _ = std::os::unix::net::UnixStream::connect(pipe);
577}
578
579fn rpassword_read() -> Result<String> {
582 rpassword::prompt_password("Vault password: ").context("failed to read password")
584}
585
586#[cfg(target_os = "windows")]
589mod ffi {
590 use std::ffi::c_void;
591
592 extern "system" {
593 pub fn CreateNamedPipeW(
594 name: *const u16,
595 open_mode: u32,
596 pipe_mode: u32,
597 max_instances: u32,
598 out_buf: u32,
599 in_buf: u32,
600 default_timeout: u32,
601 security: *mut c_void,
602 ) -> *mut c_void;
603 pub fn CreateFileW(
604 name: *const u16,
605 access: u32,
606 share: u32,
607 security: *mut c_void,
608 creation: u32,
609 flags: u32,
610 template: *mut c_void,
611 ) -> *mut c_void;
612 pub fn ConnectNamedPipe(pipe: *mut c_void, overlapped: *mut c_void) -> i32;
613 pub fn CloseHandle(handle: *mut c_void) -> i32;
614 pub fn GetNamedPipeClientProcessId(pipe: *mut c_void, client_process_id: *mut u32) -> i32;
615 #[allow(dead_code)]
616 pub fn WaitForSingleObject(handle: *mut c_void, ms: u32) -> u32;
617 pub fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
621 string_security_descriptor: *const u16,
622 string_sd_revision: u32,
623 security_descriptor: *mut *mut c_void,
624 security_descriptor_size: *mut u32,
625 ) -> i32;
626 pub fn LocalFree(mem: *mut c_void) -> *mut c_void;
627 }
628
629 #[repr(C)]
631 pub struct SecurityAttributes {
632 pub n_length: u32,
633 pub lp_security_descriptor: *mut c_void,
634 pub b_inherit_handle: i32,
635 }
636}
637
638#[cfg(target_os = "windows")]
639unsafe fn windows_create_named_pipe(pipe_wide: &[u16]) -> Result<*mut std::ffi::c_void> {
640 use std::os::windows::ffi::OsStrExt;
641
642 const SDDL_REVISION_1: u32 = 1;
649 let sddl: Vec<u16> = std::ffi::OsStr::new("D:P(A;;GA;;;OW)(A;;GA;;;SY)")
650 .encode_wide()
651 .chain(std::iter::once(0))
652 .collect();
653
654 let mut psd: *mut std::ffi::c_void = std::ptr::null_mut();
655 let ok = ffi::ConvertStringSecurityDescriptorToSecurityDescriptorW(
656 sddl.as_ptr(),
657 SDDL_REVISION_1,
658 &mut psd,
659 std::ptr::null_mut(),
660 );
661 if ok == 0 || psd.is_null() {
662 anyhow::bail!("failed to build named-pipe security descriptor");
663 }
664
665 let mut sa = ffi::SecurityAttributes {
666 n_length: std::mem::size_of::<ffi::SecurityAttributes>() as u32,
667 lp_security_descriptor: psd,
668 b_inherit_handle: 0,
669 };
670
671 let h = ffi::CreateNamedPipeW(
673 pipe_wide.as_ptr(),
674 3, 0x00, 1, 4096,
678 4096,
679 0,
680 &mut sa as *mut _ as *mut std::ffi::c_void,
681 );
682
683 ffi::LocalFree(psd);
685
686 if h as isize == -1 || h.is_null() {
687 anyhow::bail!("CreateNamedPipeW failed");
688 }
689 Ok(h)
690}
691
692#[cfg(target_os = "windows")]
693unsafe fn windows_connect_with_timeout(handle: *mut std::ffi::c_void, _ms: u32) -> bool {
694 ffi::ConnectNamedPipe(handle, std::ptr::null_mut()) != 0
697}
698
699#[cfg(target_os = "windows")]
700unsafe fn windows_close_handle(handle: *mut std::ffi::c_void) {
701 ffi::CloseHandle(handle);
702}
703
704#[cfg(target_os = "windows")]
705unsafe fn windows_get_named_pipe_client_process_id(handle: *mut std::ffi::c_void) -> Result<u32> {
706 let mut pid = 0u32;
707 if ffi::GetNamedPipeClientProcessId(handle, &mut pid) == 0 {
708 anyhow::bail!("GetNamedPipeClientProcessId failed");
709 }
710 Ok(pid)
711}
712
713#[cfg(target_os = "windows")]
714fn windows_connect_pipe_client(pipe: &str) -> Result<std::fs::File> {
715 use std::os::windows::ffi::OsStrExt;
716 use std::os::windows::io::FromRawHandle;
717
718 let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
719 .encode_wide()
720 .chain(std::iter::once(0))
721 .collect();
722
723 let handle = unsafe {
724 ffi::CreateFileW(
725 wide.as_ptr(),
726 0xC000_0000, 0,
728 std::ptr::null_mut(),
729 3, 128, std::ptr::null_mut(),
732 )
733 };
734
735 if handle.is_null() || handle as isize == -1 {
736 anyhow::bail!("CreateFileW failed");
737 }
738
739 Ok(unsafe { std::fs::File::from_raw_handle(handle as _) })
740}
741
742#[cfg(not(target_os = "windows"))]
744fn serve(
745 pipe: &str,
746 pw: &Password,
747 session: &mut AgentSession,
748 _requesting_pid: u32,
749 deadline: Instant,
750 stop: Arc<AtomicBool>,
751) -> Result<()> {
752 use std::os::unix::net::UnixListener;
753
754 let _ = std::fs::remove_file(pipe);
756
757 let listener =
758 UnixListener::bind(pipe).with_context(|| format!("failed to bind Unix socket: {pipe}"))?;
759
760 #[cfg(unix)]
762 {
763 use std::os::unix::fs::PermissionsExt;
764 let _ = std::fs::set_permissions(pipe, std::fs::Permissions::from_mode(0o600));
765 }
766
767 listener.set_nonblocking(true)?;
769
770 let _cleanup = SocketCleanup(pipe.to_string());
771
772 loop {
773 if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
774 break;
775 }
776 match listener.accept() {
777 Ok((stream, _)) => {
778 stream.set_nonblocking(false)?;
779 let mut reader = BufReader::new(&stream);
780 let mut writer = &stream;
781 handle_connection(
782 &mut reader,
783 &mut writer,
784 pw,
785 session,
786 Some(unix_peer_pid(&stream)?),
787 &stop,
788 )?;
789 }
790 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
791 std::thread::sleep(Duration::from_millis(200));
792 }
793 Err(e) => return Err(e.into()),
794 }
795 }
796
797 Ok(())
798}
799
800#[cfg(not(target_os = "windows"))]
802struct SocketCleanup(String);
803
804#[cfg(not(target_os = "windows"))]
805impl Drop for SocketCleanup {
806 fn drop(&mut self) {
807 let _ = std::fs::remove_file(&self.0);
808 }
809}
810
811#[cfg(unix)]
812struct PeerCredential {
813 pid: u32,
814 uid: u32,
815}
816
817#[cfg(target_os = "linux")]
819fn unix_peer_credential(
820 stream: &std::os::unix::net::UnixStream,
821) -> std::io::Result<PeerCredential> {
822 use std::mem::size_of;
823 use std::os::fd::AsRawFd;
824 let fd = stream.as_raw_fd();
825 let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
829 let mut len = size_of::<libc::ucred>() as libc::socklen_t;
830 let rc = unsafe {
838 libc::getsockopt(
839 fd,
840 libc::SOL_SOCKET,
841 libc::SO_PEERCRED,
842 &mut cred as *mut _ as *mut libc::c_void,
843 &mut len,
844 )
845 };
846 if rc != 0 {
847 return Err(std::io::Error::last_os_error());
848 }
849 Ok(PeerCredential {
850 pid: cred.pid as u32,
851 uid: cred.uid,
852 })
853}
854
855#[cfg(target_os = "macos")]
856fn unix_peer_credential(
857 stream: &std::os::unix::net::UnixStream,
858) -> std::io::Result<PeerCredential> {
859 use std::mem::size_of;
860 use std::os::fd::AsRawFd;
861 let fd = stream.as_raw_fd();
862 let mut uid: libc::uid_t = 0;
863 let mut gid: libc::gid_t = 0;
864 let mut pid: libc::pid_t = 0;
865 let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
866 let rc_uid = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
871 let rc_pid = unsafe {
876 libc::getsockopt(
877 fd,
878 libc::SOL_LOCAL,
879 libc::LOCAL_PEERPID,
880 &mut pid as *mut _ as *mut libc::c_void,
881 &mut len,
882 )
883 };
884 if rc_uid != 0 {
885 return Err(std::io::Error::last_os_error());
886 }
887 if rc_pid != 0 {
888 return Err(std::io::Error::last_os_error());
889 }
890 Ok(PeerCredential {
891 pid: pid as u32,
892 uid: uid as u32,
893 })
894}
895
896#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
897fn unix_peer_credential(
898 _stream: &std::os::unix::net::UnixStream,
899) -> std::io::Result<PeerCredential> {
900 Err(std::io::Error::new(
901 std::io::ErrorKind::Unsupported,
902 "peer credentials unsupported on this platform",
903 ))
904}
905
906#[cfg(unix)]
907fn unix_peer_pid(stream: &std::os::unix::net::UnixStream) -> Result<u32> {
908 unix_peer_credential(stream)
909 .map(|c| c.pid)
910 .map_err(Into::into)
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916
917 fn run_request(
918 req: AgentRequest,
919 peer_pid: Option<u32>,
920 absolute_deadline: Instant,
921 ) -> (AgentResponse, bool) {
922 let stop = Arc::new(AtomicBool::new(false));
923 let mut input = std::io::Cursor::new(format!("{}\n", serde_json::to_string(&req).unwrap()));
924 let mut output = Vec::new();
925 let password = Password("secret".to_string());
926 let idle_secs = if absolute_deadline > Instant::now() {
928 (absolute_deadline - Instant::now()).as_secs().max(1)
929 } else {
930 1
931 };
932 let mut session = AgentSession::new("token-123", idle_secs, absolute_deadline);
933
934 handle_connection(
935 &mut input,
936 &mut output,
937 &password,
938 &mut session,
939 peer_pid,
940 &stop,
941 )
942 .unwrap();
943
944 let response: AgentResponse = serde_json::from_slice(&output).unwrap();
945 (response, stop.load(Ordering::Relaxed))
946 }
947
948 #[test]
949 fn open_vault_allows_matching_peer_pid() {
950 let (response, stop) = run_request(
951 AgentRequest::OpenVault {
952 profile: "default".into(),
953 session_token: "token-123".into(),
954 requesting_pid: 4242,
955 },
956 Some(4242),
957 Instant::now() + Duration::from_secs(60),
958 );
959
960 assert!(!stop);
961 match response {
962 AgentResponse::Password { password } => assert_eq!(password, "secret"),
963 other => panic!("expected password response, got {other:?}"),
964 }
965 }
966
967 #[test]
968 fn open_vault_rejects_pid_mismatch() {
969 let (response, stop) = run_request(
970 AgentRequest::OpenVault {
971 profile: "default".into(),
972 session_token: "token-123".into(),
973 requesting_pid: 4242,
974 },
975 Some(9001),
976 Instant::now() + Duration::from_secs(60),
977 );
978
979 assert!(!stop);
980 match response {
981 AgentResponse::Err { reason } => {
982 assert!(reason.contains("does not match the connecting process"));
983 }
984 other => panic!("expected authorization error, got {other:?}"),
985 }
986 }
987
988 #[test]
989 fn expired_session_rejects_requests_and_stops() {
990 let (response, stop) = run_request(
991 AgentRequest::Ping,
992 Some(4242),
993 Instant::now() - Duration::from_secs(1),
994 );
995
996 assert!(stop);
997 match response {
998 AgentResponse::Err { reason } => assert!(
999 reason.contains("agent session expired"),
1000 "unexpected: {reason}"
1001 ),
1002 other => panic!("expected expiry error, got {other:?}"),
1003 }
1004 }
1005
1006 #[test]
1007 fn lock_request_transitions_session_and_stops() {
1008 let (response, stop) = run_request(
1009 AgentRequest::Lock {
1010 session_token: "token-123".into(),
1011 },
1012 Some(4242),
1013 Instant::now() + Duration::from_secs(60),
1014 );
1015
1016 assert!(stop);
1017 assert!(matches!(response, AgentResponse::Ok));
1018 }
1019}