Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## [Unreleased]

### Fixed

- Client certificate authentication (mTLS) for PostgreSQL servers requiring
it (e.g. Google Cloud SQL). `ConnectionParams` already carried `ssl_cert`
and `ssl_key`, but `build_tls_connector` never read them — every TLS
branch called `.with_no_client_auth()` unconditionally, so connections
failed with "connection requires a valid client certificate" the same way
the builtin driver's `pool_manager.rs` did before it was fixed upstream
(TabularisDB/tabularis#666). Added `load_client_cert_from_pem`, reusing
the same `rustls::pki_types::pem::PemObject` machinery as the existing
`load_roots_from_pem` (rather than reintroducing `rustls-pemfile`, removed
above for being unmaintained) — `PrivateKeyDer` supports PKCS1/SEC1/PKCS8
via the same trait. Both TLS branches now present the client cert via
`.with_client_auth_cert(...)` when `ssl_cert`/`ssl_key` are set, and
`build_tls_connector` errors clearly if only one of the pair is provided.

## [1.0.0-beta.7] - 2026-08-17

### Removed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ both drivers against the same live database and compares every response.
| `password` | Database password | If required by the server |
| `ssl_mode` | `disable`, `require`, `verify-ca`, or `verify-full` | No |
| `ssl_ca` | Path to a custom CA bundle PEM file, used to validate the server's certificate under `verify-ca`/`verify-full` instead of the system trust store | No |
| `ssl_cert` | Path to a client certificate PEM file, for servers requiring mutual TLS (e.g. Google Cloud SQL). Must be set together with `ssl_key` | No |
| `ssl_key` | Path to the private key PEM file matching `ssl_cert`. Must be set together with `ssl_cert` | No |
| `connection_string` | Full `postgres://user:pass@host:port/db` URL, as an alternative to the discrete fields above | No |
| `startup_script` | SQL run on every new pooled connection (e.g. `SET search_path = ...`) before it's handed to a query | No |

Expand Down
84 changes: 76 additions & 8 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,11 +414,33 @@ fn needs_tls(params: &ConnectionParams) -> bool {
/// server's certificate chain — against a caller-supplied CA bundle
/// (`ssl_ca`) when present, or the platform trust store otherwise. `require`
/// forces TLS without certificate validation (matches the builtin driver's
/// `require` behavior — see `src-tauri/src/pool_manager.rs`).
/// `require` behavior — see `src-tauri/src/pool_manager.rs`). When
/// `ssl_cert`/`ssl_key` are both supplied, presents them as a client
/// certificate for servers requiring mTLS (e.g. Google Cloud SQL) — matches
/// the builtin driver's `build_postgres_tls_connector` client-auth handling.
fn build_tls_connector(params: &ConnectionParams) -> Result<rustls::ClientConfig, String> {
use rustls_platform_verifier::BuilderVerifierExt;

let user_ca = params.ssl_ca.as_deref().filter(|s| !s.trim().is_empty());
let user_cert = params.ssl_cert.as_deref().filter(|s| !s.trim().is_empty());
let user_key = params.ssl_key.as_deref().filter(|s| !s.trim().is_empty());

let client_auth = match (user_cert, user_key) {
(Some(cert), Some(key)) => Some(load_client_cert_from_pem(cert, key)?),
(Some(_), None) => {
return Err(
"Client certificate provided (ssl_cert) without a client private key (ssl_key)"
.to_string(),
);
}
(None, Some(_)) => {
return Err(
"Client private key provided (ssl_key) without a client certificate (ssl_cert)"
.to_string(),
);
}
(None, None) => None,
};

let needs_cert_validation = matches!(
params.ssl_mode.as_deref(),
Expand All @@ -432,18 +454,27 @@ fn build_tls_connector(params: &ConnectionParams) -> Result<rustls::ClientConfig
rustls::client::WebPkiServerVerifier::builder(std::sync::Arc::new(roots))
.build()
.map_err(|e| format!("Failed to build certificate verifier: {e}"))?;
return Ok(rustls::ClientConfig::builder()
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth());
.with_custom_certificate_verifier(verifier);
return match client_auth {
Some((certs, key)) => builder
.with_client_auth_cert(certs, key)
.map_err(|e| format!("Failed to configure client certificate: {e}")),
None => Ok(builder.with_no_client_auth()),
};
}
}

let config = rustls::ClientConfig::builder()
let builder = rustls::ClientConfig::builder()
.with_platform_verifier()
.map_err(|e| format!("Failed to build platform TLS verifier: {e}"))?
.with_no_client_auth();
Ok(config)
.map_err(|e| format!("Failed to build platform TLS verifier: {e}"))?;
match client_auth {
Some((certs, key)) => builder
.with_client_auth_cert(certs, key)
.map_err(|e| format!("Failed to configure client certificate: {e}")),
None => Ok(builder.with_no_client_auth()),
}
}

/// Load root certificates from a PEM file (used for `ssl_ca`-pinned
Expand All @@ -468,6 +499,43 @@ fn load_roots_from_pem(path: &str) -> Result<rustls::RootCertStore, String> {
Ok(roots)
}

/// Load a client certificate chain and private key from PEM files, for
/// mTLS-required servers (e.g. Google Cloud SQL). Uses the same
/// `pki_types::pem::PemObject` machinery as `load_roots_from_pem` rather than
/// `rustls_pemfile` — deliberately avoided as a dependency here after it was
/// removed for being unmaintained (RUSTSEC-2025-0134); `PrivateKeyDer`
/// supports PKCS1/SEC1/PKCS8 via the same trait.
fn load_client_cert_from_pem(
cert_path: &str,
key_path: &str,
) -> Result<
(
Vec<rustls::pki_types::CertificateDer<'static>>,
rustls::pki_types::PrivateKeyDer<'static>,
),
String,
> {
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};

let cert_pem = std::fs::read(cert_path)
.map_err(|e| format!("Failed to read ssl_cert file '{cert_path}': {e}"))?;
let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&cert_pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to parse ssl_cert '{cert_path}': {e}"))?;
if certs.is_empty() {
return Err(format!(
"ssl_cert '{cert_path}' contained no PEM CERTIFICATE blocks"
));
}

let key_pem = std::fs::read(key_path)
.map_err(|e| format!("Failed to read ssl_key file '{key_path}': {e}"))?;
let key = PrivateKeyDer::from_pem_slice(&key_pem)
.map_err(|e| format!("Failed to parse ssl_key '{key_path}': {e}"))?;

Ok((certs, key))
}

