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, SignRequest,
55};
56use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey, Signature};
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("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                GitwayError::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 [`GitwayError::from`] the underlying I/O error when the
152    /// socket cannot be opened.
153    pub fn connect(path: &std::path::Path) -> Result<Self, GitwayError> {
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 [`GitwayError`] on agent protocol or I/O failure.
165    pub fn list(&mut self) -> Result<Vec<Identity>, GitwayError> {
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 [`GitwayError`] 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<(), GitwayError> {
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| GitwayError::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 [`GitwayError`] 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<(), GitwayError> {
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 [`GitwayError`] on agent protocol or I/O failure.
248    pub fn remove_all(&mut self) -> Result<(), GitwayError> {
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 [`GitwayError`] 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<(), GitwayError> {
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 [`GitwayError`] when the agent rejects the passphrase or
276    /// on I/O failure.
277    pub fn unlock(&mut self, passphrase: &Zeroizing<String>) -> Result<(), GitwayError> {
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 [`GitwayError`] 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, GitwayError> {
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 `GitwayError` with an
320/// `std::io::Error` source carrying `message`.
321fn io_err(message: String) -> GitwayError {
322    GitwayError::from(std::io::Error::other(message))
323}