[PATCH v1] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support

Started by Renaud Métrich12 days ago9 messageshackers
Jump to latest
#1Renaud Métrich
rmetrich@redhat.com

Hi hackers,

PostgreSQL currently supports only one SSL certificate per server instance
(via ssl_cert_file/ssl_key_file).  Web servers like httpd, nginx, and
haproxy have long supported serving multiple certificates of different key
types (e.g., RSA and ECDSA simultaneously), letting OpenSSL select the
appropriate one during the TLS handshake based on the negotiated cipher
suite.  PostgreSQL cannot do this today, and there is no viable workaround
— TLS-terminating proxies don't work because PostgreSQL uses an
in-protocol SSL upgrade rather than raw TLS connections.

This patch adds two new GUC parameters, ssl_alt_cert_file and
ssl_alt_key_file, which allow loading an alternate certificate alongside
the primary one.  OpenSSL natively supports one certificate per key type
in a single SSL_CTX; we just need to call SSL_CTX_use_certificate_file()
and SSL_CTX_use_PrivateKey_file() a second time for the alternate key
type.

The main challenge was the SNI architecture introduced in PG 19. The
ssl_update_ssl() function copies the certificate from the per-host
SSL_CTX to the per-connection SSL object, but the original code calls
SSL_CTX_get0_certificate() only once — which returns a single cert —
and SSL_use_cert_and_key() with override=1, wiping any additional certs.
The fix iterates all certificate types in the SSL_CTX using
SSL_CTX_set_current_cert(SSL_CERT_SET_FIRST/NEXT) and loads each onto
the SSL object with override=1 for the first and override=0 for
subsequent ones.

A secondary issue was that be_tls_get_server_cert_types() originally
accessed SSL_hosts->default_host->ssl_ctx at query time, but SSL_hosts
is allocated in PostmasterContext which child backends delete after
InitPostgres().  This caused a segfault.  The fix caches the cert types
string in a static variable during be_tls_init() while the SSL_CTX is
still valid.

For observability, the patch adds ssl_server_cert_type() and
ssl_server_cert_types() to the sslinfo extension (bumped to v1.3):

    SELECT ssl_server_cert_type();   -- 'ECDSA' (per-connection)
    SELECT ssl_server_cert_types();  -- 'RSA, ECDSA' (server-wide)

Usage is straightforward:

    # postgresql.conf
    ssl = on
    ssl_cert_file = 'server.crt'           # RSA certificate
    ssl_key_file = 'server.key'
    ssl_alt_cert_file = 'server-ecdsa.crt'  # ECDSA certificate
    ssl_alt_key_file = 'server-ecdsa.key'

Both parameters must be set together; setting one without the other
produces a configuration error.  When neither is set, behavior is
identical to an unpatched server.

The patch targets master (currently 19beta1) and is intended for
application — it is not WIP.  It compiles cleanly, and the full SSL
test suite passes without regressions: 443/443 tests (001_ssltests,
002_scram, 003_sslinfo, 004_sni, 004_ssl_alt_cert).  The new test
004_ssl_alt_cert.pl covers 20 subtests: basic connectivity, GUC
verification, cipher-specific cert selection via openssl s_client,
configuration mismatch validation, and the sslinfo observability
functions.

Testing was done on RHEL 9.8 (x86_64) with OpenSSL 3.5.5.  There are
no platform-specific items; the patch uses only standard OpenSSL APIs
available since OpenSSL 1.1.0.

Performance impact is negligible — the only added overhead is one extra
SSL_CTX_use_certificate_file()/SSL_CTX_use_PrivateKey_file() call during
server startup or SIGHUP reload, and the SSL_CTX_set_current_cert()
iteration in ssl_update_ssl() which adds one loop iteration per
additional key type (typically just one: ECDSA alongside RSA).

The patch includes documentation updates (config.sgml, runtime.sgml,
sslinfo.sgml) and regression tests.

