use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use zeroize::Zeroizing;
use secrets_vault::{is_valid_key, is_valid_project, MasterSecret, VaultError, VaultReader};
use crate::registry;
#[cfg(unix)]
pub fn socket_path(secrets_dir: &Path) -> PathBuf {
secrets_dir.join("session.sock")
}
#[cfg(windows)]
pub fn socket_path(secrets_dir: &Path) -> PathBuf {
PathBuf::from(crate::session_win::pipe_name(secrets_dir))
}
fn log_path(secrets_dir: &Path) -> PathBuf {
secrets_dir.join("session.log")
}
pub enum Probe {
Live,
Absent,
Foreign(String),
}
#[cfg(unix)]
pub fn probe(secrets_dir: &Path) -> Probe {
if socket_path(secrets_dir).exists() {
Probe::Live
} else {
Probe::Absent
}
}
#[cfg(windows)]
pub fn probe(secrets_dir: &Path) -> Probe {
crate::session_win::probe(secrets_dir)
}
pub(crate) struct Peer {
pub pid: i32,
pub same_user: bool,
pub detail: String,
}
pub(crate) fn audit(secrets_dir: &Path, line: &str) {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = log_path(secrets_dir);
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&path)
};
#[cfg(not(unix))]
let file = std::fs::OpenOptions::new().create(true).append(true).open(&path);
if let Ok(mut f) = file {
use std::io::Write as _;
let _ = writeln!(f, "{ts} {line}");
}
}
pub(crate) enum Verdict {
Ok(Zeroizing<Vec<u8>>),
Denied,
UnknownKey,
Scheme,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_get(
secrets_dir: &Path,
vault_path: &Path,
master: &MasterSecret,
registry_key: &[u8; 32],
peer: Option<Peer>,
project: &str,
key: &str,
) -> Verdict {
if !is_valid_project(project) || !is_valid_key(key) {
return Verdict::Denied;
}
let Some(peer) = peer else {
return Verdict::Denied;
};
if !peer.same_user {
return Verdict::Denied;
}
let Some(agent) = registry::resolve_agent_from(peer.pid) else {
audit(
secrets_dir,
&format!(
"DENY pid={} {} (no agent) {project}/{key}",
peer.pid, peer.detail
),
);
return Verdict::Denied;
};
let reg = match registry::Registry::load_raw(secrets_dir, registry_key) {
Ok(r) => r,
Err(_) => return Verdict::Denied,
};
if reg.grant_for(&agent, project, registry::now()).is_none() {
audit(secrets_dir, &format!("DENY agent={agent} (no grant) {project}/{key}"));
return Verdict::Denied;
}
if let Some(meta) = reg.projects.get(project) {
if !meta.keys.is_empty() && !meta.keys.iter().any(|k| k == key) {
audit(
secrets_dir,
&format!("DENY agent={agent} (outside manifest) {project}/{key}"),
);
return Verdict::Denied;
}
}
let data = match std::fs::read(vault_path) {
Ok(d) => d,
Err(_) => return Verdict::Denied,
};
let reader = match VaultReader::open(data, master) {
Ok(r) => r,
Err(_) => return Verdict::Denied,
};
let storage = format!("{project}/{key}");
let looked_up = match reader.decrypt_one(master, &storage) {
Err(VaultError::NotFound) => reader.decrypt_one(master, key),
other => other,
};
match looked_up {
Ok(value) => {
audit(secrets_dir, &format!("SERVE agent={agent} {project}/{key}"));
Verdict::Ok(value)
}
Err(VaultError::NotFound) => {
audit(secrets_dir, &format!("UNKNOWN agent={agent} {project}/{key}"));
Verdict::UnknownKey
}
Err(VaultError::UnknownScheme(_)) => Verdict::Scheme,
Err(_) => Verdict::Denied,
}
}
pub(crate) enum Request<'a> {
Get { project: &'a str, key: &'a str },
End,
LegacyGet,
Malformed,
}
pub(crate) fn parse_request(line: &str) -> Request<'_> {
let mut parts = line.split_whitespace();
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some("END"), None, None, None) => Request::End,
(Some("GET"), Some(project), Some(key), None) => Request::Get { project, key },
(Some("GET"), None, None, None) => Request::LegacyGet,
_ => Request::Malformed,
}
}
pub(crate) fn encode_verdict(v: Verdict) -> Zeroizing<Vec<u8>> {
match v {
Verdict::Ok(value) => {
let mut out = Zeroizing::new(Vec::with_capacity(16 + value.len()));
out.extend_from_slice(format!("OK {}\n", value.len()).as_bytes());
out.extend_from_slice(&value);
out
}
Verdict::Denied => Zeroizing::new(b"ERR denied\n".to_vec()),
Verdict::UnknownKey => Zeroizing::new(b"ERR unknown-key\n".to_vec()),
Verdict::Scheme => Zeroizing::new(b"ERR scheme\n".to_vec()),
}
}
pub(crate) fn decode_response(buf: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
let nl = buf.iter().position(|&b| b == b'\n')?;
let header = std::str::from_utf8(&buf[..nl]).ok()?;
let len: usize = header.strip_prefix("OK ")?.parse().ok()?;
let body = &buf[nl + 1..];
if body.len() != len {
return None;
}
Some(Zeroizing::new(body.to_vec()))
}
#[cfg(unix)]
mod imp {
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
use secrets_vault::{v2_salt, MasterSecret, VaultReader};
use super::{
audit, decode_response, encode_verdict, handle_get, parse_request, socket_path, Peer,
Request,
};
pub fn request_value(
secrets_dir: &Path,
project: &str,
key: &str,
) -> Option<Zeroizing<Vec<u8>>> {
let path = socket_path(secrets_dir);
let mut stream = UnixStream::connect(&path).ok()?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?;
stream
.write_all(format!("GET {project} {key}\n").as_bytes())
.ok()?;
let mut buf = Zeroizing::new(Vec::new());
stream.read_to_end(&mut buf).ok()?;
decode_response(&buf)
}
pub fn end(secrets_dir: &Path) {
let path = socket_path(secrets_dir);
if let Ok(mut s) = UnixStream::connect(&path) {
let _ = s.write_all(b"END\n");
}
let _ = std::fs::remove_file(&path);
}
#[cfg(target_os = "macos")]
fn peer_identity(stream: &UnixStream) -> Option<Peer> {
use std::os::unix::io::AsRawFd;
#[repr(C)]
#[derive(Clone, Copy)]
struct AuditToken {
val: [u32; 8],
}
const SOL_LOCAL: libc::c_int = 0; const LOCAL_PEERTOKEN: libc::c_int = 0x006;
#[link(name = "bsm")]
unsafe extern "C" {
fn audit_token_to_pid(t: AuditToken) -> libc::pid_t;
fn audit_token_to_pidversion(t: AuditToken) -> libc::c_int;
fn audit_token_to_euid(t: AuditToken) -> libc::uid_t;
}
let mut token = AuditToken { val: [0; 8] };
let mut len = std::mem::size_of::<AuditToken>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
SOL_LOCAL,
LOCAL_PEERTOKEN,
&mut token as *mut _ as *mut libc::c_void,
&mut len,
)
};
if rc != 0 || len as usize != std::mem::size_of::<AuditToken>() {
return None;
}
unsafe {
let pid = audit_token_to_pid(token);
let pidversion = audit_token_to_pidversion(token);
let euid = audit_token_to_euid(token);
Some(Peer {
pid,
same_user: euid == libc::geteuid(),
detail: format!("pidv={pidversion}"),
})
}
}
#[cfg(not(target_os = "macos"))]
fn peer_identity(_stream: &UnixStream) -> Option<Peer> {
None
}
pub fn serve(secrets_dir: &Path, minutes: u64, pass: Zeroizing<String>) -> Result<(), String> {
let vault_path = secrets_dir.join("vault.qvlt");
let data = std::fs::read(&vault_path).map_err(|e| format!("read vault: {e}"))?;
let salt = v2_salt(&data).map_err(|e| format!("not a v2 vault: {e}"))?;
let master = MasterSecret::derive(&pass, &salt);
drop(pass); let registry_key_z = master.registry_key();
let registry_key: &[u8; 32] = ®istry_key_z;
VaultReader::open(data, &master).map_err(|e| format!("vault open: {e}"))?;
let path = socket_path(secrets_dir);
let _ = std::fs::remove_file(&path);
let listener =
UnixListener::bind(&path).map_err(|e| format!("bind {}: {e}", path.display()))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("chmod socket: {e}"))?;
audit(secrets_dir, &format!("START lifetime={minutes}m"));
let deadline = Instant::now() + Duration::from_secs(minutes.saturating_mul(60));
listener
.set_nonblocking(true)
.map_err(|e| format!("nonblocking: {e}"))?;
'outer: loop {
if Instant::now() >= deadline {
break;
}
match listener.accept() {
Ok((mut stream, _)) => {
let peer = peer_identity(&stream);
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut req = Vec::with_capacity(64);
let mut byte = [0u8; 1];
while req.len() < 1024 {
match stream.read(&mut byte) {
Ok(1) if byte[0] == b'\n' => break,
Ok(1) => req.push(byte[0]),
_ => break,
}
}
let line = String::from_utf8_lossy(&req);
match parse_request(&line) {
Request::End => {
audit(secrets_dir, "END (requested)");
break 'outer;
}
Request::Get { project, key } => {
let verdict = handle_get(
secrets_dir,
&vault_path,
&master,
registry_key,
peer,
project,
key,
);
let _ = stream.write_all(&encode_verdict(verdict));
}
Request::LegacyGet => {
audit(secrets_dir, "LEGACY-GET (empty close)");
}
Request::Malformed => {
let _ = stream.write_all(b"ERR denied\n");
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(200));
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
}
}
audit(secrets_dir, "STOP");
let _ = std::fs::remove_file(&path);
Ok(())
}
pub fn detach() {
unsafe {
libc::setsid();
}
}
}
#[cfg(windows)]
mod imp {
pub use crate::session_win::{detach, end, request_value, serve};
}
pub use imp::{detach, end, request_value, serve};