From a90bd14f3d2a07ce1261506aaa334fa538012feb Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Wed, 19 Aug 2026 08:10:20 -0400 Subject: [PATCH] fix: support client certificate authentication (mTLS) 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 servers requiring client-certificate auth (e.g. Google Cloud SQL with mTLS enabled) rejected connections with "connection requires a valid client certificate," the same bug fixed upstream in the builtin driver (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 in #21 for being unmaintained, RUSTSEC-2025-0134) -- PrivateKeyDer supports PKCS1/SEC1/PKCS8 via the same trait, so no new dependency is needed. 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. Left connection_key unchanged -- it doesn't currently key on ssl_mode/ssl_ca either, so folding in just ssl_cert/ssl_key would be inconsistent scope creep beyond "client certs don't work at all." Fixes #34. --- CHANGELOG.md | 18 ++++ README.md | 2 + src/client.rs | 84 +++++++++++++++++-- src/client_tests.rs | 198 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 293 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cad15a..1ed209b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a533169..a01c83d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/client.rs b/src/client.rs index 7ddd4d1..39758ee 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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 { 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(), @@ -432,18 +454,27 @@ fn build_tls_connector(params: &ConnectionParams) -> Result 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 @@ -468,6 +499,43 @@ fn load_roots_from_pem(path: &str) -> Result { 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::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::pem_slice_iter(&cert_pem) + .collect::, _>>() + .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; diff --git a/src/client_tests.rs b/src/client_tests.rs index 0a9495f..940039c 100644 --- a/src/client_tests.rs +++ b/src/client_tests.rs @@ -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 @@ -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(¶ms); + 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(¶ms) + .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(¶ms) + .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}" + ); +}