gitway_lib/agent/client.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Blocking SSH-agent client.
4//!
5//! Wraps [`ssh_agent_lib::blocking::Client`] with a Gitway-native error
6//! surface and a small convenience API: `connect`, `add`, `list`, `remove`,
7//! `remove_all`, `lock`, `unlock`.
8//!
9//! The blocking API is chosen deliberately — an `ssh-add`-style binary has
10//! no use for async concurrency, and avoiding tokio here keeps the
11//! dependency graph small.
12//!
13//! # Cross-platform transport
14//!
15//! On Unix the client connects to the Unix domain socket at
16//! `$SSH_AUTH_SOCK` via [`std::os::unix::net::UnixStream`]. On Windows
17//! the same env var conventionally carries a named-pipe path (OpenSSH
18//! for Windows uses `\\.\pipe\openssh-ssh-agent`); we open that with
19//! [`std::fs::OpenOptions::read(true).write(true).open(path)`], which
20//! gives us a `Read + Write` handle that drives `ssh_agent_lib`'s
21//! transport exactly the same way.
22//!
23//! # Examples
24//!
25//! ```no_run
26//! use std::path::Path;
27//! use gitway_lib::agent::client::Agent;
28//!
29//! let mut agent = Agent::from_env()?;
30//! agent.list()?.iter().for_each(|id| println!("{}", id.fingerprint));
31//! # Ok::<(), gitway_lib::GitwayError>(())
32//! ```
33//!
34//! # Errors
35//!
36//! Every operation returns [`GitwayError`]. Agent-protocol failures and
37//! I/O failures are both folded into the `Io` variant with a descriptive
38//! message; callers that care can match via [`GitwayError::is_io`].
39//!
40//! # Zeroization
41//!
42//! `ssh-agent-lib` 0.5.2's `lock` / `unlock` take a plain `String` by
43//! value, so the passphrase copy inside the library cannot be cleared on
44//! our behalf. Callers supply a [`Zeroizing<String>`] and this module
45//! clones only the byte contents into the library's expected `String`
46//! argument; the caller's original buffer remains zeroizable.
47
48use std::env;
49use std::path::PathBuf;
50use std::time::Duration;
51
52use ssh_agent_lib::blocking::Client;
53use ssh_agent_lib::proto::{
54 AddIdentity, AddIdentityConstrained, Credential, KeyConstraint, RemoveIdentity,
55};
56use ssh_key::{HashAlg, PrivateKey, PublicKey};
57use zeroize::Zeroizing;
58
59use crate::GitwayError;
60
61// ── Transport abstraction ─────────────────────────────────────────────────────
62//
63// The blocking wire protocol only needs `Read + Write`. On Unix that is
64// a stream socket; on Windows it is a file handle opened against the
65// named pipe. Both are `Sized` + `Debug`, which is all `Client<S>` asks
66// of its inner stream.
67
68/// Underlying byte stream to the agent.
69#[cfg(unix)]
70type Transport = std::os::unix::net::UnixStream;
71#[cfg(windows)]
72type Transport = std::fs::File;
73
74fn open_transport(path: &std::path::Path) -> std::io::Result<Transport> {
75 #[cfg(unix)]
76 {
77 std::os::unix::net::UnixStream::connect(path)
78 }
79 #[cfg(windows)]
80 {
81 // `\\.\pipe\<name>` — OpenSSH for Windows places its agent here.
82 // Opening the pipe for read+write gives us the same byte-stream
83 // semantics as a Unix domain socket from the SSH-agent protocol's
84 // point of view.
85 std::fs::OpenOptions::new()
86 .read(true)
87 .write(true)
88 .open(path)
89 }
90}
91
92// ── Public types ──────────────────────────────────────────────────────────────
93
94/// One identity loaded into the agent.
95#[derive(Debug, Clone)]
96pub struct Identity {
97 /// The public key part, as returned by the agent.
98 pub public_key: PublicKey,
99 /// Comment the key was added with (often `user@host` or the file path).
100 pub comment: String,
101 /// `SHA256:<base64>` fingerprint — cached here to avoid recomputing.
102 pub fingerprint: String,
103}
104
105/// Handle to a running SSH agent.
106///
107/// Thin wrapper over [`ssh_agent_lib::blocking::Client`] that translates
108/// its error type into [`GitwayError`] and the protocol structs into
109/// more convenient Gitway types.
110#[derive(Debug)]
111pub struct Agent {
112 inner: Client<Transport>,
113}
114
115impl Agent {
116 /// Connects to the agent at `$SSH_AUTH_SOCK`.
117 ///
118 /// # Errors
119 ///
120 /// Returns [`GitwayError::invalid_config`] when `$SSH_AUTH_SOCK` is
121 /// unset or empty, and [`GitwayError::from`] an I/O error when the
122 /// socket cannot be opened.
123 pub fn from_env() -> Result<Self, GitwayError> {
124 let sock = env::var("SSH_AUTH_SOCK").map_err(|_e| {
125 GitwayError::invalid_config(
126 "SSH_AUTH_SOCK is not set — start an agent first \
127 (e.g. `eval $(ssh-agent -s)`) or pass --socket",
128 )
129 })?;
130 if sock.is_empty() {
131 return Err(GitwayError::invalid_config("SSH_AUTH_SOCK is empty"));
132 }
133 Self::connect(&PathBuf::from(sock))
134 }
135
136 /// Connects to the agent socket at `path`.
137 ///
138 /// # Errors
139 ///
140 /// Returns [`GitwayError::from`] the underlying I/O error when the
141 /// socket cannot be opened.
142 pub fn connect(path: &std::path::Path) -> Result<Self, GitwayError> {
143 let stream = open_transport(path)?;
144 Ok(Self {
145 inner: Client::new(stream),
146 })
147 }
148
149 /// Returns the identities currently loaded into the agent.
150 ///
151 /// # Errors
152 ///
153 /// Returns [`GitwayError`] on agent protocol or I/O failure.
154 pub fn list(&mut self) -> Result<Vec<Identity>, GitwayError> {
155 let raw = self
156 .inner
157 .request_identities()
158 .map_err(|e| io_err(format!("agent list failed: {e}")))?;
159 let mut out = Vec::with_capacity(raw.len());
160 for id in raw {
161 let public_key = PublicKey::new(id.pubkey, id.comment.clone());
162 let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
163 out.push(Identity {
164 public_key,
165 comment: id.comment,
166 fingerprint,
167 });
168 }
169 Ok(out)
170 }
171
172 /// Adds an identity to the agent.
173 ///
174 /// `lifetime` (if `Some`) caps how long the agent retains the key;
175 /// once elapsed the agent silently evicts it — matching
176 /// `ssh-add -t <seconds>`. `confirm` asks the agent to prompt the
177 /// user interactively before each signing operation (agent-dependent).
178 ///
179 /// # Errors
180 ///
181 /// Returns [`GitwayError`] on agent protocol or I/O failure.
182 pub fn add(
183 &mut self,
184 key: &PrivateKey,
185 lifetime: Option<Duration>,
186 confirm: bool,
187 ) -> Result<(), GitwayError> {
188 let identity = AddIdentity {
189 credential: Credential::Key {
190 privkey: key.key_data().clone(),
191 comment: key.comment().to_owned(),
192 },
193 };
194 if lifetime.is_none() && !confirm {
195 self.inner
196 .add_identity(identity)
197 .map_err(|e| io_err(format!("agent add failed: {e}")))?;
198 return Ok(());
199 }
200 let mut constraints: Vec<KeyConstraint> = Vec::with_capacity(2);
201 if let Some(d) = lifetime {
202 let secs = u32::try_from(d.as_secs())
203 .map_err(|_e| GitwayError::invalid_config("lifetime exceeds u32 seconds"))?;
204 constraints.push(KeyConstraint::Lifetime(secs));
205 }
206 if confirm {
207 constraints.push(KeyConstraint::Confirm);
208 }
209 self.inner
210 .add_identity_constrained(AddIdentityConstrained {
211 identity,
212 constraints,
213 })
214 .map_err(|e| io_err(format!("agent add (constrained) failed: {e}")))?;
215 Ok(())
216 }
217
218 /// Removes a single identity from the agent.
219 ///
220 /// # Errors
221 ///
222 /// Returns [`GitwayError`] when the agent rejects the request (e.g.
223 /// identity not loaded) or on I/O failure.
224 pub fn remove(&mut self, public_key: &PublicKey) -> Result<(), GitwayError> {
225 self.inner
226 .remove_identity(RemoveIdentity {
227 pubkey: public_key.key_data().clone(),
228 })
229 .map_err(|e| io_err(format!("agent remove failed: {e}")))
230 }
231
232 /// Removes all identities from the agent (matches `ssh-add -D`).
233 ///
234 /// # Errors
235 ///
236 /// Returns [`GitwayError`] on agent protocol or I/O failure.
237 pub fn remove_all(&mut self) -> Result<(), GitwayError> {
238 self.inner
239 .remove_all_identities()
240 .map_err(|e| io_err(format!("agent remove-all failed: {e}")))
241 }
242
243 /// Locks the agent with a passphrase (matches `ssh-add -x`).
244 ///
245 /// The agent refuses all signing requests until [`unlock`](Self::unlock)
246 /// is called with the same passphrase.
247 ///
248 /// # Errors
249 ///
250 /// Returns [`GitwayError`] when the agent rejects the passphrase or
251 /// on I/O failure. The passphrase string passed through to
252 /// `ssh-agent-lib` is a fresh `String` derived from `passphrase`; the
253 /// caller's [`Zeroizing`] buffer is not moved.
254 pub fn lock(&mut self, passphrase: &Zeroizing<String>) -> Result<(), GitwayError> {
255 self.inner
256 .lock(passphrase.as_str().to_owned())
257 .map_err(|e| io_err(format!("agent lock failed: {e}")))
258 }
259
260 /// Unlocks a previously-locked agent (matches `ssh-add -X`).
261 ///
262 /// # Errors
263 ///
264 /// Returns [`GitwayError`] when the agent rejects the passphrase or
265 /// on I/O failure.
266 pub fn unlock(&mut self, passphrase: &Zeroizing<String>) -> Result<(), GitwayError> {
267 self.inner
268 .unlock(passphrase.as_str().to_owned())
269 .map_err(|e| io_err(format!("agent unlock failed: {e}")))
270 }
271}
272
273// ── Internal helpers ──────────────────────────────────────────────────────────
274
275/// Convert any display-able error into a `GitwayError` with an
276/// `std::io::Error` source carrying `message`.
277fn io_err(message: String) -> GitwayError {
278 GitwayError::from(std::io::Error::other(message))
279}