Skip to main content

cossh/auth/
ipc.rs

1//! Local IPC protocol used by the password-vault unlock agent.
2
3use 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)]
34/// Result of trying to bind the agent socket listener.
35pub enum ListenerBindResult {
36    /// Listener successfully bound on this process.
37    Bound(LocalSocketListener),
38    /// Another live agent already owns the endpoint.
39    AlreadyRunning,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43/// Unlock timeout policy sent to the agent.
44pub struct UnlockPolicy {
45    /// Idle timeout after which the vault is re-locked.
46    pub idle_timeout_seconds: u64,
47    /// Absolute unlock lifetime cap.
48    pub session_timeout_seconds: u64,
49}
50
51impl UnlockPolicy {
52    /// Build a new unlock policy.
53    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)]
62/// Current vault state reported by the agent.
63pub struct VaultStatus {
64    /// Whether the vault metadata exists on disk.
65    pub vault_exists: bool,
66    /// Whether the vault is currently unlocked in the agent.
67    pub unlocked: bool,
68    /// Remaining unlock time in seconds, if unlocked.
69    pub unlock_expires_in_seconds: Option<u64>,
70    /// Effective idle timeout for the current session.
71    pub idle_timeout_seconds: Option<u64>,
72    /// Effective absolute timeout for the current session.
73    pub absolute_timeout_seconds: Option<u64>,
74    /// Absolute timeout wall-clock epoch, if available.
75    pub absolute_timeout_at_epoch_seconds: Option<u64>,
76}
77
78impl VaultStatus {
79    /// Build a locked-status snapshot.
80    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")]
94/// Emitted vault status transition kind.
95pub enum VaultStatusEventKind {
96    /// Vault transitioned to locked.
97    Locked,
98    /// Vault transitioned to unlocked.
99    Unlocked,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103/// Broadcast event stored in the status event file.
104pub struct VaultStatusEvent {
105    /// Transition kind.
106    pub kind: VaultStatusEventKind,
107    /// Vault status snapshot at event time.
108    pub status: VaultStatus,
109    /// Monotonic-ish event id derived from timestamp nanos.
110    pub event_id: u128,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(tag = "type", rename_all = "snake_case")]
115/// Request payload sent from clients to the unlock agent.
116pub 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    /// Stable debug label for logging request flow.
138    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)]
151/// Top-level client request wrapper.
152pub struct AgentRequest {
153    /// Request payload.
154    pub payload: AgentRequestPayload,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(tag = "type", rename_all = "snake_case")]
159/// Response envelope returned by the unlock agent.
160pub 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    /// Borrow the status snapshot included in any response variant.
193    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
210/// Bind the unlock-agent local socket listener.
211pub 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
221/// Send one request and wait for one response.
222pub 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
230/// Connect directly to the current agent endpoint.
231pub 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
239/// Remove endpoint resources used by the unlock agent.
240pub 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
246/// Persist a vault status event for local consumers.
247pub 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
264/// Read the latest persisted vault status event.
265pub 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
271/// Read one IPC request from a connected stream.
272pub fn read_request(stream: &mut LocalSocketStream) -> io::Result<AgentRequest> {
273    read_json_line(stream)
274}
275
276/// Write one IPC response to a connected stream.
277pub 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;