I chose to use a separate ssl_alt_cert_file/ssl_alt_key_file GUC pair
rather than a list-based approach (e.g., ssl_cert_file accepting
multiple values) to keep the interface simple and backward-compatible.
In practice, the only two key types in widespread use today are RSA and
ECDSA — EdDSA (Ed25519/Ed448) server certificates are still not
commonly issued by public CAs and have limited client support.  A single
alternate pair therefore covers the real-world need.  Should EdDSA
adoption grow in the future, the ssl_update_ssl() fix in this patch
already handles an arbitrary number of key types in the SSL_CTX, so
extending the interface would be straightforward.

The attached patch is:
  v1-0001-Add-ssl_alt_cert_file-ssl_alt_key_file-for-dual-R.patch

16 files changed, 591 insertions(+), 22 deletions(-)

Thanks,
Renaud Métrich
Red Hat

Attachments:

v1-0001-Add-ssl_alt_cert_file-ssl_alt_key_file-for-dual-R.patchtext/x-patch; charset=UTF-8; name=v1-0001-Add-ssl_alt_cert_file-ssl_alt_key_file-for-dual-R.patchDownload+591-23
#2Zsolt Parragi
zsolt.parragi@percona.com
In reply to: Renaud Métrich (#1)
Re: [PATCH v1] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support

Hello!

The problem the patch tries to solve is real, but I see several gaps/problems with the current implementation with some testing:

1. it seems to break TLS 1.3 HelloRetryRequest as it tries to add the second certificate with override=0. Connection then fails with "SSL error: tlsv1 alert internal error", server log shows "could not update certificate chain: not replacing certificate" / "failed to switch to SSL configuration for host, terminating connection"

2. The global ssl_alt_* GUCs are loaded into every pg_hosts context. If the SNI cert is a different type, it loads the alternative certificates as alternatives, if it's the same type, it replaces the hosts entry.

3. pg_hosts/SNI has no support for the new GUCs, there's no way to configure per host versions of the feature. Shouldn't the patch include proper support for SNI?

4. Shouldn't alternative certificates load the entire chain, not just the first block?

5. If both have the same type, the alternate certificate silently replaces the primary one. Shouldn't that result in a startup error instead?

6. Won't this cause build failure with LibreSSL, or older OpenSSL?

#3Renaud Métrich
rmetrich@redhat.com
In reply to: Zsolt Parragi (#2)
Re: [PATCH v2] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support

To: pgsql-hackers@lists.postgresql.org
Subject: Re: [PATCH v2] Add ssl_alt_cert_file/ssl_alt_key_file for dual
RSA+ECDSA certificate support
In-Reply-To: <0cabf744-6d58-4bc4-817a-413c804cf61d@redhat.com>

Hi Zsolt,

Thanks for the thorough review.  Here is v2 addressing all six issues
you raised.  The fixes are in separate commits so each one can be
reviewed independently:

v2-0001: original patch (unchanged from v1)
v2-0002: reject alternate certificate with same key type as primary (#5)
v2-0003: load full certificate chain for alternate certificate (#4)
v2-0004: load alternate certificates only for the default host context (#2)
v2-0005: add compatibility guards for LibreSSL and older OpenSSL (#6)
v2-0006: document ssl_alt_cert_file limitations (#3)
v2-0007: fix TLS 1.3 HelloRetryRequest with multiple certificate types (#1)
v2-0008: expand test coverage (same-type rejection, SIGHUP reload,
         single-cert regression, TLS 1.3 actually exercised)

Specific answers:

1) TLS 1.3 HelloRetryRequest: the root cause was using override=0 for
   the second certificate.  During HRR the ClientHello callback fires
   twice; on the second invocation the key type was already loaded from
   the first, so SSL_use_cert_and_key() refused to replace it. Fixed
   by always using override=1 — since we load a complete set from the
   SSL_CTX, replacing is always correct.  Added a TLS 1.3 connectivity
   test and removed the TLS 1.2 protocol restriction.

2) Global GUCs leaking into SNI contexts: added an is_default parameter
   to init_host_context().  Alt certs are now loaded only for the
   default host context (postgresql.conf), not for per-host entries from
   pg_hosts.conf.

3) Per-host SNI support: documented that ssl_alt_cert_file applies only
   to the default SSL configuration from postgresql.conf.

4) Full chain loading: switched from SSL_CTX_use_certificate_file() to
   SSL_CTX_use_certificate_chain_file(), matching how the primary
   certificate is loaded.

5) Same-type detection: after loading the alt cert, we iterate with
   SSL_CTX_set_current_cert(FIRST/NEXT) and compare
   EVP_PKEY_get_base_id() on both.  Produces a FATAL error if they
   match.

6) LibreSSL/older OpenSSL: guarded SSL_CTX_set_current_cert() and
   SSL_CERT_SET_FIRST/NEXT with #ifdef SSL_CERT_SET_FIRST.  When not
   available, ssl_alt_cert_file produces a clear error, ssl_update_ssl()
   falls back to the original single-cert copy, and cert types caching
   is skipped.  Verified by building with #undef SSL_CERT_SET_FIRST to
   exercise the fallback code paths.

Test results: 452/452 SSL tests pass (29 alt cert subtests including
TLS 1.3, same-type rejection, SIGHUP reload add/remove, single-cert
regression, plus the existing 423), no regressions.  RHEL 9.8 /
OpenSSL 3.5.5.

---

Looking ahead, I want to flag an alternative design direction worth
considering.  The ssl_alt_cert_file approach is inherently limited to
two certificates (primary + one alternate), but OpenSSL supports up to
three key types (RSA, ECDSA, EdDSA).  It also introduces new GUC names
that don't generalize well.

An approach that other projects have converged on is to make
ssl_cert_file and ssl_key_file multi-valued — httpd and nginx already
work this way, and I am doing the same for MariaDB [1]https://github.com/MariaDB/server/pull/5178 where we went
through a similar evolution: we started with --ssl-alt-cert, then
redesigned to allow repeated --ssl-cert/--ssl-key options and let
OpenSSL handle cert/key matching and type verification internally.

For PostgreSQL, since GUCs are strings, this could take the form of
comma-separated paths:

    ssl_cert_file = 'server-rsa.crt, server-ecdsa.crt'
    ssl_key_file  = 'server-rsa.key, server-ecdsa.key'

The server would load each pair via
SSL_CTX_use_certificate_chain_file() / SSL_CTX_use_PrivateKey_file()
and let OpenSSL sort out the type matching — no same-type detection
needed, no "alt" naming, and natural support for all three key types.
The ssl_update_ssl() fix from this patch (iterating all cert types)
would still be needed regardless.

I'm happy to go either direction — the current v2 is functional and
complete, but if the community prefers the multi-valued approach, I
can rework it.

[1]: https://github.com/MariaDB/server/pull/5178

Thanks,
Renaud Métrich
Red Hat

Le 12/06/2026 à 10:39 PM, Zsolt Parragi a écrit :

Show quoted text

Hello!

The problem the patch tries to solve is real, but I see several
gaps/problems with the current implementation with some testing:

1. it seems to break TLS 1.3 HelloRetryRequest as it tries to add the
second certificate with override=0. Connection then fails with "SSL
error: tlsv1 alert internal error", server log shows "could not update
certificate chain: not replacing certificate" / "failed to switch to
SSL configuration for host, terminating connection"

2. The global ssl_alt_* GUCs are loaded into every pg_hosts context.
If the SNI cert is a different type, it loads the alternative
certificates as alternatives, if it's the same type, it replaces the
hosts entry.

3. pg_hosts/SNI has no support for the new GUCs, there's no way to
configure per host versions of the feature. Shouldn't the patch
include proper support for SNI?

4. Shouldn't alternative certificates load the entire chain, not just
the first block?

5. If both have the same type, the alternate certificate silently
replaces the primary one. Shouldn't that result in a startup error
instead?

6. Won't this cause build failure with LibreSSL, or older OpenSSL?

Attachments:

v2-0001-Add-ssl_alt_cert_file-ssl_alt_key_file-for-dual-R.patchtext/x-patch; charset=UTF-8; name=v2-0001-Add-ssl_alt_cert_file-ssl_alt_key_file-for-dual-R.patchDownload+591-23
v2-0002-Reject-alternate-certificate-with-same-key-type-a.patchtext/x-patch; charset=UTF-8; name=v2-0002-Reject-alternate-certificate-with-same-key-type-a.patchDownload+33-1
v2-0003-Load-full-certificate-chain-for-alternate-certifi.patchtext/x-patch; charset=UTF-8; name=v2-0003-Load-full-certificate-chain-for-alternate-certifi.patchDownload+1-3
v2-0004-Load-alternate-certificates-only-for-the-default-.patchtext/x-patch; charset=UTF-8; name=v2-0004-Load-alternate-certificates-only-for-the-default-.patchDownload+10-9
v2-0005-Add-compatibility-guards-for-LibreSSL-and-older-O.patchtext/x-patch; charset=UTF-8; name=v2-0005-Add-compatibility-guards-for-LibreSSL-and-older-O.patchDownload+36-4
v2-0006-Document-ssl_alt_cert_file-limitations.patchtext/x-patch; charset=UTF-8; name=v2-0006-Document-ssl_alt_cert_file-limitations.patchDownload+8-1
v2-0007-Fix-TLS-1.3-HelloRetryRequest-with-multiple-certi.patchtext/x-patch; charset=UTF-8; name=v2-0007-Fix-TLS-1.3-HelloRetryRequest-with-multiple-certi.patchDownload+16-10
v2-0008-Expand-test-coverage-for-dual-certificate-support.patchtext/x-patch; charset=UTF-8; name=v2-0008-Expand-test-coverage-for-dual-certificate-support.patchDownload+86-30
#4Zsolt Parragi
zsolt.parragi@percona.com
In reply to: Renaud Métrich (#3)
Re: [PATCH v2] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support
+				primary_type = EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert));
+				alt_type = EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));
+

