Skip to content

feat: per-user tls_client_certificate_required - #1283

Draft
bhargavtheertham-cb wants to merge 1 commit into
pgdogdev:mainfrom
bhargavtheertham-cb:feat/per-user-tls-client-certificate-required
Draft

feat: per-user tls_client_certificate_required#1283
bhargavtheertham-cb wants to merge 1 commit into
pgdogdev:mainfrom
bhargavtheertham-cb:feat/per-user-tls-client-certificate-required

Conversation

@bhargavtheertham-cb

Copy link
Copy Markdown
Contributor

Problem

Setting tls_client_ca_certificate makes client certificates mandatory for every TLS connection, because rustls' default client verifier denies anonymous clients. On a listener serving both mTLS users (identity) and password users (password/password_hash), that leaves the password users unable to negotiate TLS at all — they get a certificate_required alert, so their only working option is a plaintext connection:

$ psql "host=... user=some_password_user sslmode=require"
psql: error: connection to server at "..." failed:
      SSL error: tlsv13 alert certificate required

$ psql "host=... user=some_password_user sslmode=disable"
psql (17.7)          # works, unencrypted

PgDog can't resolve this during the handshake, which completes before the startup packet carrying user= arrives — at that point it has no idea who is connecting. So the choice is currently all-or-nothing for every connection on the listener.

Worth noting the current requirement doesn't actually protect those users: they're already reachable unauthenticated over plaintext. It only forces the legitimate ones onto the unencrypted path.

Change

Build the verifier with allow_unauthenticated() so a certificate is verified when presented but its absence no longer fails the handshake, and move the requirement to authentication, where the user is known.

A new per-user tls_client_certificate_required defaults to true and is enforced only when a client CA is configured and the client connected over TLS:

  • without a client CA, no certificate is ever requested (with_no_client_auth), so requiring one could never be satisfied — enforcing it unconditionally would reject every user in every deployment that doesn't configure client CAs;
  • plaintext connections stay governed by tls_client_required.
[[users]]
name = "app"
database = "db"
password_hash = "SCRAM-SHA-256$4096:..."
tls_client_certificate_required = false   # TLS, no client cert

Backwards compatibility

Behavior is unchanged at the default:

Client Before After
TLS, no cert, client CA set rejected (TLS alert) rejected (NoClientCertificate)
TLS, no cert, opted out rejected authenticates over TLS
TLS, valid cert verified verified, unchanged
TLS, untrusted cert rejected at handshake rejected at handshake
Plaintext governed by tls_client_required unchanged
No client CA configured cert never requested unchanged

The one visible difference at default is that a certless client is now rejected after the handshake rather than during it, so the error is a Postgres auth failure instead of a TLS alert. That also means slightly more work per rejected connection — happy to gate the verifier change behind a config flag instead if you'd prefer to keep the early rejection.

mTLS is unaffected

Identity enforcement never lived in the handshake — client/mod.rs already requires stream.tls_identity() == Some(identity) for any user with a configured identity, and a certless client gets None, so it still fails with NoIdentity. An entry with neither identity nor password still fails closed via the existing NoPasswordConfig guard. Certificates that are presented are verified against the CA bundle exactly as before; only the empty-certificate case changes.

Notes

  • Certificate presence is tracked on Stream separately from tls_identity, which is None both when no certificate was sent and when one was sent without a dNSName SAN or Subject CN.
  • .schema/users.schema.json regenerated via cargo run -p pgdog-jsonschema.
  • Happy to rename the setting, or add a [general] fallback with a per-user override in the style of pooler_mode, if you'd prefer that shape.

Testing

  • cargo fmt --all -- --check and cargo clippy --all-targets -- -D warnings clean
  • cargo test -p pgdog-config — 83 passed
  • New: client_cert_verifier_allows_anonymous_clients asserts offer_client_auth() stays true while client_auth_mandatory() becomes false; two config tests cover the unset and opted-out cases

I wasn't able to run the full pgdog lib suite locally (it needs the Postgres fixtures from integration/setup.sh), so CI is the first full run.

Setting `tls_client_ca_certificate` makes client certificates mandatory
for every TLS connection, because rustls' default client verifier denies
anonymous clients. On a listener serving both mTLS users (`identity`) and
password users, that leaves the password users unable to negotiate TLS at
all: they get a `certificate_required` alert, so their only working option
is a plaintext connection.

PgDog cannot decide this during the handshake, which completes before the
startup packet carrying `user=` arrives. At that point it has no idea who
is connecting.

Build the verifier with `allow_unauthenticated()` so a certificate is
verified when presented but its absence no longer fails the handshake, and
move the requirement to authentication, where the user is known. A new
per-user `tls_client_certificate_required` defaults to true and is enforced
only when a client CA is configured and the client connected over TLS:

  - without a client CA no certificate is ever requested, so requiring one
    could never be satisfied;
  - plaintext connections stay governed by `tls_client_required`.

Behavior is unchanged at the default. A user connecting over TLS without a
certificate is still rejected, now with `NoClientCertificate` at the auth
layer rather than a TLS alert. mTLS is unaffected: users with `identity`
are still rejected by the existing `tls_identity()` comparison, and an
entry with neither identity nor password still fails closed via
`NoPasswordConfig`. Certificates that are presented are verified against
the CA bundle exactly as before; only the empty-certificate case changes.

Track certificate presence on `Stream` separately from `tls_identity`,
which is `None` both when no certificate was sent and when one was sent
without a dNSName SAN or Subject CN.

Co-Authored-By: Claude <noreply@anthropic.com>
@bhargavtheertham-cb
bhargavtheertham-cb marked this pull request as draft July 29, 2026 22:28
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.66102% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pgdog/src/frontend/client/mod.rs 33.33% 4 Missing ⚠️
pgdog/src/backend/pool/cluster.rs 50.00% 3 Missing ⚠️
pgdog/src/net/stream.rs 57.14% 3 Missing ⚠️
pgdog/src/auth/auth_result.rs 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant