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