Skip to main content

faucet_core/
tls.rs

1//! Shared mutual-TLS (client-certificate) configuration for HTTP connectors.
2//!
3//! This is a pure data + validation type — it deliberately has **no** dependency
4//! on any TLS or HTTP crate, so `faucet-core` stays lightweight. Each HTTP
5//! source that supports mTLS (`rest` / `xml` / `graphql`) owns the small,
6//! feature-gated code that turns a [`TlsClientConfig`] into a `reqwest::Identity`.
7
8use crate::FaucetError;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12/// Client-certificate (mutual TLS) configuration for the HTTP sources.
13///
14/// Supply **either** a PEM certificate + key pair (`client_cert` + `client_key`)
15/// **or** a PKCS#12 identity file (`client_identity_pkcs12` [+ `pkcs12_password`]).
16/// PEM values may be inline or pulled in with `${file:…}` / `${secret:…}` /
17/// `${vault:…}`; the PKCS#12 value is a path to a `.p12`/`.pfx` file (its binary
18/// content can't be embedded in a text config).
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
20pub struct TlsClientConfig {
21    /// PEM-encoded client certificate chain. Pair with `client_key`.
22    pub client_cert: Option<String>,
23    /// PEM-encoded PKCS#8 private key. Pair with `client_cert`.
24    pub client_key: Option<String>,
25    /// Path to a PKCS#12 (`.p12`/`.pfx`) identity file — an alternative to the
26    /// PEM pair.
27    pub client_identity_pkcs12: Option<String>,
28    /// Password for the PKCS#12 file (omit or empty if the file is unencrypted).
29    pub pkcs12_password: Option<String>,
30    /// Minimum negotiated TLS version: `"1.2"` or `"1.3"`. Defaults to the
31    /// TLS backend's own minimum when unset.
32    pub min_version: Option<String>,
33}
34
35impl TlsClientConfig {
36    /// Validate the shape before any network setup: exactly one identity source
37    /// (PEM pair XOR PKCS#12), no half-specified PEM pair, and a recognized
38    /// `min_version`. Cheap and dependency-free — safe to call in an infallible
39    /// connector's registry-side validation.
40    pub fn validate(&self) -> Result<(), FaucetError> {
41        let has_pem = self.client_cert.is_some() || self.client_key.is_some();
42        let has_p12 = self.client_identity_pkcs12.is_some();
43        if has_pem && has_p12 {
44            return Err(FaucetError::Config(
45                "tls: specify either a PEM pair (client_cert + client_key) or \
46                 client_identity_pkcs12, not both"
47                    .into(),
48            ));
49        }
50        if !has_pem && !has_p12 {
51            return Err(FaucetError::Config(
52                "tls: provide a PEM pair (client_cert + client_key) or \
53                 client_identity_pkcs12"
54                    .into(),
55            ));
56        }
57        if has_pem && (self.client_cert.is_none() || self.client_key.is_none()) {
58            return Err(FaucetError::Config(
59                "tls: client_cert and client_key must be provided together".into(),
60            ));
61        }
62        if let Some(v) = &self.min_version
63            && !matches!(v.as_str(), "1.2" | "1.3")
64        {
65            return Err(FaucetError::Config(format!(
66                "tls: unsupported min_version {v:?} (expected \"1.2\" or \"1.3\")"
67            )));
68        }
69        Ok(())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::TlsClientConfig;
76
77    fn pem() -> TlsClientConfig {
78        TlsClientConfig {
79            client_cert: Some("cert".into()),
80            client_key: Some("key".into()),
81            ..Default::default()
82        }
83    }
84
85    #[test]
86    fn valid_pem_pair_passes() {
87        assert!(pem().validate().is_ok());
88    }
89
90    #[test]
91    fn valid_pkcs12_passes() {
92        let c = TlsClientConfig {
93            client_identity_pkcs12: Some("/path/id.p12".into()),
94            pkcs12_password: Some("pw".into()),
95            ..Default::default()
96        };
97        assert!(c.validate().is_ok());
98    }
99
100    #[test]
101    fn pem_and_pkcs12_together_is_rejected() {
102        let c = TlsClientConfig {
103            client_cert: Some("cert".into()),
104            client_key: Some("key".into()),
105            client_identity_pkcs12: Some("/path/id.p12".into()),
106            ..Default::default()
107        };
108        assert!(c.validate().is_err());
109    }
110
111    #[test]
112    fn empty_config_is_rejected() {
113        assert!(TlsClientConfig::default().validate().is_err());
114    }
115
116    #[test]
117    fn half_pem_pair_is_rejected() {
118        let cert_only = TlsClientConfig {
119            client_cert: Some("cert".into()),
120            ..Default::default()
121        };
122        assert!(cert_only.validate().is_err());
123        let key_only = TlsClientConfig {
124            client_key: Some("key".into()),
125            ..Default::default()
126        };
127        assert!(key_only.validate().is_err());
128    }
129
130    #[test]
131    fn min_version_is_validated() {
132        let mut c = pem();
133        c.min_version = Some("1.3".into());
134        assert!(c.validate().is_ok());
135        c.min_version = Some("1.2".into());
136        assert!(c.validate().is_ok());
137        c.min_version = Some("1.1".into());
138        assert!(c.validate().is_err());
139    }
140}