#[cfg(test)]
#[path = "client_tests.rs"]
mod client_tests;
198 changes: 197 additions & 1 deletion src/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

use tokio::sync::Mutex;

use super::{cleanup_idle_pools, connection_key, get_or_create_pool, load_roots_from_pem, POOLS};
use super::{
build_tls_connector, cleanup_idle_pools, connection_key, get_or_create_pool,
load_client_cert_from_pem, load_roots_from_pem, POOLS,
};
use crate::models::ConnectionParams;

// `POOLS` is a single process-wide static, and Rust's test harness runs
Expand Down Expand Up @@ -189,3 +192,196 @@ fn load_roots_from_pem_reports_a_clear_error_for_a_missing_file() {
"unexpected error message: {err}"
);
}

// Self-signed RSA client cert + matching PKCS#8 private key (CN=test-client)
// — a real X.509v3 cert/key pair (openssl req -x509 -newkey rsa:2048 -nodes
// -addext basicConstraints=CA:FALSE -addext keyUsage=digitalSignature), not
// a real trust anchor, just shaped like what a user's ssl_cert/ssl_key
// files hold for mTLS. rustls's client-auth path requires v3 (basic
// constraints present) — a v1 cert is rejected with UnsupportedCertVersion.
const FIXTURE_CLIENT_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
MIICyTCCAbGgAwIBAgIJAL2rJBvvf1YfMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV
BAMMC3Rlc3QtY2xpZW50MB4XDTI2MDgxOTEyMDU1MVoXDTM2MDgxNjEyMDU1MVow
FjEUMBIGA1UEAwwLdGVzdC1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQDYG5QxpH4lT6J+dmSZKn905KrDi++om1OV8K3cPIG5sni3phLVWcX/
I2MOH8DkLtQgR3gnjSOHFk6RKE4ezMfCMMQ+6nXIRP/B3lt06Ub3uTvGmRApk3hh
5JE6ae8+xhowh4IXdC2wYEi81PIh/RGyyylsitmUyAt/4j3q9Kt/StPmLbrXMl02
mYSC3Z8QabSnAh+Yd9MFRfaJDXRYpoUtOror9S4u1JU6+FLyvjIeUWbCFZU6EvDP
MleiG3pbiZX/EPK3t3gwYg40AAS+LIijhJ+1T2LlOE+6wPJjEYiMPzNdnSnh6MzT
tXCX1a5AqRRajb5jdZv20Eqf1E/7HRFpAgMBAAGjGjAYMAkGA1UdEwQCMAAwCwYD
VR0PBAQDAgeAMA0GCSqGSIb3DQEBCwUAA4IBAQBPCAEFChrzv2oY1KSS5/Z2qc9D
0PDjquvTLyDOcxQywBpLxEhWPTQVMoryZlSyqoKn2n1aj1+CASsuBcemoL3714IJ
hcU0GLDcRmJnWvU8JZfJgI4tdFvpFiBfn9hixpgTSnS8J/9/3CrE/c/tqakrYncd
+PwDBIyo36f78sToa853LmWabC/KelfzhFpJFsTygu3KtAeyAvm/0S5tkqh9GOkg
U3d2kk7Mb7fmDTzT83A4vIGVbTG4wP4HDr4AkapAmFb9BvQBjtU1nywhqkM2PQ8z
1T+5CI0B8rJ2pTrZo25nc7EaPWzojE9hce6FGuIPOexbFzFBcxLt/yP3h8eJ
-----END CERTIFICATE-----
";

