Skip to main content

fraiseql_wire/connection/tls/
mod.rs

1//! TLS configuration and support for secure connections to Postgres.
2//!
3//! This module provides TLS configuration for connecting to remote Postgres servers.
4//! TLS is recommended for all non-local connections to prevent credential interception.
5
6use crate::{Result, WireError};
7use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
8use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
9use rustls::RootCertStore;
10use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
11use rustls_pemfile::Item;
12use std::fmt::Debug;
13use std::fs;
14use std::sync::Arc;
15
16/// TLS configuration for secure Postgres connections.
17///
18/// Provides a builder for creating TLS configurations with various certificate handling options.
19/// By default, server certificates are validated against system root certificates.
20///
21/// # Examples
22///
23/// ```no_run
24/// // Requires: system root certificates or a CA certificate file on disk.
25/// use fraiseql_wire::connection::TlsConfig;
26///
27/// // With system root certificates (production)
28/// let tls = TlsConfig::builder()
29///     .verify_hostname(true)
30///     .build()?;
31///
32/// // With custom CA certificate
33/// let tls = TlsConfig::builder()
34///     .ca_cert_path("/path/to/ca.pem")
35///     .verify_hostname(true)
36///     .build()?;
37///
38/// // For development (danger: disables verification)
39/// let tls = TlsConfig::builder()
40///     .danger_accept_invalid_certs(true)
41///     .danger_accept_invalid_hostnames(true)
42///     .build()?;
43/// # fraiseql_wire::Result::Ok(())
44/// ```
45#[derive(Clone)]
46pub struct TlsConfig {
47    /// Path to CA certificate file (None = use system roots)
48    ca_cert_path: Option<String>,
49    /// Whether to verify hostname matches certificate
50    verify_hostname: bool,
51    /// Whether to accept invalid certificates (development only)
52    danger_accept_invalid_certs: bool,
53    /// Whether to accept invalid hostnames (development only)
54    danger_accept_invalid_hostnames: bool,
55    /// Compiled rustls `ClientConfig`
56    client_config: Arc<ClientConfig>,
57}
58
59impl TlsConfig {
60    /// Create a new TLS configuration builder.
61    ///
62    /// # Examples
63    ///
64    /// ```no_run
65    /// // Requires: system root certificates.
66    /// use fraiseql_wire::connection::TlsConfig;
67    /// let tls = TlsConfig::builder()
68    ///     .verify_hostname(true)
69    ///     .build()?;
70    /// # fraiseql_wire::Result::Ok(())
71    /// ```
72    pub fn builder() -> TlsConfigBuilder {
73        TlsConfigBuilder::default()
74    }
75
76    /// Get the rustls `ClientConfig` for this TLS configuration.
77    #[must_use]
78    pub fn client_config(&self) -> Arc<ClientConfig> {
79        self.client_config.clone()
80    }
81
82    /// Check if hostname verification is enabled.
83    #[must_use]
84    pub const fn verify_hostname(&self) -> bool {
85        self.verify_hostname
86    }
87
88    /// Check if invalid certificates are accepted (development only).
89    #[must_use]
90    pub const fn danger_accept_invalid_certs(&self) -> bool {
91        self.danger_accept_invalid_certs
92    }
93
94    /// Check if invalid hostnames are accepted (development only).
95    #[must_use]
96    pub const fn danger_accept_invalid_hostnames(&self) -> bool {
97        self.danger_accept_invalid_hostnames
98    }
99}
100
101impl std::fmt::Debug for TlsConfig {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("TlsConfig")
104            .field("ca_cert_path", &self.ca_cert_path)
105            .field("verify_hostname", &self.verify_hostname)
106            .field(
107                "danger_accept_invalid_certs",
108                &self.danger_accept_invalid_certs,
109            )
110            .field(
111                "danger_accept_invalid_hostnames",
112                &self.danger_accept_invalid_hostnames,
113            )
114            .field("client_config", &"<ClientConfig>")
115            .finish()
116    }
117}
118
119/// Builder for TLS configuration.
120///
121/// Provides a fluent API for constructing TLS configurations with custom settings.
122#[must_use = "call .build() to construct the final value"]
123pub struct TlsConfigBuilder {
124    ca_cert_path: Option<String>,
125    verify_hostname: bool,
126    danger_accept_invalid_certs: bool,
127    danger_accept_invalid_hostnames: bool,
128}
129
130impl Default for TlsConfigBuilder {
131    fn default() -> Self {
132        Self {
133            ca_cert_path: None,
134            verify_hostname: true,
135            danger_accept_invalid_certs: false,
136            danger_accept_invalid_hostnames: false,
137        }
138    }
139}
140
141impl TlsConfigBuilder {
142    /// Set the path to a custom CA certificate file (PEM format).
143    ///
144    /// If not set, system root certificates will be used.
145    ///
146    /// # Arguments
147    ///
148    /// * `path` - Path to CA certificate file in PEM format
149    ///
150    /// # Examples
151    ///
152    /// ```no_run
153    /// // Requires: CA certificate file at the specified path.
154    /// use fraiseql_wire::connection::TlsConfig;
155    /// let tls = TlsConfig::builder()
156    ///     .ca_cert_path("/etc/ssl/certs/ca.pem")
157    ///     .build()?;
158    /// # fraiseql_wire::Result::Ok(())
159    /// ```
160    pub fn ca_cert_path(mut self, path: impl Into<String>) -> Self {
161        self.ca_cert_path = Some(path.into());
162        self
163    }
164
165    /// Enable or disable hostname verification (default: enabled).
166    ///
167    /// When enabled, the certificate's subject alternative names (SANs) are verified
168    /// to match the server hostname.
169    ///
170    /// # Arguments
171    ///
172    /// * `verify` - Whether to verify hostname matches certificate
173    ///
174    /// # Examples
175    ///
176    /// ```no_run
177    /// // Requires: system root certificates.
178    /// use fraiseql_wire::connection::TlsConfig;
179    /// let tls = TlsConfig::builder()
180    ///     .verify_hostname(true)
181    ///     .build()?;
182    /// # fraiseql_wire::Result::Ok(())
183    /// ```
184    pub const fn verify_hostname(mut self, verify: bool) -> Self {
185        self.verify_hostname = verify;
186        self
187    }
188
189    /// ⚠️ **DANGER**: Accept invalid certificates (development only).
190    ///
191    /// **NEVER use in production.** This disables certificate validation entirely,
192    /// making the connection vulnerable to man-in-the-middle attacks.
193    ///
194    /// Only use for testing with self-signed certificates.
195    ///
196    /// # Errors
197    ///
198    /// [`TlsConfigBuilder::build`] returns `WireError::Config` when this option is `true`
199    /// in a release build (`cfg(not(debug_assertions))`).
200    ///
201    /// # Examples
202    ///
203    /// ```no_run
204    /// // Requires: debug build only (returns Err in release mode).
205    /// use fraiseql_wire::connection::TlsConfig;
206    /// let tls = TlsConfig::builder()
207    ///     .danger_accept_invalid_certs(true)
208    ///     .build()?;
209    /// # fraiseql_wire::Result::Ok(())
210    /// ```
211    pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
212        self.danger_accept_invalid_certs = accept;
213        self
214    }
215
216    /// ⚠️ **DANGER**: Accept invalid hostnames (development only).
217    ///
218    /// **NEVER use in production.** This disables hostname verification,
219    /// making the connection vulnerable to man-in-the-middle attacks.
220    ///
221    /// Only use for testing with self-signed certificates where you can't
222    /// match the hostname.
223    ///
224    /// # Examples
225    ///
226    /// ```no_run
227    /// // Requires: debug build only.
228    /// use fraiseql_wire::connection::TlsConfig;
229    /// let tls = TlsConfig::builder()
230    ///     .danger_accept_invalid_hostnames(true)
231    ///     .build()?;
232    /// # fraiseql_wire::Result::Ok(())
233    /// ```
234    pub const fn danger_accept_invalid_hostnames(mut self, accept: bool) -> Self {
235        self.danger_accept_invalid_hostnames = accept;
236        self
237    }
238
239    /// Build the TLS configuration.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if:
244    /// - CA certificate file cannot be read
245    /// - CA certificate is invalid PEM
246    /// - Dangerous options are configured incorrectly
247    ///
248    /// # Examples
249    ///
250    /// ```no_run
251    /// // Requires: system root certificates.
252    /// use fraiseql_wire::connection::TlsConfig;
253    /// let tls = TlsConfig::builder()
254    ///     .verify_hostname(true)
255    ///     .build()?;
256    /// # fraiseql_wire::Result::Ok(())
257    /// ```
258    pub fn build(self) -> Result<TlsConfig> {
259        // SECURITY: Validate TLS configuration before creating client
260        validate_tls_security(self.danger_accept_invalid_certs)?;
261
262        let client_config = if self.danger_accept_invalid_certs {
263            // Create a client config that accepts any certificate (development only)
264            let verifier = Arc::new(NoVerifier);
265            Arc::new(
266                ClientConfig::builder()
267                    .dangerous()
268                    .with_custom_certificate_verifier(verifier)
269                    .with_no_client_auth(),
270            )
271        } else {
272            // Load root certificates
273            let root_store = if let Some(ca_path) = &self.ca_cert_path {
274                // Load custom CA certificate from file
275                self.load_custom_ca(ca_path)?
276            } else {
277                // Use system root certificates via rustls-native-certs
278                let result = rustls_native_certs::load_native_certs();
279
280                let mut store = RootCertStore::empty();
281                for cert in result.certs {
282                    let _ = store.add_parsable_certificates(std::iter::once(cert));
283                }
284
285                // Log warnings if there were errors, but don't fail
286                if !result.errors.is_empty() && store.is_empty() {
287                    return Err(WireError::Config(
288                        "Failed to load any system root certificates".to_string(),
289                    ));
290                }
291
292                store
293            };
294
295            // Create ClientConfig using the correct API for rustls 0.23
296            Arc::new(
297                ClientConfig::builder()
298                    .with_root_certificates(root_store)
299                    .with_no_client_auth(),
300            )
301        };
302
303        Ok(TlsConfig {
304            ca_cert_path: self.ca_cert_path,
305            verify_hostname: self.verify_hostname,
306            danger_accept_invalid_certs: self.danger_accept_invalid_certs,
307            danger_accept_invalid_hostnames: self.danger_accept_invalid_hostnames,
308            client_config,
309        })
310    }
311
312    /// Load a custom CA certificate from a PEM file.
313    fn load_custom_ca(&self, ca_path: &str) -> Result<RootCertStore> {
314        let ca_cert_data = fs::read(ca_path).map_err(|e| {
315            WireError::Config(format!(
316                "Failed to read CA certificate file '{}': {}",
317                ca_path, e
318            ))
319        })?;
320
321        let mut reader = std::io::Cursor::new(&ca_cert_data);
322        let mut root_store = RootCertStore::empty();
323        let mut found_certs = 0;
324
325        // Parse PEM file and extract certificates
326        loop {
327            match rustls_pemfile::read_one(&mut reader) {
328                Ok(Some(Item::X509Certificate(cert))) => {
329                    let _ = root_store.add_parsable_certificates(std::iter::once(cert));
330                    found_certs += 1;
331                }
332                Ok(Some(_)) => {
333                    // Skip non-certificate items (private keys, etc.)
334                }
335                Ok(None) => {
336                    // End of file
337                    break;
338                }
339                Err(_) => {
340                    return Err(WireError::Config(format!(
341                        "Failed to parse CA certificate from '{}'",
342                        ca_path
343                    )));
344                }
345            }
346        }
347
348        if found_certs == 0 {
349            return Err(WireError::Config(format!(
350                "No valid certificates found in '{}'",
351                ca_path
352            )));
353        }
354
355        Ok(root_store)
356    }
357}
358
359/// Validate TLS configuration for security constraints.
360///
361/// Enforces that release builds cannot use `danger_accept_invalid_certs`.
362/// Development builds emit a warning but proceed.
363///
364/// # Arguments
365///
366/// * `danger_accept_invalid_certs` - Whether danger mode is enabled
367///
368/// # Errors
369///
370/// Returns `WireError::Config` if `danger_accept_invalid_certs` is set in a release build.
371fn validate_tls_security(danger_accept_invalid_certs: bool) -> Result<()> {
372    if danger_accept_invalid_certs {
373        // SECURITY: Return an error in release builds to prevent accidental production use
374        #[cfg(not(debug_assertions))]
375        return Err(WireError::Config(
376            "TLS certificate validation bypass not permitted in release builds".into(),
377        ));
378
379        // Development builds: warn but allow
380        #[cfg(debug_assertions)]
381        {
382            tracing::warn!("TLS certificate validation is DISABLED (development only)");
383            tracing::warn!("This mode is only for development with self-signed certificates");
384        }
385    }
386    Ok(())
387}
388
389/// Parse server name from hostname for TLS SNI (Server Name Indication).
390///
391/// # Arguments
392///
393/// * `hostname` - Hostname to parse (without port)
394///
395/// # Returns
396///
397/// A string suitable for TLS server name indication
398///
399/// # Errors
400///
401/// Returns an error if the hostname is invalid.
402pub fn parse_server_name(hostname: &str) -> Result<String> {
403    // Remove trailing dot if present
404    let hostname = hostname.trim_end_matches('.');
405
406    // Validate hostname (basic check)
407    if hostname.is_empty() || hostname.len() > 253 {
408        return Err(WireError::Config(format!(
409            "Invalid hostname for TLS: '{}'",
410            hostname
411        )));
412    }
413
414    // Check for invalid characters
415    if !hostname
416        .chars()
417        .all(|c| c.is_alphanumeric() || c == '-' || c == '.')
418    {
419        return Err(WireError::Config(format!(
420            "Invalid hostname for TLS: '{}'",
421            hostname
422        )));
423    }
424
425    Ok(hostname.to_string())
426}
427
428#[cfg(test)]
429mod tests;
430
431/// A certificate verifier that accepts any certificate.
432///
433/// **DANGER**: This should ONLY be used for development/testing with self-signed certificates.
434/// Using this in production is a serious security vulnerability.
435#[derive(Debug)]
436struct NoVerifier;
437
438impl ServerCertVerifier for NoVerifier {
439    fn verify_server_cert(
440        &self,
441        _end_entity: &CertificateDer<'_>,
442        _intermediates: &[CertificateDer<'_>],
443        _server_name: &ServerName<'_>,
444        _ocsp_response: &[u8],
445        _now: UnixTime,
446    ) -> std::result::Result<ServerCertVerified, rustls::Error> {
447        // Accept any certificate
448        Ok(ServerCertVerified::assertion())
449    }
450
451    fn verify_tls12_signature(
452        &self,
453        _message: &[u8],
454        _cert: &CertificateDer<'_>,
455        _dss: &DigitallySignedStruct,
456    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
457        Ok(HandshakeSignatureValid::assertion())
458    }
459
460    fn verify_tls13_signature(
461        &self,
462        _message: &[u8],
463        _cert: &CertificateDer<'_>,
464        _dss: &DigitallySignedStruct,
465    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
466        Ok(HandshakeSignatureValid::assertion())
467    }
468
469    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
470        // Support all common signature schemes
471        vec![
472            SignatureScheme::RSA_PKCS1_SHA256,
473            SignatureScheme::RSA_PKCS1_SHA384,
474            SignatureScheme::RSA_PKCS1_SHA512,
475            SignatureScheme::ECDSA_NISTP256_SHA256,
476            SignatureScheme::ECDSA_NISTP384_SHA384,
477            SignatureScheme::ECDSA_NISTP521_SHA512,
478            SignatureScheme::RSA_PSS_SHA256,
479            SignatureScheme::RSA_PSS_SHA384,
480            SignatureScheme::RSA_PSS_SHA512,
481            SignatureScheme::ED25519,
482        ]
483    }
484}