Skip to main content

kipuka/config/
tls.rs

1//! TLS configuration for EST and admin listeners.
2//!
3//! EST requires TLS 1.2+ (RFC 7030 §3.3.1).  The NIAP CA PP FTP_TRP.1
4//! further constrains the cipher suite selection to AEAD-only suites
5//! with forward secrecy (ECDHE or DHE key exchange).
6//!
7//! Two separate truststores are supported (RHELBU-3536 R18):
8//!
9//! - **EST truststore** (`ca_file`) — validates EST client certificates
10//!   for `/simpleenroll`, `/simplereenroll`, and `/serverkeygen`.
11//! - **Admin truststore** — configured in `[admin]` — validates admin
12//!   operator mTLS certificates independently.
13
14use serde::Deserialize;
15
16/// Client certificate authentication mode.
17///
18/// RFC 7030 §3.3.2: EST servers SHOULD support certificate-based client
19/// authentication.  The mode determines whether the TLS handshake
20/// requests and/or requires a client certificate.
21#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "lowercase")]
23#[derive(Default)]
24pub enum ClientAuthMode {
25    /// TLS handshake requires a valid client certificate.
26    Required,
27    /// TLS handshake requests but does not require a client certificate.
28    /// Authentication falls through to HTTP-layer methods (OTP, etc.).
29    #[default]
30    Optional,
31    /// No client certificate is requested.
32    None,
33}
34
35/// `[tls]` section — TLS configuration for the EST listener.
36///
37/// # NIAP CA PP FTP_TRP.1 compliance
38///
39/// - TLS 1.2 is the minimum supported version; TLS 1.0 and 1.1 are rejected.
40/// - Only AEAD cipher suites with forward secrecy (ECDHE/DHE key exchange)
41///   are permitted.
42/// - The default cipher suite list excludes CBC-mode suites and static
43///   RSA key exchange.
44#[derive(Debug, Clone, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct TlsConfig {
47    /// Enable TLS on the EST listener.  Default: `false`.
48    ///
49    /// When `false`, the server listens in plain HTTP mode (intended only
50    /// for development behind a TLS-terminating reverse proxy).
51    #[serde(default)]
52    pub enabled: bool,
53
54    /// Path to the server certificate chain in PEM format.
55    ///
56    /// The file MUST contain the server's end-entity certificate first,
57    /// followed by any intermediate CA certificates.
58    #[serde(default)]
59    pub cert_file: String,
60
61    /// Path to the server private key in PEM format.
62    #[serde(default)]
63    pub key_file: String,
64
65    /// Client certificate authentication mode.
66    ///
67    /// - `required` — mTLS is mandatory; unauthenticated clients are rejected
68    ///   at the TLS layer.
69    /// - `optional` (default) — the server requests a client certificate but
70    ///   accepts connections without one.  EST enrollment can fall back to
71    ///   HTTP-layer authentication (OTP, HTTP Basic, etc.).
72    /// - `none` — no client certificate is requested.
73    #[serde(default)]
74    pub client_auth: ClientAuthMode,
75
76    /// Path to the CA certificate bundle (PEM) for validating EST client
77    /// certificates.
78    ///
79    /// RHELBU-3536 R18: this truststore is dedicated to the EST listener.
80    /// Admin operator mTLS uses a separate truststore configured in `[admin]`.
81    #[serde(default)]
82    pub ca_file: String,
83
84    /// Minimum TLS protocol version.
85    ///
86    /// NIAP CA PP FTP_TRP.1: must be `"1.2"` or `"1.3"`.
87    /// Default: `"1.2"`.
88    #[serde(default = "default_min_protocol")]
89    pub min_protocol: String,
90
91    /// Maximum TLS protocol version.
92    ///
93    /// Default: `"1.3"`.
94    #[serde(default = "default_max_protocol")]
95    pub max_protocol: String,
96
97    /// Allowed cipher suites (IANA names).
98    ///
99    /// When empty, the server uses the rustls default selection which
100    /// already satisfies FTP_TRP.1 (AEAD + forward secrecy only).
101    ///
102    /// Example:
103    /// ```toml
104    /// ciphersuites = [
105    ///     "TLS_AES_256_GCM_SHA384",
106    ///     "TLS_AES_128_GCM_SHA256",
107    ///     "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
108    ///     "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
109    /// ]
110    /// ```
111    #[serde(default)]
112    pub ciphersuites: Vec<String>,
113
114    /// OCSP stapling configuration (RFC 7633 / RFC 6066 Section 8).
115    ///
116    /// When the server's TLS certificate contains the TLS Feature Extension
117    /// (must-staple, OID 1.3.6.1.5.5.7.1.24), OCSP stapling MUST be
118    /// enabled to satisfy RFC 7633 Section 4 requirements.  Clients that
119    /// understand must-staple will abort the handshake if no stapled OCSP
120    /// response is provided.
121    ///
122    /// Even without must-staple, enabling OCSP stapling improves TLS
123    /// handshake performance by eliminating the client-side OCSP lookup.
124    #[serde(default)]
125    pub ocsp_stapling: OcspStaplingConfig,
126}
127
128/// OCSP stapling configuration for the TLS listener.
129///
130/// RFC 6066 Section 8: the `status_request` TLS extension allows the
131/// server to provide a stapled OCSP response during the TLS handshake,
132/// eliminating the client's need to contact the OCSP responder directly.
133///
134/// RFC 7633 Section 4: when the server certificate contains the TLS
135/// Feature Extension (must-staple), the server MUST provide a stapled
136/// response; failure to do so causes compliant clients to abort.
137#[derive(Debug, Clone, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct OcspStaplingConfig {
140    /// Enable OCSP stapling.
141    ///
142    /// When `true`, the server fetches an OCSP response for its own
143    /// certificate at startup and refreshes it periodically.
144    ///
145    /// Default: `false`.
146    #[serde(default)]
147    pub enabled: bool,
148
149    /// Override the OCSP responder URL.
150    ///
151    /// When `None`, the responder URL is extracted from the server
152    /// certificate's Authority Information Access (AIA) extension
153    /// (OID 1.3.6.1.5.5.7.48.1).
154    ///
155    /// Set this when the AIA URL is not reachable from the server
156    /// (e.g., behind a firewall) and a local OCSP responder proxy
157    /// is available.
158    #[serde(default)]
159    pub responder_url: Option<String>,
160
161    /// Interval in seconds between OCSP response refreshes.
162    ///
163    /// The server fetches a fresh OCSP response from the responder
164    /// at this interval, replacing the cached stapled response.
165    ///
166    /// Default: `14400` (4 hours).  OCSP responses typically have a
167    /// `nextUpdate` validity of 24-48 hours, so refreshing every 4
168    /// hours provides adequate margin.
169    #[serde(default = "default_ocsp_refresh_interval")]
170    pub refresh_interval_secs: u64,
171
172    /// Allow serving TLS without a stapled OCSP response when the
173    /// OCSP responder is unreachable.
174    ///
175    /// When `true` (soft-fail mode), the server continues to accept
176    /// TLS connections without a stapled response if the OCSP
177    /// responder cannot be reached.  A stale cached response is
178    /// served if still within its `nextUpdate` window.  A warning
179    /// is logged on each failed refresh attempt.
180    ///
181    /// When `false` (hard-fail mode), the server refuses to start
182    /// if the initial OCSP fetch fails, and transitions to
183    /// unhealthy status if subsequent refreshes fail with no valid
184    /// cached response.
185    ///
186    /// Default: `true`.
187    #[serde(default = "default_soft_fail")]
188    pub soft_fail: bool,
189}
190
191fn default_min_protocol() -> String {
192    "1.2".to_string()
193}
194
195fn default_max_protocol() -> String {
196    "1.3".to_string()
197}
198
199fn default_ocsp_refresh_interval() -> u64 {
200    14400 // 4 hours
201}
202
203fn default_soft_fail() -> bool {
204    true
205}
206
207impl Default for OcspStaplingConfig {
208    fn default() -> Self {
209        Self {
210            enabled: false,
211            responder_url: None,
212            refresh_interval_secs: default_ocsp_refresh_interval(),
213            soft_fail: default_soft_fail(),
214        }
215    }
216}
217
218impl Default for TlsConfig {
219    fn default() -> Self {
220        Self {
221            enabled: false,
222            cert_file: String::new(),
223            key_file: String::new(),
224            client_auth: ClientAuthMode::default(),
225            ca_file: String::new(),
226            min_protocol: default_min_protocol(),
227            max_protocol: default_max_protocol(),
228            ciphersuites: Vec::new(),
229            ocsp_stapling: OcspStaplingConfig::default(),
230        }
231    }
232}
233
234impl TlsConfig {
235    /// Validate TLS configuration constraints.
236    pub fn validate(&self) -> std::result::Result<(), String> {
237        if !self.enabled {
238            return Ok(());
239        }
240
241        if self.cert_file.is_empty() {
242            return Err("[tls].cert_file must not be empty when TLS is enabled".into());
243        }
244        if self.key_file.is_empty() {
245            return Err("[tls].key_file must not be empty when TLS is enabled".into());
246        }
247
248        // NIAP CA PP FTP_TRP.1: TLS 1.2 minimum
249        match self.min_protocol.as_str() {
250            "1.2" | "1.3" => {}
251            other => {
252                return Err(format!(
253                    "[tls].min_protocol must be \"1.2\" or \"1.3\", got {other:?}"
254                ));
255            }
256        }
257        match self.max_protocol.as_str() {
258            "1.2" | "1.3" => {}
259            other => {
260                return Err(format!(
261                    "[tls].max_protocol must be \"1.2\" or \"1.3\", got {other:?}"
262                ));
263            }
264        }
265        if self.min_protocol == "1.3" && self.max_protocol == "1.2" {
266            return Err("[tls].min_protocol (1.3) cannot exceed max_protocol (1.2)".into());
267        }
268
269        // Client auth modes that need a CA file
270        if self.client_auth != ClientAuthMode::None && self.ca_file.is_empty() {
271            return Err(
272                "[tls].ca_file must be set when client_auth is \"required\" or \"optional\"".into(),
273            );
274        }
275
276        Ok(())
277    }
278}