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

From a230d54069b1615520ce2c5c78731041076ff1c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]>
Date: Mon, 8 Jun 2026 17:38:32 +0200
Subject: [PATCH v1] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA
 certificate support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add two new GUC parameters (ssl_alt_cert_file, ssl_alt_key_file) that allow
loading an alternate SSL certificate alongside the primary one.  OpenSSL
selects the appropriate certificate during the TLS handshake based on the
negotiated cipher suite.

Fix ssl_update_ssl() to iterate all certificate types in the SSL_CTX
using SSL_CTX_set_current_cert(FIRST/NEXT) and copy each to the
per-connection SSL object, preserving multi-cert state across the SNI
architecture.

Add ssl_server_cert_type()/ssl_server_cert_types() to the sslinfo
extension (v1.3) for observability of which certificate types are loaded
and which was selected for the current connection.

Author: Renaud Métrich <[email protected]>
---
 contrib/sslinfo/Makefile                      |   2 +-
 contrib/sslinfo/meson.build                   |   2 +
 contrib/sslinfo/sslinfo--1.2--1.3.sql         |  12 ++
 contrib/sslinfo/sslinfo--1.3.sql              |  56 +++++
 contrib/sslinfo/sslinfo.c                     |  42 ++++
 contrib/sslinfo/sslinfo.control               |   2 +-
 doc/src/sgml/config.sgml                      |  43 +++-
 doc/src/sgml/runtime.sgml                     |  13 ++
 doc/src/sgml/sslinfo.sgml                     |  34 +++
 src/backend/libpq/be-secure-openssl.c         | 199 ++++++++++++++++--
 src/backend/libpq/be-secure.c                 |   2 +
 src/backend/utils/misc/guc_parameters.dat     |  12 ++
 src/backend/utils/misc/postgresql.conf.sample |   2 +
 src/include/libpq/libpq-be.h                  |   2 +
 src/include/libpq/libpq.h                     |   2 +
 src/test/ssl/t/004_ssl_alt_cert.pl            | 188 +++++++++++++++++
 16 files changed, 591 insertions(+), 22 deletions(-)
 create mode 100644 contrib/sslinfo/sslinfo--1.2--1.3.sql
 create mode 100644 contrib/sslinfo/sslinfo--1.3.sql
 create mode 100644 src/test/ssl/t/004_ssl_alt_cert.pl

diff --git a/contrib/sslinfo/Makefile b/contrib/sslinfo/Makefile
index 14305594e2d..2d3ee10bf1e 100644
--- a/contrib/sslinfo/Makefile
+++ b/contrib/sslinfo/Makefile
@@ -6,7 +6,7 @@ OBJS = \
 	sslinfo.o
 
 EXTENSION = sslinfo
-DATA = sslinfo--1.2.sql sslinfo--1.1--1.2.sql sslinfo--1.0--1.1.sql
+DATA = sslinfo--1.3.sql sslinfo--1.2--1.3.sql sslinfo--1.2.sql sslinfo--1.1--1.2.sql sslinfo--1.0--1.1.sql
 PGFILEDESC = "sslinfo - information about client SSL certificate"
 
 ifdef USE_PGXS