Isn't EVP_PKEY_get_base_id also a function introduced by OpenSSL 3.0?

-# Test 5: Verify ssl_server_cert_type() returns correct type per connection
+# Test 5: Verify TLS 1.3 connection works (exercises HelloRetryRequest path)
+note "testing TLS 1.3 connection with dual certs";
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"connect via TLS 1.3 with dual certs (default negotiation)",
+	sql => "SELECT 1");
+
+# Test 6: Verify ssl_server_cert_type() returns correct type per connection

I don't think this properly tests HelloRetryRequest, as there's no key-share group mismatch in the testcase.

Also, the testcase file should be included in src/test/ssl/meson.build, currently it is only executed by make.

+
+		/*
+		 * Verify that the alternate certificate uses a different key type
+		 * than the primary.  If both are the same type (e.g. both RSA),
+		 * the alternate silently replaces the primary, which is not useful.
+		 */
+		{
+			X509	   *primary_cert;
+			X509	   *alt_cert;
+			int			primary_type;
+			int			alt_type;
+
+			SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_FIRST);
+			primary_cert = SSL_CTX_get0_certificate(ctx);
+
+			SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT);
+			alt_cert = SSL_CTX_get0_certificate(ctx);
+
+			if (primary_cert && alt_cert)
+			{
+				primary_type = EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert));
+				alt_type = EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));
+
+				if (primary_type == alt_type)
+				{
+					ereport(isServerStart ? FATAL : LOG,
+							(errcode(ERRCODE_CONFIG_FILE_ERROR),
+							 errmsg("alternate certificate has the same key type (%s) as the primary certificate",
+									evp_pkey_type_name(alt_type))));
+					goto error;
+				}
+			}
+		}

