diff --git a/Cargo.lock b/Cargo.lock index 2b393360ae..654bfc5045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.9.1" @@ -141,6 +147,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -236,6 +248,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "digest" version = "0.10.7" @@ -253,7 +288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -287,6 +322,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -1361,6 +1402,7 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1381,6 +1423,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -1579,6 +1630,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1655,11 +1716,15 @@ dependencies = [ "regex", "reqwest", "rustc_version", + "rustls", + "rustls-native-certs", + "rustls-pemfile", "serde", "serde_json", "sha2", "tokio", "tokio-postgres", + "tokio-postgres-rustls", "ulid", ] @@ -1734,6 +1799,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "tokio" version = "1.53.1" @@ -1774,6 +1860,20 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls", + "sha2", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -2192,6 +2292,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yoke" version = "0.8.0" @@ -2242,6 +2354,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] [[package]] name = "zerotrie" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3c31a581cf..2643d1ee69 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -77,6 +77,19 @@ itertools = "0.15.0" # libpq is an optional runtime dependency rather than a build-time one. libloading = "0.8" postgres-protocol = "0.6.10" +# Use the `ring` crypto provider (not the default `aws-lc-rs`, which needs cmake +# and isn't available here) — `ring` is already in the tree via reqwest. +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "logging", + "std", + "tls12", +] } +rustls-native-certs = "0.8" +rustls-pemfile = "2" +tokio-postgres-rustls = { version = "0.14.0", default-features = false, features = [ + "ring", +] } [build-dependencies] blake2 = "0.11.0" diff --git a/rust/src/database/postgres/mod.rs b/rust/src/database/postgres/mod.rs index 46d970bb27..925703ece5 100644 --- a/rust/src/database/postgres/mod.rs +++ b/rust/src/database/postgres/mod.rs @@ -16,6 +16,7 @@ mod cursor_state; mod errors; mod helpers; pub mod pool; +mod tls; mod value; /// Register the `postgres` submodule (the `ConnectionPool`, `Connection` and diff --git a/rust/src/database/postgres/pool.rs b/rust/src/database/postgres/pool.rs index c2241cda17..2b5f1be648 100644 --- a/rust/src/database/postgres/pool.rs +++ b/rust/src/database/postgres/pool.rs @@ -22,13 +22,15 @@ use deadpool::managed::{Manager, Metrics, Object, Pool, PoolError, RecycleError, use log::warn; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio::runtime::Handle; -use tokio_postgres::{Client, Config, NoTls, Statement}; +use tokio_postgres::{Client, Config, Connection as PgConnection, NoTls, Socket, Statement}; use crate::database::postgres::connection::Connection; use crate::database::postgres::errors::{pg_err_to_py, OperationalError}; use crate::database::postgres::fixup_config_defaults; use crate::database::postgres::helpers::BlockingPostgres; +use crate::database::postgres::tls::{self, TlsConnector, TlsParams}; use crate::runtime::RustRuntime; /// Per-connection session settings applied once when a pooled connection is @@ -81,7 +83,8 @@ impl SessionConfig { /// Creates and recycles [`tokio_postgres`] connections for a [`ConnectionPool`]. pub struct ConnectionManager { /// The resolved connection config, parsed once from the DSN (with libpq's - /// default host filled in if the DSN omitted one). + /// default host filled in if the DSN omitted one, and the SSL mode set from + /// the `ssl*` params). config: Config, /// Handle to the extension's shared tokio runtime (see /// [`crate::runtime::RustRuntime`]). Each connection's long-lived connection task @@ -90,24 +93,50 @@ pub struct ConnectionManager { handle: Handle, /// Session settings applied to each connection when it is opened. session: SessionConfig, + /// How to (or whether to) negotiate TLS for each connection. + tls: TlsConnector, } impl ConnectionManager { - /// Build a manager from a libpq-style DSN and session settings, spawning - /// connection tasks on the runtime behind `handle`. + /// Build a manager from a libpq-style DSN, session settings and TLS params, + /// spawning connection tasks on the runtime behind `handle`. pub fn from_dsn( dsn: &str, handle: Handle, session: SessionConfig, + tls_params: &TlsParams, ) -> Result { + let mut config = fixup_config_defaults(dsn)?; + // Resolve the `PGSSL*` env fallbacks here, next to the other `PG*` + // env handling in `fixup_config_defaults`, keeping `tls::build` (and + // its unit tests) a pure function of its params. + let tls_params = tls::with_env_defaults(tls_params); + let (ssl_mode, connector) = tls::build(&tls_params)?; + config.ssl_mode(ssl_mode); Ok(Self { - config: fixup_config_defaults(dsn)?, + config, handle, session, + tls: connector, }) } } +/// Drive a connection's background task (which pumps the socket) on the shared +/// runtime. The task ends on its own when the `Client` is dropped, i.e. when the +/// pool discards the connection. Generic over the stream so it works for both +/// the plaintext and TLS connectors. +fn spawn_connection_task(handle: &Handle, connection: PgConnection) +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + handle.spawn(async move { + if let Err(e) = connection.await { + warn!("postgres connection error: {e}"); + } + }); +} + /// A pooled [`Client`] plus its cache of prepared statements. /// /// The cache lives *with* the pooled connection (not with a checkout): named @@ -180,13 +209,18 @@ impl Manager for ConnectionManager { // (which pumps the socket) on the shared runtime. The task ends on its // own when the `Client` is dropped, i.e. when the pool discards this // connection. - let (client, connection) = self.config.connect(NoTls).await?; - - self.handle.spawn(async move { - if let Err(e) = connection.await { - warn!("postgres connection error: {e}"); + let client = match &self.tls { + TlsConnector::NoTls => { + let (client, connection) = self.config.connect(NoTls).await?; + spawn_connection_task(&self.handle, connection); + client } - }); + TlsConnector::Rustls(connector) => { + let (client, connection) = self.config.connect(connector.clone()).await?; + spawn_connection_task(&self.handle, connection); + client + } + }; // Apply per-connection session setup once, up front (the native // equivalent of the engine's `on_new_connection`). @@ -225,17 +259,25 @@ pub fn create_pool( max_size: usize, handle: Handle, ) -> Result { - create_pool_with_session(dsn, max_size, handle, SessionConfig::default()) + create_pool_with_session( + dsn, + max_size, + handle, + SessionConfig::default(), + &TlsParams::default(), + ) } -/// Build a [`ConnectionPool`] with explicit per-connection [`SessionConfig`]. +/// Build a [`ConnectionPool`] with explicit per-connection [`SessionConfig`] and +/// TLS params. pub fn create_pool_with_session( dsn: &str, max_size: usize, handle: Handle, session: SessionConfig, + tls_params: &TlsParams, ) -> Result { - let manager = ConnectionManager::from_dsn(dsn, handle, session)?; + let manager = ConnectionManager::from_dsn(dsn, handle, session, tls_params)?; Ok(Pool::builder(manager).max_size(max_size).build()?) } @@ -268,21 +310,34 @@ impl PyConnectionPool { /// (REPEATABLE READ) isolation level, plus `synchronous_commit` / /// `statement_timeout` if configured here. #[new] - #[pyo3(signature = (runtime, dsn, max_size = 10, *, synchronous_commit = true, statement_timeout_ms = None))] + #[pyo3(signature = (runtime, dsn, max_size = 10, *, synchronous_commit = true, statement_timeout_ms = None, sslmode = None, sslrootcert = None, sslcert = None, sslkey = None, sslpassword = None))] + #[allow(clippy::too_many_arguments)] fn new( runtime: &RustRuntime, dsn: &str, max_size: usize, synchronous_commit: bool, statement_timeout_ms: Option, + sslmode: Option, + sslrootcert: Option, + sslcert: Option, + sslkey: Option, + sslpassword: Option, ) -> PyResult { let handle = runtime.tokio_handle()?; let session = SessionConfig { synchronous_commit, statement_timeout_ms, }; - let pool = - create_pool_with_session(dsn, max_size, handle.clone(), session).map_err(|e| { + let tls_params = TlsParams { + sslmode, + sslrootcert, + sslcert, + sslkey, + sslpassword, + }; + let pool = create_pool_with_session(dsn, max_size, handle.clone(), session, &tls_params) + .map_err(|e| { PyRuntimeError::new_err(format!("failed to build connection pool: {e}")) })?; Ok(Self { pool, handle }) @@ -427,7 +482,9 @@ mod tests { statement_timeout_ms: Some(1234), }; let rt = test_runtime(); - let pool = create_pool_with_session(&dsn, 1, rt.handle().clone(), session).unwrap(); + let pool = + create_pool_with_session(&dsn, 1, rt.handle().clone(), session, &TlsParams::default()) + .unwrap(); rt.block_on(async { let client = pool.get().await.unwrap(); diff --git a/rust/src/database/postgres/tls.rs b/rust/src/database/postgres/tls.rs new file mode 100644 index 0000000000..ea3db87e09 --- /dev/null +++ b/rust/src/database/postgres/tls.rs @@ -0,0 +1,455 @@ +// This file is licensed under the Affero General Public License (AGPL) version 3. +// +// Copyright (C) 2026 Element Creations Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// See the GNU Affero General Public License for more details: +// . + +//! TLS for the native Rust Postgres backend. +//! +//! psycopg2 hands `sslmode` / `sslrootcert` / `sslcert` / `sslkey` / +//! `sslpassword` straight to libpq, so those keys already work in a Synapse +//! `database.args`. This module reproduces libpq's behaviour on top of +//! `tokio-postgres` + `rustls`, so the same config keeps working on the Rust +//! backend. +//! +//! `tokio-postgres` only understands `sslmode` `disable`/`prefer`/`require` and +//! does no verification itself, so we drive both the handshake mode *and* the +//! verification level from here: +//! +//! | `sslmode` | encrypt | verify chain | verify host | verifier | +//! |---------------|--------------|--------------|-------------|---------------------| +//! | `disable` | no | – | – | (no TLS) | +//! | `allow` | opportunistic| no | no | accept-any | +//! | `prefer`\* | opportunistic| no | no | accept-any | +//! | `require` | yes | no | no | accept-any | +//! | `verify-ca` | yes | yes | no | chain-only | +//! | `verify-full` | yes | yes | yes | full (WebPKI) | +//! +//! \* libpq's default when `sslmode` is unset. +//! +//! Note that libpq's `require` encrypts *without* verifying the certificate, so +//! the accept-any verifier below is the compatible behaviour, not a shortcut. + +use std::fs::File; +use std::io::BufReader; +use std::sync::Arc; + +use anyhow::{anyhow, Context}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::client::WebPkiServerVerifier; +use rustls::crypto::{ring, verify_tls12_signature, verify_tls13_signature, CryptoProvider}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use rustls::{ + CertificateError, ClientConfig, DigitallySignedStruct, Error as RustlsError, RootCertStore, + SignatureScheme, +}; +use tokio_postgres::config::SslMode; +use tokio_postgres_rustls::MakeRustlsConnect; + +/// The libpq TLS keywords, extracted from `database.args` before the DSN is +/// built (see `synapse.storage.rust_dbapi.split_ssl_params`), since +/// `tokio-postgres` can't parse the cert paths or the `verify-*` modes. +#[derive(Debug, Default, Clone)] +pub struct TlsParams { + pub sslmode: Option, + pub sslrootcert: Option, + pub sslcert: Option, + pub sslkey: Option, + pub sslpassword: Option, +} + +/// The TLS connector to hand to [`tokio_postgres::Config::connect`]. +#[derive(Clone)] +pub enum TlsConnector { + /// `sslmode=disable`: never negotiate TLS. + NoTls, + /// Any other mode: negotiate TLS with the verifier chosen per `sslmode`. + Rustls(MakeRustlsConnect), +} + +/// How strictly the server certificate is checked, derived from `sslmode`. +enum Verification { + /// Encrypt but don't verify the certificate (`prefer` / `require`). + AcceptAny, + /// Verify the certificate chain but not the hostname (`verify-ca`). + ChainOnly, + /// Verify the chain and the hostname (`verify-full`). + Full, +} + +/// Fill unset [`TlsParams`] fields from libpq's `PGSSL*` environment +/// variables, as libpq does for keywords the connection string omits. +/// +/// Called at pool/connection construction (see `ConnectionManager::from_dsn`, +/// next to the other `PG*` env handling in `fixup_config_defaults`) rather +/// than inside [`build`], which stays a pure function of its params. +/// +/// Deliberately more conservative than libpq where a stray variable could +/// break a working setup: +/// - a set-but-empty variable is treated as unset, as libpq does; +/// - `PGSSLCERT`/`PGSSLKEY` are imported only as a complete pair, and only +/// when neither is configured explicitly. libpq resolves a lone sslcert's +/// key lazily (`~/.postgresql/postgresql.key`) and only fails if the +/// server actually requests a client certificate; we build the TLS config +/// eagerly, so a leftover `PGSSLCERT` from some other tool must not abort +/// every connection. +pub(crate) fn with_env_defaults(params: &TlsParams) -> TlsParams { + fn env_nonempty(var: &str) -> Option { + std::env::var(var).ok().filter(|value| !value.is_empty()) + } + fn fallback(value: &Option, var: &str) -> Option { + value.clone().or_else(|| env_nonempty(var)) + } + + let (sslcert, sslkey) = match (¶ms.sslcert, ¶ms.sslkey) { + (None, None) => match (env_nonempty("PGSSLCERT"), env_nonempty("PGSSLKEY")) { + (Some(cert), Some(key)) => (Some(cert), Some(key)), + _ => (None, None), + }, + (cert, key) => (cert.clone(), key.clone()), + }; + + TlsParams { + sslmode: fallback(¶ms.sslmode, "PGSSLMODE"), + sslrootcert: fallback(¶ms.sslrootcert, "PGSSLROOTCERT"), + sslcert, + sslkey, + sslpassword: fallback(¶ms.sslpassword, "PGSSLPASSWORD"), + } +} + +/// Resolve [`TlsParams`] into the `tokio-postgres` [`SslMode`] to set on the +/// connection config and the [`TlsConnector`] to connect with. A pure +/// function of its params: the `PGSSL*` env fallbacks are applied by the +/// caller (see [`with_env_defaults`]). +pub fn build(params: &TlsParams) -> anyhow::Result<(SslMode, TlsConnector)> { + let mode = params.sslmode.as_deref().unwrap_or("prefer"); + let (ssl_mode, verification) = match mode { + "disable" => return Ok((SslMode::Disable, TlsConnector::NoTls)), + // libpq's `allow` tries plaintext first then TLS; `tokio-postgres` has no + // such mode, so map it to `prefer` (TLS first, then plaintext) — both end + // up encrypted-or-not without verification. + "allow" | "prefer" => (SslMode::Prefer, Verification::AcceptAny), + "require" => (SslMode::Require, Verification::AcceptAny), + "verify-ca" => (SslMode::Require, Verification::ChainOnly), + "verify-full" => (SslMode::Require, Verification::Full), + other => return Err(anyhow!("unsupported sslmode: {other:?}")), + }; + + let config = build_client_config(params, verification)?; + Ok(( + ssl_mode, + TlsConnector::Rustls(MakeRustlsConnect::new(config)), + )) +} + +/// Build the `rustls` client config: the verifier for the chosen level, plus a +/// client certificate for mutual TLS if `sslcert`/`sslkey` are set. +fn build_client_config( + params: &TlsParams, + verification: Verification, +) -> anyhow::Result { + // Use the `ring` provider explicitly (the crate is built without the default + // `aws-lc-rs`, which needs a C toolchain we don't have here). + let provider = Arc::new(ring::default_provider()); + + let builder = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .context("no TLS protocol versions available")?; + + let builder = match verification { + Verification::AcceptAny => builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert { provider })), + Verification::ChainOnly => { + let verifier = WebPkiServerVerifier::builder_with_provider( + Arc::new(root_store(params)?), + provider, + ) + .build() + .context("building the certificate verifier")?; + builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoHostnameServerCert { + inner: verifier, + })) + } + Verification::Full => builder.with_root_certificates(root_store(params)?), + }; + + // Mutual TLS: present a client certificate if configured. + let config = match (¶ms.sslcert, ¶ms.sslkey) { + (Some(cert), Some(key)) => builder + .with_client_auth_cert( + load_certs(cert)?, + load_key(key, params.sslpassword.as_deref())?, + ) + .context("configuring the client certificate")?, + (None, None) => builder.with_no_client_auth(), + _ => { + return Err(anyhow!( + "sslcert and sslkey must be set together for client-certificate auth" + )) + } + }; + + Ok(config) +} + +/// The root store for chain verification: `sslrootcert` if given (a PEM CA +/// bundle), otherwise the platform's native root store — as libpq falls back to +/// the system trust store. +fn root_store(params: &TlsParams) -> anyhow::Result { + let mut store = RootCertStore::empty(); + match ¶ms.sslrootcert { + Some(path) => { + for cert in load_certs(path)? { + store + .add(cert) + .with_context(|| format!("adding a CA certificate from {path}"))?; + } + } + None => { + let result = rustls_native_certs::load_native_certs(); + for cert in result.certs { + // Ignore individual malformed system certs, as rustls does. + let _ = store.add(cert); + } + if store.is_empty() { + return Err(anyhow!( + "no usable system CA certificates found; set sslrootcert" + )); + } + } + } + Ok(store) +} + +/// Load a PEM certificate (chain) file into DER certificates. +fn load_certs(path: &str) -> anyhow::Result>> { + let mut reader = BufReader::new(File::open(path).with_context(|| format!("opening {path}"))?); + let certs = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .with_context(|| format!("reading certificates from {path}"))?; + if certs.is_empty() { + return Err(anyhow!("no certificates found in {path}")); + } + Ok(certs) +} + +/// Load a PEM private-key file. Encrypted keys are not supported by +/// `rustls-pemfile`; if no usable key is found and an `sslpassword` was +/// supplied, say so in the error. A password alongside an *unencrypted* key +/// is simply ignored, as libpq ignores it — a stray `PGSSLPASSWORD` in the +/// environment must not break working mutual TLS. +fn load_key(path: &str, sslpassword: Option<&str>) -> anyhow::Result> { + let mut reader = BufReader::new(File::open(path).with_context(|| format!("opening {path}"))?); + match rustls_pemfile::private_key(&mut reader) + .with_context(|| format!("reading a private key from {path}"))? + { + Some(key) => Ok(key), + None if sslpassword.is_some() => Err(anyhow!( + "no usable private key found in {path}: encrypted client keys \ + (sslpassword) are not supported by the Rust driver" + )), + None => Err(anyhow!("no private key found in {path}")), + } +} + +/// Accept any server certificate (encryption without verification), for +/// `sslmode=prefer`/`require`. The TLS handshake signatures are still checked by +/// the crypto provider — only the certificate's trust chain is not. +#[derive(Debug)] +struct AcceptAnyServerCert { + provider: Arc, +} + +impl ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Verify the certificate chain but tolerate a hostname mismatch, for +/// `sslmode=verify-ca`. Delegates everything to a [`WebPkiServerVerifier`] and +/// only maps its hostname-mismatch error to success. +#[derive(Debug)] +struct NoHostnameServerCert { + inner: Arc, +} + +impl ServerCertVerifier for NoHostnameServerCert { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + tolerate_name_mismatch(self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + )) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +/// Map a hostname-mismatch verification error to success, for `verify-ca`. +/// rustls reports mismatches as `NotValidForNameContext` (`NotValidForName` is +/// its context-free legacy spelling; match both in case a future verifier +/// reverts to it). Everything else — expiry, unknown CA, … — passes through. +fn tolerate_name_mismatch( + result: Result, +) -> Result { + match result { + Err(RustlsError::InvalidCertificate( + CertificateError::NotValidForName | CertificateError::NotValidForNameContext { .. }, + )) => Ok(ServerCertVerified::assertion()), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(sslmode: &str) -> TlsParams { + TlsParams { + sslmode: Some(sslmode.to_owned()), + ..Default::default() + } + } + + #[test] + fn disable_uses_no_tls() { + let (mode, connector) = build(¶ms("disable")).unwrap(); + assert_eq!(mode, SslMode::Disable); + assert!(matches!(connector, TlsConnector::NoTls)); + } + + #[test] + fn default_is_prefer() { + let (mode, connector) = build(&TlsParams::default()).unwrap(); + assert_eq!(mode, SslMode::Prefer); + assert!(matches!(connector, TlsConnector::Rustls(_))); + } + + #[test] + fn require_and_verify_modes_use_require_handshake() { + for mode in ["require", "verify-ca", "verify-full"] { + let (ssl_mode, connector) = build(¶ms(mode)).unwrap(); + assert_eq!(ssl_mode, SslMode::Require, "mode {mode}"); + assert!(matches!(connector, TlsConnector::Rustls(_)), "mode {mode}"); + } + } + + #[test] + fn unknown_sslmode_is_rejected() { + assert!(build(¶ms("bogus")).is_err()); + } + + #[test] + fn sslcert_without_sslkey_is_rejected() { + let p = TlsParams { + sslmode: Some("require".to_owned()), + sslcert: Some("/tmp/cert.pem".to_owned()), + ..Default::default() + }; + assert!(build(&p).is_err()); + } + + #[test] + fn verify_ca_tolerates_both_hostname_mismatch_spellings() { + // The context-carrying variant rustls's webpki verifier actually + // returns for a hostname mismatch... + let ctx = CertificateError::NotValidForNameContext { + expected: ServerName::try_from("db.example.com").unwrap(), + presented: vec!["DnsName(\"other.example.com\")".to_owned()], + }; + assert!(tolerate_name_mismatch(Err(RustlsError::InvalidCertificate(ctx))).is_ok()); + + // ...and its context-free legacy spelling. + assert!(tolerate_name_mismatch(Err(RustlsError::InvalidCertificate( + CertificateError::NotValidForName + ))) + .is_ok()); + + // Any other certificate failure still propagates. + assert!(tolerate_name_mismatch(Err(RustlsError::InvalidCertificate( + CertificateError::Expired + ))) + .is_err()); + } +} diff --git a/synapse/storage/database.py b/synapse/storage/database.py index bf688c7a3c..a53607d500 100644 --- a/synapse/storage/database.py +++ b/synapse/storage/database.py @@ -222,9 +222,10 @@ def _make_rust_pool( from synapse.storage.rust_pool import RustConnectionPool db_args = db_config.config.get("args", {}) - dsn = rust_dbapi.build_dsn( + dsn_args, ssl_params = rust_dbapi.split_ssl_params( {k: v for k, v in db_args.items() if not k.startswith("cp_")} ) + dsn = rust_dbapi.build_dsn(dsn_args) # Size the pool (threads and connections, 1:1) to the configured cp_max; # Twisted's adbapi default is 5. threads = db_args.get("cp_max", 5) @@ -237,6 +238,7 @@ def _make_rust_pool( threads=threads, synchronous_commit=engine.synchronous_commit, statement_timeout_ms=engine.statement_timeout, + ssl_params=ssl_params, ) pool.start() clock.add_system_event_trigger("during", "shutdown", pool.close) @@ -282,16 +284,19 @@ def make_conn( if isinstance(engine, RustPostgresEngine): # The Rust backend has no `module.connect`; open a standalone (pool-of-one) # connection from the same libpq args, with the engine's session settings. + # The `ssl*` keys go to the pool as explicit params rather than in the DSN. from synapse.storage import rust_dbapi if runtime is None: runtime = rust_dbapi.standalone_runtime() + dsn_args, ssl_params = rust_dbapi.split_ssl_params(db_params) native_db_conn = rust_dbapi.connect( runtime, - rust_dbapi.build_dsn(db_params), + rust_dbapi.build_dsn(dsn_args), synchronous_commit=engine.synchronous_commit, statement_timeout_ms=engine.statement_timeout, + ssl_params=ssl_params, ) else: native_db_conn = engine.module.connect(**db_params) diff --git a/synapse/storage/rust_dbapi.py b/synapse/storage/rust_dbapi.py index 0a47387655..b8e37e1b18 100644 --- a/synapse/storage/rust_dbapi.py +++ b/synapse/storage/rust_dbapi.py @@ -165,6 +165,41 @@ def build_dsn(params: Mapping[str, Any]) -> str: return " ".join(parts) +# libpq TLS keywords. psycopg2 forwards these to libpq; the Rust pool takes them +# as explicit params to :class:`ConnectionPool` rather than in the DSN, because +# ``tokio_postgres`` can't parse the cert-file paths and rejects the +# ``verify-ca`` / ``verify-full`` modes. See ``rust/src/database/postgres/tls.rs``. +SSL_PARAM_KEYS = ("sslmode", "sslrootcert", "sslcert", "sslkey", "sslpassword") + + +def split_ssl_params( + args: Mapping[str, Any], +) -> "tuple[dict[str, Any], dict[str, Any]]": + """Split connection args into (DSN args, TLS params). + + The TLS params (the ``ssl*`` keys, ``None`` values dropped) are passed to the + Rust pool separately; everything else goes into the DSN. Keeps existing + psycopg2-style ``database.args`` — which may carry ``sslmode`` etc. — working + on the Rust backend. + + libpq's deprecated ``requiressl`` is translated to the ``sslmode`` it + spells (``1`` → ``require``, else ``prefer``) rather than dropped — a + config that demanded an encrypted connection must not silently lose that + requirement. An explicit ``sslmode`` wins, as in libpq. + """ + ssl_params = {key: args[key] for key in SSL_PARAM_KEYS if args.get(key) is not None} + requiressl = args.get("requiressl") + if requiressl is not None and "sslmode" not in ssl_params: + truthy = str(requiressl).lower() in ("1", "true", "yes", "on") + ssl_params["sslmode"] = "require" if truthy else "prefer" + dsn_args = { + key: value + for key, value in args.items() + if key not in SSL_PARAM_KEYS and key != "requiressl" + } + return dsn_args, ssl_params + + class _StandaloneReactor: """What ``RustRuntime`` asks of the reactor: somewhere to register its Twisted-dispatch reader. A standalone runtime only ever drives blocking @@ -213,13 +248,15 @@ def connect( *, synchronous_commit: bool = True, statement_timeout_ms: int | None = None, + ssl_params: Mapping[str, Any] | None = None, ) -> "Connection": """Open a single standalone connection for bootstrap/one-off use. The Rust shim is pool-only, so a lone connection is a pool of one; the returned :class:`Connection` keeps that pool alive for its lifetime. Used by ``make_conn`` for the startup connection that runs schema preparation before - the real pool exists. + the real pool exists. ``ssl_params`` are the libpq ``ssl*`` keys (see + :func:`split_ssl_params`). ``runtime`` is the ``RustRuntime`` whose tokio runtime drives the connection; it is started on first use, so this works during startup. @@ -230,6 +267,7 @@ def connect( 1, synchronous_commit=synchronous_commit, statement_timeout_ms=statement_timeout_ms, + **(ssl_params or {}), ) return Connection(pool.connect(), pool=pool, owns_pool=True) diff --git a/synapse/storage/rust_pool.py b/synapse/storage/rust_pool.py index 33696ba56d..b15b0e3dcd 100644 --- a/synapse/storage/rust_pool.py +++ b/synapse/storage/rust_pool.py @@ -75,6 +75,7 @@ class RustConnectionPool: threads: int = 10, synchronous_commit: bool = True, statement_timeout_ms: int | None = None, + ssl_params: "dict[str, Any] | None" = None, ) -> None: """ Args: @@ -90,6 +91,9 @@ class RustConnectionPool: synchronous_commit: passed to each pooled connection's session setup. statement_timeout_ms: passed to each pooled connection's session setup (statements running longer are aborted). + ssl_params: the libpq ``ssl*`` keys (see + :func:`synapse.storage.rust_dbapi.split_ssl_params`), passed to + each pooled connection's TLS setup. The owner is responsible for the lifecycle: call :meth:`start` before use and :meth:`close` on shutdown (e.g. via the Synapse clock's @@ -101,6 +105,7 @@ class RustConnectionPool: self._dsn = dsn self._synchronous_commit = synchronous_commit self._statement_timeout_ms = statement_timeout_ms + self._ssl_params = dict(ssl_params or {}) self._threads = threads self._pool: Any = self._open_pool() self.threadpool = ThreadPool(minthreads=1, maxthreads=threads, name=name) @@ -121,6 +126,7 @@ class RustConnectionPool: self._threads, synchronous_commit=self._synchronous_commit, statement_timeout_ms=self._statement_timeout_ms, + **self._ssl_params, ) def _default_connection_factory( diff --git a/synapse/synapse_rust/database/postgres.pyi b/synapse/synapse_rust/database/postgres.pyi index c16d6dd98b..9aded1e679 100644 --- a/synapse/synapse_rust/database/postgres.pyi +++ b/synapse/synapse_rust/database/postgres.pyi @@ -30,6 +30,11 @@ class ConnectionPool: *, synchronous_commit: bool = True, statement_timeout_ms: int | None = None, + sslmode: str | None = None, + sslrootcert: str | None = None, + sslcert: str | None = None, + sslkey: str | None = None, + sslpassword: str | None = None, ) -> None: """ ``runtime`` is the homeserver's ``RustRuntime`` (``hs.get_rust_runtime()``); @@ -39,6 +44,9 @@ class ConnectionPool: Each connection gets the same session setup when opened: the default (REPEATABLE READ) isolation level, plus ``synchronous_commit`` off and ``statement_timeout`` (milliseconds) if configured. + + The ``ssl*`` keyword arguments are the libpq TLS settings; see + ``rust/src/database/postgres/tls.rs``. """ def connect(self) -> Connection: diff --git a/tests/storage/test_rust_dbapi.py b/tests/storage/test_rust_dbapi.py index 5199131945..c5530d9e69 100644 --- a/tests/storage/test_rust_dbapi.py +++ b/tests/storage/test_rust_dbapi.py @@ -122,6 +122,24 @@ class BuildDsnTestCase(unittest.TestCase): rust_dbapi.build_dsn({"dbname": "d", key: value}) self.assertIn(key, str(ctx.exception)) + def test_requiressl_maps_to_sslmode(self) -> None: + # libpq's deprecated requiressl=1 spells sslmode=require; the + # encryption requirement must not be silently dropped. An explicit + # sslmode wins, as in libpq. + dsn_args, ssl_params = rust_dbapi.split_ssl_params( + {"dbname": "d", "requiressl": 1} + ) + self.assertEqual(ssl_params, {"sslmode": "require"}) + self.assertEqual(dsn_args, {"dbname": "d"}) + + _, ssl_params = rust_dbapi.split_ssl_params({"requiressl": 0}) + self.assertEqual(ssl_params, {"sslmode": "prefer"}) + + _, ssl_params = rust_dbapi.split_ssl_params( + {"requiressl": 1, "sslmode": "verify-full"} + ) + self.assertEqual(ssl_params, {"sslmode": "verify-full"}) + def test_supported_dsn_keys_are_accepted_by_the_parser(self) -> None: # _SUPPORTED_DSN_KEYS mirrors tokio_postgres's Config::param keyword # set; if the crate is upgraded and a key is renamed or removed, this