Skip to main content

russh_extra_core/
auth.rs

1//! Authentication domain types.
2
3use std::fmt;
4use std::future::Future;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::Arc;
8
9/// Username used for SSH authentication.
10#[non_exhaustive]
11#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
12#[derive(Clone, Debug, Eq, Hash, PartialEq)]
13pub struct Username(String);
14
15impl Username {
16    /// Creates a username.
17    pub fn new(value: impl Into<String>) -> Self {
18        Self(value.into())
19    }
20
21    /// Returns the username as a string slice.
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25}
26
27impl From<&str> for Username {
28    fn from(value: &str) -> Self {
29        Self::new(value)
30    }
31}
32
33impl From<String> for Username {
34    fn from(value: String) -> Self {
35        Self::new(value)
36    }
37}
38
39impl fmt::Display for Username {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(&self.0)
42    }
43}
44
45/// Password value with redacted debug output.
46///
47/// When the `serde` feature is enabled, this type does **not** implement
48/// `Serialize` or `Deserialize` to prevent accidental credential leakage.
49/// Use environment variables or secret managers instead of serializing
50/// passwords directly.
51#[non_exhaustive]
52#[derive(Clone, Eq, PartialEq)]
53pub struct Password(String);
54
55impl Password {
56    /// Creates a password.
57    pub fn new(value: impl Into<String>) -> Self {
58        Self(value.into())
59    }
60
61    /// Exposes the password to code that performs authentication.
62    pub fn expose_secret(&self) -> &str {
63        &self.0
64    }
65}
66
67impl fmt::Debug for Password {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str("Password(***)")
70    }
71}
72
73impl From<&str> for Password {
74    fn from(value: &str) -> Self {
75        Self::new(value)
76    }
77}
78
79impl From<String> for Password {
80    fn from(value: String) -> Self {
81        Self::new(value)
82    }
83}
84
85/// SSH identity material.
86#[non_exhaustive]
87#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
88#[derive(Clone, Eq, PartialEq)]
89pub enum Identity {
90    /// Use an SSH agent.
91    #[cfg_attr(feature = "serde", serde(skip))]
92    Agent,
93    /// Use a private key from disk.
94    #[cfg_attr(feature = "serde", serde(skip))]
95    KeyFile {
96        /// Path to the private key.
97        path: PathBuf,
98        /// Optional passphrase for the private key.
99        passphrase: Option<Password>,
100    },
101    /// Use an in-memory private key.
102    #[cfg_attr(feature = "serde", serde(skip))]
103    PrivateKey {
104        /// PEM or OpenSSH private key bytes.
105        data: Vec<u8>,
106        /// Optional passphrase for the private key.
107        passphrase: Option<Password>,
108    },
109}
110
111impl fmt::Debug for Identity {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::Agent => f.write_str("Agent"),
115            Self::KeyFile { path, passphrase } => f
116                .debug_struct("KeyFile")
117                .field("path", path)
118                .field("passphrase", passphrase)
119                .finish(),
120            Self::PrivateKey { data, passphrase } => f
121                .debug_struct("PrivateKey")
122                .field("data", &format_args!("<{} bytes redacted>", data.len()))
123                .field("passphrase", passphrase)
124                .finish(),
125        }
126    }
127}
128
129impl Identity {
130    /// Creates an agent identity.
131    pub fn agent() -> Self {
132        Self::Agent
133    }
134
135    /// Creates a key-file identity.
136    pub fn key_file(path: impl Into<PathBuf>) -> Self {
137        let path = expand_tilde(&path.into());
138        Self::KeyFile {
139            path,
140            passphrase: None,
141        }
142    }
143
144    /// Loads an OpenSSH private key from a file.
145    ///
146    /// On Unix, the file must not be accessible by group or others.
147    /// The key bytes are stored in memory; actual key parsing happens
148    /// during authentication.
149    pub fn load_openssh_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
150        let path = path.as_ref();
151        let path = expand_tilde(path);
152        validate_private_key_permissions(&path)?;
153        let data = std::fs::read(&path)?;
154        Ok(Self::PrivateKey {
155            data,
156            passphrase: None,
157        })
158    }
159
160    /// Creates an identity from in-memory PEM or OpenSSH key bytes.
161    pub fn load_openssh_pem(data: impl Into<Vec<u8>>) -> Self {
162        Self::PrivateKey {
163            data: data.into(),
164            passphrase: None,
165        }
166    }
167
168    /// Adds a passphrase to a key identity.
169    pub fn with_passphrase(mut self, passphrase: impl Into<Password>) -> Self {
170        match &mut self {
171            Self::Agent => {}
172            Self::KeyFile {
173                passphrase: current,
174                ..
175            }
176            | Self::PrivateKey {
177                passphrase: current,
178                ..
179            } => *current = Some(passphrase.into()),
180        }
181
182        self
183    }
184}
185
186/// Information about a keyboard-interactive authentication prompt.
187#[non_exhaustive]
188#[derive(Clone)]
189pub struct ClientKeyboardInteractiveInfo {
190    /// Human-readable name describing the authentication step.
191    pub name: String,
192    /// Instructions for the user (may be empty).
193    pub instructions: String,
194    prompts: Vec<ClientKeyboardInteractivePrompt>,
195}
196
197impl ClientKeyboardInteractiveInfo {
198    /// Creates a new info request from the given fields.
199    ///
200    /// This constructor is intended for wrapping raw russh keyboard-interactive
201    /// info requests.
202    pub fn new(
203        name: String,
204        instructions: String,
205        prompts: Vec<ClientKeyboardInteractivePrompt>,
206    ) -> Self {
207        Self {
208            name,
209            instructions,
210            prompts,
211        }
212    }
213
214    /// Returns the prompts in this keyboard-interactive challenge.
215    pub fn prompts(&self) -> &[ClientKeyboardInteractivePrompt] {
216        &self.prompts
217    }
218}
219
220impl fmt::Debug for ClientKeyboardInteractiveInfo {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        f.debug_struct("ClientKeyboardInteractiveInfo")
223            .field("name", &self.name)
224            .field("instructions", &self.instructions)
225            .field("prompts", &self.prompts)
226            .finish()
227    }
228}
229
230/// A single keyboard-interactive prompt.
231#[non_exhaustive]
232#[derive(Clone, Debug)]
233pub struct ClientKeyboardInteractivePrompt {
234    /// Prompt text to display to the user.
235    pub prompt: String,
236    /// Whether the response should be echoed back.
237    pub echo: bool,
238}
239
240impl ClientKeyboardInteractivePrompt {
241    /// Creates a new keyboard-interactive prompt.
242    pub fn new(prompt: String, echo: bool) -> Self {
243        Self { prompt, echo }
244    }
245}
246
247/// Reply to a keyboard-interactive authentication challenge.
248#[non_exhaustive]
249#[derive(Clone, Debug)]
250pub enum KeyboardInteractiveReply {
251    /// Send these responses to the server.
252    Responses(Vec<String>),
253    /// Abort keyboard-interactive authentication (try next credential).
254    Abort,
255}
256
257/// Handler for keyboard-interactive authentication challenges.
258pub type KeyboardInteractiveHandler = Arc<
259    dyn Fn(
260            ClientKeyboardInteractiveInfo,
261        ) -> Pin<Box<dyn Future<Output = KeyboardInteractiveReply> + Send>>
262        + Send
263        + Sync,
264>;
265
266/// Authentication credential.
267#[non_exhaustive]
268#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
269#[derive(Clone)]
270pub enum Credential {
271    /// Password authentication.
272    #[cfg_attr(feature = "serde", serde(skip))]
273    Password(Password),
274    /// Public key authentication.
275    #[cfg_attr(feature = "serde", serde(skip))]
276    Identity(Identity),
277    /// Attempt `none` authentication.
278    None,
279    /// Keyboard-interactive authentication with a challenge handler.
280    #[cfg_attr(feature = "serde", serde(skip))]
281    KeyboardInteractive(KeyboardInteractiveHandler),
282}
283
284impl fmt::Debug for Credential {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        match self {
287            Self::Password(_) => f.write_str("Password(***)"),
288            Self::Identity(identity) => identity.fmt(f),
289            Self::None => f.write_str("None"),
290            Self::KeyboardInteractive(_) => f.write_str("KeyboardInteractive"),
291        }
292    }
293}
294
295impl PartialEq for Credential {
296    fn eq(&self, other: &Self) -> bool {
297        match (self, other) {
298            (Self::Password(a), Self::Password(b)) => a == b,
299            (Self::Identity(a), Self::Identity(b)) => a == b,
300            (Self::None, Self::None) => true,
301            (Self::KeyboardInteractive(_), Self::KeyboardInteractive(_)) => true,
302            _ => false,
303        }
304    }
305}
306
307impl Eq for Credential {}
308
309impl Credential {
310    /// Creates a password credential.
311    pub fn password(password: impl Into<Password>) -> Self {
312        Self::Password(password.into())
313    }
314
315    /// Creates an identity credential.
316    pub fn identity(identity: Identity) -> Self {
317        Self::Identity(identity)
318    }
319
320    /// Creates a keyboard-interactive credential with the given handler.
321    pub fn keyboard_interactive(
322        handler: impl Fn(
323            ClientKeyboardInteractiveInfo,
324        ) -> Pin<Box<dyn Future<Output = KeyboardInteractiveReply> + Send>>
325        + Send
326        + Sync
327        + 'static,
328    ) -> Self {
329        Self::KeyboardInteractive(Arc::new(handler))
330    }
331}
332
333fn expand_tilde(path: &Path) -> PathBuf {
334    if let Some(path_str) = path.to_str()
335        && (path_str == "~" || path_str.starts_with("~/"))
336    {
337        #[cfg(target_os = "windows")]
338        let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"));
339        #[cfg(not(target_os = "windows"))]
340        let home = std::env::var("HOME");
341
342        if let Ok(home) = home {
343            if path_str == "~" {
344                return PathBuf::from(home);
345            }
346            return PathBuf::from(home).join(&path_str[2..]);
347        }
348    }
349
350    path.to_path_buf()
351}
352
353fn validate_private_key_permissions(path: &Path) -> crate::Result<()> {
354    #[cfg(unix)]
355    {
356        use std::os::unix::fs::PermissionsExt;
357
358        let metadata = std::fs::metadata(path).map_err(|source| {
359            crate::Error::invalid_config(format!(
360                "cannot access private key file `{}`: {source}",
361                path.display()
362            ))
363        })?;
364        let mode = metadata.permissions().mode();
365        if mode & 0o077 != 0 {
366            return Err(crate::Error::invalid_config(format!(
367                "private key file `{}` must not be accessible by group or others",
368                path.display()
369            )));
370        }
371    }
372
373    #[cfg(not(unix))]
374    {
375        let _ = path;
376    }
377
378    Ok(())
379}
380
381#[cfg(test)]
382mod tests {
383    use super::{Credential, Identity, KeyboardInteractiveReply, Password};
384
385    #[test]
386    fn password_debug_redacts_secret() {
387        let password = Password::new("do-not-print");
388
389        let debug = format!("{password:?}");
390
391        assert!(debug.contains("***"));
392        assert!(!debug.contains("do-not-print"));
393    }
394
395    #[test]
396    fn private_key_debug_redacts_key_bytes_and_passphrase() {
397        let identity = Identity::PrivateKey {
398            data: b"private-key-material".to_vec(),
399            passphrase: Some(Password::new("do-not-print")),
400        };
401
402        let debug = format!("{identity:?}");
403
404        assert!(debug.contains("redacted"));
405        assert!(!debug.contains("private-key-material"));
406        assert!(!debug.contains("do-not-print"));
407    }
408
409    #[test]
410    fn credential_debug_redacts_nested_secret() {
411        let credential = Credential::password("do-not-print");
412
413        let debug = format!("{credential:?}");
414
415        assert!(!debug.contains("do-not-print"));
416    }
417
418    #[test]
419    fn identity_load_openssh_file_permits_pem_round_trip() {
420        let identity = Identity::load_openssh_pem(b"private key data");
421
422        let Identity::PrivateKey { data, passphrase } = identity else {
423            panic!("expected PrivateKey variant");
424        };
425        assert_eq!(data, b"private key data");
426        assert!(passphrase.is_none());
427    }
428
429    #[test]
430    fn identity_with_passphrase_sets_passphrase_on_key_variants() {
431        let keyfile = Identity::key_file("id_rsa").with_passphrase("secret");
432        let Identity::KeyFile { passphrase, .. } = keyfile else {
433            panic!("expected KeyFile");
434        };
435        assert_eq!(passphrase.unwrap().expose_secret(), "secret");
436
437        let privkey = Identity::load_openssh_pem(b"key").with_passphrase("secret");
438        let Identity::PrivateKey { passphrase, .. } = privkey else {
439            panic!("expected PrivateKey");
440        };
441        assert_eq!(passphrase.unwrap().expose_secret(), "secret");
442    }
443
444    #[test]
445    fn keyboard_interactive_credentials_compare_equal() {
446        let a = Credential::keyboard_interactive(|_info| {
447            Box::pin(async { KeyboardInteractiveReply::Abort })
448        });
449        let b = Credential::keyboard_interactive(|_info| {
450            Box::pin(async { KeyboardInteractiveReply::Abort })
451        });
452        assert_eq!(a, b);
453        assert_eq!(b, a);
454    }
455
456    #[test]
457    fn different_credential_kinds_not_equal() {
458        let pw1 = Credential::password("hello");
459        let pw2 = Credential::password("hello");
460        assert_eq!(pw1, pw2);
461
462        let ki = Credential::keyboard_interactive(|_info| {
463            Box::pin(async { KeyboardInteractiveReply::Abort })
464        });
465        assert_ne!(pw1, ki);
466        assert_ne!(Credential::None, ki);
467    }
468
469    #[test]
470    fn identity_key_file_expands_tilde() {
471        let id = Identity::key_file("~/nonexistent_key");
472        let Identity::KeyFile { path, .. } = id else {
473            panic!("expected KeyFile");
474        };
475        let path_str = path.to_string_lossy();
476        assert!(!path_str.starts_with("~"), "tilde not expanded: {path_str}");
477    }
478
479    #[test]
480    fn identity_key_file_no_tilde_passes_through() {
481        let id = Identity::key_file("/absolute/path/to/key");
482        let Identity::KeyFile { path, .. } = id else {
483            panic!("expected KeyFile");
484        };
485        assert_eq!(path.to_string_lossy(), "/absolute/path/to/key");
486    }
487}