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