fix: support client certificate authentication (mTLS) - #35
Conversation
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.
Version suggestionBased on this PR's title (
This is informational only — no tag or release is created automatically yet. |
Checked debba's follow-up review comment on upstream tabularis#666debba's comment flagged an edge case in the upstream fix: This plugin isn't affected. fn needs_tls(params: &ConnectionParams) -> bool {
matches!(params.ssl_mode.as_deref(), Some("require" | "verify-ca" | "verify-full"))
}if needs_tls(params) {
let tls_config = build_tls_connector(params)?;
...For His other nit (fixed temp filenames in the new PEM test colliding across runs) also doesn't apply — the new tests in |
connection_key matched only on host:port:database:user:startup_script, ignoring ssl_mode/ssl_ca/ssl_cert/ssl_key entirely. So two connections to the same target differing only in TLS configuration -- require vs. verify-full, or two different client certs -- could incorrectly share a cached pool and its already-negotiated TLS setup. This wasn't inherited from the builtin driver: the builtin's build_connection_key already keyed on ssl_mode/ssl_ca before this plugin's client.rs was even staged (2026-08-11, over two months after tabularis#278 added that keying upstream on 2026-06-04). So this was a parity miss during extraction, not a later upstream change -- surfaced while reviewing #34/#35's client-cert fix, which touches the same TLS config but not the cache key. Folded all four TLS params into connection_key, matching the builtin's TLS-param keying. Updated CLAUDE.md's description of the pool cache key to match. Fixes #36.
connection_key matched only on host:port:database:user:startup_script, ignoring ssl_mode/ssl_ca/ssl_cert/ssl_key entirely. So two connections to the same target differing only in TLS configuration -- require vs. verify-full, or two different client certs -- could incorrectly share a cached pool and its already-negotiated TLS setup. This wasn't inherited from the builtin driver: the builtin's build_connection_key already keyed on ssl_mode/ssl_ca before this plugin's client.rs was even staged (2026-08-11, over two months after tabularis#278 added that keying upstream on 2026-06-04). So this was a parity miss during extraction, not a later upstream change -- surfaced while reviewing #34/#35's client-cert fix, which touches the same TLS config but not the cache key. Folded all four TLS params into connection_key, matching the builtin's TLS-param keying. Updated CLAUDE.md's description of the pool cache key to match. Fixes #36.
build_tls_connector wrapped rustls::client::WebPkiServerVerifier for verify-ca, whose verify_server_cert unconditionally checks the hostname via verify_server_name with no way to opt out -- making verify-ca behave identically to verify-full. verify-ca is supposed to validate the certificate chain but skip hostname verification -- that's the entire distinction from verify-full, matching libpq sslmode=verify-ca semantics. Ported VerifyCaCertVerifier from the builtin driver's src-tauri/src/pool_manager.rs, which deliberately avoids WebPkiServerVerifier for this exact reason and instead calls rustls::client::verify_server_cert_signed_by_trust_anchor directly (never reads server_name at all). Proved the bug and the fix with a chain-valid cert whose hostname deliberately doesn't match the connection target -- rejected before this fix, accepted after -- while confirming a CA-untrusted cert is still correctly rejected, and verify-full still correctly rejects the same hostname mismatch. Rebasing onto main (after #35 and #37 merged) surfaced a real merge hazard: naively combining #38's verify-ca branch with #35's client-auth logic would have dropped client_auth entirely on the verify-ca path, always calling .with_no_client_auth() regardless of ssl_cert/ssl_key. Fixed during this rebase so verify-ca now attaches a configured client cert the same way verify-full and the default branch already do. Added build_tls_connector_verify_ca_attaches_a_configured_client_cert, checking ClientConfig::client_auth_cert_resolver.has_certs() (not just "builds successfully", which the buggy version also did) -- confirmed it fails against the naive merge and passes against this fix. Fixes #38.
build_tls_connector wrapped rustls::client::WebPkiServerVerifier for verify-ca, whose verify_server_cert unconditionally checks the hostname via verify_server_name with no way to opt out -- making verify-ca behave identically to verify-full. verify-ca is supposed to validate the certificate chain but skip hostname verification -- that's the entire distinction from verify-full, matching libpq sslmode=verify-ca semantics. Ported VerifyCaCertVerifier from the builtin driver's src-tauri/src/pool_manager.rs, which deliberately avoids WebPkiServerVerifier for this exact reason and instead calls rustls::client::verify_server_cert_signed_by_trust_anchor directly (never reads server_name at all). Proved the bug and the fix with a chain-valid cert whose hostname deliberately doesn't match the connection target -- rejected before this fix, accepted after -- while confirming a CA-untrusted cert is still correctly rejected, and verify-full still correctly rejects the same hostname mismatch. Rebasing onto main (after #35 and #37 merged) surfaced a real merge hazard: naively combining #38's verify-ca branch with #35's client-auth logic would have dropped client_auth entirely on the verify-ca path, always calling .with_no_client_auth() regardless of ssl_cert/ssl_key. Fixed during this rebase so verify-ca now attaches a configured client cert the same way verify-full and the default branch already do. Added build_tls_connector_verify_ca_attaches_a_configured_client_cert, checking ClientConfig::client_auth_cert_resolver.has_certs() (not just "builds successfully", which the buggy version also did) -- confirmed it fails against the naive merge and passes against this fix. Fixes #38.
build_tls_connector wrapped rustls::client::WebPkiServerVerifier for verify-ca, whose verify_server_cert unconditionally checks the hostname via verify_server_name with no way to opt out -- making verify-ca behave identically to verify-full. verify-ca is supposed to validate the certificate chain but skip hostname verification -- that's the entire distinction from verify-full, matching libpq sslmode=verify-ca semantics. Ported VerifyCaCertVerifier from the builtin driver's src-tauri/src/pool_manager.rs, which deliberately avoids WebPkiServerVerifier for this exact reason and instead calls rustls::client::verify_server_cert_signed_by_trust_anchor directly (never reads server_name at all). Proved the bug and the fix with a chain-valid cert whose hostname deliberately doesn't match the connection target -- rejected before this fix, accepted after -- while confirming a CA-untrusted cert is still correctly rejected, and verify-full still correctly rejects the same hostname mismatch. Rebasing onto main (after #35 and #37 merged) surfaced a real merge hazard: naively combining #38's verify-ca branch with #35's client-auth logic would have dropped client_auth entirely on the verify-ca path, always calling .with_no_client_auth() regardless of ssl_cert/ssl_key. Fixed during this rebase so verify-ca now attaches a configured client cert the same way verify-full and the default branch already do. Added build_tls_connector_verify_ca_attaches_a_configured_client_cert, checking ClientConfig::client_auth_cert_resolver.has_certs() (not just "builds successfully", which the buggy version also did) -- confirmed it fails against the naive merge and passes against this fix. Fixes #38.
Summary
ConnectionParamsalready carriedssl_cert/ssl_key, butbuild_tls_connectornever read them — every TLS branch called.with_no_client_auth()unconditionally, so PostgreSQL servers requiring client-certificate auth (e.g. Google Cloud SQL with mTLS enabled) rejected connections with "connection requires a valid client certificate." This is the same bug fixed upstream in the builtin driver via TabularisDB/tabularis#666.Fixes #34.
Changes
load_client_cert_from_pem, reusing the samerustls::pki_types::pem::PemObjectmachinery as the existingload_roots_from_pem— notrustls_pemfile, which this repo removed in fix: security-audit permissions gap and unmaintained rustls-pemfile #21 for being unmaintained (RUSTSEC-2025-0134).PrivateKeyDersupports PKCS1/SEC1/PKCS8 via the same trait, so no new dependency is needed.build_tls_connectornow call.with_client_auth_cert(certs, key)whenssl_cert/ssl_keyare both set,.with_no_client_auth()otherwise.build_tls_connectorerrors clearly if only one ofssl_cert/ssl_keyis provided (matches upstream's pairing validation).ssl_cert/ssl_keyto the README's connection parameters table.Scope decision: pool cache key
Left
connection_key(pool cache key,src/client.rs:220-229) unchanged in this PR — it keys only onhost:port:database:user:startup_scriptand folds in no TLS params at all, notssl_mode/ssl_caeither.Note this is not parity with the builtin: the builtin's
build_connection_keyalready includedssl_mode/ssl_cain its key before upstream #666 addedssl_cert/ssl_keyon top of that. So this plugin's gap is broader and predates this PR's work — it's a real correctness bug (two connections differing only in anyssl_*param could incorrectly share a cached pool and its already-negotiated TLS config), not something inherited from the builtin. Filed separately as #36 rather than folding into this PR, since it spans all TLS params, not just cert/key.TDD / parity approach
Per CLAUDE.md's parity mandate, wrote the tests first (confirmed failing —
load_client_cert_from_pemdidn't exist,build_tls_connectorsilently ignoredssl_cert/ssl_key), then implemented until green, then diffed validation order and error-message wording against upstream'sbuild_postgres_tls_connector.Test plan
cargo test --lib --bins— 96/96 pass (88 previous + 8 new)cargo clippy --all-targets -- -D warnings— cleancargo fmt --all -- --check— cleannpx markdownlint README.md CHANGELOG.md— clean