Skip to main content

anvil_ssh/
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::AnvilConfig;
24use crate::error::{AnvilError, AnvilErrorKind};
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/// [`AnvilSession::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/// [`AnvilSession::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: &AnvilConfig) -> Result<IdentityResolution, AnvilError> {
94    // Explicit-paths branch: when the config carries any `identity_files`
95    // entries (from `--identity`, an `IdentityFile` directive in
96    // `ssh_config`, or both), probe them in order and return the first
97    // usable one.  Per OpenSSH behavior, an explicit `IdentityFile` /
98    // `--identity` *suppresses* the default search — we do not fall
99    // through to `~/.ssh/id_*` when the explicit list is exhausted.
100    if !config.identity_files.is_empty() {
101        for path in &config.identity_files {
102            match probe_key(path)? {
103                IdentityResolution::NotFound => {}
104                found => return Ok(found),
105            }
106        }
107        return Ok(IdentityResolution::NotFound);
108    }
109
110    // Default-paths branch: only reached when no explicit identity files
111    // were configured.  Walk the well-known default key files in order.
112    for path in default_key_paths() {
113        if !path.exists() {
114            continue;
115        }
116        match probe_key(&path)? {
117            IdentityResolution::NotFound => {}
118            found => return Ok(found),
119        }
120    }
121
122    Ok(IdentityResolution::NotFound)
123}
124
125/// Loads a passphrase-protected key file with the supplied passphrase.
126///
127/// Use this after receiving [`IdentityResolution::Encrypted`] and prompting
128/// the user with `rpassword` (or equivalent) in the CLI layer.
129///
130/// # Errors
131///
132/// Returns an error if the passphrase is wrong or the file cannot be read.
133pub fn load_encrypted_key(path: &Path, passphrase: &str) -> Result<PrivateKey, AnvilError> {
134    russh::keys::load_secret_key(path, Some(passphrase)).map_err(AnvilError::from)
135}
136
137/// Loads an OpenSSH certificate from `path` (FR-12).
138///
139/// The certificate is presented alongside the private key during
140/// [`AnvilSession::authenticate_with_cert`].
141///
142/// # Errors
143///
144/// Returns an error if the file cannot be read or is not a valid OpenSSH
145/// certificate.
146pub fn load_cert(path: &Path) -> Result<russh::keys::Certificate, AnvilError> {
147    russh::keys::load_openssh_certificate(path)
148        .map_err(|e| AnvilError::from(russh::keys::Error::from(e)))
149}
150
151/// Wraps a [`PrivateKey`] with the appropriate RSA hash algorithm.
152///
153/// For RSA keys, `rsa_hash` should be the result of
154/// [`Handle::best_supported_rsa_hash`](russh::client::Handle::best_supported_rsa_hash)
155/// (falling back to `SHA-256` if the query fails or returns `None`).
156/// For all other key types the `hash_alg` field is ignored by russh.
157#[must_use]
158pub fn wrap_key(key: PrivateKey, rsa_hash: Option<HashAlg>) -> PrivateKeyWithHashAlg {
159    PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash)
160}
161
162/// Attempts to connect to the SSH agent via `$SSH_AUTH_SOCK` and retrieve its
163/// advertised identities (FR-9, priority 3).
164///
165/// Returns `Ok(None)` when:
166/// - `SSH_AUTH_SOCK` is not set in the environment.
167/// - The socket file does not exist (agent not running).
168/// - The agent holds no identities.
169///
170/// Returns `Err` only for unexpected I/O or protocol failures.
171///
172/// # Errors
173///
174/// Returns an error on socket read/write failures after a connection has been
175/// established.
176#[cfg(unix)]
177pub async fn connect_agent() -> Result<Option<AgentConnection>, AnvilError> {
178    use russh::keys::agent::client::AgentClient;
179
180    let mut client = match AgentClient::connect_env().await {
181        Ok(c) => c,
182        Err(russh::keys::Error::EnvVar(_)) => {
183            log::debug!("auth: SSH_AUTH_SOCK not set; skipping agent");
184            return Ok(None);
185        }
186        Err(russh::keys::Error::BadAuthSock) => {
187            log::debug!("auth: SSH_AUTH_SOCK socket not found; skipping agent");
188            return Ok(None);
189        }
190        Err(e) => return Err(AnvilError::from(e)),
191    };
192
193    let identities = client
194        .request_identities()
195        .await
196        .map_err(AnvilError::from)?;
197
198    if identities.is_empty() {
199        log::debug!("auth: SSH agent has no identities");
200        return Ok(None);
201    }
202
203    log::debug!(
204        "auth: SSH agent offered {} identity/identities",
205        identities.len()
206    );
207    Ok(Some(AgentConnection { client, identities }))
208}
209
210// ── Internal helpers ──────────────────────────────────────────────────────────
211
212/// Returns the ordered list of default key paths to probe.
213///
214/// Ed25519 is checked first to prefer the most secure modern key type.
215/// Legacy DSA is intentionally excluded (NFR-6).
216fn default_key_paths() -> Vec<PathBuf> {
217    let Some(home) = dirs::home_dir() else {
218        log::warn!("auth: could not determine home directory; skipping default key paths");
219        return Vec::new();
220    };
221
222    let ssh = home.join(".ssh");
223    vec![
224        ssh.join("id_ed25519"),
225        ssh.join("id_ecdsa"),
226        ssh.join("id_rsa"),
227    ]
228}
229
230/// Attempts to load a key from `path` without a passphrase.
231///
232/// Returns:
233/// - `Found` if the key loaded successfully.
234/// - `Encrypted` if the key exists but needs a passphrase.
235/// - `NotFound` if the file does not exist.
236/// - `Err` on any other failure.
237fn probe_key(path: &Path) -> Result<IdentityResolution, AnvilError> {
238    match russh::keys::load_secret_key(path, None) {
239        Ok(key) => {
240            log::debug!("auth: loaded identity key from {}", path.display());
241            Ok(IdentityResolution::Found {
242                key,
243                path: path.to_owned(),
244            })
245        }
246        Err(russh::keys::Error::KeyIsEncrypted) => {
247            log::debug!(
248                "auth: identity key at {} is passphrase-protected",
249                path.display()
250            );
251            Ok(IdentityResolution::Encrypted {
252                path: path.to_owned(),
253            })
254        }
255        Err(russh::keys::Error::CouldNotReadKey) => {
256            // Treat unreadable-for-unknown-reason the same as absent.
257            Ok(IdentityResolution::NotFound)
258        }
259        Err(russh::keys::Error::IO(e)) if e.kind() == std::io::ErrorKind::NotFound => {
260            // File does not exist — not an error at probe time.
261            Ok(IdentityResolution::NotFound)
262        }
263        Err(e) => Err(AnvilError::new(AnvilErrorKind::Keys(e))),
264    }
265}
266
267// ── Tests ─────────────────────────────────────────────────────────────────────
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    // ── find_identity / probe_key ─────────────────────────────────────────────
274
275    #[test]
276    fn explicit_nonexistent_path_returns_not_found() {
277        let config = AnvilConfig::builder("github.com")
278            .add_identity_file("/tmp/gitway_test_nonexistent_key_xyz")
279            .build();
280        let result = find_identity(&config).unwrap();
281        assert!(matches!(result, IdentityResolution::NotFound));
282    }
283
284    #[test]
285    fn explicit_path_takes_priority_over_defaults() {
286        // Point --identity at a nonexistent file; find_identity must probe
287        // only that path and return NotFound — it must NOT fall through to
288        // the default ~/.ssh search.
289        let config = AnvilConfig::builder("github.com")
290            .add_identity_file("/tmp/gitway_test_explicit_priority_xyz")
291            .build();
292        let result = find_identity(&config).unwrap();
293        // The file doesn't exist so we get NotFound, but crucially the
294        // function must return at priority 1 without touching ~/.ssh.
295        assert!(
296            matches!(result, IdentityResolution::NotFound),
297            "explicit path must short-circuit default search"
298        );
299    }
300
301    #[test]
302    fn multi_identity_files_probed_in_order() {
303        // All paths nonexistent: each is probed; final result is NotFound.
304        // Order verification beyond NotFound requires actual key files,
305        // which is the integration-test layer's job.
306        let config = AnvilConfig::builder("github.com")
307            .add_identity_file("/tmp/gitway_test_id_a_xyz")
308            .add_identity_file("/tmp/gitway_test_id_b_xyz")
309            .add_identity_file("/tmp/gitway_test_id_c_xyz")
310            .build();
311        let result = find_identity(&config).unwrap();
312        assert!(matches!(result, IdentityResolution::NotFound));
313    }
314
315    #[test]
316    fn no_identity_file_falls_through_to_defaults() {
317        // Without --identity, find_identity walks ~/.ssh/*.  Even if no key
318        // is present, it must return NotFound (not panic or error).
319        let config = AnvilConfig::builder("github.com").build();
320        let result = find_identity(&config);
321        assert!(
322            result.is_ok(),
323            "missing default keys must yield Ok(NotFound), not Err"
324        );
325    }
326
327    // ── load_cert ─────────────────────────────────────────────────────────────
328
329    #[test]
330    fn load_cert_nonexistent_file_returns_error() {
331        let result = load_cert(Path::new("/tmp/gitway_test_nonexistent_cert_xyz.pub"));
332        assert!(result.is_err(), "loading a missing cert must return Err");
333    }
334
335    // ── default_key_paths ─────────────────────────────────────────────────────
336
337    #[test]
338    fn default_key_paths_order_is_ed25519_ecdsa_rsa() {
339        let paths = default_key_paths();
340        // Home dir may be unavailable in some CI environments; skip if so.
341        if paths.is_empty() {
342            return;
343        }
344        assert_eq!(paths.len(), 3);
345        assert!(
346            paths[0].ends_with("id_ed25519"),
347            "first path must be id_ed25519"
348        );
349        assert!(
350            paths[1].ends_with("id_ecdsa"),
351            "second path must be id_ecdsa"
352        );
353        assert!(paths[2].ends_with("id_rsa"), "third path must be id_rsa");
354    }
355}