Add TLS support to the native Rust Postgres backend

The Rust backend connected with `NoTls`, so it couldn't talk to a Postgres that
requires (or, as libpq defaults, prefers) TLS — a blocker for most managed /
networked databases. Honour the same libpq keys psycopg2 already forwards, so an
existing `database.args` keeps working: `sslmode`, `sslrootcert`, `sslcert`,
`sslkey`, `sslpassword`.

Implemented on rustls (already in the tree via reqwest; pinned to the `ring`
provider since `aws-lc-rs` needs a C toolchain we don't have). `tokio-postgres`
only knows `sslmode` disable/prefer/require and does no verification itself, so a
new `postgres/tls.rs` drives both the handshake mode and the verifier:

  disable      -> no TLS
  allow/prefer -> encrypt opportunistically, no verification  (accept-any)
  require      -> encrypt, no verification (libpq's `require` does NOT verify)
  verify-ca    -> verify the chain, not the hostname          (WebPKI, name bypassed)
  verify-full  -> verify chain + hostname                     (WebPKI)

`sslrootcert` (or the system trust store) supplies the roots; `sslcert`/`sslkey`
add a client certificate for mutual TLS. The cert-path keys and the `verify-*`
modes are stripped from the DSN and passed to the pool as explicit params
(`rust_dbapi.split_ssl_params`), since `tokio-postgres` can't parse them.

Behaviour change: the default (no `sslmode`) is now libpq's `prefer` — attempt
TLS, fall back to plaintext — where it was previously no TLS. This matches
psycopg2; plaintext servers still connect via the fallback.

Caveats vs libpq/openssl: rustls is stricter (requires SANs, rejects a
non-CA self-signed root), and an encrypted client key (`sslpassword`) isn't
supported and is rejected with a clear error.

Validated end-to-end against an SSL Postgres: require / verify-ca / verify-full
connect and use TLS, verify-full with only the system roots correctly rejects an
unknown CA, and hostname is checked (verify-full) / ignored (verify-ca). Unit
tests cover the sslmode->verifier mapping; the Rust-backend storage suites still
pass with the new default (prefer falls back against the plaintext test server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
This commit is contained in:
Erik Johnston
2026-07-06 10:17:56 +00:00
co-authored by Claude Opus 4.8
parent 48c834f455
commit 9717da8ba5
9 changed files with 641 additions and 25 deletions
Generated
+138 -5
View File
@@ -46,6 +46,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[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"
@@ -99,10 +105,11 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.30"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
@@ -135,6 +142,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"
@@ -230,6 +243,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",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -248,7 +284,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",
]
@@ -282,6 +318,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[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"
@@ -1391,6 +1439,7 @@ version = "0.23.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -1411,6 +1460,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"
@@ -1578,9 +1636,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "siphasher"
@@ -1620,6 +1678,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[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"
@@ -1683,11 +1751,15 @@ dependencies = [
"regex",
"reqwest",
"rustc_version",
"rustls",
"rustls-native-certs",
"rustls-pemfile",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"tokio-postgres",
"tokio-postgres-rustls",
"ulid",
]
@@ -1762,6 +1834,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",
]
[[package]]
name = "tokio"
version = "1.50.0"
@@ -1802,6 +1895,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 0.11.0",
"tokio",
"tokio-postgres",
"tokio-rustls",
"x509-cert",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
@@ -2233,6 +2340,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"
@@ -2303,6 +2422,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",
]
[[package]]
name = "zerotrie"
+13
View File
@@ -76,6 +76,19 @@ postgres-protocol = "0.6.10"
# default host. Ships pre-generated bindings and links the system libpq itself,
# so — unlike a bindgen-based binding — the build needs no libclang.
pq-sys = "0.7.5"
# 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.10.4"
+1
View File
@@ -14,6 +14,7 @@ mod helpers;
mod libpq;
pub mod pool;
pub(crate) mod query;
mod tls;
mod value;
/// Register the `postgres` submodule (the `ConnectionPool`, `Connection` and
+70 -17
View File
@@ -22,12 +22,14 @@ use deadpool::managed::{Manager, Metrics, Object, Pool, PoolError, RecycleError,
use log::warn;
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use tokio_postgres::{Client, Config, NoTls, Statement};
use tokio::io::{AsyncRead, AsyncWrite};
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::database::runtime::runtime;
/// Per-connection session settings applied once when a pooled connection is
@@ -80,22 +82,48 @@ 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,
/// 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.
pub fn from_dsn(dsn: &str, session: SessionConfig) -> Result<Self, anyhow::Error> {
/// Build a manager from a libpq-style DSN, session settings and TLS params.
pub fn from_dsn(
dsn: &str,
session: SessionConfig,
tls_params: &TlsParams,
) -> Result<Self, anyhow::Error> {
let mut config = fixup_config_defaults(dsn)?;
let (ssl_mode, connector) = tls::build(tls_params)?;
config.ssl_mode(ssl_mode);
Ok(Self {
config: fixup_config_defaults(dsn)?,
config,
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<S>(connection: PgConnection<Socket, S>)
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
runtime().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
@@ -168,13 +196,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?;
runtime().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(connection);
client
}
});
TlsConnector::Rustls(connector) => {
let (client, connection) = self.config.connect(connector.clone()).await?;
spawn_connection_task(connection);
client
}
};
// Apply per-connection session setup once, up front (the native
// equivalent of the engine's `on_new_connection`).
@@ -208,16 +241,23 @@ pub type PooledConnection = Object<ConnectionManager>;
/// Build a [`ConnectionPool`] from a libpq-style DSN, capped at `max_size`
/// connections, using the default [`SessionConfig`].
pub fn create_pool(dsn: &str, max_size: usize) -> Result<ConnectionPool, anyhow::Error> {
create_pool_with_session(dsn, max_size, SessionConfig::default())
create_pool_with_session(
dsn,
max_size,
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,
session: SessionConfig,
tls_params: &TlsParams,
) -> Result<ConnectionPool, anyhow::Error> {
let manager = ConnectionManager::from_dsn(dsn, session)?;
let manager = ConnectionManager::from_dsn(dsn, session, tls_params)?;
Ok(Pool::builder(manager).max_size(max_size).build()?)
}
@@ -242,18 +282,31 @@ impl PyConnectionPool {
/// (REPEATABLE READ) isolation level, plus `synchronous_commit` /
/// `statement_timeout` if configured here.
#[new]
#[pyo3(signature = (dsn, max_size = 10, *, synchronous_commit = true, statement_timeout_ms = None))]
#[pyo3(signature = (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(
dsn: &str,
max_size: usize,
synchronous_commit: bool,
statement_timeout_ms: Option<i32>,
sslmode: Option<String>,
sslrootcert: Option<String>,
sslcert: Option<String>,
sslkey: Option<String>,
sslpassword: Option<String>,
) -> PyResult<Self> {
let session = SessionConfig {
synchronous_commit,
statement_timeout_ms,
};
let pool = create_pool_with_session(dsn, max_size, session).map_err(|e| {
let tls_params = TlsParams {
sslmode,
sslrootcert,
sslcert,
sslkey,
sslpassword,
};
let pool = create_pool_with_session(dsn, max_size, session, &tls_params).map_err(|e| {
PyRuntimeError::new_err(format!("failed to build connection pool: {e}"))
})?;
Ok(Self { pool })
@@ -379,7 +432,7 @@ mod tests {
synchronous_commit: false,
statement_timeout_ms: Some(1234),
};
let pool = create_pool_with_session(&dsn, 1, session).unwrap();
let pool = create_pool_with_session(&dsn, 1, session, &TlsParams::default()).unwrap();
runtime().block_on(async {
let client = pool.get().await.unwrap();
+375
View File
@@ -0,0 +1,375 @@
// 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:
// <https://www.gnu.org/licenses/agpl-3.0.html>.
//! 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<String>,
pub sslrootcert: Option<String>,
pub sslcert: Option<String>,
pub sslkey: Option<String>,
pub sslpassword: Option<String>,
}
/// 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,
}
/// Resolve [`TlsParams`] into the `tokio-postgres` [`SslMode`] to set on the
/// connection config and the [`TlsConnector`] to connect with.
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<ClientConfig> {
// 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 (&params.sslcert, &params.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<RootCertStore> {
let mut store = RootCertStore::empty();
match &params.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<Vec<CertificateDer<'static>>> {
let mut reader = BufReader::new(File::open(path).with_context(|| format!("opening {path}"))?);
let certs = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.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 (needing `sslpassword`) are not
/// supported by `rustls-pemfile`, so reject them with a clear error rather than
/// silently failing.
fn load_key(path: &str, sslpassword: Option<&str>) -> anyhow::Result<PrivateKeyDer<'static>> {
if sslpassword.is_some() {
return Err(anyhow!(
"encrypted client keys (sslpassword) are not supported by the Rust driver"
));
}
let mut reader = BufReader::new(File::open(path).with_context(|| format!("opening {path}"))?);
rustls_pemfile::private_key(&mut reader)
.with_context(|| format!("reading a private key from {path}"))?
.ok_or_else(|| 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<CryptoProvider>,
}
impl ServerCertVerifier for AcceptAnyServerCert {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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<WebPkiServerVerifier>,
}
impl ServerCertVerifier for NoHostnameServerCert {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
match self.inner.verify_server_cert(
end_entity,
intermediates,
server_name,
ocsp_response,
now,
) {
Err(RustlsError::InvalidCertificate(CertificateError::NotValidForName)) => {
Ok(ServerCertVerified::assertion())
}
other => other,
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
self.inner.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
self.inner.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.inner.supported_verify_schemes()
}
}
#[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(&params("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(&params(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(&params("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());
}
}
+7 -2
View File
@@ -209,9 +209,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)
@@ -223,6 +224,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)
@@ -261,12 +263,15 @@ 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
dsn_args, ssl_params = rust_dbapi.split_ssl_params(db_params)
native_db_conn = rust_dbapi.connect(
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)
+26 -1
View File
@@ -79,24 +79,49 @@ def build_dsn(params: Mapping[str, Any]) -> str:
)
# 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.
"""
ssl_params = {key: args[key] for key in SSL_PARAM_KEYS if args.get(key) is not None}
dsn_args = {key: value for key, value in args.items() if key not in SSL_PARAM_KEYS}
return dsn_args, ssl_params
def connect(
dsn: str,
*,
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`).
"""
pool = postgres.ConnectionPool(
dsn,
1,
synchronous_commit=synchronous_commit,
statement_timeout_ms=statement_timeout_ms,
**(ssl_params or {}),
)
return Connection(pool.connect(), pool=pool, owns_pool=True)
+6
View File
@@ -74,6 +74,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:
@@ -87,6 +88,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
@@ -97,6 +101,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)
@@ -116,6 +121,7 @@ class RustConnectionPool:
self._threads,
synchronous_commit=self._synchronous_commit,
statement_timeout_ms=self._statement_timeout_ms,
**self._ssl_params,
)
def _default_connection_factory(
@@ -28,6 +28,11 @@ class ConnectionPool:
*,
synchronous_commit: bool = True,
statement_timeout_ms: Optional[int] = None,
sslmode: Optional[str] = None,
sslrootcert: Optional[str] = None,
sslcert: Optional[str] = None,
sslkey: Optional[str] = None,
sslpassword: Optional[str] = None,
) -> None: ...
def connect(self) -> Connection:
"""Check a connection out of the pool, blocking until one is available.