Skip to main content

signstar_request_signature/ssh/
client.rs

1//! SSH-client for sending signing requests.
2//!
3//! This module provides Signstar client. The client is used to
4//! connect to a Signstar host and request signatures for given files.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! # async fn sign() -> testresult::TestResult {
10//! use signstar_request_signature::Request;
11//! use signstar_request_signature::ssh::client::ConnectConfig;
12//!
13//! let options = ConnectConfig::from_first_system_config()?;
14//!
15//! let mut session = options.connect("user").await?;
16//! let request = Request::for_file("package")?;
17//! let response = session.send(&request).await?;
18//! // process response
19//! #     Ok(()) }
20//! ```
21use std::collections::{BTreeMap, HashMap};
22use std::fs::read;
23use std::num::NonZeroU32;
24use std::path::Path;
25use std::str::FromStr;
26use std::{path::PathBuf, sync::Arc, time::Duration};
27
28use garde::Validate;
29use rand::seq::SliceRandom;
30use rand::thread_rng;
31use russh::client::AuthResult;
32use russh::keys::PublicKeyOrCertificate;
33use russh::keys::agent::client::AgentClient;
34use russh::keys::ssh_key::known_hosts::Entry;
35use russh::keys::ssh_key::{HashAlg, PublicKey};
36use russh::{ChannelMsg, Disconnect, MethodSet, client};
37use serde::{Deserialize, Deserializer};
38use tokio::net::UnixStream;
39
40use crate::{Request, Response};
41
42/// SSH communication error.
43#[derive(Debug, thiserror::Error)]
44pub enum Error {
45    /// Invalid options used.
46    #[error("Invalid options used: {0}")]
47    InvalidOptions(String),
48
49    /// Authentication failed.
50    #[error("Authentication failed")]
51    AuthFailed {
52        /// The server suggests to proceed with these authentication methods.
53        remaining_methods: MethodSet,
54
55        /// The server says that though authentication method has been accepted, further
56        /// authentication is required.
57        partial_success: bool,
58    },
59
60    /// Invalid user specified.
61    #[error("User {user} is not defined in the config file")]
62    InvalidUser {
63        /// User name that is missing.
64        user: String,
65    },
66
67    /// Invalid Signstar host specified.
68    #[error("Host {host} is not defined in the config file")]
69    InvalidSignstarHost {
70        /// Host name that is not defined.
71        host: String,
72    },
73
74    /// I/O error occurred.
75    #[error("I/O error: {source} when processing {file}")]
76    Io {
77        /// File being processed.
78        ///
79        /// This field will be empty ([`PathBuf::new`]) if the error
80        /// was encountered when processing generic I/O streams.
81        file: PathBuf,
82
83        /// Source error.
84        source: std::io::Error,
85    },
86
87    /// The remote program did not exit cleanly.
88    #[error("Program did not exit cleanly")]
89    UncleanExit,
90
91    /// The remote application returned a non-zero status code.
92    #[error("Remote application failed with status code: {status_code}")]
93    RemoteApplicationFailure {
94        /// Status code returned by the application.
95        status_code: NonZeroU32,
96    },
97
98    /// Internal `russh` protocol error.
99    #[error("SSH protocol error: {0}")]
100    SshProtocol(#[from] russh::Error),
101
102    /// SSH format error.
103    #[error("SSH format error: {0}")]
104    SshFormat(#[from] russh::keys::Error),
105
106    /// Internal `russh` client agent error.
107    #[error("SSH agent error: {0}")]
108    Agent(#[from] russh::AgentAuthError),
109
110    /// JSON serialization error.
111    #[error("Serde serialization error: {0}")]
112    Serialization(#[from] serde_json::Error),
113}
114
115type Result<T> = std::result::Result<T, Error>;
116
117/// The default config file below "/usr/".
118pub const DEFAULT_CONFIG: &str = "/usr/share/signstar/request-signature.toml";
119
120/// The override config file below "/run/".
121pub const RUN_OVERRIDE_CONFIG: &str = "/run/signstar/request-signature.toml";
122
123/// The override config file below "/etc/".
124pub const ETC_OVERRIDE_CONFIG: &str = "/etc/signstar/request-signature.toml";
125
126/// The connection to a Signstar system a specific user can use.
127#[derive(Debug, Deserialize, Validate)]
128pub struct SignstarHost {
129    /// The host name of the Signstar system.
130    #[garde(length(min = 1))]
131    host: String,
132
133    /// The SSH port of the Signstar system.
134    #[garde(range(min = 1))]
135    port: u16,
136
137    /// The known_hosts entries for the Signstar system.
138    #[serde(deserialize_with = "deserialize_entries")]
139    #[garde(length(min = 1))]
140    known_hosts: Vec<Entry>,
141}
142
143/// Connection configuration for sending a signature request.
144///
145/// The configuration tracks a list of all valid targets for connecting.
146#[derive(Debug, Default, Deserialize, Validate)]
147#[garde(custom(validate_host_ids))]
148pub struct ConnectConfig {
149    /// List of hosts configured.
150    #[garde(
151        custom(validate_host_port_uniqueness),
152        custom(validate_known_hosts_consistency),
153        dive
154    )]
155    pub hosts: BTreeMap<String, SignstarHost>,
156
157    /// List of users.
158    #[garde(custom(validate_ssh_public_key_consistency), dive)]
159    pub users: BTreeMap<String, ConnectOptions>,
160}
161
162impl ConnectConfig {
163    /// The order of configuration files.
164    ///
165    /// The following files are inspected, in descending priority:
166    /// - `/etc/signstar/request-signature.toml`
167    /// - `/run/signstar/request-signature.toml`
168    /// - `/usr/share/signstar/request-signature.toml`
169    pub const CONFIG_ORDER: &[&str] = &[ETC_OVERRIDE_CONFIG, RUN_OVERRIDE_CONFIG, DEFAULT_CONFIG];
170
171    /// Connects to a host over SSH and returns a [`Session`] object.
172    ///
173    /// This function sets up an authenticated, bidirectional channel
174    /// between the client and the server. No signing requests are exchanged at this point but any
175    /// number of them can be issued later using [`Session::send`] function.
176    ///
177    /// # Examples
178    ///
179    /// ```no_run
180    /// # async fn sign() -> testresult::TestResult {
181    /// use signstar_request_signature::ssh::client::ConnectConfig;
182    ///
183    /// let options = ConnectConfig::from_first_system_config()?;
184    ///
185    /// let mut session = options.connect("user").await?;
186    /// // use session to send signing requests
187    /// #     Ok(()) }
188    /// ```
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if:
193    /// - the client public key is not set,
194    /// - the server public key is not present in the provided SSH `known_hosts` data,
195    /// - the client public key is not recognized by the server,
196    /// - the client authentication with the agent fails,
197    /// - an SSH protocol error is encountered.
198    pub async fn connect(&self, username: &str) -> Result<Session> {
199        let user = &self.users.get(username).ok_or(Error::InvalidUser {
200            user: username.to_string(),
201        })?;
202        let host_id = user.hosts.choose(&mut thread_rng());
203        let target = if let Some(host_id) = host_id {
204            &self.hosts.get(host_id).ok_or(Error::InvalidSignstarHost {
205                host: host_id.to_string(),
206            })?
207        } else {
208            return Err(Error::InvalidOptions(
209                "at least one host must be defined".into(),
210            ));
211        };
212        let client_auth_public_key = &user.user_public_key;
213
214        let config = Arc::new(client::Config {
215            inactivity_timeout: Some(Duration::from_secs(5)),
216            ..Default::default()
217        });
218
219        let stream = UnixStream::connect(&user.agent_socket)
220            .await
221            .map_err(|source| Error::Io {
222                file: user.agent_socket.clone(),
223                source,
224            })?;
225        let mut future = AgentClient::connect(stream);
226        let mut session = client::connect(
227            config,
228            (target.host.clone(), target.port),
229            KeyValidator {
230                host: target.host.clone(),
231                port: target.port,
232                entries: target.known_hosts.clone(),
233            },
234        )
235        .await?;
236        let auth_res = session
237            .authenticate_publickey_with(
238                username,
239                client_auth_public_key.clone(),
240                Some(HashAlg::Sha512),
241                &mut future,
242            )
243            .await?;
244
245        if let AuthResult::Failure {
246            remaining_methods,
247            partial_success,
248        } = auth_res
249        {
250            return Err(Error::AuthFailed {
251                remaining_methods,
252                partial_success,
253            });
254        }
255
256        Ok(Session {
257            session,
258            host: target.host.clone(),
259            port: target.port,
260        })
261    }
262
263    /// Return the first configuration file that exists in the filesystem out of
264    /// [`Self::CONFIG_ORDER`] list or returns a [`crate::Error::ConfigMissing`] error.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error:
269    /// - [`crate::Error::Io`] if checking the config fails
270    /// - [`crate::Error::ConfigMissing`] if no configuration files have been found
271    fn first_existing_system_config() -> std::result::Result<PathBuf, crate::Error> {
272        let candidates = Self::CONFIG_ORDER.iter();
273        for file in candidates {
274            let file = PathBuf::from(file);
275            if std::fs::exists(&file).map_err(|source| crate::Error::Io {
276                file: file.clone(),
277                source,
278            })? {
279                return Ok(file);
280            }
281        }
282        Err(crate::Error::ConfigMissing)
283    }
284
285    /// Return the configuration created from the first configuration file that exists in the
286    /// filesystem out of [`Self::CONFIG_ORDER`] list or returns a
287    /// [`crate::Error::ConfigMissing`] error.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error:
292    /// - [`crate::Error::Io`] if checking the config fails
293    /// - [`crate::Error::ConfigMissing`] if no configuration files have been found
294    /// - [`crate::Error::Validation`] if the config file has invalid contents
295    pub fn from_first_system_config() -> std::result::Result<Self, crate::Error> {
296        Self::from_config_file(Self::first_existing_system_config()?)
297    }
298
299    /// Reads the configuration file, validates it and returns the [`ConnectConfig`].
300    ///
301    /// # Errors
302    ///
303    /// Returns an error:
304    /// - [`crate::Error::Io`] if reading the config file fails
305    /// - [`crate::Error::Validation`] if the config file has invalid contents
306    pub fn from_config_file(file: impl AsRef<Path>) -> std::result::Result<Self, crate::Error> {
307        let contents = read(file.as_ref()).map_err(|source| crate::Error::Io {
308            file: file.as_ref().into(),
309            source,
310        })?;
311        let config: Self = toml::from_slice(&contents)?;
312        config
313            .validate()
314            .map_err(|source| crate::Error::Validation {
315                context: "reading configuration file".to_owned(),
316                source,
317            })?;
318        Ok(config)
319    }
320}
321
322/// Ensures, that SSH public keys are unique per user.
323///
324/// # Errors
325///
326/// Returns an error if an SSH public key is used by more than one user.
327fn validate_ssh_public_key_consistency(
328    users: &BTreeMap<String, ConnectOptions>,
329    _context: &(),
330) -> garde::Result {
331    let failures = users
332        .iter()
333        .map(|(user, connect_option)| (user, &connect_option.user_public_key))
334        .fold(
335            HashMap::<&PublicKey, Vec<&str>>::new(),
336            |mut acc, (user, user_public_key)| {
337                acc.entry(user_public_key).or_default().push(user);
338                acc
339            },
340        )
341        .into_iter()
342        .filter(|(_, users)| users.len() > 1)
343        .map(|(pk, users)| {
344            format!(
345                "Public key {pk} is shared by these users: {users}",
346                pk = pk.to_string(),
347                users = users.join(", ")
348            )
349        })
350        .collect::<Vec<_>>();
351    if !failures.is_empty() {
352        return Err(garde::Error::new(format!(
353            "The SSH public key used for a user cannot be used for another user:\n{failures}",
354            failures = failures.join("\n")
355        )));
356    }
357
358    Ok(())
359}
360
361/// Ensures, that the `known_hosts` entries are unique per `host:port` combinations.
362///
363/// # Errors
364///
365/// Returns an error if the `known_hosts` entries differ for any instance of a `host:port`
366/// combination.
367fn validate_known_hosts_consistency(
368    connect_options: &BTreeMap<String, SignstarHost>,
369    _context: &(),
370) -> garde::Result {
371    let failures = connect_options
372        .iter()
373        .map(|(host, config)| (host, &config.known_hosts))
374        .fold(
375            HashMap::<String, Vec<&str>>::new(),
376            |mut acc, (host, known_hosts)| {
377                for known_host in known_hosts {
378                    acc.entry(known_host.to_string()).or_default().push(host);
379                }
380                acc
381            },
382        )
383        .into_iter()
384        .filter(|(_, hosts)| hosts.len() > 1)
385        .map(|(entry, hosts)| {
386            format!(
387                "Entry {entry} is shared by these hosts: {hosts}",
388                hosts = hosts.join(", ")
389            )
390        })
391        .collect::<Vec<_>>();
392    if !failures.is_empty() {
393        return Err(garde::Error::new(format!(
394            "The known host entry used for a host cannot be used for another host:\n{failures}",
395            failures = failures.join("\n")
396        )));
397    }
398
399    Ok(())
400}
401
402/// Ensures, that each host:port combination is unique.
403///
404/// # Errors
405///
406/// Returns an error if there is a duplicate host:port combination.
407fn validate_host_port_uniqueness(
408    connect_options: &BTreeMap<String, SignstarHost>,
409    _context: &(),
410) -> garde::Result {
411    let failures = connect_options
412        .iter()
413        .map(|(host_id, config)| (host_id, &config.host, config.port))
414        .fold(
415            HashMap::<(&str, u16), Vec<&str>>::new(),
416            |mut acc, (host_id, host, port)| {
417                acc.entry((host, port)).or_default().push(host_id);
418
419                acc
420            },
421        )
422        .into_iter()
423        .filter(|(_, host_ids)| host_ids.len() > 1)
424        .map(|((host, port), host_ids)| {
425            format!(
426                "Host {host}:{port} is shared by these hosts: {host_ids}",
427                host_ids = host_ids.join(", ")
428            )
429        })
430        .collect::<Vec<_>>();
431    if !failures.is_empty() {
432        return Err(garde::Error::new(format!(
433            "Two connections cannot have the same host:port combinations:\n{failures}",
434            failures = failures.join("\n")
435        )));
436    }
437
438    Ok(())
439}
440
441/// Ensures, that each `host_id` in a [`ConnectConfig`] is defined.
442///
443/// # Errors
444///
445/// Returns an error if there are host_ids which are not defined.
446fn validate_host_ids(config: &ConnectConfig, _context: &()) -> garde::Result {
447    let failures = config
448        .users
449        .iter()
450        .flat_map(|(user_id, options)| {
451            options
452                .hosts
453                .iter()
454                .filter(|host| !config.hosts.contains_key(*host))
455                .map(move |invalid_host| (user_id, invalid_host))
456        })
457        .map(|(user, invalid_host)| {
458            format!("User {user} is using host_id which is not defined: {invalid_host}",)
459        })
460        .collect::<Vec<_>>();
461
462    if !failures.is_empty() {
463        return Err(garde::Error::new(failures.join("\n")));
464    }
465
466    Ok(())
467}
468
469/// Connection options for sending a signature request.
470///
471/// The options capture target host parameters and all necessary
472/// information related to authentication for both the client
473/// (client's public key and authentication agent) and server (a list
474/// of valid and known server public keys).
475///
476/// # Examples
477///
478/// ```no_run
479/// # fn main() -> testresult::TestResult {
480/// use signstar_request_signature::ssh::client::ConnectOptions;
481///
482/// let options = ConnectOptions::new("localhost".into(), "ssh-ed25519 ...")?
483///     .agent_socket(std::env::var("SSH_AUTH_SOCK")?);
484/// # Ok(()) }
485/// ```
486#[derive(Debug, Deserialize, Validate)]
487pub struct ConnectOptions {
488    /// The path to the SSH agent socket used for the connection to `user` on the Signstar
489    /// system(s).
490    #[garde(skip)]
491    pub agent_socket: PathBuf,
492
493    /// The SSH public key used for the connections to `user` on the Signstar system(s).
494    #[garde(skip)]
495    pub user_public_key: PublicKey,
496
497    /// The list of Signstar hosts that the `user` can connect to.
498    #[garde(length(min = 1))]
499    pub hosts: Vec<String>,
500}
501
502impl ConnectOptions {
503    /// Sets the path to an OpenSSH agent socket for client authentication.
504    pub fn agent_socket(mut self, agent_socket: impl Into<PathBuf>) -> Self {
505        self.agent_socket = agent_socket.into();
506        self
507    }
508
509    /// Sets an SSH public key of a client for SSH authentication.
510    ///
511    /// # Examples
512    ///
513    /// ```
514    /// # fn main() -> testresult::TestResult {
515    /// use signstar_request_signature::ssh::client::ConnectOptions;
516    ///
517    /// let client_pk =
518    ///     "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILHCXBJYlPPkrt2WYyP3SZoMx43lDBB5QALjE762EQlc";
519    /// let options = ConnectOptions::new("localhost".into(), client_pk)?;
520    /// #     Ok(()) }
521    /// ```
522    ///
523    /// # Errors
524    ///
525    /// Returns an error if the public key is not well-formatted. This
526    /// function only accepts public keys following the
527    /// [`authorized_keys` file format].
528    ///
529    /// [`authorized_keys` file format]: https://man.archlinux.org/man/core/openssh/sshd.8.en#AUTHORIZED_KEYS_FILE_FORMAT.
530    pub fn user_public_key(mut self, user_public_key: impl Into<String>) -> Result<Self> {
531        self.user_public_key =
532            PublicKey::from_openssh(&user_public_key.into()).map_err(russh::keys::Error::SshKey)?;
533        Ok(self)
534    }
535
536    /// Constructs a new [`ConnectOptions`] with target host and client public key.
537    pub fn new(host: String, client_auth_public_key: impl AsRef<str>) -> Result<Self> {
538        let client_auth_public_key = PublicKey::from_openssh(client_auth_public_key.as_ref())
539            .map_err(russh::keys::Error::SshKey)?;
540        Ok(Self {
541            hosts: vec![host],
542            agent_socket: Default::default(),
543            user_public_key: client_auth_public_key,
544        })
545    }
546}
547
548fn deserialize_entries<'de, D>(deserializer: D) -> std::result::Result<Vec<Entry>, D::Error>
549where
550    D: Deserializer<'de>,
551{
552    Vec::<String>::deserialize(deserializer)?
553        .into_iter()
554        .map(|entry: String| Entry::from_str(&entry).map_err(serde::de::Error::custom))
555        .collect::<std::result::Result<_, _>>()
556}
557
558/// Validator for a host's SSH keys and a list of `known_hosts` entries.
559///
560/// Tracks a `host` and its `port`, as well as a list of `entries` in the [SSH `known_hosts` file
561/// format].
562///
563/// [SSH `known_hosts` file format]: https://man.archlinux.org/man/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT
564struct KeyValidator {
565    host: String,
566    port: u16,
567    entries: Vec<Entry>,
568}
569
570impl client::Handler for KeyValidator {
571    type Error = Error;
572
573    /// Checks whether a set of server details can be found in SSH `known_hosts` data.
574    ///
575    /// Based on a `host` and its `port`, this function evaluates whether a supplied `key` is part
576    /// of a list of `entries` in the SSH known_hosts file format. Returns `true`, if the
577    /// combination of `key`, `host` and `port` matches an entry in the list of `entries` and that
578    /// entry is not a CA key or a revoked key. Returns `false` in all other cases.
579    async fn check_server_key(
580        &mut self,
581        server_public_key: &PublicKeyOrCertificate,
582    ) -> Result<bool> {
583        if let PublicKeyOrCertificate::PublicKey { key, .. } = server_public_key {
584            Ok(crate::ssh::known_hosts::is_server_known(
585                self.entries.iter(),
586                &self.host,
587                self.port,
588                key,
589            ))
590        } else {
591            Ok(false)
592        }
593    }
594}
595
596/// An open session with a host that can be used to send multiple signing requests.
597pub struct Session {
598    session: client::Handle<KeyValidator>,
599    host: String,
600    port: u16,
601}
602
603impl std::fmt::Debug for Session {
604    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        write!(
606            f,
607            "SSH session for host {} on port {}",
608            self.host, self.port
609        )
610    }
611}
612
613impl Session {
614    /// Send a signing request to the server and return a signing response.
615    ///
616    /// # Examples
617    ///
618    /// ```no_run
619    /// # async fn sign() -> testresult::TestResult {
620    /// use signstar_request_signature::Request;
621    /// use signstar_request_signature::ssh::client::ConnectConfig;
622    ///
623    /// let options = ConnectConfig::from_first_system_config()?;
624    ///
625    /// let mut session = options.connect("user").await?;
626    /// let request = Request::for_file("package")?;
627    /// let response = session.send(&request).await?;
628    /// // process response
629    /// #     Ok(()) }
630    /// ```
631    ///
632    /// # Errors
633    ///
634    /// Returns an error if sending or processing the signing request fails:
635    /// - if the remote server rejects the signing request,
636    /// - if the remote application exits unexpectedly,
637    /// - the returned data cannot be deserialized into a [`Response`],
638    /// - if an SSH protocol error is encountered.
639    pub async fn send(&mut self, data: &Request) -> Result<Response> {
640        let mut channel = self.session.channel_open_session().await?;
641        // the command name is empty as it is assumed that the server will
642        // pick correct binary anyway
643        let command_name = b"";
644        channel.exec(true, command_name).await?;
645        let data = serde_json::to_vec(&data)?;
646        channel.data(data.as_ref()).await?;
647        channel.eof().await?;
648
649        let mut code = None;
650        let mut stdout = vec![];
651
652        while let Some(msg) = channel.wait().await {
653            match msg {
654                ChannelMsg::Data { ref data } => {
655                    stdout.extend(data.iter());
656                }
657                ChannelMsg::ExitStatus { exit_status } => {
658                    code = Some(exit_status);
659                    // cannot leave the loop immediately, there might still be more data to receive
660                }
661                _ => {}
662            }
663        }
664
665        if let Some(code) = code {
666            if let Some(status_code) = NonZeroU32::new(code) {
667                Err(Error::RemoteApplicationFailure { status_code })
668            } else {
669                Ok(serde_json::from_slice(&stdout)?)
670            }
671        } else {
672            Err(Error::UncleanExit)
673        }
674    }
675
676    /// Close the authentication session.
677    ///
678    /// This function cleanly closes the session and informs the
679    /// server that no further requests will be sent.
680    ///
681    /// # Examples
682    ///
683    /// This example shows that after the [`Session::close`] function is invoked no further requests
684    /// can be sent.
685    ///
686    /// ```compile_fail
687    /// # async fn sign() -> testresult::TestResult {
688    /// use signstar_request_signature::ssh::client::ConnectConfig;
689    ///
690    /// let options = ConnectConfig::from_first_system_config();
691    ///
692    /// let mut session = options.connect("user").await?;
693    /// session.close();
694    ///
695    /// // the session object has been consumed and cannot be reused
696    /// let request = Request::for_file("package")?;
697    /// let response = session.send(&request).await?;
698    /// #     Ok(()) }
699    /// ```
700    ///
701    /// # Errors
702    ///
703    /// Returns an error if at any stage of the connecting process fails:
704    /// - if the client public key is not set,
705    /// - if the server public key is not pinned in the known hosts file,
706    /// - if the client public key is not recognized by the server,
707    /// - if the client authentication with the agent fails,
708    /// - if an SSH protocol error is encountered.
709    pub async fn close(self) -> Result<()> {
710        self.session
711            .disconnect(
712                Disconnect::ByApplication,
713                "Client is closing the connection",
714                "en",
715            )
716            .await?;
717        Ok(())
718    }
719}
720
721#[cfg(test)]
722mod tests {
723
724    use garde::Validate;
725    use insta::assert_snapshot;
726    use testresult::TestResult;
727
728    use super::*;
729
730    #[test]
731    fn validate_success() -> TestResult {
732        let config = ConnectConfig {
733                users: [("test-user".into(), ConnectOptions::new(
734                    "local".into(),
735                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
736                )?)].into_iter().collect(),
737                hosts: [("local".into(), SignstarHost {
738                    host: "127.0.0.1".into(),
739                    port: 22,
740                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
741                })].into_iter().collect(),
742            };
743
744        config.validate()?;
745
746        Ok(())
747    }
748
749    #[test]
750    fn validate_ssh_public_key_cannot_be_reused() -> TestResult {
751        let config = ConnectConfig {
752                users: [("test-user".into(), ConnectOptions::new(
753                    "local".into(),
754                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
755                )?),("test-user2".into(), ConnectOptions::new(
756                    "local".into(),
757                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
758                )?)].into_iter().collect(),
759                hosts: [("local".into(), SignstarHost {
760                    host: "127.0.0.1".into(),
761                    port: 22,
762                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
763                })].into_iter().collect(),
764            };
765
766        let error_msg = match config.validate() {
767            Ok(()) => {
768                panic!("Expected to fail with garde::Error, but succeeded instead.")
769            }
770            Err(error) => error.to_string(),
771        };
772
773        assert_snapshot!(error_msg);
774
775        Ok(())
776    }
777
778    #[test]
779    fn validate_known_hosts_entries_unique_per_host_port_of_the_user() -> TestResult {
780        let config = ConnectConfig {
781                users: [("test-user".into(), ConnectOptions::new(
782                    "local".into(),
783                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
784                )?),("test-user2".into(), ConnectOptions::new(
785                    "local2".into(),
786                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
787                )?)].into_iter().collect(),
788                hosts: [("local".into(), SignstarHost {
789                    host: "127.0.0.1".into(),
790                    port: 22,
791                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
792                }),("local2".into(), SignstarHost {
793                    host: "127.0.0.2".into(),
794                    port: 22,
795                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
796                })].into_iter().collect(),
797            };
798
799        let error_msg = match config.validate() {
800            Ok(()) => {
801                panic!("Expected to fail with garde::Error, but succeeded instead.")
802            }
803            Err(error) => error.to_string(),
804        };
805
806        assert_snapshot!(error_msg);
807
808        Ok(())
809    }
810
811    #[test]
812    fn validate_host_port_uniqueness() -> TestResult {
813        let config = ConnectConfig {
814                users: [("test-user".into(), ConnectOptions::new(
815                    "local".into(),
816                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
817                )?),("test-user2".into(), ConnectOptions::new(
818                    "local2".into(),
819                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
820                )?)].into_iter().collect(),
821                hosts: [("local".into(), SignstarHost {
822                    host: "127.0.0.1".into(),
823                    port: 22,
824                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAD")?],
825                }),("local2".into(), SignstarHost {
826                    host: "127.0.0.1".into(),
827                    port: 22,
828                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
829                })].into_iter().collect(),
830            };
831
832        let error_msg = match config.validate() {
833            Ok(()) => {
834                panic!("Expected to fail with garde::Error, but succeeded instead.")
835            }
836            Err(error) => error.to_string(),
837        };
838
839        assert_snapshot!(error_msg);
840
841        Ok(())
842    }
843
844    #[test]
845    fn validate_host_ids() -> TestResult {
846        let config = ConnectConfig {
847                users: [("test-user".into(), ConnectOptions::new(
848                    "test".into(),
849                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
850                )?),("test-user2".into(), ConnectOptions::new(
851                    "test".into(),
852                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
853                )?)].into_iter().collect(),
854                hosts: [("test2".into(), SignstarHost {
855                    host: "127.0.0.1".into(),
856                    port: 22,
857                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
858                })].into_iter().collect(),
859            };
860
861        let error_msg = match config.validate() {
862            Ok(()) => {
863                panic!("Expected to fail with garde::Error, but succeeded instead.")
864            }
865            Err(error) => error.to_string(),
866        };
867
868        assert_snapshot!(error_msg);
869
870        Ok(())
871    }
872}