Are you sure about this code? The comment says "the alternate silently replaces the primary, which is not useful." - which was also my observation, but replacing means that there's no alt certificate really. I think this check works by accident because if there's no NEXT certificate, OpenSSL returns the first certificate again, but this behavior is undocumented.

3) Per-host SNI support: documented that ssl_alt_cert_file applies only
to the default SSL configuration from postgresql.conf.

I don't think documenting the limitation is a good approach for this feature, it should be supported uniformly everywhere. The question of how it could fit into pg_hosts is a different question, so I am not suggesting that you should simply add a few more columns there, but I would at least keep it as a TODO item. The format/extensibility of hosts and other configuration files is a bigger question I want to start a discussion about soon, and this could fit there.

For PostgreSQL, since GUCs are strings, this could take the form of
comma-separated paths:

ssl_cert_file = 'server-rsa.crt, server-ecdsa.crt'
ssl_key_file = 'server-rsa.key, server-ecdsa.key'

List style GUCs already exist (for example unix_socket_directories), so there's a good precedent for this. This could also fit into pg_hosts, where the host part already accepts a list of hosts.

Also I'm not sure if changing the single string GUC to a list could cause any issues with dump/restore. I didn't check this in detail, but there's a comment about this in src/backend/utils/misc/guc_parameters.dat and dumputils.c.

