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//! # let known_hosts = "/dev/null";
11//! use signstar_request_signature::Request;
12//! use signstar_request_signature::ssh::client::ConnectOptions;
13//!
14//! let options = ConnectOptions::target("localhost".into(), 22)
15//!     .append_known_hosts_from_file(known_hosts)?
16//!     .client_auth_agent_sock(std::env::var("SSH_AUTH_SOCK")?)
17//!     .client_auth_public_key(
18//!         "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILHCXBJYlPPkrt2WYyP3SZoMx43lDBB5QALjE762EQlc",
19//!     )?
20//!     .user("signstar");
21//!
22//! let mut session = options.connect().await?;
23//! let request = Request::for_file("package")?;
24//! let response = session.send(&request).await?;
25//! // process response
26//! #     Ok(()) }
27//! ```
28use std::path::Path;
29use std::{path::PathBuf, sync::Arc, time::Duration};
30
31use russh::client::AuthResult;
32use russh::keys::agent::client::AgentClient;
33use russh::keys::ssh_key::known_hosts::Entry;
34use russh::keys::ssh_key::{HashAlg, KnownHosts, PublicKey};
35use russh::{ChannelMsg, Disconnect, MethodSet, client};
36use tokio::net::UnixStream;
37
38use crate::{Request, Response};
39
40/// SSH communication error.
41#[derive(Debug, thiserror::Error)]
42pub enum Error {
43    /// Invalid options used.
44    #[error("Invalid options used: {0}")]
45    InvalidOptions(String),
46
47    /// Authentication failed.
48    #[error("Authentication failed")]
49    AuthFailed {
50        /// The server suggests to proceed with these authentication methods.
51        remaining_methods: MethodSet,
52
53        /// The server says that though authentication method has been accepted, further
54        /// authentication is required.
55        partial_success: bool,
56    },
57
58    /// I/O error occurred.
59    #[error("I/O error: {source} when processing {file}")]
60    Io {
61        /// File being processed.
62        ///
63        /// This field will be empty ([`PathBuf::new`]) if the error
64        /// was encountered when processing generic I/O streams.
65        file: PathBuf,
66
67        /// Source error.
68        source: std::io::Error,
69    },
70
71    /// The remote program did not exit cleanly.
72    #[error("Program did not exit cleanly")]
73    UncleanExit,
74
75    /// The remote application returned a non-zero status code.
76    #[error("Remote application failed with status code: {status_code}")]
77    RemoteApplicationFailure {
78        /// Status code returned by the application.
79        status_code: u32,
80    },
81
82    /// Internal `russh` protocol error.
83    #[error("SSH protocol error: {0}")]
84    SshProtocol(#[from] russh::Error),
85
86    /// SSH format error.
87    #[error("SSH format error: {0}")]
88    SshFormat(#[from] russh::keys::Error),
89
90    /// Internal `russh` client agent error.
91    #[error("SSH agent error: {0}")]
92    Agent(#[from] russh::AgentAuthError),
93
94    /// JSON serialization error.
95    #[error("Serde serialization error: {0}")]
96    Serialization(#[from] serde_json::Error),
97}
98
99type Result<T> = std::result::Result<T, Error>;
100
101/// Connection options for sending a signature request.
102///
103/// The options capture target host parameters and all necessary
104/// information related to authentication for both the client
105/// (client's public key and authentication agent) and server (a list
106/// of valid and known server public keys).
107///
108/// # Examples
109///
110/// ```no_run
111/// # fn main() -> testresult::TestResult {
112/// use signstar_request_signature::ssh::client::ConnectOptions;
113///
114/// let options = ConnectOptions::target("localhost".into(), 22)
115///     .append_known_hosts_from_file("/home/user/.ssh/known_hosts")?
116///     .client_auth_agent_sock(std::env::var("SSH_AUTH_SOCK")?)
117///     .client_auth_public_key("ssh-ed25519 ...")?
118///     .user("signstar");
119/// # Ok(()) }
120/// ```
121#[derive(Debug, Default)]
122pub struct ConnectOptions {
123    known_hosts: Vec<Entry>,
124
125    client_auth_agent_sock: PathBuf,
126
127    client_auth_public_key: Option<PublicKey>,
128
129    user: String,
130
131    hostname: String,
132
133    port: u16,
134}
135
136impl ConnectOptions {
137    /// Adds known hosts from a file containing data in the [SSH `known_hosts` file format].
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the file is badly formatted or reading the file fails.
142    ///
143    /// [SSH `known_hosts` file format]: https://man.archlinux.org/man/core/openssh/sshd.8.en#SSH_KNOWN_HOSTS_FILE_FORMAT
144    pub fn append_known_hosts_from_file(
145        mut self,
146        known_hosts_file: impl AsRef<Path>,
147    ) -> Result<Self> {
148        let known_hosts_file = known_hosts_file.as_ref();
149        let input = std::fs::read_to_string(known_hosts_file).map_err(|source| Error::Io {
150            file: known_hosts_file.to_path_buf(),
151            source,
152        })?;
153        self.known_hosts.extend(KnownHosts::new(&input).flatten());
154        Ok(self)
155    }
156
157    /// Sets the path to an OpenSSH agent socket for client authentication.
158    pub fn client_auth_agent_sock(mut self, agent_sock: impl Into<PathBuf>) -> Self {
159        self.client_auth_agent_sock = agent_sock.into();
160        self
161    }
162
163    /// Sets an SSH public key of a client for SSH authentication.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// # fn main() -> testresult::TestResult {
169    /// use signstar_request_signature::ssh::client::ConnectOptions;
170    ///
171    /// let options = ConnectOptions::target("localhost".into(), 22)
172    ///     .client_auth_public_key(
173    ///         "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILHCXBJYlPPkrt2WYyP3SZoMx43lDBB5QALjE762EQlc",
174    ///     )?
175    ///     .user("signstar");
176    /// #     Ok(()) }
177    /// ```
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the public key is not well-formatted. This
182    /// function only accepts public keys following the
183    /// [`authorized_keys` file format].
184    ///
185    /// [`authorized_keys` file format]: https://man.archlinux.org/man/core/openssh/sshd.8.en#AUTHORIZED_KEYS_FILE_FORMAT.
186    pub fn client_auth_public_key(mut self, public_key: impl Into<String>) -> Result<Self> {
187        self.client_auth_public_key =
188            Some(PublicKey::from_openssh(&public_key.into()).map_err(russh::keys::Error::SshKey)?);
189        Ok(self)
190    }
191
192    /// Sets the username on the remote host for the client.
193    pub fn user(mut self, user: impl Into<String>) -> Self {
194        self.user = user.into();
195        self
196    }
197
198    /// Sets the target host and a port number to use when connecting.
199    pub fn target(hostname: String, port: u16) -> Self {
200        Self {
201            hostname,
202            port,
203            known_hosts: Default::default(),
204            client_auth_agent_sock: Default::default(),
205            client_auth_public_key: Default::default(),
206            user: Default::default(),
207        }
208    }
209
210    /// Connects to a host over SSH and returns a [`Session`] object.
211    ///
212    /// This function sets up an authenticated, bidirectional channel
213    /// between the client and the server. No signing requests are exchanged at this point but any
214    /// number of them can be issued later using [`Session::send`] function.
215    ///
216    /// # Examples
217    ///
218    /// ```no_run
219    /// # async fn sign() -> testresult::TestResult {
220    /// use signstar_request_signature::ssh::client::ConnectOptions;
221    ///
222    /// let options = ConnectOptions::target("localhost".into(), 22);
223    ///
224    /// let mut session = options.connect().await?;
225    /// // use session to send signing requests
226    /// #     Ok(()) }
227    /// ```
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if:
232    /// - the client public key is not set,
233    /// - the server public key is not present in the provided SSH `known_hosts` data,
234    /// - the client public key is not recognized by the server,
235    /// - the client authentication with the agent fails,
236    /// - an SSH protocol error is encountered.
237    pub async fn connect(self) -> Result<Session> {
238        let Some(client_auth_public_key) = self.client_auth_public_key else {
239            return Err(Error::InvalidOptions(
240                "Public key for client authentication has not been set but is required.".into(),
241            ));
242        };
243
244        let config = Arc::new(client::Config {
245            inactivity_timeout: Some(Duration::from_secs(5)),
246            ..Default::default()
247        });
248
249        let stream = UnixStream::connect(&self.client_auth_agent_sock)
250            .await
251            .map_err(|source| Error::Io {
252                file: self.client_auth_agent_sock,
253                source,
254            })?;
255        let mut future = AgentClient::connect(stream);
256        let mut session = client::connect(
257            config,
258            (self.hostname.clone(), self.port),
259            KeyValidator {
260                host: self.hostname.clone(),
261                port: self.port,
262                entries: self.known_hosts,
263            },
264        )
265        .await?;
266        let auth_res = session
267            .authenticate_publickey_with(
268                self.user,
269                client_auth_public_key,
270                Some(HashAlg::Sha512),
271                &mut future,
272            )
273            .await?;
274
275        if let AuthResult::Failure {
276            remaining_methods,
277            partial_success,
278        } = auth_res
279        {
280            return Err(Error::AuthFailed {
281                remaining_methods,
282                partial_success,
283            });
284        }
285
286        Ok(Session {
287            session,
288            host: self.hostname,
289            port: self.port,
290        })
291    }
292}
293
294/// Validator for a host's SSH keys and a list of `known_hosts` entries.
295///
296/// Tracks a `host` and its `port`, as well as a list of `entries` in the [SSH `known_hosts` file
297/// format].
298///
299/// [SSH `known_hosts` file format]: https://man.archlinux.org/man/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT
300struct KeyValidator {
301    host: String,
302    port: u16,
303    entries: Vec<Entry>,
304}
305
306impl client::Handler for KeyValidator {
307    type Error = Error;
308
309    /// Checks whether a set of server details can be found in SSH `known_hosts` data.
310    ///
311    /// Based on a `host` and its `port`, this function evaluates whether a supplied `key` is part
312    /// of a list of `entries` in the SSH known_hosts file format. Returns `true`, if the
313    /// combination of `key`, `host` and `port` matches an entry in the list of `entries` and that
314    /// entry is not a CA key or a revoked key. Returns `false` in all other cases.
315    async fn check_server_key(&mut self, server_public_key: &PublicKey) -> Result<bool> {
316        Ok(crate::ssh::known_hosts::is_server_known(
317            self.entries.iter(),
318            &self.host,
319            self.port,
320            server_public_key,
321        ))
322    }
323}
324
325/// An open session with a host that can be used to send multiple signing requests.
326pub struct Session {
327    session: client::Handle<KeyValidator>,
328    host: String,
329    port: u16,
330}
331
332impl std::fmt::Debug for Session {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        write!(
335            f,
336            "SSH session for host {} on port {}",
337            self.host, self.port
338        )
339    }
340}
341
342impl Session {
343    /// Send a signing request to the server and return a signing response.
344    ///
345    /// # Examples
346    ///
347    /// ```no_run
348    /// # async fn sign() -> testresult::TestResult {
349    /// use signstar_request_signature::Request;
350    /// use signstar_request_signature::ssh::client::ConnectOptions;
351    ///
352    /// let options = ConnectOptions::target("localhost".into(), 22);
353    ///
354    /// let mut session = options.connect().await?;
355    /// let request = Request::for_file("package")?;
356    /// let response = session.send(&request).await?;
357    /// // process response
358    /// #     Ok(()) }
359    /// ```
360    ///
361    /// # Errors
362    ///
363    /// Returns an error if sending or processing the signing request fails:
364    /// - if the remote server rejects the signing request,
365    /// - if the remote application exits unexpectedly,
366    /// - the returned data cannot be deserialized into a [`Response`],
367    /// - if an SSH protocol error is encountered.
368    pub async fn send(&mut self, data: &Request) -> Result<Response> {
369        let mut channel = self.session.channel_open_session().await?;
370        // the command name is empty as it is assumed that the server will
371        // pick correct binary anyway
372        let command_name = b"";
373        channel.exec(true, command_name).await?;
374        let data = serde_json::to_vec(&data)?;
375        channel.data(data.as_ref()).await?;
376        channel.eof().await?;
377
378        let mut code = None;
379        let mut stdout = vec![];
380
381        while let Some(msg) = channel.wait().await {
382            match msg {
383                ChannelMsg::Data { ref data } => {
384                    stdout.extend(data.iter());
385                }
386                ChannelMsg::ExitStatus { exit_status } => {
387                    code = Some(exit_status);
388                    // cannot leave the loop immediately, there might still be more data to receive
389                }
390                _ => {}
391            }
392        }
393
394        if let Some(code) = code {
395            if code != 0 {
396                Err(Error::RemoteApplicationFailure { status_code: code })
397            } else {
398                Ok(serde_json::from_slice(&stdout)?)
399            }
400        } else {
401            Err(Error::UncleanExit)
402        }
403    }
404
405    /// Close the authentication session.
406    ///
407    /// This function cleanly closes the session and informs the
408    /// server that no further requests will be sent.
409    ///
410    /// # Examples
411    ///
412    /// This example shows that after the [`Session::close`] function is invoked no further requests
413    /// can be sent.
414    ///
415    /// ```compile_fail
416    /// # async fn sign() -> testresult::TestResult {
417    /// use signstar_request_signature::ssh::client::ConnectOptions;
418    ///
419    /// let options = ConnectOptions::target("localhost".into(), 22);
420    ///
421    /// let mut session = options.connect().await?;
422    /// session.close();
423    ///
424    /// // the session object has been consumed and cannot be reused
425    /// let request = Request::for_file("package")?;
426    /// let response = session.send(&request).await?;
427    /// #     Ok(()) }
428    /// ```
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if at any stage of the connecting process fails:
433    /// - if the client public key is not set,
434    /// - if the server public key is not pinned in the known hosts file,
435    /// - if the client public key is not recognized by the server,
436    /// - if the client authentication with the agent fails,
437    /// - if an SSH protocol error is encountered.
438    pub async fn close(self) -> Result<()> {
439        self.session
440            .disconnect(
441                Disconnect::ByApplication,
442                "Client is closing the connection",
443                "en",
444            )
445            .await?;
446        Ok(())
447    }
448}