1use std::fmt;
4use std::future::Future;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::Arc;
8
9#[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 pub fn new(value: impl Into<String>) -> Self {
18 Self(value.into())
19 }
20
21 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#[non_exhaustive]
52#[derive(Clone, Eq, PartialEq)]
53pub struct Password(String);
54
55impl Password {
56 pub fn new(value: impl Into<String>) -> Self {
58 Self(value.into())
59 }
60
61 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#[non_exhaustive]
87#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
88#[derive(Clone, Eq, PartialEq)]
89pub enum Identity {
90 #[cfg_attr(feature = "serde", serde(skip))]
92 Agent,
93 #[cfg_attr(feature = "serde", serde(skip))]
95 KeyFile {
96 path: PathBuf,
98 passphrase: Option<Password>,
100 },
101 #[cfg_attr(feature = "serde", serde(skip))]
103 PrivateKey {
104 data: Vec<u8>,
106 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 pub fn agent() -> Self {
132 Self::Agent
133 }
134
135 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 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 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 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#[non_exhaustive]
188#[derive(Clone)]
189pub struct ClientKeyboardInteractiveInfo {
190 pub name: String,
192 pub instructions: String,
194 prompts: Vec<ClientKeyboardInteractivePrompt>,
195}
196
197impl ClientKeyboardInteractiveInfo {
198 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 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#[non_exhaustive]
232#[derive(Clone, Debug)]
233pub struct ClientKeyboardInteractivePrompt {
234 pub prompt: String,
236 pub echo: bool,
238}
239
240impl ClientKeyboardInteractivePrompt {
241 pub fn new(prompt: String, echo: bool) -> Self {
243 Self { prompt, echo }
244 }
245}
246
247#[non_exhaustive]
249#[derive(Clone, Debug)]
250pub enum KeyboardInteractiveReply {
251 Responses(Vec<String>),
253 Abort,
255}
256
257pub type KeyboardInteractiveHandler = Arc<
259 dyn Fn(
260 ClientKeyboardInteractiveInfo,
261 ) -> Pin<Box<dyn Future<Output = KeyboardInteractiveReply> + Send>>
262 + Send
263 + Sync,
264>;
265
266#[non_exhaustive]
268#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
269#[derive(Clone)]
270pub enum Credential {
271 #[cfg_attr(feature = "serde", serde(skip))]
273 Password(Password),
274 #[cfg_attr(feature = "serde", serde(skip))]
276 Identity(Identity),
277 None,
279 #[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 pub fn password(password: impl Into<Password>) -> Self {
312 Self::Password(password.into())
313 }
314
315 pub fn identity(identity: Identity) -> Self {
317 Self::Identity(identity)
318 }
319
320 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}