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