anvil_ssh/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 anvil_ssh::agent::client::Agent;
28//!
29//! let mut agent = Agent::from_env()?;
30//! agent.list()?.iter().for_each(|id| println!("{}", id.fingerprint));
31//! # Ok::<(), anvil_ssh::AnvilError>(())
32//! ```
33//!
34//! # Errors
35//!
36//! Every operation returns [`AnvilError`]. 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 [`AnvilError::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, SignRequest,
55};
56use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey, Signature};
57use zeroize::Zeroizing;
58
59use crate::AnvilError;
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 [`AnvilError`] 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 [`AnvilError::invalid_config`] when `$SSH_AUTH_SOCK` is
121 /// unset or empty, and [`AnvilError::from`] an I/O error when the
122 /// socket cannot be opened.
123 pub fn from_env() -> Result<Self, AnvilError> {
124 let sock = env::var("SSH_AUTH_SOCK").map_err(|_e| {
125 AnvilError::invalid_config("SSH_AUTH_SOCK is not set").with_hint(
126 "No SSH agent is advertised in this shell. Start one with \
127 `gitway agent start -s` and eval the output, or enable the \
128 bundled systemd user unit (`systemctl --user enable --now \
129 gitway-agent.service`). NixOS + Home Manager users can set \
130 `services.gitway-agent.enable = true;` — the unit runs \
131 automatically and `SSH_AUTH_SOCK` is exported to every \
132 child of `systemd --user`.",
133 )
134 })?;
135 if sock.is_empty() {
136 return Err(
137 AnvilError::invalid_config("SSH_AUTH_SOCK is empty").with_hint(
138 "Something cleared `SSH_AUTH_SOCK` to the empty string. \
139 Unset it (`unset SSH_AUTH_SOCK`) and re-export it to a \
140 real socket path, or just restart the shell.",
141 ),
142 );
143 }
144 Self::connect(&PathBuf::from(sock))
145 }
146
147 /// Connects to the agent socket at `path`.
148 ///
149 /// # Errors
150 ///
151 /// Returns [`AnvilError::from`] the underlying I/O error when the
152 /// socket cannot be opened.
153 pub fn connect(path: &std::path::Path) -> Result<Self, AnvilError> {
154 let stream = open_transport(path)?;
155 Ok(Self {
156 inner: Client::new(stream),
157 })
158 }
159
160 /// Returns the identities currently loaded into the agent.
161 ///
162 /// # Errors
163 ///
164 /// Returns [`AnvilError`] on agent protocol or I/O failure.
165 pub fn list(&mut self) -> Result<Vec<Identity>, AnvilError> {
166 let raw = self
167 .inner
168 .request_identities()
169 .map_err(|e| io_err(format!("agent list failed: {e}")))?;
170 let mut out = Vec::with_capacity(raw.len());
171 for id in raw {
172 let public_key = PublicKey::new(id.pubkey, id.comment.clone());
173 let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
174 out.push(Identity {
175 public_key,
176 comment: id.comment,
177 fingerprint,
178 });
179 }
180 Ok(out)
181 }
182
183 /// Adds an identity to the agent.
184 ///
185 /// `lifetime` (if `Some`) caps how long the agent retains the key;
186 /// once elapsed the agent silently evicts it — matching
187 /// `ssh-add -t <seconds>`. `confirm` asks the agent to prompt the
188 /// user interactively before each signing operation (agent-dependent).
189 ///
190 /// # Errors
191 ///
192 /// Returns [`AnvilError`] on agent protocol or I/O failure.
193 pub fn add(
194 &mut self,
195 key: &PrivateKey,
196 lifetime: Option<Duration>,
197 confirm: bool,
198 ) -> Result<(), AnvilError> {
199 let identity = AddIdentity {
200 credential: Credential::Key {
201 privkey: key.key_data().clone(),
202 comment: key.comment().to_owned(),
203 },
204 };
205 if lifetime.is_none() && !confirm {
206 self.inner
207 .add_identity(identity)
208 .map_err(|e| io_err(format!("agent add failed: {e}")))?;
209 return Ok(());
210 }
211 let mut constraints: Vec<KeyConstraint> = Vec::with_capacity(2);
212 if let Some(d) = lifetime {
213 let secs = u32::try_from(d.as_secs())
214 .map_err(|_e| AnvilError::invalid_config("lifetime exceeds u32 seconds"))?;
215 constraints.push(KeyConstraint::Lifetime(secs));
216 }
217 if confirm {
218 constraints.push(KeyConstraint::Confirm);
219 }
220 self.inner
221 .add_identity_constrained(AddIdentityConstrained {
222 identity,
223 constraints,
224 })
225 .map_err(|e| io_err(format!("agent add (constrained) failed: {e}")))?;
226 Ok(())
227 }
228
229 /// Removes a single identity from the agent.
230 ///
231 /// # Errors
232 ///
233 /// Returns [`AnvilError`] when the agent rejects the request (e.g.
234 /// identity not loaded) or on I/O failure.
235 pub fn remove(&mut self, public_key: &PublicKey) -> Result<(), AnvilError> {
236 self.inner
237 .remove_identity(RemoveIdentity {
238 pubkey: public_key.key_data().clone(),
239 })
240 .map_err(|e| io_err(format!("agent remove failed: {e}")))
241 }
242
243 /// Removes all identities from the agent (matches `ssh-add -D`).
244 ///
245 /// # Errors
246 ///
247 /// Returns [`AnvilError`] on agent protocol or I/O failure.
248 pub fn remove_all(&mut self) -> Result<(), AnvilError> {
249 self.inner
250 .remove_all_identities()
251 .map_err(|e| io_err(format!("agent remove-all failed: {e}")))
252 }
253
254 /// Locks the agent with a passphrase (matches `ssh-add -x`).
255 ///
256 /// The agent refuses all signing requests until [`unlock`](Self::unlock)
257 /// is called with the same passphrase.
258 ///
259 /// # Errors
260 ///
261 /// Returns [`AnvilError`] when the agent rejects the passphrase or
262 /// on I/O failure. The passphrase string passed through to
263 /// `ssh-agent-lib` is a fresh `String` derived from `passphrase`; the
264 /// caller's [`Zeroizing`] buffer is not moved.
265 pub fn lock(&mut self, passphrase: &Zeroizing<String>) -> Result<(), AnvilError> {
266 self.inner
267 .lock(passphrase.as_str().to_owned())
268 .map_err(|e| io_err(format!("agent lock failed: {e}")))
269 }
270
271 /// Unlocks a previously-locked agent (matches `ssh-add -X`).
272 ///
273 /// # Errors
274 ///
275 /// Returns [`AnvilError`] when the agent rejects the passphrase or
276 /// on I/O failure.
277 pub fn unlock(&mut self, passphrase: &Zeroizing<String>) -> Result<(), AnvilError> {
278 self.inner
279 .unlock(passphrase.as_str().to_owned())
280 .map_err(|e| io_err(format!("agent unlock failed: {e}")))
281 }
282
283 /// Asks the agent to sign `data` with the loaded private key whose
284 /// public counterpart matches `public_key`.
285 ///
286 /// For RSA keys the request carries `SSH_AGENT_RSA_SHA2_512`
287 /// (flag = 4) so the agent returns an `rsa-sha2-512` signature —
288 /// matching OpenSSH's `-Y sign` default and the one SSHSIG
289 /// verifiers expect. Ed25519 and ECDSA ignore the flag field; the
290 /// algorithm is fixed by the key type.
291 ///
292 /// SHA-1 `ssh-rsa` downgrade (flag = 0 on an RSA key) is not
293 /// requested here — OpenSSH 8.2+ (Jan 2020) always asks for
294 /// SHA-2, and our own daemon rejects SHA-1 RSA requests in
295 /// [`crate::agent::daemon`].
296 ///
297 /// # Errors
298 ///
299 /// Returns [`AnvilError`] when the agent rejects the request
300 /// (commonly because the key is not loaded, the agent is locked,
301 /// or a `--confirm` prompt was denied) or on I/O failure.
302 pub fn sign(&mut self, public_key: &PublicKey, data: &[u8]) -> Result<Signature, AnvilError> {
303 let flags: u32 = match public_key.algorithm() {
304 Algorithm::Rsa { .. } => 4, // SSH_AGENT_RSA_SHA2_512
305 _ => 0,
306 };
307 self.inner
308 .sign(SignRequest {
309 pubkey: public_key.key_data().clone(),
310 data: data.to_vec(),
311 flags,
312 })
313 .map_err(|e| io_err(format!("agent sign failed: {e}")))
314 }
315}
316
317// ── Internal helpers ──────────────────────────────────────────────────────────
318
319/// Convert any display-able error into a `AnvilError` with an
320/// `std::io::Error` source carrying `message`.
321fn io_err(message: String) -> AnvilError {
322 AnvilError::from(std::io::Error::other(message))
323}