Another option would be to add new GUCs ending with _files, and make them mutually exclusive?

and let OpenSSL sort out the type matching — no same-type detection
needed, no "alt" naming, and natural support for all three key types.

I'm quite sure we would still have to do some validation, but that's a minor detail.

#5Renaud Métrich
rmetrich@redhat.com
In reply to: Zsolt Parragi (#4)
Re: [PATCH v2] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support

Hi Zsolt,

Thanks for the thorough review, I will take a look at all this.

Best regards,

Renaud Métrich
Red Hat

Le 15/06/2026 à 11:13 PM, Zsolt Parragi a écrit :

Show quoted text
+				primary_type = EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert));
+				alt_type = EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));
+

Isn't EVP_PKEY_get_base_id also a function introduced by OpenSSL 3.0?

-# Test 5: Verify ssl_server_cert_type() returns correct type per connection
+# Test 5: Verify TLS 1.3 connection works (exercises HelloRetryRequest path)
+note "testing TLS 1.3 connection with dual certs";
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"connect via TLS 1.3 with dual certs (default negotiation)",
+	sql => "SELECT 1");
+
+# Test 6: Verify ssl_server_cert_type() returns correct type per connection

I don't think this properly tests HelloRetryRequest, as there's no
key-share group mismatch in the testcase.

Also, the testcase file should be included in
src/test/ssl/meson.build, currently it is only executed by make.

+
+		/*
+		 * Verify that the alternate certificate uses a different key type
+		 * than the primary.  If both are the same type (e.g. both RSA),
+		 * the alternate silently replaces the primary, which is not useful.
+		 */
+		{
+			X509	   *primary_cert;
+			X509	   *alt_cert;
+			int			primary_type;
+			int			alt_type;
+
+			SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_FIRST);
+			primary_cert = SSL_CTX_get0_certificate(ctx);
+
+			SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT);
+			alt_cert = SSL_CTX_get0_certificate(ctx);
+
+			if (primary_cert && alt_cert)
+			{
+				primary_type = EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert));
+				alt_type = EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));
+
+				if (primary_type == alt_type)
+				{
+					ereport(isServerStart ? FATAL : LOG,
+							(errcode(ERRCODE_CONFIG_FILE_ERROR),
+							 errmsg("alternate certificate has the same key type (%s) as
the primary certificate",
+									evp_pkey_type_name(alt_type))));
+					goto error;
+				}
+			}
+		}

Are you sure about this code? The comment says "the alternate silently
replaces the primary, which is not useful." - which was also my
observation, but replacing means that there's no alt certificate
really. I think this check works by accident because if there's no
NEXT certificate, OpenSSL returns the first certificate again, but
this behavior is undocumented.

3) Per-host SNI support: documented that ssl_alt_cert_file applies only
to the default SSL configuration from postgresql.conf.

I don't think documenting the limitation is a good approach for this
feature, it should be supported uniformly everywhere. The question of
how it could fit into pg_hosts is a different question, so I am not
suggesting that you should simply add a few more columns there, but I
would at least keep it as a TODO item. The format/extensibility of
hosts and other configuration files is a bigger question I want to
start a discussion about soon, and this could fit there.

