Skip to main content

fraiseql_wire/connection/
tls.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::{Error, Result};
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    pub fn client_config(&self) -> Arc<ClientConfig> {
78        self.client_config.clone()
79    }
80
81    /// Check if hostname verification is enabled.
82    pub const fn verify_hostname(&self) -> bool {
83        self.verify_hostname
84    }
85
86    /// Check if invalid certificates are accepted (development only).
87    pub const fn danger_accept_invalid_certs(&self) -> bool {
88        self.danger_accept_invalid_certs
89    }
90
91    /// Check if invalid hostnames are accepted (development only).
92    pub const fn danger_accept_invalid_hostnames(&self) -> bool {
93        self.danger_accept_invalid_hostnames
94    }
95}
96
97impl std::fmt::Debug for TlsConfig {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("TlsConfig")
100            .field("ca_cert_path", &self.ca_cert_path)
101            .field("verify_hostname", &self.verify_hostname)
102            .field(
103                "danger_accept_invalid_certs",
104                &self.danger_accept_invalid_certs,
105            )
106            .field(
107                "danger_accept_invalid_hostnames",
108                &self.danger_accept_invalid_hostnames,
109            )
110            .field("client_config", &"<ClientConfig>")
111            .finish()
112    }
113}
114
115/// Builder for TLS configuration.
116///
117/// Provides a fluent API for constructing TLS configurations with custom settings.
118pub struct TlsConfigBuilder {
119    ca_cert_path: Option<String>,
120    verify_hostname: bool,
121    danger_accept_invalid_certs: bool,
122    danger_accept_invalid_hostnames: bool,
123}
124
125impl Default for TlsConfigBuilder {
126    fn default() -> Self {
127        Self {
128            ca_cert_path: None,
129            verify_hostname: true,
130            danger_accept_invalid_certs: false,
131            danger_accept_invalid_hostnames: false,
132        }
133    }
134}
135
136impl TlsConfigBuilder {
137    /// Set the path to a custom CA certificate file (PEM format).
138    ///
139    /// If not set, system root certificates will be used.
140    ///
141    /// # Arguments
142    ///
143    /// * `path` - Path to CA certificate file in PEM format
144    ///
145    /// # Examples
146    ///
147    /// ```no_run
148    /// // Requires: CA certificate file at the specified path.
149    /// use fraiseql_wire::connection::TlsConfig;
150    /// let tls = TlsConfig::builder()
151    ///     .ca_cert_path("/etc/ssl/certs/ca.pem")
152    ///     .build()?;
153    /// # fraiseql_wire::Result::Ok(())
154    /// ```
155    pub fn ca_cert_path(mut self, path: impl Into<String>) -> Self {
156        self.ca_cert_path = Some(path.into());
157        self
158    }
159
160    /// Enable or disable hostname verification (default: enabled).
161    ///
162    /// When enabled, the certificate's subject alternative names (SANs) are verified
163    /// to match the server hostname.
164    ///
165    /// # Arguments
166    ///
167    /// * `verify` - Whether to verify hostname matches certificate
168    ///
169    /// # Examples
170    ///
171    /// ```no_run
172    /// // Requires: system root certificates.
173    /// use fraiseql_wire::connection::TlsConfig;
174    /// let tls = TlsConfig::builder()
175    ///     .verify_hostname(true)
176    ///     .build()?;
177    /// # fraiseql_wire::Result::Ok(())
178    /// ```
179    pub const fn verify_hostname(mut self, verify: bool) -> Self {
180        self.verify_hostname = verify;
181        self
182    }
183
184    /// ⚠️ **DANGER**: Accept invalid certificates (development only).
185    ///
186    /// **NEVER use in production.** This disables certificate validation entirely,
187    /// making the connection vulnerable to man-in-the-middle attacks.
188    ///
189    /// Only use for testing with self-signed certificates.
190    ///
191    /// # Errors
192    ///
193    /// [`TlsConfigBuilder::build`] returns `Error::Config` when this option is `true`
194    /// in a release build (`cfg(not(debug_assertions))`).
195    ///
196    /// # Examples
197    ///
198    /// ```no_run
199    /// // Requires: debug build only (returns Err in release mode).
200    /// use fraiseql_wire::connection::TlsConfig;
201    /// let tls = TlsConfig::builder()
202    ///     .danger_accept_invalid_certs(true)
203    ///     .build()?;
204    /// # fraiseql_wire::Result::Ok(())
205    /// ```
206    pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
207        self.danger_accept_invalid_certs = accept;
208        self
209    }
210
211    /// ⚠️ **DANGER**: Accept invalid hostnames (development only).
212    ///
213    /// **NEVER use in production.** This disables hostname verification,
214    /// making the connection vulnerable to man-in-the-middle attacks.
215    ///
216    /// Only use for testing with self-signed certificates where you can't
217    /// match the hostname.
218    ///
219    /// # Examples
220    ///
221    /// ```no_run
222    /// // Requires: debug build only.
223    /// use fraiseql_wire::connection::TlsConfig;
224    /// let tls = TlsConfig::builder()
225    ///     .danger_accept_invalid_hostnames(true)
226    ///     .build()?;
227    /// # fraiseql_wire::Result::Ok(())
228    /// ```
229    pub const fn danger_accept_invalid_hostnames(mut self, accept: bool) -> Self {
230        self.danger_accept_invalid_hostnames = accept;
231        self
232    }
233
234    /// Build the TLS configuration.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if:
239    /// - CA certificate file cannot be read
240    /// - CA certificate is invalid PEM
241    /// - Dangerous options are configured incorrectly
242    ///
243    /// # Examples
244    ///
245    /// ```no_run
246    /// // Requires: system root certificates.
247    /// use fraiseql_wire::connection::TlsConfig;
248    /// let tls = TlsConfig::builder()
249    ///     .verify_hostname(true)
250    ///     .build()?;
251    /// # fraiseql_wire::Result::Ok(())
252    /// ```
253    pub fn build(self) -> Result<TlsConfig> {
254        // SECURITY: Validate TLS configuration before creating client
255        validate_tls_security(self.danger_accept_invalid_certs)?;
256
257        let client_config = if self.danger_accept_invalid_certs {
258            // Create a client config that accepts any certificate (development only)
259            let verifier = Arc::new(NoVerifier);
260            Arc::new(
261                ClientConfig::builder()
262                    .dangerous()
263                    .with_custom_certificate_verifier(verifier)
264                    .with_no_client_auth(),
265            )
266        } else {
267            // Load root certificates
268            let root_store = if let Some(ca_path) = &self.ca_cert_path {
269                // Load custom CA certificate from file
270                self.load_custom_ca(ca_path)?
271            } else {
272                // Use system root certificates via rustls-native-certs
273                let result = rustls_native_certs::load_native_certs();
274
275                let mut store = RootCertStore::empty();
276                for cert in result.certs {
277                    let _ = store.add_parsable_certificates(std::iter::once(cert));
278                }
279
280                // Log warnings if there were errors, but don't fail
281                if !result.errors.is_empty() && store.is_empty() {
282                    return Err(Error::Config(
283                        "Failed to load any system root certificates".to_string(),
284                    ));
285                }
286
287                store
288            };
289
290            // Create ClientConfig using the correct API for rustls 0.23
291            Arc::new(
292                ClientConfig::builder()
293                    .with_root_certificates(root_store)
294                    .with_no_client_auth(),
295            )
296        };
297
298        Ok(TlsConfig {
299            ca_cert_path: self.ca_cert_path,
300            verify_hostname: self.verify_hostname,
301            danger_accept_invalid_certs: self.danger_accept_invalid_certs,
302            danger_accept_invalid_hostnames: self.danger_accept_invalid_hostnames,
303            client_config,
304        })
305    }
306
307    /// Load a custom CA certificate from a PEM file.
308    fn load_custom_ca(&self, ca_path: &str) -> Result<RootCertStore> {
309        let ca_cert_data = fs::read(ca_path).map_err(|e| {
310            Error::Config(format!(
311                "Failed to read CA certificate file '{}': {}",
312                ca_path, e
313            ))
314        })?;
315
316        let mut reader = std::io::Cursor::new(&ca_cert_data);
317        let mut root_store = RootCertStore::empty();
318        let mut found_certs = 0;
319
320        // Parse PEM file and extract certificates
321        loop {
322            match rustls_pemfile::read_one(&mut reader) {
323                Ok(Some(Item::X509Certificate(cert))) => {
324                    let _ = root_store.add_parsable_certificates(std::iter::once(cert));
325                    found_certs += 1;
326                }
327                Ok(Some(_)) => {
328                    // Skip non-certificate items (private keys, etc.)
329                }
330                Ok(None) => {
331                    // End of file
332                    break;
333                }
334                Err(_) => {
335                    return Err(Error::Config(format!(
336                        "Failed to parse CA certificate from '{}'",
337                        ca_path
338                    )));
339                }
340            }
341        }
342
343        if found_certs == 0 {
344            return Err(Error::Config(format!(
345                "No valid certificates found in '{}'",
346                ca_path
347            )));
348        }
349
350        Ok(root_store)
351    }
352}
353
354/// Validate TLS configuration for security constraints.
355///
356/// Enforces that release builds cannot use `danger_accept_invalid_certs`.
357/// Development builds emit a warning but proceed.
358///
359/// # Arguments
360///
361/// * `danger_accept_invalid_certs` - Whether danger mode is enabled
362///
363/// # Errors
364///
365/// Returns `Error::Config` if `danger_accept_invalid_certs` is set in a release build.
366fn validate_tls_security(danger_accept_invalid_certs: bool) -> Result<()> {
367    if danger_accept_invalid_certs {
368        // SECURITY: Return an error in release builds to prevent accidental production use
369        #[cfg(not(debug_assertions))]
370        return Err(Error::Config(
371            "TLS certificate validation bypass not permitted in release builds".into(),
372        ));
373
374        // Development builds: warn but allow
375        #[cfg(debug_assertions)]
376        {
377            tracing::warn!("TLS certificate validation is DISABLED (development only)");
378            tracing::warn!("This mode is only for development with self-signed certificates");
379        }
380    }
381    Ok(())
382}
383
384/// Parse server name from hostname for TLS SNI (Server Name Indication).
385///
386/// # Arguments
387///
388/// * `hostname` - Hostname to parse (without port)
389///
390/// # Returns
391///
392/// A string suitable for TLS server name indication
393///
394/// # Errors
395///
396/// Returns an error if the hostname is invalid.
397pub fn parse_server_name(hostname: &str) -> Result<String> {
398    // Remove trailing dot if present
399    let hostname = hostname.trim_end_matches('.');
400
401    // Validate hostname (basic check)
402    if hostname.is_empty() || hostname.len() > 253 {
403        return Err(Error::Config(format!(
404            "Invalid hostname for TLS: '{}'",
405            hostname
406        )));
407    }
408
409    // Check for invalid characters
410    if !hostname
411        .chars()
412        .all(|c| c.is_alphanumeric() || c == '-' || c == '.')
413    {
414        return Err(Error::Config(format!(
415            "Invalid hostname for TLS: '{}'",
416            hostname
417        )));
418    }
419
420    Ok(hostname.to_string())
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    /// Install a crypto provider for rustls tests.
428    /// This is needed because multiple crypto providers (ring and aws-lc-rs)
429    /// may be enabled via transitive dependencies, requiring explicit selection.
430    fn install_crypto_provider() {
431        // Try to install ring as the default provider, ignore if already installed
432        let _ = rustls::crypto::ring::default_provider().install_default();
433    }
434
435    #[test]
436    fn test_tls_config_builder_defaults() {
437        let tls = TlsConfigBuilder::default();
438        assert!(!tls.danger_accept_invalid_certs);
439        assert!(!tls.danger_accept_invalid_hostnames);
440        assert!(tls.verify_hostname);
441        assert!(tls.ca_cert_path.is_none());
442    }
443
444    #[test]
445    fn test_tls_config_builder_with_hostname_verification() {
446        install_crypto_provider();
447
448        let tls = TlsConfig::builder()
449            .verify_hostname(true)
450            .build()
451            .expect("Failed to build TLS config");
452
453        assert!(tls.verify_hostname());
454        assert!(!tls.danger_accept_invalid_certs());
455    }
456
457    #[test]
458    #[ignore = "requires PEM file on filesystem"]
459    fn test_tls_config_builder_with_custom_ca() {
460        // This test would require an actual PEM file
461    }
462
463    #[test]
464    fn test_parse_server_name_valid() {
465        let _name =
466            parse_server_name("localhost").expect("localhost should be a valid server name");
467        let _name =
468            parse_server_name("example.com").expect("example.com should be a valid server name");
469        let _name = parse_server_name("db.internal.example.com")
470            .expect("subdomain should be a valid server name");
471    }
472
473    #[test]
474    fn test_parse_server_name_trailing_dot() {
475        let _name = parse_server_name("example.com.")
476            .expect("trailing dot should be accepted as valid server name");
477    }
478
479    #[test]
480    fn test_parse_server_name_with_port() {
481        // ServerName expects just hostname, not host:port.
482        // Whether this succeeds or fails depends on the rustls version,
483        // so we only verify it doesn't panic.
484        let _result = parse_server_name("example.com:5432");
485    }
486
487    #[test]
488    fn test_tls_config_debug() {
489        install_crypto_provider();
490
491        let tls = TlsConfig::builder()
492            .verify_hostname(true)
493            .build()
494            .expect("Failed to build TLS config");
495
496        let debug_str = format!("{:?}", tls);
497        assert!(debug_str.contains("TlsConfig"));
498        assert!(debug_str.contains("verify_hostname"));
499    }
500
501    #[test]
502    #[cfg(not(debug_assertions))]
503    fn test_danger_mode_returns_error_in_release_build() {
504        // This test only runs in release builds; danger mode must return an error
505        let result = TlsConfig::builder()
506            .danger_accept_invalid_certs(true)
507            .build();
508        assert!(
509            result.is_err(),
510            "danger mode must be rejected in release builds"
511        );
512        let err = result.unwrap_err();
513        assert!(
514            err.to_string().contains("not permitted in release builds"),
515            "error message must explain the restriction",
516        );
517    }
518
519    #[test]
520    fn test_danger_mode_allowed_in_debug_build() {
521        install_crypto_provider();
522
523        let config = TlsConfig::builder()
524            .danger_accept_invalid_certs(true)
525            .build()
526            .expect("danger mode should be allowed in debug builds");
527
528        assert!(config.danger_accept_invalid_certs());
529    }
530
531    #[test]
532    fn test_normal_tls_config_works() {
533        install_crypto_provider();
534
535        let config = TlsConfig::builder()
536            .verify_hostname(true)
537            .build()
538            .expect("normal TLS config should build successfully");
539
540        assert!(!config.danger_accept_invalid_certs());
541    }
542}
543
544/// A certificate verifier that accepts any certificate.
545///
546/// ⚠️ **DANGER**: This should ONLY be used for development/testing with self-signed certificates.
547/// Using this in production is a serious security vulnerability.
548#[derive(Debug)]
549struct NoVerifier;
550
551impl ServerCertVerifier for NoVerifier {
552    fn verify_server_cert(
553        &self,
554        _end_entity: &CertificateDer<'_>,
555        _intermediates: &[CertificateDer<'_>],
556        _server_name: &ServerName<'_>,
557        _ocsp_response: &[u8],
558        _now: UnixTime,
559    ) -> std::result::Result<ServerCertVerified, rustls::Error> {
560        // Accept any certificate
561        Ok(ServerCertVerified::assertion())
562    }
563
564    fn verify_tls12_signature(
565        &self,
566        _message: &[u8],
567        _cert: &CertificateDer<'_>,
568        _dss: &DigitallySignedStruct,
569    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
570        Ok(HandshakeSignatureValid::assertion())
571    }
572
573    fn verify_tls13_signature(
574        &self,
575        _message: &[u8],
576        _cert: &CertificateDer<'_>,
577        _dss: &DigitallySignedStruct,
578    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
579        Ok(HandshakeSignatureValid::assertion())
580    }
581
582    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
583        // Support all common signature schemes
584        vec![
585            SignatureScheme::RSA_PKCS1_SHA256,
586            SignatureScheme::RSA_PKCS1_SHA384,
587            SignatureScheme::RSA_PKCS1_SHA512,
588            SignatureScheme::ECDSA_NISTP256_SHA256,
589            SignatureScheme::ECDSA_NISTP384_SHA384,
590            SignatureScheme::ECDSA_NISTP521_SHA512,
591            SignatureScheme::RSA_PSS_SHA256,
592            SignatureScheme::RSA_PSS_SHA384,
593            SignatureScheme::RSA_PSS_SHA512,
594            SignatureScheme::ED25519,
595        ]
596    }
597}