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}