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}