Skip to main content

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}