Skip to main content

cossh/auth/agent/
client.rs

1use super::error::{AgentError, map_remote_error};
2use crate::auth::ipc::{self, AgentRequestPayload, AgentResponse, UnlockPolicy, VaultStatus};
3use crate::auth::secret::{SensitiveString, sensitive_string};
4use crate::auth::vault::VaultPaths;
5use crate::command_path;
6use crate::log_debug;
7use std::process::{Command, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10
11const AGENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
12const AGENT_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(10);
13
14/// Result of checking whether a vault entry is available for use.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AgentEntryStatus {
17    /// Current vault lock/unlock status.
18    pub status: VaultStatus,
19    /// Whether the queried entry name exists in the vault.
20    pub exists: bool,
21}
22
23/// Client used by runtime command paths to communicate with the unlock agent.
24#[derive(Debug, Clone)]
25pub struct AgentClient {
26    paths: VaultPaths,
27}
28
29impl AgentClient {
30    /// Create a client bound to the default `~/.color-ssh` runtime paths.
31    pub fn new() -> Result<Self, AgentError> {
32        let paths = VaultPaths::resolve_default()?;
33        log_debug!("Initialized password vault client for '{}'", paths.base_dir().display());
34        Ok(Self { paths })
35    }
36
37    /// Query current vault status.
38    pub fn status(&self) -> Result<VaultStatus, AgentError> {
39        log_debug!("Requesting password vault status");
40        match self.request(AgentRequestPayload::Status, false) {
41            Ok(AgentResponse::Status { status }) => Ok(status),
42            Ok(response) => Err(AgentError::Protocol(format!("unexpected status response: {response:?}"))),
43            Err(AgentError::Io(_)) => Ok(VaultStatus::locked(self.paths.metadata_path().is_file())),
44            Err(err) => Err(err),
45        }
46    }
47
48    /// Unlock the vault using a master password and timeout policy.
49    pub fn unlock(&self, master_password: &str, policy: UnlockPolicy) -> Result<VaultStatus, AgentError> {
50        log_debug!(
51            "Requesting password vault unlock with idle={}s absolute={}s",
52            policy.idle_timeout_seconds,
53            policy.session_timeout_seconds
54        );
55        match self.request(
56            AgentRequestPayload::Unlock {
57                master_password: sensitive_string(master_password),
58                policy,
59            },
60            true,
61        )? {
62            AgentResponse::Success { status, .. } | AgentResponse::Status { status } => Ok(status),
63            AgentResponse::Error { code, message, .. } => Err(map_remote_error(&code, message)),
64            response => Err(AgentError::Protocol(format!("unexpected unlock response: {response:?}"))),
65        }
66    }
67
68    /// Query whether a vault entry exists and whether the vault is unlocked.
69    pub fn entry_status(&self, name: &str) -> Result<AgentEntryStatus, AgentError> {
70        log_debug!("Requesting password vault entry status '{}'", name);
71        match self.request(AgentRequestPayload::EntryStatus { name: name.to_string() }, true)? {
72            AgentResponse::EntryStatus { status, exists, .. } => Ok(AgentEntryStatus { status, exists }),
73            AgentResponse::Error { code, message, .. } => Err(map_remote_error(&code, message)),
74            response => Err(AgentError::Protocol(format!("unexpected entry-status response: {response:?}"))),
75        }
76    }
77
78    /// Authorize one short-lived askpass token for the named vault entry.
79    pub fn authorize_askpass(&self, name: &str) -> Result<SensitiveString, AgentError> {
80        log_debug!("Requesting internal askpass authorization for '{}'", name);
81        match self.request(AgentRequestPayload::AuthorizeAskpass { name: name.to_string() }, true)? {
82            AgentResponse::AskpassAuthorized { token, .. } => Ok(token),
83            AgentResponse::Error { code, message, .. } => Err(map_remote_error(&code, message)),
84            response => Err(AgentError::Protocol(format!("unexpected askpass authorization response: {response:?}"))),
85        }
86    }
87
88    /// Resolve a secret by askpass token.
89    pub fn get_secret(&self, token: &str) -> Result<SensitiveString, AgentError> {
90        log_debug!("Requesting password vault secret using askpass token");
91        match self.request(
92            AgentRequestPayload::GetSecret {
93                token: sensitive_string(token),
94            },
95            true,
96        )? {
97            AgentResponse::Secret { secret, .. } => Ok(secret),
98            AgentResponse::Error { code, message, .. } => Err(map_remote_error(&code, message)),
99            response => Err(AgentError::Protocol(format!("unexpected get-secret response: {response:?}"))),
100        }
101    }
102
103    /// Request an explicit vault lock and agent shutdown.
104    pub fn lock(&self) -> Result<VaultStatus, AgentError> {
105        log_debug!("Requesting password vault lock");
106        match self.request(AgentRequestPayload::Lock, false)? {
107            AgentResponse::Success { status, .. } | AgentResponse::Status { status } => Ok(status),
108            AgentResponse::Error { code, message, .. } => Err(map_remote_error(&code, message)),
109            response => Err(AgentError::Protocol(format!("unexpected lock response: {response:?}"))),
110        }
111    }
112
113    fn request(&self, payload: AgentRequestPayload, auto_start: bool) -> Result<AgentResponse, AgentError> {
114        let payload_name = payload.debug_name();
115        log_debug!("Sending password vault agent request '{}' (auto_start={})", payload_name, auto_start);
116        match ipc::send_request(&self.paths, &payload) {
117            Ok(response) => Ok(response),
118            Err(first_err) if auto_start => {
119                log_debug!(
120                    "Password vault agent request '{}' failed initially ({}); attempting auto-start",
121                    payload_name,
122                    first_err
123                );
124                self.spawn_server()?;
125                ipc::send_request(&self.paths, &payload)
126                    .map_err(|second_err| AgentError::Protocol(format!("failed to contact password vault agent after restart: {first_err}; {second_err}")))
127            }
128            Err(err) => Err(AgentError::Io(err)),
129        }
130    }
131
132    fn spawn_server(&self) -> Result<(), AgentError> {
133        log_debug!("Starting password vault agent process");
134        let cossh_path = command_path::cossh_path()?;
135        let mut command = Command::new(cossh_path);
136        command
137            .arg("agent")
138            .arg("--serve")
139            .stdin(Stdio::null())
140            .stdout(Stdio::null())
141            .stderr(Stdio::null())
142            .env_remove(crate::auth::transport::INTERNAL_ASKPASS_MODE_ENV)
143            .env_remove(crate::auth::transport::INTERNAL_ASKPASS_TOKEN_ENV)
144            .env_remove("SSH_ASKPASS")
145            .env_remove("SSH_ASKPASS_REQUIRE");
146        command.spawn()?;
147
148        // Poll readiness briefly so first caller does not race the agent boot.
149        let started_at = Instant::now();
150        while started_at.elapsed() < AGENT_STARTUP_TIMEOUT {
151            if ipc::send_request(&self.paths, &AgentRequestPayload::Status).is_ok() {
152                log_debug!("Password vault agent became ready in {:?}", started_at.elapsed());
153                return Ok(());
154            }
155            thread::sleep(AGENT_STARTUP_POLL_INTERVAL);
156        }
157
158        Err(AgentError::Protocol("password vault agent did not become ready in time".to_string()))
159    }
160}