const FIXTURE_CLIENT_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDYG5QxpH4lT6J+
dmSZKn905KrDi++om1OV8K3cPIG5sni3phLVWcX/I2MOH8DkLtQgR3gnjSOHFk6R
KE4ezMfCMMQ+6nXIRP/B3lt06Ub3uTvGmRApk3hh5JE6ae8+xhowh4IXdC2wYEi8
1PIh/RGyyylsitmUyAt/4j3q9Kt/StPmLbrXMl02mYSC3Z8QabSnAh+Yd9MFRfaJ
DXRYpoUtOror9S4u1JU6+FLyvjIeUWbCFZU6EvDPMleiG3pbiZX/EPK3t3gwYg40
AAS+LIijhJ+1T2LlOE+6wPJjEYiMPzNdnSnh6MzTtXCX1a5AqRRajb5jdZv20Eqf
1E/7HRFpAgMBAAECggEADVC1stFVzGq5sl0NGbram5MzSlUm8RaQ8d4geD9qJszu
TzJ2Wprrbir6AXbHZcfU3iBJMParR7mCIcN//LnVXQuwK8g6dZp6v7E5pVxyOPU6
z6PCsS0a770rjZPVX+LI3lCbHWLVJdbo5GmieaGkI4YNCVkMyvQAcWK5Oe7VWkRf
BXeZVXswocBzTOTjGRK1ZtoINWuLKZjb6J8QkB46SwcQrvW6MUP+YocxMDPt7tyk
YBGuRoWcFYxnNzxrlubQJshHIK0vYD/zidvxA/7z1lG3torpgpsVVyjZqEL4vKEw
5wy5VbUWLUPNMTGeRuerqz3XjYUD9s+PaWEll4j2GQKBgQDzdcBxhVBDPZPG2myg
1w4XnofqOCP523aUc7lvUBovMmeMTqso+2jk609Gx+UjUx6TvcmK1xmqOSQ4N3z0
IKoDGWAkxmtArSTU8NIGGf69QcLiRwGMvT9m/a01loHoq+wia6ua18OLHNJwCh8v
1Vy5VIaFPycmNvjkXQzEIaus6wKBgQDjPSmG37vtyckxVkQbpKOaZfYlsDlwGrhW
c3m6yHdjd6Hfwb+dCfJeHVlwHtu2PYgURXdh/UqDX+HzD5pqH9cjeD0sN8ayqrcW
Z9tEw19hzrPKWPZ4nJYjjmd/XA25Ac2UiyLbmMOBN3VsA1Phacew7tsMS/UaFliX
BHzNXAHV+wKBgAZe93lBBterndlfT+ZpmknN8TqU24QnVRQPbzPVgcnoZMNML7hz
08vhyIJOqtVg0HUHS2XhuR82PZdnBFMTI7/PAzATLS1VGpij8KsONRdYyDPJreWz
8hvM2aKEXMPs89H2xVfY+5oBWBRsf2JuD+4doyOLgofCeoLnWHUteGOfAoGAIaWk
yHvIb+U5DT0gyJcQQoRmdh4p4xeRw/tFQwr74paMOX2Oycn3QUhHPfrTvaBOzfGb
Q78lkV5ZLoxY6O3eBTqAlFON8Fam1YJ7TStArFLW/Fc/54wIDyu+13Th80r5Dc2s
U6fDCxcTI/M6MF5hWymC9ccpe7tjUrkvYZkGDJECgYBKg1inw1umpsMLr+CbOdEz
c2YEH/DT1A4OSvsksNTGR9pySU5xIKQx0hOVA8eLQ6m97yxFwhU8mCnyAKqahsMz
5dY86Z3aBuAfLt3FUk2jnAx0pEA/Lf7HeR/EPCdgUWG05ZymXUnnU5OgQ0qow6wm
yORfscWKlsDf+tv4Zb2jYQ==
-----END PRIVATE KEY-----
";