diff --git a/contrib/sslinfo/meson.build b/contrib/sslinfo/meson.build
index 6e9cb96430a..e3c578415de 100644
--- a/contrib/sslinfo/meson.build
+++ b/contrib/sslinfo/meson.build
@@ -25,6 +25,8 @@ contrib_targets += sslinfo
 install_data(
   'sslinfo--1.0--1.1.sql',
   'sslinfo--1.1--1.2.sql',
+  'sslinfo--1.3.sql',
+  'sslinfo--1.2--1.3.sql',
   'sslinfo--1.2.sql',
   'sslinfo.control',
   kwargs: contrib_data_args,
diff --git a/contrib/sslinfo/sslinfo--1.2--1.3.sql b/contrib/sslinfo/sslinfo--1.2--1.3.sql
new file mode 100644
index 00000000000..87af04b34de
--- /dev/null
+++ b/contrib/sslinfo/sslinfo--1.2--1.3.sql
@@ -0,0 +1,12 @@
+/* contrib/sslinfo/sslinfo--1.2--1.3.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION sslinfo UPDATE TO '1.3'" to load this file. \quit
+
+CREATE FUNCTION ssl_server_cert_type() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_server_cert_type'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_server_cert_types() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_server_cert_types'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
diff --git a/contrib/sslinfo/sslinfo--1.3.sql b/contrib/sslinfo/sslinfo--1.3.sql
new file mode 100644
index 00000000000..55c53137ff0
--- /dev/null
+++ b/contrib/sslinfo/sslinfo--1.3.sql
@@ -0,0 +1,56 @@
+/* contrib/sslinfo/sslinfo--1.3.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION sslinfo" to load this file. \quit
+
+CREATE FUNCTION ssl_client_serial() RETURNS numeric
+AS 'MODULE_PATHNAME', 'ssl_client_serial'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_is_used() RETURNS boolean
+AS 'MODULE_PATHNAME', 'ssl_is_used'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_version() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_version'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_cipher() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_cipher'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_client_cert_present() RETURNS boolean
+AS 'MODULE_PATHNAME', 'ssl_client_cert_present'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_client_dn_field(text) RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_client_dn_field'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_issuer_field(text) RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_issuer_field'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_client_dn() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_client_dn'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_issuer_dn() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_issuer_dn'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION
+ssl_extension_info(OUT name text,
+    OUT value text,
+    OUT critical boolean
+) RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'ssl_extension_info'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_server_cert_type() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_server_cert_type'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
+
+CREATE FUNCTION ssl_server_cert_types() RETURNS text
+AS 'MODULE_PATHNAME', 'ssl_server_cert_types'
+LANGUAGE C STRICT PARALLEL RESTRICTED;
diff --git a/contrib/sslinfo/sslinfo.c b/contrib/sslinfo/sslinfo.c
index c4ae847880d..96b2907312a 100644
--- a/contrib/sslinfo/sslinfo.c
+++ b/contrib/sslinfo/sslinfo.c
@@ -474,3 +474,45 @@ ssl_extension_info(PG_FUNCTION_ARGS)
 	/* All done */
 	SRF_RETURN_DONE(funcctx);
 }
+
+
+/*
+ * Returns the key type of the server certificate used in the current
+ * SSL connection (e.g. "RSA", "ECDSA", "EdDSA").
+ */
+PG_FUNCTION_INFO_V1(ssl_server_cert_type);
+Datum
+ssl_server_cert_type(PG_FUNCTION_ARGS)
+{
+	const char *cert_type;
+
+	if (!MyProcPort->ssl_in_use)
+		PG_RETURN_NULL();
+
+	cert_type = be_tls_get_server_cert_type(MyProcPort);
+	if (cert_type == NULL)
+		PG_RETURN_NULL();
+
+	PG_RETURN_TEXT_P(cstring_to_text(cert_type));
+}
+
+
+/*
+ * Returns a comma-separated list of all certificate key types loaded
+ * by the server (e.g. "RSA, ECDSA").
+ */
+PG_FUNCTION_INFO_V1(ssl_server_cert_types);
+Datum
+ssl_server_cert_types(PG_FUNCTION_ARGS)
+{
+	const char *cert_types;
+
+	if (!MyProcPort->ssl_in_use)
+		PG_RETURN_NULL();
+
+	cert_types = be_tls_get_server_cert_types();
+	if (cert_types == NULL || cert_types[0] == '\0')
+		PG_RETURN_NULL();
+
+	PG_RETURN_TEXT_P(cstring_to_text(cert_types));
+}
diff --git a/contrib/sslinfo/sslinfo.control b/contrib/sslinfo/sslinfo.control
index c7754f924cf..b53e95b7da8 100644
--- a/contrib/sslinfo/sslinfo.control
+++ b/contrib/sslinfo/sslinfo.control
@@ -1,5 +1,5 @@
 # sslinfo extension
 comment = 'information about SSL certificates'
