Skip to content

fix: support client certificate authentication (mTLS) - #35

Merged
aesslinger merged 2 commits into
mainfrom
fix/mtls-client-certificate-auth
Aug 19, 2026
Merged

fix: support client certificate authentication (mTLS)#35
aesslinger merged 2 commits into
mainfrom
fix/mtls-client-certificate-auth

Conversation

@aesslinger

@aesslinger aesslinger commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

ConnectionParams already carried ssl_cert/ssl_key, but build_tls_connector never 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

  • Added load_client_cert_from_pem, reusing the same rustls::pki_types::pem::PemObject machinery as the existing load_roots_from_pemnot rustls_pemfile, which this repo removed in fix: security-audit permissions gap and unmaintained rustls-pemfile #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 in build_tls_connector now call .with_client_auth_cert(certs, key) when ssl_cert/ssl_key are both set, .with_no_client_auth() otherwise.
  • build_tls_connector errors clearly if only one of ssl_cert/ssl_key is provided (matches upstream's pairing validation).
  • Added ssl_cert/ssl_key to the README's connection parameters table.
  • Added a CHANGELOG entry.

Scope decision: pool cache key

Left connection_key (pool cache key, src/client.rs:220-229) unchanged in this PR — it keys only on host:port:database:user:startup_script and folds in no TLS params at all, not ssl_mode/ssl_ca either.

Note this is not parity with the builtin: the builtin's build_connection_key already included ssl_mode/ssl_ca in its key before upstream #666 added ssl_cert/ssl_key on 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 any ssl_* 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_pem didn't exist, build_tls_connector silently ignored ssl_cert/ssl_key), then implemented until green, then diffed validation order and error-message wording against upstream's build_postgres_tls_connector.

Test plan

  • cargo test --lib --bins — 96/96 pass (88 previous + 8 new)
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --all -- --check — clean
  • npx markdownlint README.md CHANGELOG.md — clean
  • New tests cover: successful connector build with valid cert+key, cert-without-key error, key-without-cert error, PEM parse failures (empty cert/key file, missing cert/key file)

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.
@aesslinger aesslinger added the prerelease:beta Version suggestion targets a beta prerelease label Aug 19, 2026
@github-actions

Copy link
Copy Markdown

Version suggestion

Based on this PR's title (fix) and the prerelease:beta label:

Current 1.0.0-beta.7
Suggested next tag v1.0.0-beta.8

This is informational only — no tag or release is created automatically yet.

@aesslinger

Copy link
Copy Markdown
Collaborator Author

Checked debba's follow-up review comment on upstream tabularis#666

debba's comment flagged an edge case in the upstream fix: build_postgres_tls_connector runs unconditionally regardless of ssl_mode, so with ssl_mode=disable the code still reads and validates the ssl_cert/ssl_key PEM files even though TLS is never negotiated — a small behavior regression for a connection that once had a client cert configured and was later switched to disable (fails to connect if the PEM files were moved/deleted, or if only one of the two fields is set, where previously those fields were simply ignored).

This plugin isn't affected. build_tls_connector (and load_client_cert_from_pem inside it) is only ever called when needs_tls(params) is true:

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 ssl_mode=disable (or unset), TLS setup — including client-cert loading — is skipped entirely, so ssl_cert/ssl_key are never read or validated in that mode. Different call structure from upstream (connector-building happens later, gated on the TLS decision, rather than unconditionally), so this edge case doesn't arise here.

His other nit (fixed temp filenames in the new PEM test colliding across runs) also doesn't apply — the new tests in client_tests.rs reuse the existing write_temp_file helper, which already generates unique names via a PID + atomic counter.

@aesslinger
aesslinger merged commit 2a2501f into main Aug 19, 2026
7 checks passed
@aesslinger
aesslinger deleted the fix/mtls-client-certificate-auth branch August 19, 2026 13:23
aesslinger added a commit that referenced this pull request Aug 19, 2026
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.
aesslinger added a commit that referenced this pull request Aug 19, 2026
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.
aesslinger added a commit that referenced this pull request Aug 19, 2026
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.
aesslinger added a commit that referenced this pull request Aug 19, 2026
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.
aesslinger added a commit that referenced this pull request Aug 19, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease:beta Version suggestion targets a beta prerelease

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support client certificate authentication (mTLS)

1 participant