Skip to main content

ssh_cli/tls/
client_config.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![forbid(unsafe_code)]
3//! rustls [`ClientConfig`] construction (library-safe: no `install_default`).
4
5use std::sync::Arc;
6
7use rustls::ClientConfig;
8
9use super::pem::{load_cert_chain, load_private_key};
10use super::provider::ensure_provider;
11use crate::errors::{SshCliError, SshCliResult};
12
13/// Inputs for building a shared [`ClientConfig`].
14#[derive(Debug, Clone, Default)]
15pub struct TlsClientOptions {
16    /// Client certificate PEM path (mTLS).
17    pub client_cert: Option<std::path::PathBuf>,
18    /// Client private key PEM path (mTLS).
19    pub client_key: Option<std::path::PathBuf>,
20    /// Extra PEM roots to **append** to Mozilla webpki-roots (optional).
21    pub extra_root_pem: Option<std::path::PathBuf>,
22}
23
24/// Builds an [`Arc<ClientConfig>`] with webpki-roots and optional mTLS.
25///
26/// Uses the process default [`rustls::crypto::CryptoProvider`] (must be
27/// installed by the binary via [`super::install_default_provider`]). Falls back
28/// to installing aws_lc_rs once if missing (tests / embedders).
29///
30/// # Errors
31/// Provider missing after ensure, PEM load failure, or rustls config error.
32pub fn build_client_config(opts: &TlsClientOptions) -> SshCliResult<Arc<ClientConfig>> {
33    ensure_provider()?;
34
35    let mut roots = rustls::RootCertStore::empty();
36    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
37
38    if let Some(ref extra) = opts.extra_root_pem {
39        let extra_certs = load_cert_chain(extra)?;
40        for c in extra_certs {
41            roots
42                .add(c)
43                .map_err(|e| SshCliError::tls_msg(format!("add extra root: {e}")))?;
44        }
45    }
46
47    let builder = ClientConfig::builder().with_root_certificates(roots);
48
49    let config = match (&opts.client_cert, &opts.client_key) {
50        (Some(cert), Some(key)) => {
51            let chain = load_cert_chain(cert)?;
52            let key_der = load_private_key(key)?;
53            builder
54                .with_client_auth_cert(chain, key_der)
55                .map_err(|e| SshCliError::tls_msg(format!("mTLS client auth cert: {e}")))?
56        }
57        (None, None) => builder.with_no_client_auth(),
58        _ => {
59            return Err(SshCliError::InvalidArgument(
60                "mTLS requires both client_cert and client_key".into(),
61            ));
62        }
63    };
64
65    Ok(Arc::new(config))
66}
67
68/// Loads only roots (no client auth) — used by unit tests / library defaults.
69#[cfg_attr(not(test), allow(dead_code))]
70pub(crate) fn root_only_config() -> SshCliResult<Arc<ClientConfig>> {
71    build_client_config(&TlsClientOptions::default())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn root_only_builds() {
80        let cfg = root_only_config().expect("client config");
81        // ClientConfig is opaque; Arc refcount proves construction.
82        assert!(Arc::strong_count(&cfg) >= 1);
83    }
84}