-default_version = '1.2'
+default_version = '1.3'
 module_pathname = '$libdir/sslinfo'
 relocatable = true
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index fa566c9e553..4d712ed852b 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1327,7 +1327,48 @@ include_dir 'conf.d'
       </listitem>
      </varlistentry>
 
-     <varlistentry id="guc-ssl-ca-file" xreflabel="ssl_ca_file">
+     <varlistentry id="guc-ssl-alt-cert-file" xreflabel="ssl_alt_cert_file">
+      <term><varname>ssl_alt_cert_file</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>ssl_alt_cert_file</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies the name of the file containing an alternate SSL server
+        certificate of a different key type (e.g., ECDSA if the primary is
+        RSA).  <productname>OpenSSL</productname> selects the appropriate
+        certificate during the TLS handshake based on the negotiated cipher
+        suite.  Relative paths are relative to the data directory.
+        This parameter can only be set in the <filename>postgresql.conf</filename>
+        file or on the server command line.
+        The default is empty, meaning no alternate certificate is loaded.
+        Both <varname>ssl_alt_cert_file</varname> and
+        <xref linkend="guc-ssl-alt-key-file"/> must be set together.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-ssl-alt-key-file" xreflabel="ssl_alt_key_file">
+      <term><varname>ssl_alt_key_file</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>ssl_alt_key_file</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies the name of the file containing the private key for the
+        alternate SSL server certificate specified by
+        <xref linkend="guc-ssl-alt-cert-file"/>.
+        Relative paths are relative to the data directory.
+        This parameter can only be set in the <filename>postgresql.conf</filename>
+        file or on the server command line.
+        The default is empty.
+       </para>
+      </listitem>
+     </varlistentry>
+
+          <varlistentry id="guc-ssl-ca-file" xreflabel="ssl_ca_file">
       <term><varname>ssl_ca_file</varname> (<type>string</type>)
       <indexterm>
        <primary><varname>ssl_ca_file</varname> configuration parameter</primary>
diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml
index dfa292c2c3a..cc2ab2168d3 100644
--- a/doc/src/sgml/runtime.sgml
+++ b/doc/src/sgml/runtime.sgml
@@ -2451,6 +2451,19 @@ pg_dumpall -p 5432 | psql -d postgres -p 5433
       certificate owner is trustworthy</entry>
      </row>
 
+     <row>
+      <entry><xref linkend="guc-ssl-alt-cert-file"/></entry>
+      <entry>alternate server certificate</entry>
+      <entry>loaded alongside the primary certificate for dual key-type
+      support (e.g., ECDSA alongside RSA)</entry>
+     </row>
+
+     <row>
+      <entry><xref linkend="guc-ssl-alt-key-file"/></entry>
+      <entry>alternate server private key</entry>
+      <entry>proves alternate server certificate was sent by the owner</entry>
+     </row>
+
      <row>
       <entry><xref linkend="guc-ssl-ca-file"/></entry>
       <entry>trusted certificate authorities</entry>
diff --git a/doc/src/sgml/sslinfo.sgml b/doc/src/sgml/sslinfo.sgml
index 85d49f66537..4ad3fecbf02 100644
--- a/doc/src/sgml/sslinfo.sgml
+++ b/doc/src/sgml/sslinfo.sgml
@@ -240,6 +240,40 @@ emailAddress
     </para>
     </listitem>
    </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ssl_server_cert_type() returns text</function>
+     <indexterm>
+      <primary>ssl_server_cert_type</primary>
+     </indexterm>
+    </term>
+    <listitem>
+    <para>
+     Returns the key type of the server certificate selected for the current
+     SSL connection (e.g., <literal>RSA</literal>,
+     <literal>ECDSA</literal>, or <literal>EdDSA</literal>).
+    </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>ssl_server_cert_types() returns text</function>
+     <indexterm>
+      <primary>ssl_server_cert_types</primary>
+     </indexterm>
+    </term>
+    <listitem>
+    <para>
+     Returns a comma-separated list of all certificate key types loaded by
+     the server (e.g., <literal>RSA, ECDSA</literal>).  This reflects all
+     certificates configured via
+     <xref linkend="guc-ssl-cert-file"/> and
+     <xref linkend="guc-ssl-alt-cert-file"/>.
+    </para>
+    </listitem>
+   </varlistentry>
+
   </variablelist>
  </sect2>
 
diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c
index 7890e6c2de2..cf7d981b697 100644
--- a/src/backend/libpq/be-secure-openssl.c
+++ b/src/backend/libpq/be-secure-openssl.c
@@ -125,7 +125,9 @@ static struct hosts
 	 */
 	HostsLine  *default_host;
 }		   *SSL_hosts;
+static char ssl_cert_types_cached[64];
 
+static const char *evp_pkey_type_name(int type);
 static bool dummy_ssl_passwd_cb_called = false;
 static bool ssl_is_server_start;
 
@@ -548,6 +550,50 @@ be_tls_init(bool isServerStart)
 	if (SSLPreferServerCiphers)
 		SSL_CTX_set_options(context, SSL_OP_CIPHER_SERVER_PREFERENCE);
 
+
+	/*
+	 * Cache the loaded certificate types for be_tls_get_server_cert_types().
+	 * This must be done before the host memory context is replaced, since
+	 * child backends will inherit this cached value via fork() and the host
+	 * contexts will be freed when PostmasterContext is deleted.
+	 */
+	ssl_cert_types_cached[0] = '\0';
+	if (new_hosts->default_host && new_hosts->default_host->ssl_ctx)
+	{
+		SSL_CTX    *cache_ctx = new_hosts->default_host->ssl_ctx;
+		int			cpos = 0;
+
+		SSL_CTX_set_current_cert(cache_ctx, SSL_CERT_SET_FIRST);
+		do
+		{
+			X509	   *cert = SSL_CTX_get0_certificate(cache_ctx);
+
+			if (cert)
+			{
+				EVP_PKEY   *pkey = X509_get0_pubkey(cert);
+
+				if (pkey)
+				{
+					const char *name = evp_pkey_type_name(EVP_PKEY_get_base_id(pkey));
+					size_t		nlen = strlen(name);
+
+					if (cpos > 0 && cpos + 2 < (int) sizeof(ssl_cert_types_cached))
+					{
+						ssl_cert_types_cached[cpos++] = ',';
+						ssl_cert_types_cached[cpos++] = ' ';
+					}
+					if (cpos + nlen < sizeof(ssl_cert_types_cached))
+					{
+						memcpy(ssl_cert_types_cached + cpos, name, nlen);
+						cpos += (int) nlen;
+					}
+				}
+			}
+		} while (SSL_CTX_set_current_cert(cache_ctx, SSL_CERT_SET_NEXT));
+
+		ssl_cert_types_cached[cpos] = '\0';
+	}
+
 	/*
 	 * Success!  Replace any existing SSL_context and host configurations.
 	 */
@@ -735,6 +781,60 @@ init_host_context(HostsLine *host, bool isServerStart)
 		goto error;
 	}
 
+	/*
+	 * Load alternate certificate (e.g. ECDSA alongside RSA) into the same
+	 * context.  OpenSSL supports one certificate per key type and selects
+	 * the appropriate one during the TLS handshake.
+	 */
+	if (ssl_alt_cert_file[0] && !ssl_alt_key_file[0])
+	{
+		ereport(isServerStart ? FATAL : LOG,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("ssl_alt_cert_file is set but ssl_alt_key_file is not")));
+		goto error;
+	}
+	if (!ssl_alt_cert_file[0] && ssl_alt_key_file[0])
+	{
+		ereport(isServerStart ? FATAL : LOG,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("ssl_alt_key_file is set but ssl_alt_cert_file is not")));
+		goto error;
+	}
+	if (ssl_alt_cert_file[0] && ssl_alt_key_file[0])
+	{
+		if (SSL_CTX_use_certificate_file(ctx, ssl_alt_cert_file,
+										 SSL_FILETYPE_PEM) != 1)
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("could not load alternate server certificate file \"%s\": %s",
+							ssl_alt_cert_file, SSLerrmessage(ERR_get_error()))));
+			goto error;
+		}
+
+		if (!check_ssl_key_file_permissions(ssl_alt_key_file, isServerStart))
+			goto error;
+
+		if (SSL_CTX_use_PrivateKey_file(ctx, ssl_alt_key_file,
+										   SSL_FILETYPE_PEM) != 1)
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("could not load alternate private key file \"%s\": %s",
+							ssl_alt_key_file, SSLerrmessage(ERR_get_error()))));
+			goto error;
+		}
+
+		if (SSL_CTX_check_private_key(ctx) != 1)
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("check of alternate private key failed: %s",
+							SSLerrmessage(ERR_get_error()))));
+			goto error;
+		}
+	}
+
 	/*
 	 * Load CA store, so we can verify client certificates if needed.
 	 */