For PostgreSQL, since GUCs are strings, this could take the form of
comma-separated paths:

ssl_cert_file = 'server-rsa.crt, server-ecdsa.crt'
ssl_key_file = 'server-rsa.key, server-ecdsa.key'

List style GUCs already exist (for example unix_socket_directories),
so there's a good precedent for this. This could also fit into
pg_hosts, where the host part already accepts a list of hosts.

Also I'm not sure if changing the single string GUC to a list could
cause any issues with dump/restore. I didn't check this in detail, but
there's a comment about this in
src/backend/utils/misc/guc_parameters.dat and dumputils.c.

Another option would be to add new GUCs ending with _files, and make
them mutually exclusive?

and let OpenSSL sort out the type matching — no same-type detection
needed, no "alt" naming, and natural support for all three key types.

I'm quite sure we would still have to do some validation, but that's a
minor detail.

#6Jacob Champion
jacob.champion@enterprisedb.com
In reply to: Renaud Métrich (#1)
Re: [PATCH v1] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support

On Fri, Jun 12, 2026 at 3:05 AM Renaud Métrich <rmetrich@redhat.com> wrote:

there is no viable workaround
— TLS-terminating proxies don't work because PostgreSQL uses an
in-protocol SSL upgrade rather than raw TLS connections.

(Haven't looked at the patch, but raw TLS connections are possible
since PG17; see sslnegotiation=direct.)

--Jacob

#7Renaud Métrich
rmetrich@redhat.com
In reply to: Jacob Champion (#6)
Re: [PATCH v1] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support

Hi Jacob,

raw TLS connections are possible since PG17; see sslnegotiation=direct.

Good point, I wasn't aware of sslnegotiation=direct.  I tested the
proxy workaround on RHEL 9.8 with PG 18 and can confirm it works,
with some caveats:

nginx works as a TLS-terminating proxy with dual RSA+ECDSA certs,
but it requires nginx >= 1.21.4 for the ssl_alpn directive in the
stream module (PG 18 psql rejects direct SSL connections without ALPN
negotiation).  RHEL 9's base nginx is 1.20 which lacks this, but the
1.24 modular package works.  The nginx config is straightforward:

    stream {
        server {
            listen 5433 ssl;
            ssl_certificate     server-rsa.crt;
            ssl_certificate_key server-rsa.key;
            ssl_certificate     server-ecdsa.crt;
            ssl_certificate_key server-ecdsa.key;
            ssl_alpn postgresql;
            proxy_pass 127.0.0.1:5432;
        }
    }

haproxy 2.8 supports ALPN (so psql connects), but doesn't do proper
dual cert selection — only one cert type is served regardless of the
negotiated cipher.

So the workaround is viable with the right nginx version, but it does
require:
- PG 17+ clients (sslnegotiation=direct)
- nginx >= 1.21.4 with stream + ssl_alpn
- hostnossl trust in pg_hba.conf for proxy connections

Native support avoids the proxy dependency and works with all clients
regardless of version or sslnegotiation support.  I've updated the
patch description accordingly — thanks for the correction.

Renaud

Le 16/06/2026 à 5:17 PM, Jacob Champion a écrit :

Show quoted text

On Fri, Jun 12, 2026 at 3:05 AM Renaud Métrich <rmetrich@redhat.com> wrote:

there is no viable workaround
— TLS-terminating proxies don't work because PostgreSQL uses an
in-protocol SSL upgrade rather than raw TLS connections.

(Haven't looked at the patch, but raw TLS connections are possible
since PG17; see sslnegotiation=direct.)

--Jacob

#8Renaud Métrich
rmetrich@redhat.com
In reply to: Renaud Métrich (#5)
Re: [PATCH v3] Add ssl_cert_files/ssl_key_files for multi-certificate support

Hi,

Here is v3, redesigned based on Zsolt's v2 feedback and the proxy
workaround discussion with Jacob.

The main change: the ssl_alt_cert_file/ssl_alt_key_file approach is
dropped entirely.  Instead, v3 introduces two new list-valued GUCs:

    ssl_cert_files = 'server-rsa.crt, server-ecdsa.crt'
    ssl_key_files  = 'server-rsa.key, server-ecdsa.key'

This follows the same pattern as unix_socket_directories and
shared_preload_libraries (GUC_LIST_INPUT | GUC_LIST_QUOTE).  When set,
ssl_cert_files takes precedence over ssl_cert_file.  Each entry is
paired positionally with the corresponding entry in ssl_key_files.
Certificates are loaded via SSL_CTX_use_certificate_chain_file() (full
chain) and OpenSSL handles key-type matching internally — no same-type
detection needed, no ordering validation, and natural support for all
three key types (RSA, ECDSA, EdDSA).

Addressing each v2 review point:

1+4) Same-type detection / EVP_PKEY_get_base_id — dropped entirely.
   With ssl_cert_files, OpenSSL handles duplicate key types internally
   (last wins), so no application-level detection is needed.  This
   also eliminates the fragile SSL_CTX_set_current_cert(NEXT) logic.

2) TLS 1.3 HRR test — added a proper test that forces HelloRetryRequest
   by setting ssl_groups='secp384r1' on the server and connecting with
   -groups X25519:secp384r1.  The ssl_update_ssl() fix (override=1
   always) is carried over from v2.

3) Test in meson.build — the new test is t/005_ssl_multi_cert.pl,
   added to both Makefile and src/test/ssl/meson.build.

