Skip to main content

fraiseql_db/postgres/
tls.rs

1//! Transport security for PostgreSQL connections.
2//!
3//! # Why this module exists
4//!
5//! `build_pool` used to call `create_pool(.., NoTls)` unconditionally. Every knob
6//! that claimed to configure database TLS — the server's `[database_tls]
7//! postgres_ssl_mode`, the CLI's `[database] ssl_mode` — was parsed, whitelist-
8//! validated (so a typo failed loudly, which is what convinces an operator the
9//! setting is live) and then discarded, while the boot log printed "Database
10//! connection TLS configuration applied". The result was a documented, validated,
11//! log-confirmed belief that database traffic was encrypted, over a cleartext
12//! socket (#801, #824).
13//!
14//! [`PostgresTlsConfig`] is therefore a **required** field of
15//! [`PoolPrewarmConfig`](super::PoolPrewarmConfig) rather than an option with a
16//! fallback: every site that builds a pool has to say what transport security it
17//! wants, and adding a new one is a compile error until it does.
18//!
19//! # Modes
20//!
21//! The ladder follows libpq, because that is what the names mean everywhere else
22//! and an operator who writes `require` is importing that meaning:
23//!
24//! | Mode | Encrypts | Verifies chain | Verifies hostname |
25//! |---|---|---|---|
26//! | [`Disable`](PostgresSslMode::Disable) | no | — | — |
27//! | [`Prefer`](PostgresSslMode::Prefer) (default) | if offered | no | no |
28//! | [`Require`](PostgresSslMode::Require) | yes, or fails | no | no |
29//! | [`VerifyFull`](PostgresSslMode::VerifyFull) | yes, or fails | yes | yes |
30//!
31//! `prefer` and `require` do not authenticate the server, so they stop passive
32//! eavesdropping but not an active machine-in-the-middle. That is libpq's meaning,
33//! not a shortcut taken here — but since a mode that encrypts without verifying is
34//! its own quiet fail-open, [`PostgresTlsConfig::warn_if_unverified`] exists so the
35//! boot path can say so out loud.
36//!
37//! libpq's `allow` and `verify-ca` are **rejected** rather than approximated:
38//! `allow` (plaintext first, TLS on retry) has no expression in the driver, and
39//! `verify-ca` (chain without hostname) would need a bespoke certificate verifier
40//! whose only purpose is to check less than the default. Both fail at parse time
41//! with a message naming the mode to use instead.
42
43use std::{path::PathBuf, sync::Arc};
44
45use fraiseql_error::{FraiseQLError, Result};
46use rustls::{
47    ClientConfig, DigitallySignedStruct, Error as RustlsError, RootCertStore, SignatureScheme,
48    client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
49    pki_types::{CertificateDer, ServerName, UnixTime, pem::PemObject},
50};
51use tokio_postgres_rustls::MakeRustlsConnect;
52
53/// How much transport security a PostgreSQL connection must have.
54///
55/// See this module's documentation for the meaning of each rung, and for why libpq's
56/// `allow` and `verify-ca` are refused rather than approximated.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum PostgresSslMode {
59    /// Never negotiate TLS; connect in cleartext.
60    Disable,
61    /// Negotiate TLS when the server offers it, fall back to cleartext otherwise.
62    ///
63    /// The default, and libpq's. Does not authenticate the server.
64    #[default]
65    Prefer,
66    /// Require TLS; fail if the server cannot provide it. Does not authenticate
67    /// the server — use [`VerifyFull`](Self::VerifyFull) for that.
68    Require,
69    /// Require TLS *and* verify the server's certificate chain and hostname.
70    VerifyFull,
71}
72
73impl PostgresSslMode {
74    /// The spelling used in configuration files and connection URLs.
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Disable => "disable",
79            Self::Prefer => "prefer",
80            Self::Require => "require",
81            Self::VerifyFull => "verify-full",
82        }
83    }
84
85    /// Whether this mode authenticates the server it connects to.
86    #[must_use]
87    pub const fn verifies_server(self) -> bool {
88        matches!(self, Self::VerifyFull)
89    }
90
91    /// Whether this mode refuses to fall back to cleartext.
92    #[must_use]
93    pub const fn requires_encryption(self) -> bool {
94        matches!(self, Self::Require | Self::VerifyFull)
95    }
96}
97
98impl std::fmt::Display for PostgresSslMode {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(self.as_str())
101    }
102}
103
104impl std::str::FromStr for PostgresSslMode {
105    type Err = FraiseQLError;
106
107    /// Parse a libpq ssl-mode spelling.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`FraiseQLError::Configuration`] for an unknown mode, and for the
112    /// two libpq modes this driver cannot honour (`allow`, `verify-ca`) — each with
113    /// a message naming the mode to use instead. Silently downgrading either one
114    /// would reintroduce the defect this module exists to fix.
115    fn from_str(s: &str) -> Result<Self> {
116        match s.trim().to_ascii_lowercase().as_str() {
117            "disable" => Ok(Self::Disable),
118            "prefer" => Ok(Self::Prefer),
119            "require" => Ok(Self::Require),
120            "verify-full" => Ok(Self::VerifyFull),
121            "allow" => Err(FraiseQLError::Configuration {
122                message: "postgres ssl mode \"allow\" (try cleartext first, retry with TLS) is \
123                          not supported by the PostgreSQL driver FraiseQL uses. Use \"prefer\" \
124                          for the same best-effort behaviour with TLS attempted first, or \
125                          \"require\" to insist on encryption."
126                    .to_string(),
127            }),
128            "verify-ca" => Err(FraiseQLError::Configuration {
129                message: "postgres ssl mode \"verify-ca\" (verify the certificate chain but not \
130                          the hostname) is not supported. Use \"verify-full\", which also checks \
131                          that the certificate was issued for the host being connected to — \
132                          without it, any host holding any certificate from the same CA can \
133                          impersonate the database."
134                    .to_string(),
135            }),
136            other => Err(FraiseQLError::Configuration {
137                message: format!(
138                    "unknown postgres ssl mode {other:?}. Supported: disable, prefer, require, \
139                     verify-full."
140                ),
141            }),
142        }
143    }
144}
145
146/// Transport security settings for a PostgreSQL connection pool.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct PostgresTlsConfig {
149    /// How much transport security to demand, or `None` to let the connection URL
150    /// decide.
151    ///
152    /// `None` is not the same as `Some(Prefer)`. An unset mode leaves the driver to
153    /// read `?sslmode=` out of the URL; forcing `Prefer` in its place would
154    /// *override* an operator's explicit `?sslmode=require` and silently downgrade
155    /// it to opportunistic encryption. The URL form was the one surface that already
156    /// failed loudly here, so a config default must not be allowed to quietly
157    /// outrank it.
158    pub mode: Option<PostgresSslMode>,
159
160    /// PEM bundle of certificate authorities to trust when verifying the server.
161    ///
162    /// When set, these roots **replace** the platform trust store rather than
163    /// adding to it — a managed-database CA (RDS, Cloud SQL) is normally not in
164    /// the system store, and an operator who names a CA file means "this CA",
165    /// not "this CA or any of the several hundred the OS ships".
166    ///
167    /// Only consulted by [`PostgresSslMode::VerifyFull`]; the weaker modes verify
168    /// nothing, so supplying a bundle alongside them is rejected by
169    /// [`validate`](Self::validate) rather than silently ignored.
170    pub ca_bundle_path: Option<PathBuf>,
171}
172
173impl PostgresTlsConfig {
174    /// Settings that explicitly demand `mode`, using the platform trust store.
175    #[must_use]
176    pub const fn new(mode: PostgresSslMode) -> Self {
177        Self {
178            mode:           Some(mode),
179            ca_bundle_path: None,
180        }
181    }
182
183    /// The mode the connector should enforce, treating "unset" as libpq's default.
184    ///
185    /// Used to decide *verification*, which must be settled before the handshake.
186    /// An unset mode cannot be `verify-full`, because the only thing that could have
187    /// set it is the URL and `?sslmode=` tops out at `require`.
188    #[must_use]
189    pub fn effective_mode(&self) -> PostgresSslMode {
190        self.mode.unwrap_or_default()
191    }
192
193    /// Reject combinations whose parts contradict each other.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`FraiseQLError::Configuration`] when a CA bundle is supplied for a
198    /// mode that never verifies a certificate. Accepting it would leave the
199    /// operator believing the named CA was pinned while the connection
200    /// authenticated nothing — the shape of defect this module exists to remove.
201    pub fn validate(&self) -> Result<()> {
202        if self.ca_bundle_path.is_some() && !self.effective_mode().verifies_server() {
203            return Err(FraiseQLError::Configuration {
204                message: format!(
205                    "a CA bundle was configured for database TLS, but ssl mode {:?} never \
206                     verifies the server certificate, so the bundle would have no effect. Use \
207                     \"verify-full\" to check the server against it, or remove the bundle.",
208                    self.effective_mode().as_str()
209                ),
210            });
211        }
212        Ok(())
213    }
214
215    /// Emit a startup warning when the configured mode encrypts without
216    /// authenticating the peer.
217    ///
218    /// Scoped to `require`, which is the mode an operator chooses *in order to* get
219    /// transport security and which therefore carries the false confidence:
220    /// "encrypted" and "encrypted to the right server" are the two things most
221    /// easily conflated, so the boot log separates them. `prefer` is the default and
222    /// promises nothing, so warning on it would be noise on every boot.
223    pub fn warn_if_unverified(&self) {
224        if self.mode == Some(PostgresSslMode::Require) {
225            tracing::warn!(
226                postgres_ssl_mode = PostgresSslMode::Require.as_str(),
227                "Database connections are encrypted but the server certificate is NOT verified; \
228                 an active machine-in-the-middle can still intercept them. Use \
229                 postgres_ssl_mode = \"verify-full\" (with ca_bundle_path if the CA is not in \
230                 the platform trust store) to authenticate the database."
231            );
232        }
233    }
234
235    /// Build the connector deadpool should create connections with.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`FraiseQLError::Configuration`] if the settings contradict
240    /// themselves, if the CA bundle cannot be read or contains no usable
241    /// certificate, or if no trust anchors are available for a verifying mode.
242    pub fn connector(&self) -> Result<PostgresConnector> {
243        self.validate()?;
244
245        let mode = self.effective_mode();
246        if mode == PostgresSslMode::Disable {
247            return Ok(PostgresConnector::Plaintext);
248        }
249
250        // Built against an explicit `ring` provider rather than the process-level
251        // default. `ClientConfig::builder()` *panics* when no default is installed —
252        // or when two providers are compiled in and neither was selected — and a
253        // panic while constructing the connection pool would take the server down at
254        // boot for a reason that names none of this.
255        let provider = Arc::new(rustls::crypto::ring::default_provider());
256        let builder = ClientConfig::builder_with_provider(Arc::clone(&provider))
257            .with_safe_default_protocol_versions()
258            .map_err(|e| FraiseQLError::Configuration {
259                message: format!("failed to initialise the database TLS client: {e}"),
260            })?;
261
262        let config = if mode.verifies_server() {
263            builder.with_root_certificates(self.root_store()?).with_no_client_auth()
264        } else {
265            // Reason: `prefer`/`require` are libpq's encrypt-without-authenticating
266            // rungs. Expressing them needs a verifier that accepts any chain; the
267            // alternative is to silently promote them to `verify-full`, which breaks
268            // every deployment using a private CA, or to keep `NoTls`, which is the
269            // bug. `dangerous()` is the honest spelling of what the mode means.
270            builder
271                .dangerous()
272                .with_custom_certificate_verifier(Arc::new(UnverifiedServer::new(&provider)))
273                .with_no_client_auth()
274        };
275
276        Ok(PostgresConnector::Tls(Box::new(MakeRustlsConnect::new(config))))
277    }
278
279    /// Assemble the trust anchors a verifying connection checks the server against.
280    fn root_store(&self) -> Result<RootCertStore> {
281        let mut roots = RootCertStore::empty();
282
283        if let Some(path) = &self.ca_bundle_path {
284            let pem = std::fs::read(path).map_err(|e| FraiseQLError::Configuration {
285                message: format!(
286                    "failed to read the database TLS CA bundle at {}: {e}",
287                    path.display()
288                ),
289            })?;
290            let certs = CertificateDer::pem_slice_iter(&pem)
291                .collect::<std::result::Result<Vec<_>, _>>()
292                .map_err(|e| FraiseQLError::Configuration {
293                    message: format!(
294                        "failed to parse the database TLS CA bundle at {}: {e}",
295                        path.display()
296                    ),
297                })?;
298
299            // Count what the trust store *accepted*, not what the file contained.
300            // A bundle whose certificates rustls rejects would otherwise yield an
301            // empty trust store and a "successful" load, turning a CA problem into
302            // an unexplained handshake failure at the first connection (#887).
303            let (added, ignored) = roots.add_parsable_certificates(certs);
304            if added == 0 {
305                return Err(FraiseQLError::Configuration {
306                    message: format!(
307                        "the database TLS CA bundle at {} contains no usable certificate \
308                         ({ignored} item(s) present but unparseable as X.509). Database \
309                         connections would fail verification against an empty trust store.",
310                        path.display()
311                    ),
312                });
313            }
314            if ignored > 0 {
315                tracing::warn!(
316                    ca_bundle_path = %path.display(),
317                    added,
318                    ignored,
319                    "Some entries in the database TLS CA bundle were not usable certificates \
320                     and were skipped"
321                );
322            }
323            return Ok(roots);
324        }
325
326        let native = rustls_native_certs::load_native_certs();
327        let (added, ignored) = roots.add_parsable_certificates(native.certs);
328        if added == 0 {
329            return Err(FraiseQLError::Configuration {
330                message: format!(
331                    "database TLS is set to \"verify-full\" but no trust anchors are available: \
332                     the platform certificate store yielded no usable certificate ({ignored} \
333                     skipped, {} load error(s)). Set ca_bundle_path to the CA that issued the \
334                     database's certificate.",
335                    native.errors.len()
336                ),
337            });
338        }
339        Ok(roots)
340    }
341}
342
343/// The connector a pool creates connections with.
344///
345/// Two variants rather than one boxed trait object because `NoTls` and
346/// `MakeRustlsConnect` are distinct `MakeTlsConnect` implementations and deadpool's
347/// `create_pool` is generic over them.
348pub enum PostgresConnector {
349    /// Cleartext (`sslmode = disable`).
350    Plaintext,
351    /// TLS via rustls.
352    ///
353    /// Boxed because `MakeRustlsConnect` embeds a full rustls `ClientConfig`, which
354    /// would otherwise make every `PostgresConnector` (and the `Result` carrying it)
355    /// as large as the largest variant.
356    Tls(Box<MakeRustlsConnect>),
357}
358
359/// A certificate verifier that accepts any server certificate.
360///
361/// This is what libpq's `prefer` and `require` mean: encrypt the session, do not
362/// authenticate the peer. It is reachable only when the operator selects one of
363/// those two modes, and selecting them logs a warning saying precisely this.
364#[derive(Debug)]
365struct UnverifiedServer {
366    /// Signature algorithms of the active crypto provider, used to check the
367    /// handshake signature. The *signature* is still verified — only the
368    /// certificate's provenance is not.
369    supported: rustls::crypto::WebPkiSupportedAlgorithms,
370}
371
372impl UnverifiedServer {
373    fn new(provider: &Arc<rustls::crypto::CryptoProvider>) -> Self {
374        Self {
375            supported: provider.signature_verification_algorithms,
376        }
377    }
378}
379
380impl ServerCertVerifier for UnverifiedServer {
381    fn verify_server_cert(
382        &self,
383        _end_entity: &CertificateDer<'_>,
384        _intermediates: &[CertificateDer<'_>],
385        _server_name: &ServerName<'_>,
386        _ocsp_response: &[u8],
387        _now: UnixTime,
388    ) -> std::result::Result<ServerCertVerified, RustlsError> {
389        Ok(ServerCertVerified::assertion())
390    }
391
392    fn verify_tls12_signature(
393        &self,
394        message: &[u8],
395        cert: &CertificateDer<'_>,
396        dss: &DigitallySignedStruct,
397    ) -> std::result::Result<HandshakeSignatureValid, RustlsError> {
398        rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported)
399    }
400
401    fn verify_tls13_signature(
402        &self,
403        message: &[u8],
404        cert: &CertificateDer<'_>,
405        dss: &DigitallySignedStruct,
406    ) -> std::result::Result<HandshakeSignatureValid, RustlsError> {
407        rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported)
408    }
409
410    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
411        self.supported.supported_schemes()
412    }
413}