@@ -1826,33 +1926,43 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config)
 
 	X509	   *cert;
 	EVP_PKEY   *key;
-
-	STACK_OF(X509) * chain;
+	STACK_OF(X509) *chain;
+	bool		first = true;
 
 	Assert(ctx != NULL);
-	/*-
-	 * Make use of the already-loaded certificate chain and key. At first
-	 * glance, SSL_set_SSL_CTX() looks like the easiest way to do this, but
-	 * beware -- it has very odd behavior:
-	 *
-	 *     https://github.com/openssl/openssl/issues/6109
+
+	/*
+	 * Iterate over all certificate types loaded in the SSL_CTX (e.g. RSA
+	 * and ECDSA) and copy each onto the per-connection SSL object.
+	 * SSL_use_cert_and_key with override=1 replaces; override=0 adds a
+	 * cert for a different key type without wiping existing ones.
 	 */
-	cert = SSL_CTX_get0_certificate(ctx);
-	key = SSL_CTX_get0_privatekey(ctx);
+	SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_FIRST);
+	do
+	{
+		cert = SSL_CTX_get0_certificate(ctx);
+		key = SSL_CTX_get0_privatekey(ctx);
+
+		if (!cert || !key)
+			continue;
 
-	Assert(cert && key);
+		if (!SSL_CTX_get0_chain_certs(ctx, &chain)
+			|| !SSL_use_cert_and_key(ssl, cert, key, chain, first ? 1 : 0))
+		{
+			ereport(COMMERROR,
+					errcode(ERRCODE_INTERNAL_ERROR),
+					errmsg_internal("could not update certificate chain: %s",
+									SSLerrmessage(ERR_get_error())));
+			return false;
+		}
+		first = false;
+	} while (SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT));
 
-	if (!SSL_CTX_get0_chain_certs(ctx, &chain)
-		|| !SSL_use_cert_and_key(ssl, cert, key, chain, 1 /* override */ )
-		|| !SSL_check_private_key(ssl))
+	if (!SSL_check_private_key(ssl))
 	{
-		/*
-		 * This shouldn't really be possible, since the inputs came from a
-		 * SSL_CTX that was already populated by OpenSSL.
-		 */
 		ereport(COMMERROR,
 				errcode(ERRCODE_INTERNAL_ERROR),
-				errmsg_internal("could not update certificate chain: %s",
+				errmsg_internal("could not verify private key: %s",
 								SSLerrmessage(ERR_get_error())));
 		return false;
 	}
@@ -2194,6 +2304,29 @@ SSLerrmessage(unsigned long ecode)
 	return errbuf;
 }
 
+
+static const char *
+evp_pkey_type_name(int type)
+{
+	switch (type)
+	{
+		case EVP_PKEY_RSA:
+			return "RSA";
+		case EVP_PKEY_EC:
+			return "ECDSA";
+#ifdef EVP_PKEY_ED25519
+		case EVP_PKEY_ED25519:
+			return "EdDSA";
+#endif
+#ifdef EVP_PKEY_ED448
+		case EVP_PKEY_ED448:
+			return "EdDSA";
+#endif
+		default:
+			return "unknown";
+	}
+}
+
 int
 be_tls_get_cipher_bits(Port *port)
 {
@@ -2226,6 +2359,34 @@ be_tls_get_cipher(Port *port)
 		return NULL;
 }
 