5) SNI limitation — documented that ssl_cert_files applies to the
   default SSL configuration from postgresql.conf only.  Per-host
   support in pg_hosts.conf is left as future work per Zsolt's
   suggestion.

6) Multi-valued GUC design — implemented as new ssl_cert_files /
   ssl_key_files GUCs rather than making ssl_cert_file list-valued,
   avoiding any dump/restore implications.  Added to
   variable_is_guc_list_quote() in dumputils.c.  Verified with
   pg_dumpall: the GUCs are PGC_SIGHUP context so they never appear
   in dumps (only in postgresql.conf / postgresql.auto.conf).

The observability functions (ssl_server_cert_type/ssl_server_cert_types
via sslinfo extension) that were in v1/v2 have been split out for a
separate submission, to keep this patch focused on the core multi-cert
loading.

Test results: 30 subtests in 005_ssl_multi_cert.pl covering dual cert
negotiation (TLS 1.2 + 1.3), HelloRetryRequest, mismatched list
lengths, missing cert/key files, bad certificate path, cert/key type
mismatch, single cert regression, ssl_cert_files precedence over
ssl_cert_file, and SIGHUP reload add/remove.  Full SSL suite passes
with no regressions.  RHEL 9.8 / OpenSSL 3.5.5.  LibreSSL fallback
paths verified via #undef SSL_CERT_SET_FIRST build.

10 files changed, 522 insertions(+), 11 deletions(-)

Thanks,
Renaud Métrich
Red Hat

Le 16/06/2026 à 2:16 PM, Renaud Métrich a écrit :

Show quoted text

Hi Zsolt,

Thanks for the thorough review, I will take a look at all this.

Best regards,

Renaud Métrich
Red Hat

Le 15/06/2026 à 11:13 PM, Zsolt Parragi a écrit :

+                primary_type = 
EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert));
+                alt_type = 
EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));
+

Isn't EVP_PKEY_get_base_id also a function introduced by OpenSSL 3.0?

-# Test 5: Verify ssl_server_cert_type() returns correct type per 
connection
+# Test 5: Verify TLS 1.3 connection works (exercises 
HelloRetryRequest path)
+note "testing TLS 1.3 connection with dual certs";
+$node->connect_ok(
+    "$common_connstr sslcert=invalid",
+    "connect via TLS 1.3 with dual certs (default negotiation)",
+    sql => "SELECT 1");
+
+# Test 6: Verify ssl_server_cert_type() returns correct type per 
connection

I don't think this properly tests HelloRetryRequest, as there's no
key-share group mismatch in the testcase.

