gitway_lib/auth.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Identity resolution (FR-9 through FR-12).
4//!
5//! Key discovery follows a fixed priority order:
6//!
7//! 1. **CLI `--identity` flag** — explicit path from the user.
8//! 2. **Default `.ssh` paths** — `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`,
9//! `~/.ssh/id_rsa` (in that order, matching modern OpenSSH defaults).
10//! 3. **SSH agent** — contacted via `$SSH_AUTH_SOCK` (Unix) (FR-9).
11//!
12//! If a key file is encrypted, [`IdentityResolution::Encrypted`] is returned so
13//! the caller (the CLI) can prompt for a passphrase without this library
14//! depending on terminal I/O.
15
16use std::fmt;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use russh::keys::{HashAlg, PrivateKey, PrivateKeyWithHashAlg};
21
22use crate::config::GitwayConfig;
23use crate::error::{GitwayError, GitwayErrorKind};
24
25// ── Public resolution result ───────────────────────────────────────────────���──
26
27/// Result returned by [`find_identity`].
28#[derive(Debug)]
29#[expect(
30 clippy::large_enum_variant,
31 reason = "IdentityResolution is short-lived (created once per session on the \
32 non-hot auth path); boxing PrivateKey would harm ergonomics with no \
33 measurable benefit."
34)]
35pub enum IdentityResolution {
36 /// A key was loaded and is ready to use.
37 Found {
38 /// The loaded private key.
39 key: PrivateKey,
40 /// Path from which the key was loaded (for logging / error messages).
41 path: PathBuf,
42 },
43 /// A key file was found but is passphrase-protected.
44 Encrypted {
45 /// Path to the encrypted key file.
46 path: PathBuf,
47 },
48 /// No usable key was found on any file path.
49 NotFound,
50}
51
52// ── SSH agent connection (Unix only) ─────────────────────────────────────────
53
54/// A live connection to an SSH agent with its advertised identities.
55///
56/// Obtained via [`connect_agent`]. The connection is used by
57/// [`GitwaySession::authenticate_with_agent`] to sign authentication
58/// challenges without ever loading the private key material into this process.
59#[cfg(unix)]
60pub struct AgentConnection {
61 /// The underlying agent client over the Unix-domain socket.
62 pub client: russh::keys::agent::client::AgentClient<tokio::net::UnixStream>,
63 /// Identities advertised by the agent (public keys and/or certificates).
64 pub identities: Vec<russh::keys::agent::AgentIdentity>,
65}
66
67#[cfg(unix)]
68impl fmt::Debug for AgentConnection {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.debug_struct("AgentConnection")
71 .field("identities", &self.identities)
72 .finish_non_exhaustive()
73 }
74}
75
76// ── Public API ────────────────────────────────────────────────────────────────
77
78/// Searches for an identity key according to FR-9 priority order.
79///
80/// Returns [`IdentityResolution::Encrypted`] rather than prompting for a
81/// passphrase; the caller is responsible for prompting and calling
82/// [`load_encrypted_key`] with the result.
83///
84/// SSH agent fallback is handled separately by [`connect_agent`] and
85/// [`GitwaySession::authenticate_with_agent`]; this function covers only
86/// file-based identities.
87///
88/// # Errors
89///
90/// Returns an error only for unexpected failures (permission denied, corrupt
91/// key data, etc.). A missing or encrypted key is not an error at this stage.
92pub fn find_identity(config: &GitwayConfig) -> Result<IdentityResolution, GitwayError> {
93 // Priority 1: explicit --identity path.
94 if let Some(ref path) = config.identity_file {
95 return probe_key(path);
96 }
97
98 // Priority 2: well-known default paths.
99 for path in default_key_paths() {
100 if !path.exists() {
101 continue;
102 }
103 match probe_key(&path)? {
104 IdentityResolution::NotFound => {}
105 found => return Ok(found),
106 }
107 }
108
109 Ok(IdentityResolution::NotFound)
110}
111
112/// Loads a passphrase-protected key file with the supplied passphrase.
113///
114/// Use this after receiving [`IdentityResolution::Encrypted`] and prompting
115/// the user with `rpassword` (or equivalent) in the CLI layer.
116///
117/// # Errors
118///
119/// Returns an error if the passphrase is wrong or the file cannot be read.
120pub fn load_encrypted_key(path: &Path, passphrase: &str) -> Result<PrivateKey, GitwayError> {
121 russh::keys::load_secret_key(path, Some(passphrase)).map_err(GitwayError::from)
122}
123
124/// Loads an OpenSSH certificate from `path` (FR-12).
125///
126/// The certificate is presented alongside the private key during
127/// [`GitwaySession::authenticate_with_cert`].
128///
129/// # Errors
130///
131/// Returns an error if the file cannot be read or is not a valid OpenSSH
132/// certificate.
133pub fn load_cert(path: &Path) -> Result<russh::keys::Certificate, GitwayError> {
134 russh::keys::load_openssh_certificate(path)
135 .map_err(|e| GitwayError::from(russh::keys::Error::from(e)))
136}
137
138/// Wraps a [`PrivateKey`] with the appropriate RSA hash algorithm.
139///
140/// For RSA keys, `rsa_hash` should be the result of
141/// [`Handle::best_supported_rsa_hash`](russh::client::Handle::best_supported_rsa_hash)
142/// (falling back to `SHA-256` if the query fails or returns `None`).
143/// For all other key types the `hash_alg` field is ignored by russh.
144#[must_use]
145pub fn wrap_key(key: PrivateKey, rsa_hash: Option<HashAlg>) -> PrivateKeyWithHashAlg {
146 PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash)
147}
148
149/// Attempts to connect to the SSH agent via `$SSH_AUTH_SOCK` and retrieve its
150/// advertised identities (FR-9, priority 3).
151///
152/// Returns `Ok(None)` when:
153/// - `SSH_AUTH_SOCK` is not set in the environment.
154/// - The socket file does not exist (agent not running).
155/// - The agent holds no identities.
156///
157/// Returns `Err` only for unexpected I/O or protocol failures.
158///
159/// # Errors
160///
161/// Returns an error on socket read/write failures after a connection has been
162/// established.
163#[cfg(unix)]
164pub async fn connect_agent() -> Result<Option<AgentConnection>, GitwayError> {
165 use russh::keys::agent::client::AgentClient;
166
167 let mut client = match AgentClient::connect_env().await {
168 Ok(c) => c,
169 Err(russh::keys::Error::EnvVar(_)) => {
170 log::debug!("auth: SSH_AUTH_SOCK not set; skipping agent");
171 return Ok(None);
172 }
173 Err(russh::keys::Error::BadAuthSock) => {
174 log::debug!("auth: SSH_AUTH_SOCK socket not found; skipping agent");
175 return Ok(None);
176 }
177 Err(e) => return Err(GitwayError::from(e)),
178 };
179
180 let identities = client
181 .request_identities()
182 .await
183 .map_err(GitwayError::from)?;
184
185 if identities.is_empty() {
186 log::debug!("auth: SSH agent has no identities");
187 return Ok(None);
188 }
189
190 log::debug!(
191 "auth: SSH agent offered {} identity/identities",
192 identities.len()
193 );
194 Ok(Some(AgentConnection { client, identities }))
195}
196
197// ── Internal helpers ──────────────────────────────────────────────────────────
198
199/// Returns the ordered list of default key paths to probe.
200///
201/// Ed25519 is checked first to prefer the most secure modern key type.
202/// Legacy DSA is intentionally excluded (NFR-6).
203fn default_key_paths() -> Vec<PathBuf> {
204 let Some(home) = dirs::home_dir() else {
205 log::warn!("auth: could not determine home directory; skipping default key paths");
206 return Vec::new();
207 };
208
209 let ssh = home.join(".ssh");
210 vec![
211 ssh.join("id_ed25519"),
212 ssh.join("id_ecdsa"),
213 ssh.join("id_rsa"),
214 ]
215}
216
217/// Attempts to load a key from `path` without a passphrase.
218///
219/// Returns:
220/// - `Found` if the key loaded successfully.
221/// - `Encrypted` if the key exists but needs a passphrase.
222/// - `NotFound` if the file does not exist.
223/// - `Err` on any other failure.
224fn probe_key(path: &Path) -> Result<IdentityResolution, GitwayError> {
225 match russh::keys::load_secret_key(path, None) {
226 Ok(key) => {
227 log::debug!("auth: loaded identity key from {}", path.display());
228 Ok(IdentityResolution::Found {
229 key,
230 path: path.to_owned(),
231 })
232 }
233 Err(russh::keys::Error::KeyIsEncrypted) => {
234 log::debug!(
235 "auth: identity key at {} is passphrase-protected",
236 path.display()
237 );
238 Ok(IdentityResolution::Encrypted {
239 path: path.to_owned(),
240 })
241 }
242 Err(russh::keys::Error::CouldNotReadKey) => {
243 // Treat unreadable-for-unknown-reason the same as absent.
244 Ok(IdentityResolution::NotFound)
245 }
246 Err(russh::keys::Error::IO(e)) if e.kind() == std::io::ErrorKind::NotFound => {
247 // File does not exist — not an error at probe time.
248 Ok(IdentityResolution::NotFound)
249 }
250 Err(e) => Err(GitwayError::new(GitwayErrorKind::Keys(e))),
251 }
252}
253
254// ── Tests ─────────────────────────────────────────────────────────────────────
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 // ── find_identity / probe_key ─────────────────────────────────────────────
261
262 #[test]
263 fn explicit_nonexistent_path_returns_not_found() {
264 let config = GitwayConfig::builder("github.com")
265 .identity_file("/tmp/gitway_test_nonexistent_key_xyz")
266 .build();
267 let result = find_identity(&config).unwrap();
268 assert!(matches!(result, IdentityResolution::NotFound));
269 }
270
271 #[test]
272 fn explicit_path_takes_priority_over_defaults() {
273 // Point --identity at a nonexistent file; find_identity must probe
274 // only that path and return NotFound — it must NOT fall through to
275 // the default ~/.ssh search.
276 let config = GitwayConfig::builder("github.com")
277 .identity_file("/tmp/gitway_test_explicit_priority_xyz")
278 .build();
279 let result = find_identity(&config).unwrap();
280 // The file doesn't exist so we get NotFound, but crucially the
281 // function must return at priority 1 without touching ~/.ssh.
282 assert!(
283 matches!(result, IdentityResolution::NotFound),
284 "explicit path must short-circuit default search"
285 );
286 }
287
288 #[test]
289 fn no_identity_file_falls_through_to_defaults() {
290 // Without --identity, find_identity walks ~/.ssh/*. Even if no key
291 // is present, it must return NotFound (not panic or error).
292 let config = GitwayConfig::builder("github.com").build();
293 let result = find_identity(&config);
294 assert!(
295 result.is_ok(),
296 "missing default keys must yield Ok(NotFound), not Err"
297 );
298 }
299
300 // ── load_cert ─────────────────────────────────────────────────────────────
301
302 #[test]
303 fn load_cert_nonexistent_file_returns_error() {
304 let result = load_cert(Path::new("/tmp/gitway_test_nonexistent_cert_xyz.pub"));
305 assert!(result.is_err(), "loading a missing cert must return Err");
306 }
307
308 // ── default_key_paths ─────────────────────────────────────────────────────
309
310 #[test]
311 fn default_key_paths_order_is_ed25519_ecdsa_rsa() {
312 let paths = default_key_paths();
313 // Home dir may be unavailable in some CI environments; skip if so.
314 if paths.is_empty() {
315 return;
316 }
317 assert_eq!(paths.len(), 3);
318 assert!(
319 paths[0].ends_with("id_ed25519"),
320 "first path must be id_ed25519"
321 );
322 assert!(
323 paths[1].ends_with("id_ecdsa"),
324 "second path must be id_ecdsa"
325 );
326 assert!(paths[2].ends_with("id_rsa"), "third path must be id_rsa");
327 }
328}