+const char *
+be_tls_get_server_cert_type(Port *port)
+{
+	if (port->ssl)
+	{
+		X509	   *cert = SSL_get_certificate(port->ssl);
+
+		if (cert)
+		{
+			EVP_PKEY   *pkey = X509_get0_pubkey(cert);
+
+			if (pkey)
+				return evp_pkey_type_name(EVP_PKEY_get_base_id(pkey));
+		}
+	}
+	return NULL;
+}
+
+const char *
+be_tls_get_server_cert_types(void)
+{
+	if (ssl_cert_types_cached[0] == '\0')
+		return NULL;
+
+	return ssl_cert_types_cached;
+}
+
+
 void
 be_tls_get_peer_subject_name(Port *port, char *ptr, size_t len)
 {
diff --git a/src/backend/libpq/be-secure.c b/src/backend/libpq/be-secure.c
index 86ceea72e64..51e479c463d 100644
--- a/src/backend/libpq/be-secure.c
+++ b/src/backend/libpq/be-secure.c
@@ -37,6 +37,8 @@
 char	   *ssl_library;
 char	   *ssl_cert_file;
 char	   *ssl_key_file;
+char	   *ssl_alt_cert_file;
+char	   *ssl_alt_key_file;
 char	   *ssl_ca_file;
 char	   *ssl_crl_file;
 char	   *ssl_crl_dir;
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b046..2cfed2d42a4 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -2750,6 +2750,18 @@
   check_hook => 'check_ssl',
 },
 
