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}