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}