+{ name => 'ssl_alt_cert_file', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
+  short_desc => 'Location of the alternate SSL server certificate file.',
+  variable => 'ssl_alt_cert_file',
+  boot_val => '""',
+},
+
+{ name => 'ssl_alt_key_file', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
+  short_desc => 'Location of the alternate SSL server private key file.',
+  variable => 'ssl_alt_key_file',
+  boot_val => '""',
+},
+
 { name => 'ssl_ca_file', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
   short_desc => 'Location of the SSL certificate authority file.',
   variable => 'ssl_ca_file',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf9..d367a096079 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -115,6 +115,8 @@
 #ssl_crl_file = ''
 #ssl_crl_dir = ''
 #ssl_key_file = 'server.key'
+#ssl_alt_cert_file = ''
+#ssl_alt_key_file = ''
 #ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL'       # allowed TLSv1.2 ciphers
 #ssl_tls13_ciphers = '' # allowed TLSv1.3 cipher suites, blank for default
 #ssl_prefer_server_ciphers = on
diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h
index 921b2daa4ff..cf4fd1d282f 100644
--- a/src/include/libpq/libpq-be.h
+++ b/src/include/libpq/libpq-be.h
@@ -320,6 +320,8 @@ extern const char *be_tls_get_cipher(Port *port);
 extern void be_tls_get_peer_subject_name(Port *port, char *ptr, size_t len);
 extern void be_tls_get_peer_issuer_name(Port *port, char *ptr, size_t len);
 extern void be_tls_get_peer_serial(Port *port, char *ptr, size_t len);
+extern const char *be_tls_get_server_cert_type(Port *port);
+extern const char *be_tls_get_server_cert_types(void);
 
 /*
  * Get the server certificate hash for SCRAM channel binding type
diff --git a/src/include/libpq/libpq.h b/src/include/libpq/libpq.h
index d15073a0a93..5e7705adba7 100644
--- a/src/include/libpq/libpq.h
+++ b/src/include/libpq/libpq.h
@@ -108,6 +108,8 @@ extern PGDLLIMPORT char *ssl_cert_file;
 extern PGDLLIMPORT char *ssl_crl_file;
 extern PGDLLIMPORT char *ssl_crl_dir;
 extern PGDLLIMPORT char *ssl_key_file;
+extern PGDLLIMPORT char *ssl_alt_cert_file;
+extern PGDLLIMPORT char *ssl_alt_key_file;
 extern PGDLLIMPORT int ssl_min_protocol_version;
 extern PGDLLIMPORT int ssl_max_protocol_version;
 extern PGDLLIMPORT char *ssl_passphrase_command;
diff --git a/src/test/ssl/t/004_ssl_alt_cert.pl b/src/test/ssl/t/004_ssl_alt_cert.pl
new file mode 100644
index 00000000000..dca4da34f19
--- /dev/null
+++ b/src/test/ssl/t/004_ssl_alt_cert.pl
@@ -0,0 +1,188 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test dual RSA + ECDSA certificate support via ssl_alt_cert_file/ssl_alt_key_file
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use SSL::Server;
+
+if ($ENV{with_ssl} ne 'openssl')
+{
+	plan skip_all => 'OpenSSL not supported by this build';
+}
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bssl\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test SSL not enabled in PG_TEST_EXTRA';
+}
+
+my $ssl_server = SSL::Server->new();
+my $SERVERHOSTADDR = '127.0.0.1';
+my $SERVERHOSTCIDR = '127.0.0.1/32';
+
+#### Set up the server.
+
+note "setting up data directory";
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+
+$ENV{PGHOST} = $node->host;
+$ENV{PGPORT} = $node->port;
+$node->start;
+
+$ssl_server->configure_test_server_for_ssl($node, $SERVERHOSTADDR,
+	$SERVERHOSTCIDR, 'trust', extensions => [qw(sslinfo)]);
+
+my $pgdata = $node->data_dir;
+
+#### Generate ECDSA cert signed by the test server CA.
+
+my $ssl_dir = "$FindBin::RealBin/../ssl";
+my $ecdsa_key = "$pgdata/server-ecdsa.key";
+my $ecdsa_csr = "$pgdata/server-ecdsa.csr";
+my $ecdsa_crt = "$pgdata/server-ecdsa.crt";
+
+note "generating ECDSA server certificate";
+
+system("openssl ecparam -genkey -name prime256v1 -out $ecdsa_key 2>/dev/null") == 0
+	or die "failed to generate ECDSA key";
+system("openssl req -new -key $ecdsa_key -out $ecdsa_csr -subj '/CN=localhost' -batch 2>/dev/null") == 0
+	or die "failed to generate ECDSA CSR";
+system("openssl x509 -req -in $ecdsa_csr -CA $ssl_dir/server_ca.crt -CAkey $ssl_dir/server_ca.key "
+	. "-CAcreateserial -out $ecdsa_crt -days 3650 2>/dev/null") == 0
+	or die "failed to sign ECDSA cert";
+chmod 0600, $ecdsa_key;
+unlink $ecdsa_csr;
+
+#### Configure server with dual certs (RSA primary + ECDSA alternate).
+
+note "configuring server with dual RSA + ECDSA certificates";
+
+# Use the standard RSA cert as primary via switch_server_cert
+$ssl_server->switch_server_cert($node,
+	certfile => 'server-cn-only',
+	cafile => 'root+client_ca',
+	restart => 'no');
+
+# Add alt cert configuration
+$node->append_conf('sslconfig.conf',
+	"ssl_alt_cert_file='$ecdsa_crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_alt_key_file='$ecdsa_key'");
+
+# Force TLS 1.2 so we can control cipher selection
+$node->append_conf('sslconfig.conf',
+	"ssl_max_protocol_version='TLSv1.2'");
+
+$node->restart;
+
+#### Tests.
+
+my $common_connstr = "sslrootcert=invalid hostaddr=$SERVERHOSTADDR host=localhost "
+	. "user=ssltestuser dbname=trustdb sslmode=require";
+
+# Test 1: Connect with RSA cipher
+note "testing RSA cipher connection";
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"connect with RSA cipher via default negotiation",
+	sql => "SELECT 1");
+
+# Test 2: Verify the GUC parameters are set
+my $result = $node->safe_psql('trustdb',
+	"SHOW ssl_alt_cert_file",
+	connstr => "$common_connstr sslcert=invalid");
+like($result, qr/server-ecdsa\.crt/, 'ssl_alt_cert_file is set');
+
+$result = $node->safe_psql('trustdb',
+	"SHOW ssl_alt_key_file",
+	connstr => "$common_connstr sslcert=invalid");
+like($result, qr/server-ecdsa\.key/, 'ssl_alt_key_file is set');
+
+# Test 3: Verify both cipher types work via openssl s_client
+note "testing RSA cipher via openssl s_client";
+my $openssl_rsa = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-RSA-AES256-GCM-SHA384 2>&1`;
+like($openssl_rsa, qr/ECDHE-RSA-AES256-GCM-SHA384/, 'RSA cipher negotiates successfully');
+
+note "testing ECDSA cipher via openssl s_client";
+my $openssl_ecdsa = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1`;
+like($openssl_ecdsa, qr/ECDHE-ECDSA-AES256-GCM-SHA384/, 'ECDSA cipher negotiates successfully');
+
+# Test 4: Verify correct cert type is served for each cipher
+note "verifying RSA cert served for RSA cipher";
+my $rsa_cert = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-RSA-AES256-GCM-SHA384 2>&1 | openssl x509 -noout -text 2>/dev/null`;
+like($rsa_cert, qr/rsaEncryption/, 'RSA cert served for RSA cipher');
+
+note "verifying ECDSA cert served for ECDSA cipher";
+my $ecdsa_cert = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1 | openssl x509 -noout -text 2>/dev/null`;
+like($ecdsa_cert, qr/id-ecPublicKey/, 'ECDSA cert served for ECDSA cipher');
+
+# Test 5: Verify ssl_server_cert_type() returns correct type per connection
+note "testing ssl_server_cert_type() via default negotiation";
+$result = $node->safe_psql('trustdb',
+	"SELECT ssl_server_cert_type()",
+	connstr => "$common_connstr sslcert=invalid");
+like($result, qr/^(RSA|ECDSA)$/, 'ssl_server_cert_type() returns valid cert type');
+
+# Test 6: Verify ssl_server_cert_types() shows all loaded types
+note "testing ssl_server_cert_types() shows all loaded cert types";
+$result = $node->safe_psql('trustdb',
+	"SELECT ssl_server_cert_types()",
+	connstr => "$common_connstr sslcert=invalid");
+like($result, qr/RSA/, 'ssl_server_cert_types() includes RSA');
+like($result, qr/ECDSA/, 'ssl_server_cert_types() includes ECDSA');
+
+# Test 7: Verify server rejects mismatched alt cert/key configuration
+note "testing server rejects ssl_alt_cert_file without ssl_alt_key_file";
+
+$node->append_conf('sslconfig.conf',
+	"ssl_alt_key_file=''");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with ssl_alt_cert_file set but ssl_alt_key_file empty');
+
+# Check the log for the expected error message
+my $log = slurp_file($node->logfile);
+like($log, qr/ssl_alt_cert_file is set but ssl_alt_key_file is not/,
+	'log contains expected error for missing ssl_alt_key_file');
+
+# Test 8: Verify the reverse mismatch (key without cert)
+note "testing server rejects ssl_alt_key_file without ssl_alt_cert_file";
+
+ok(unlink($node->data_dir . '/sslconfig.conf'));
+$node->append_conf('sslconfig.conf', "ssl=on");
+$node->append_conf('sslconfig.conf',
+	"ssl_ca_file='root+client_ca.crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_cert_file='server-cn-only.crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_key_file='server-cn-only.key'");
+$node->append_conf('sslconfig.conf',
+	"ssl_alt_key_file='$ecdsa_key'");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with ssl_alt_key_file set but ssl_alt_cert_file empty');
+
+$log = slurp_file($node->logfile);
+like($log, qr/ssl_alt_key_file is set but ssl_alt_cert_file is not/,
+	'log contains expected error for missing ssl_alt_cert_file');
+
+# Restore valid config so the node can be stopped cleanly
+ok(unlink($node->data_dir . '/sslconfig.conf'));
+$node->append_conf('sslconfig.conf', "ssl=on");
+$node->append_conf('sslconfig.conf',
+	"ssl_ca_file='root+client_ca.crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_cert_file='server-cn-only.crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_key_file='server-cn-only.key'");
+$node->start;
+
+done_testing();
-- 
2.52.0

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to