Skip to main content

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}