fn params_with_ssl(ssl_mode: &str) -> ConnectionParams {
ConnectionParams {
driver: Some("postgres-plugin".to_string()),
host: Some("localhost".to_string()),
port: Some(5432),
database: Some("db".to_string()),
username: Some("user".to_string()),
password: None,
ssl_mode: Some(ssl_mode.to_string()),
ssl_ca: None,
ssl_cert: None,
ssl_key: None,
connection_string: None,
startup_script: None,
}
}

#[test]
fn load_client_cert_from_pem_accepts_a_valid_cert_and_key() {
let cert_path = write_temp_file(FIXTURE_CLIENT_CERT_PEM);
let key_path = write_temp_file(FIXTURE_CLIENT_KEY_PEM);
let result = load_client_cert_from_pem(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
std::fs::remove_file(&cert_path).ok();
std::fs::remove_file(&key_path).ok();

let (certs, _key) = result.expect("valid client cert/key PEM should load successfully");
assert_eq!(
certs.len(),
1,
"cert chain should contain the one leaf cert"
);
}

#[test]
fn load_client_cert_from_pem_rejects_a_cert_file_with_no_certificate_blocks() {
let cert_path = write_temp_file("not a real certificate\n");
let key_path = write_temp_file(FIXTURE_CLIENT_KEY_PEM);
let result = load_client_cert_from_pem(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
std::fs::remove_file(&cert_path).ok();
std::fs::remove_file(&key_path).ok();

let err = result.expect_err("non-PEM cert content should be rejected");
assert!(
err.contains("contained no PEM CERTIFICATE blocks"),
"unexpected error message: {err}"
);
}

#[test]
fn load_client_cert_from_pem_rejects_a_key_file_with_no_private_key() {
let cert_path = write_temp_file(FIXTURE_CLIENT_CERT_PEM);
let key_path = write_temp_file("not a real private key\n");
let result = load_client_cert_from_pem(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
std::fs::remove_file(&cert_path).ok();
std::fs::remove_file(&key_path).ok();

let err = result.expect_err("non-PEM key content should be rejected");
assert!(
err.contains("Failed to parse ssl_key"),
"unexpected error message: {err}"
);
}

#[test]
fn load_client_cert_from_pem_reports_a_clear_error_for_a_missing_cert_file() {
let key_path = write_temp_file(FIXTURE_CLIENT_KEY_PEM);
let result = load_client_cert_from_pem(
"/nonexistent/path/does-not-exist.pem",
key_path.to_str().unwrap(),
);
std::fs::remove_file(&key_path).ok();

let err = result.expect_err("a missing cert file should be rejected");
assert!(
err.contains("Failed to read ssl_cert file"),
"unexpected error message: {err}"
);
}

#[test]
fn load_client_cert_from_pem_reports_a_clear_error_for_a_missing_key_file() {
let cert_path = write_temp_file(FIXTURE_CLIENT_CERT_PEM);
let result = load_client_cert_from_pem(
cert_path.to_str().unwrap(),
"/nonexistent/path/does-not-exist.pem",
);
std::fs::remove_file(&cert_path).ok();

let err = result.expect_err("a missing key file should be rejected");
assert!(
err.contains("Failed to read ssl_key file"),
"unexpected error message: {err}"
);
}

#[test]
fn build_tls_connector_succeeds_with_valid_client_cert_and_key() {
let cert_path = write_temp_file(FIXTURE_CLIENT_CERT_PEM);
let key_path = write_temp_file(FIXTURE_CLIENT_KEY_PEM);

let mut params = params_with_ssl("require");
params.ssl_cert = Some(cert_path.to_str().unwrap().to_string());
params.ssl_key = Some(key_path.to_str().unwrap().to_string());
let result = build_tls_connector(&params);
std::fs::remove_file(&cert_path).ok();
std::fs::remove_file(&key_path).ok();

result.expect("connector should build successfully with a valid client cert/key pair");
}

#[test]
fn build_tls_connector_errors_when_ssl_cert_is_set_without_ssl_key() {
let mut params = params_with_ssl("require");
params.ssl_cert = Some("/path/to/cert.pem".to_string());
params.ssl_key = None;

let err = build_tls_connector(&params)
.expect_err("ssl_cert without ssl_key must be rejected as a config error");
assert!(
err.contains("ssl_cert") && err.contains("ssl_key"),
"unexpected error message: {err}"
);
}

#[test]
fn build_tls_connector_errors_when_ssl_key_is_set_without_ssl_cert() {
let mut params = params_with_ssl("require");
params.ssl_cert = None;
params.ssl_key = Some("/path/to/key.pem".to_string());

let err = build_tls_connector(&params)
.expect_err("ssl_key without ssl_cert must be rejected as a config error");
assert!(
err.contains("ssl_cert") && err.contains("ssl_key"),
"unexpected error message: {err}"
);
}
Loading