Skip to main content

rskit_security/
tls.rs

1//! TLS material and verification policy.
2
3use rskit_errors::{AppError, AppResult};
4
5/// Minimum TLS protocol version.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
7#[non_exhaustive]
8pub enum TlsVersion {
9    /// TLS 1.2 minimum.
10    #[default]
11    Tls12,
12    /// TLS 1.3 minimum.
13    Tls13,
14}
15
16/// TLS material and peer-verification settings shared by transport crates.
17#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
18pub struct TlsConfig {
19    /// Disables peer certificate verification. Intended only for controlled local/test use.
20    pub skip_verify: bool,
21    /// Path to a PEM-encoded CA bundle used to verify peers.
22    pub ca_file: Option<String>,
23    /// Path to a PEM-encoded certificate.
24    pub cert_file: Option<String>,
25    /// Path to a PEM-encoded private key paired with `cert_file`.
26    pub key_file: Option<String>,
27    /// Override the certificate server name.
28    pub server_name: Option<String>,
29    /// Minimum TLS version. Defaults to TLS 1.2 while rustls prefers TLS 1.3.
30    #[serde(default)]
31    pub min_version: TlsVersion,
32}
33
34impl TlsConfig {
35    /// Return `true` when any TLS setting is configured.
36    #[must_use]
37    pub fn is_enabled(&self) -> bool {
38        self.skip_verify
39            || self.ca_file.as_ref().is_some_and(|value| !value.is_empty())
40            || self
41                .cert_file
42                .as_ref()
43                .is_some_and(|value| !value.is_empty())
44            || self
45                .key_file
46                .as_ref()
47                .is_some_and(|value| !value.is_empty())
48            || self
49                .server_name
50                .as_ref()
51                .is_some_and(|value| !value.is_empty())
52            || self.min_version != TlsVersion::Tls12
53    }
54
55    /// Validate TLS configuration consistency.
56    ///
57    /// # Errors
58    /// Returns an error when certificate/key material is incomplete or empty.
59    pub fn validate(&self) -> AppResult<()> {
60        validate_non_empty("ca_file", self.ca_file.as_deref())?;
61        validate_non_empty("cert_file", self.cert_file.as_deref())?;
62        validate_non_empty("key_file", self.key_file.as_deref())?;
63        validate_non_empty("server_name", self.server_name.as_deref())?;
64
65        if self.cert_file.is_some() != self.key_file.is_some() {
66            return Err(AppError::invalid_input(
67                "tls",
68                "cert_file and key_file must be provided together",
69            ));
70        }
71        Ok(())
72    }
73}
74
75fn validate_non_empty(field: &str, value: Option<&str>) -> AppResult<()> {
76    if value.is_some_and(str::is_empty) {
77        return Err(AppError::invalid_input(
78            field,
79            format!("{field} must not be empty when provided"),
80        ));
81    }
82    Ok(())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn cert_and_key_must_be_configured_together() {
91        let config = TlsConfig {
92            cert_file: Some("client.pem".to_string()),
93            ..Default::default()
94        };
95        assert!(config.validate().is_err());
96    }
97
98    #[test]
99    fn detects_enabled_tls_settings() {
100        let config = TlsConfig {
101            ca_file: Some("ca.pem".to_string()),
102            ..Default::default()
103        };
104        assert!(config.is_enabled());
105    }
106
107    #[test]
108    fn default_config_is_disabled_and_valid() {
109        let config = TlsConfig::default();
110
111        assert!(!config.is_enabled());
112        assert!(config.validate().is_ok());
113    }
114
115    #[test]
116    fn cert_and_key_pair_is_valid() {
117        let config = TlsConfig {
118            cert_file: Some("client.pem".to_string()),
119            key_file: Some("client.key".to_string()),
120            ..Default::default()
121        };
122
123        assert!(config.validate().is_ok());
124        assert!(config.is_enabled());
125    }
126
127    #[test]
128    fn rejects_empty_optional_values() {
129        for config in [
130            TlsConfig {
131                ca_file: Some(String::new()),
132                ..Default::default()
133            },
134            TlsConfig {
135                cert_file: Some(String::new()),
136                key_file: Some("key.pem".to_string()),
137                ..Default::default()
138            },
139            TlsConfig {
140                key_file: Some(String::new()),
141                cert_file: Some("cert.pem".to_string()),
142                ..Default::default()
143            },
144            TlsConfig {
145                server_name: Some(String::new()),
146                ..Default::default()
147            },
148        ] {
149            assert!(config.validate().is_err());
150        }
151    }
152
153    #[test]
154    fn tls13_minimum_counts_as_enabled() {
155        let config = TlsConfig {
156            min_version: TlsVersion::Tls13,
157            ..Default::default()
158        };
159
160        assert!(config.is_enabled());
161        assert!(config.validate().is_ok());
162    }
163}