Also, the testcase file should be included in
src/test/ssl/meson.build, currently it is only executed by make.

+
+        /*
+         * Verify that the alternate certificate uses a different 
key type
+         * than the primary.  If both are the same type (e.g. both 
RSA),
+         * the alternate silently replaces the primary, which is not 
useful.
+         */
+        {
+            X509       *primary_cert;
+            X509       *alt_cert;
+            int            primary_type;
+            int            alt_type;
+
+            SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_FIRST);
+            primary_cert = SSL_CTX_get0_certificate(ctx);
+
+            SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT);
+            alt_cert = SSL_CTX_get0_certificate(ctx);
+
+            if (primary_cert && alt_cert)
+            {
+                primary_type = 
EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert));
+                alt_type = 
EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));
+
+                if (primary_type == alt_type)
+                {
+                    ereport(isServerStart ? FATAL : LOG,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+                             errmsg("alternate certificate has the 
same key type (%s) as
the primary certificate",
+ evp_pkey_type_name(alt_type))));
+                    goto error;
+                }
+            }
+        }

Are you sure about this code? The comment says "the alternate silently
replaces the primary, which is not useful." - which was also my
observation, but replacing means that there's no alt certificate
really. I think this check works by accident because if there's no
NEXT certificate, OpenSSL returns the first certificate again, but
this behavior is undocumented.

3) Per-host SNI support: documented that ssl_alt_cert_file applies only
   to the default SSL configuration from postgresql.conf.

I don't think documenting the limitation is a good approach for this
feature, it should be supported uniformly everywhere. The question of
how it could fit into pg_hosts is a different question, so I am not
suggesting that you should simply add a few more columns there, but I
would at least keep it as a TODO item. The format/extensibility of
hosts and other configuration files is a bigger question I want to
start a discussion about soon, and this could fit there.

For PostgreSQL, since GUCs are strings, this could take the form of
comma-separated paths:

    ssl_cert_file = 'server-rsa.crt, server-ecdsa.crt'
    ssl_key_file  = 'server-rsa.key, server-ecdsa.key'

List style GUCs already exist (for example unix_socket_directories),
so there's a good precedent for this. This could also fit into
pg_hosts, where the host part already accepts a list of hosts.

Also I'm not sure if changing the single string GUC to a list could
cause any issues with dump/restore. I didn't check this in detail, but
there's a comment about this in
src/backend/utils/misc/guc_parameters.dat and dumputils.c.

Another option would be to add new GUCs ending with _files, and make
them mutually exclusive?

and let OpenSSL sort out the type matching — no same-type detection
needed, no "alt" naming, and natural support for all three key types.

I'm quite sure we would still have to do some validation, but that's a
minor detail.

Attachments:

v3-0001-Add-ssl_cert_files-ssl_key_files-for-multi-certifica.patchtext/x-patch; charset=UTF-8; name=v3-0001-Add-ssl_cert_files-ssl_key_files-for-multi-certifica.patchDownload+522-12
#9Zsolt Parragi
zsolt.parragi@percona.com
In reply to: Renaud Métrich (#8)
Re: [PATCH v3] Add ssl_cert_files/ssl_key_files for multi-certificate support

When set, ssl_cert_files takes precedence over ssl_cert_file.

Are you sure? ssl_cert_files gets loaded after ssl_cert_file was already, it seems additive to me. Shouldn't specifying both result in an error instead?

2) TLS 1.3 HRR test — added a proper test that forces HelloRetryRequest
by setting ssl_groups='secp384r1' on the server and connecting with
-groups X25519:secp384r1. The ssl_update_ssl() fix (override=1
always) is carried over from v2.

I don't see it? The string secp384r1 doesn't appear in the patch at all.

LibreSSL fallback
paths verified via #undef SSL_CERT_SET_FIRST build.

I think the fallback part needs at least a proper documentation / description specifying what's the expected behavior. Currently if I follow it correctly it serves the last loaded certificate, silently ignoring others? I don't think that's a behavior I would expect from a security-focused feature. But note that I did not try to build the patch with libressl and run tests with it yet.