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(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 std::fmt::Debug for TlsClientConfig {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        let secret = |v: &Option<String>| v.as_ref().map(|_| "***");
38        f.debug_struct("TlsClientConfig")
39            .field("client_cert", &self.client_cert)
40            .field("client_key", &secret(&self.client_key))
41            .field("client_identity_pkcs12", &self.client_identity_pkcs12)
42            .field("pkcs12_password", &secret(&self.pkcs12_password))
43            .field("min_version", &self.min_version)
44            .finish()
45    }
46}
47
48impl TlsClientConfig {
49    /// Validate the shape before any network setup: exactly one identity source
50    /// (PEM pair XOR PKCS#12), no half-specified PEM pair, and a recognized
51    /// `min_version`. Cheap and dependency-free — safe to call in an infallible
52    /// connector's registry-side validation.
53    pub fn validate(&self) -> Result<(), FaucetError> {
54        let has_pem = self.client_cert.is_some() || self.client_key.is_some();
55        let has_p12 = self.client_identity_pkcs12.is_some();
56        if has_pem && has_p12 {
57            return Err(FaucetError::Config(
58                "tls: specify either a PEM pair (client_cert + client_key) or \
59                 client_identity_pkcs12, not both"
60                    .into(),
61            ));
62        }
63        if !has_pem && !has_p12 {
64            return Err(FaucetError::Config(
65                "tls: provide a PEM pair (client_cert + client_key) or \
66                 client_identity_pkcs12"
67                    .into(),
68            ));
69        }
70        if has_pem && (self.client_cert.is_none() || self.client_key.is_none()) {
71            return Err(FaucetError::Config(
72                "tls: client_cert and client_key must be provided together".into(),
73            ));
74        }
75        if let Some(v) = &self.min_version
76            && !matches!(v.as_str(), "1.2" | "1.3")
77        {
78            return Err(FaucetError::Config(format!(
79                "tls: unsupported min_version {v:?} (expected \"1.2\" or \"1.3\")"
80            )));
81        }
82        Ok(())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::TlsClientConfig;
89
90    #[test]
91    fn debug_never_prints_key_material() {
92        let cfg = TlsClientConfig {
93            client_cert: Some("CERT".into()),
94            client_key: Some("PRIVATE-KEY-PEM".into()),
95            client_identity_pkcs12: None,
96            pkcs12_password: Some("p12-pass".into()),
97            min_version: None,
98        };
99        let text = format!("{cfg:?}");
100        assert!(
101            !text.contains("PRIVATE-KEY-PEM") && !text.contains("p12-pass"),
102            "{text}"
103        );
104        assert!(text.contains("CERT") && text.contains("***"), "{text}");
105    }
106
107    fn pem() -> TlsClientConfig {
108        TlsClientConfig {
109            client_cert: Some("cert".into()),
110            client_key: Some("key".into()),
111            ..Default::default()
112        }
113    }
114
115    #[test]
116    fn valid_pem_pair_passes() {
117        assert!(pem().validate().is_ok());
118    }
119
120    #[test]
121    fn valid_pkcs12_passes() {
122        let c = TlsClientConfig {
123            client_identity_pkcs12: Some("/path/id.p12".into()),
124            pkcs12_password: Some("pw".into()),
125            ..Default::default()
126        };
127        assert!(c.validate().is_ok());
128    }
129
130    #[test]
131    fn pem_and_pkcs12_together_is_rejected() {
132        let c = TlsClientConfig {
133            client_cert: Some("cert".into()),
134            client_key: Some("key".into()),
135            client_identity_pkcs12: Some("/path/id.p12".into()),
136            ..Default::default()
137        };
138        assert!(c.validate().is_err());
139    }
140
141    #[test]
142    fn empty_config_is_rejected() {
143        assert!(TlsClientConfig::default().validate().is_err());
144    }
145
146    #[test]
147    fn half_pem_pair_is_rejected() {
148        let cert_only = TlsClientConfig {
149            client_cert: Some("cert".into()),
150            ..Default::default()
151        };
152        assert!(cert_only.validate().is_err());
153        let key_only = TlsClientConfig {
154            client_key: Some("key".into()),
155            ..Default::default()
156        };
157        assert!(key_only.validate().is_err());
158    }
159
160    #[test]
161    fn min_version_is_validated() {
162        let mut c = pem();
163        c.min_version = Some("1.3".into());
164        assert!(c.validate().is_ok());
165        c.min_version = Some("1.2".into());
166        assert!(c.validate().is_ok());
167        c.min_version = Some("1.1".into());
168        assert!(c.validate().is_err());
169    }
170}