From a230d54069b1615520ce2c5c78731041076ff1c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <rmetrich@redhat.com>
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 <rmetrich@redhat.com>
---
 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

