1use crate::auth::secret::{SensitiveString, serde_sensitive_string};
4use crate::auth::vault::VaultPaths;
5use crate::log_debug;
6use interprocess::local_socket::{GenericFilePath, ToFsName};
7use interprocess::local_socket::{Listener as LocalSocketListener, ListenerNonblockingMode, ListenerOptions, Stream as LocalSocketStream, prelude::*};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::io::{self, BufRead, BufReader, Read, Write};
11use std::os::unix::fs::{FileTypeExt, PermissionsExt};
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14use zeroize::Zeroizing;
15
16const AGENT_ENDPOINT_PREFIX: &str = "cossh-agent-v2-";
17const LEGACY_AGENT_STATE_FILENAME: &str = "agent-state.json";
18const VAULT_STATUS_EVENT_FILENAME: &str = "vault-events";
19const UNIX_SOCKET_MODE: u32 = 0o600;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) struct AgentEndpoint {
23 identifier: String,
24 socket_path: PathBuf,
25}
26
27impl AgentEndpoint {
28 fn debug_label(&self) -> &str {
29 &self.identifier
30 }
31}
32
33#[derive(Debug)]
34pub enum ListenerBindResult {
36 Bound(LocalSocketListener),
38 AlreadyRunning,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct UnlockPolicy {
45 pub idle_timeout_seconds: u64,
47 pub session_timeout_seconds: u64,
49}
50
51impl UnlockPolicy {
52 pub fn new(idle_timeout_seconds: u64, session_timeout_seconds: u64) -> Self {
54 Self {
55 idle_timeout_seconds,
56 session_timeout_seconds,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct VaultStatus {
64 pub vault_exists: bool,
66 pub unlocked: bool,
68 pub unlock_expires_in_seconds: Option<u64>,
70 pub idle_timeout_seconds: Option<u64>,
72 pub absolute_timeout_seconds: Option<u64>,
74 pub absolute_timeout_at_epoch_seconds: Option<u64>,
76}
77
78impl VaultStatus {
79 pub fn locked(vault_exists: bool) -> Self {
81 Self {
82 vault_exists,
83 unlocked: false,
84 unlock_expires_in_seconds: None,
85 idle_timeout_seconds: None,
86 absolute_timeout_seconds: None,
87 absolute_timeout_at_epoch_seconds: None,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(rename_all = "snake_case")]
94pub enum VaultStatusEventKind {
96 Locked,
98 Unlocked,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct VaultStatusEvent {
105 pub kind: VaultStatusEventKind,
107 pub status: VaultStatus,
109 pub event_id: u128,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(tag = "type", rename_all = "snake_case")]
115pub enum AgentRequestPayload {
117 Status,
118 Unlock {
119 #[serde(with = "serde_sensitive_string")]
120 master_password: SensitiveString,
121 policy: UnlockPolicy,
122 },
123 AuthorizeAskpass {
124 name: String,
125 },
126 EntryStatus {
127 name: String,
128 },
129 GetSecret {
130 #[serde(with = "serde_sensitive_string")]
131 token: SensitiveString,
132 },
133 Lock,
134}
135
136impl AgentRequestPayload {
137 pub fn debug_name(&self) -> &'static str {
139 match self {
140 Self::Status => "status",
141 Self::Unlock { .. } => "unlock",
142 Self::AuthorizeAskpass { .. } => "authorize_askpass",
143 Self::EntryStatus { .. } => "entry_status",
144 Self::GetSecret { .. } => "get_secret",
145 Self::Lock => "lock",
146 }
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub struct AgentRequest {
153 pub payload: AgentRequestPayload,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(tag = "type", rename_all = "snake_case")]
159pub enum AgentResponse {
161 Status {
162 status: VaultStatus,
163 },
164 EntryStatus {
165 status: VaultStatus,
166 name: String,
167 exists: bool,
168 },
169 AskpassAuthorized {
170 status: VaultStatus,
171 #[serde(with = "serde_sensitive_string")]
172 token: SensitiveString,
173 },
174 Secret {
175 status: VaultStatus,
176 name: String,
177 #[serde(with = "serde_sensitive_string")]
178 secret: SensitiveString,
179 },
180 Success {
181 status: VaultStatus,
182 message: String,
183 },
184 Error {
185 status: VaultStatus,
186 code: String,
187 message: String,
188 },
189}
190
191impl AgentResponse {
192 pub fn status(&self) -> &VaultStatus {
194 match self {
195 Self::Status { status }
196 | Self::EntryStatus { status, .. }
197 | Self::AskpassAuthorized { status, .. }
198 | Self::Secret { status, .. }
199 | Self::Success { status, .. }
200 | Self::Error { status, .. } => status,
201 }
202 }
203}
204
205#[derive(Serialize)]
206struct AgentRequestRef<'a> {
207 payload: &'a AgentRequestPayload,
208}
209
210pub fn bind_listener(paths: &VaultPaths) -> io::Result<ListenerBindResult> {
212 remove_legacy_state_file(paths);
213 log_debug!("Binding password vault agent endpoint");
214 match create_listener(paths) {
215 Ok(listener) => Ok(ListenerBindResult::Bound(listener)),
216 Err(err) if is_address_in_use(&err) => handle_bind_conflict(paths, err),
217 Err(err) => Err(err),
218 }
219}
220
221pub fn send_request(paths: &VaultPaths, payload: &AgentRequestPayload) -> io::Result<AgentResponse> {
223 log_debug!("Opening IPC request '{}' to password vault agent", payload.debug_name());
224 let mut stream = connect(paths)?;
225 let request = AgentRequestRef { payload };
226 write_json_line(&mut stream, &request)?;
227 read_json_line(&mut stream)
228}
229
230pub fn connect(paths: &VaultPaths) -> io::Result<LocalSocketStream> {
232 let endpoint = agent_endpoint(paths);
233 log_debug!("Connecting to password vault agent endpoint '{}'", endpoint.debug_label());
234 let stream = connect_to_endpoint(&endpoint)?;
235 remove_legacy_state_file(paths);
236 Ok(stream)
237}
238
239pub fn cleanup_endpoint(paths: &VaultPaths) -> io::Result<()> {
241 log_debug!("Cleaning password vault agent endpoint resources");
242 remove_legacy_state_file(paths);
243 cleanup_local_endpoint(paths)
244}
245
246pub fn broadcast_vault_status_event(paths: &VaultPaths, kind: VaultStatusEventKind, status: VaultStatus) -> io::Result<()> {
248 let run_dir = paths.run_dir();
249 fs::create_dir_all(&run_dir)?;
250 set_restrictive_directory_permissions(&run_dir)?;
251
252 let event_id = SystemTime::now()
253 .duration_since(UNIX_EPOCH)
254 .map_err(|err| io::Error::other(format!("failed to derive vault status event timestamp: {err}")))?
255 .as_nanos();
256 let path = vault_status_event_file_path(paths);
257 let event = VaultStatusEvent { kind, status, event_id };
258 let bytes = serde_json::to_vec(&event).map_err(|err| io::Error::other(format!("failed to serialize vault status event: {err}")))?;
259 fs::write(&path, bytes)?;
260 set_restrictive_file_permissions(&path)?;
261 Ok(())
262}
263
264pub fn read_vault_status_event(paths: &VaultPaths) -> io::Result<VaultStatusEvent> {
266 let path = vault_status_event_file_path(paths);
267 let bytes = fs::read(path)?;
268 serde_json::from_slice(&bytes).map_err(|err| io::Error::other(format!("failed to parse vault status event: {err}")))
269}
270
271pub fn read_request(stream: &mut LocalSocketStream) -> io::Result<AgentRequest> {
273 read_json_line(stream)
274}
275
276pub fn write_response(stream: &mut LocalSocketStream, response: &AgentResponse) -> io::Result<()> {
278 write_json_line(stream, response)
279}
280
281fn is_address_in_use(err: &io::Error) -> bool {
282 matches!(err.kind(), io::ErrorKind::AddrInUse | io::ErrorKind::AlreadyExists)
283}
284
285fn handle_bind_conflict(paths: &VaultPaths, original_err: io::Error) -> io::Result<ListenerBindResult> {
286 if connect(paths).is_ok() {
287 log_debug!("Password vault agent endpoint already has a live server");
288 return Ok(ListenerBindResult::AlreadyRunning);
289 }
290
291 if remove_stale_socket_file(paths)? {
292 log_debug!("Removed stale password vault agent socket file; retrying bind");
293 return match create_listener(paths) {
294 Ok(listener) => Ok(ListenerBindResult::Bound(listener)),
295 Err(err) if is_address_in_use(&err) && connect(paths).is_ok() => Ok(ListenerBindResult::AlreadyRunning),
296 Err(err) => Err(err),
297 };
298 }
299
300 Err(original_err)
301}
302
303fn create_listener(paths: &VaultPaths) -> io::Result<LocalSocketListener> {
304 let endpoint = agent_endpoint(paths);
305 create_listener_for_endpoint(paths, &endpoint)
306}
307
308fn write_json_line<T: Serialize, W: Write>(stream: &mut W, value: &T) -> io::Result<()> {
309 let mut bytes = Zeroizing::new(serde_json::to_vec(value).map_err(|err| io::Error::other(format!("failed to serialize IPC message: {err}")))?);
310 bytes.push(b'\n');
311 stream.write_all(&bytes)?;
312 stream.flush()
313}
314
315fn read_json_line<T: for<'de> Deserialize<'de>, R: Read>(stream: &mut R) -> io::Result<T> {
316 let mut reader = BufReader::new(stream);
317 let mut line = Zeroizing::new(Vec::new());
318 reader.read_until(b'\n', &mut line)?;
319 serde_json::from_slice(&line).map_err(|err| io::Error::other(format!("failed to parse IPC message: {err}")))
320}
321
322fn agent_endpoint(paths: &VaultPaths) -> AgentEndpoint {
323 let identifier = format!("{AGENT_ENDPOINT_PREFIX}{:016x}", fnv1a_64(endpoint_seed(paths).as_bytes()));
324 AgentEndpoint {
325 socket_path: paths.run_dir().join(format!("{identifier}.sock")),
326 identifier,
327 }
328}
329
330pub(crate) fn vault_status_event_file_path(paths: &VaultPaths) -> PathBuf {
331 paths.run_dir().join(VAULT_STATUS_EVENT_FILENAME)
332}
333
334fn endpoint_seed(paths: &VaultPaths) -> String {
335 canonical_base_dir(paths)
336 .unwrap_or_else(|| absolute_base_dir(paths.base_dir()))
337 .to_string_lossy()
338 .into_owned()
339}
340
341fn canonical_base_dir(paths: &VaultPaths) -> Option<PathBuf> {
342 fs::canonicalize(paths.base_dir()).ok()
343}
344
345fn absolute_base_dir(base_dir: &Path) -> PathBuf {
346 if base_dir.is_absolute() {
347 return base_dir.to_path_buf();
348 }
349
350 match std::env::current_dir() {
351 Ok(current_dir) => current_dir.join(base_dir),
352 Err(_) => base_dir.to_path_buf(),
353 }
354}
355
356fn fnv1a_64(bytes: &[u8]) -> u64 {
357 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
358 const FNV_PRIME: u64 = 0x100000001b3;
359
360 let mut hash = FNV_OFFSET;
361 for byte in bytes {
362 hash ^= u64::from(*byte);
363 hash = hash.wrapping_mul(FNV_PRIME);
364 }
365 hash
366}
367
368fn remove_legacy_state_file(paths: &VaultPaths) {
369 let path = legacy_state_file_path(paths);
370 if path.exists() {
371 log_debug!("Removing obsolete password vault agent state file '{}'", path.display());
372 let _ = fs::remove_file(path);
373 }
374}
375
376fn legacy_state_file_path(paths: &VaultPaths) -> PathBuf {
377 paths.run_dir().join(LEGACY_AGENT_STATE_FILENAME)
378}
379
380fn create_listener_for_endpoint(paths: &VaultPaths, endpoint: &AgentEndpoint) -> io::Result<LocalSocketListener> {
381 fs::create_dir_all(paths.run_dir())?;
382 set_restrictive_directory_permissions(&paths.run_dir())?;
383 let name = endpoint.socket_path.as_os_str().to_fs_name::<GenericFilePath>()?;
384 let listener = ListenerOptions::new().name(name).nonblocking(ListenerNonblockingMode::Accept).create_sync()?;
385 set_restrictive_file_permissions(&endpoint.socket_path)?;
386 Ok(listener)
387}
388
389fn connect_to_endpoint(endpoint: &AgentEndpoint) -> io::Result<LocalSocketStream> {
390 let name = endpoint.socket_path.as_os_str().to_fs_name::<GenericFilePath>()?;
391 LocalSocketStream::connect(name)
392}
393
394fn remove_stale_socket_file(paths: &VaultPaths) -> io::Result<bool> {
395 let endpoint = agent_endpoint(paths);
396 let socket_path = endpoint.socket_path;
397 if !socket_path.exists() {
398 return Ok(false);
399 }
400
401 let metadata = fs::symlink_metadata(&socket_path)?;
402 if metadata.file_type().is_socket() {
403 fs::remove_file(socket_path)?;
404 return Ok(true);
405 }
406
407 Ok(false)
408}
409
410fn cleanup_local_endpoint(paths: &VaultPaths) -> io::Result<()> {
411 let endpoint = agent_endpoint(paths);
412 if !endpoint.socket_path.exists() {
413 return Ok(());
414 }
415
416 let metadata = fs::symlink_metadata(&endpoint.socket_path)?;
417 if metadata.file_type().is_socket() {
418 fs::remove_file(endpoint.socket_path)?;
419 }
420
421 Ok(())
422}
423
424fn set_restrictive_directory_permissions(path: &Path) -> io::Result<()> {
425 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
426 Ok(())
427}
428
429fn set_restrictive_file_permissions(path: &Path) -> io::Result<()> {
430 fs::set_permissions(path, fs::Permissions::from_mode(UNIX_SOCKET_MODE))?;
431 Ok(())
432}
433
434#[cfg(test)]
435#[path = "../test/auth/ipc.rs"]
436mod tests;