From fc596782f6dcda3410738c6d3cd73a7b4b1dfee2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <rmetrich@redhat.com>
Date: Mon, 22 Jun 2026 15:48:57 +0200
Subject: [PATCH] Add ssl_cert_files/ssl_key_files for multi-certificate
 support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add two new list-valued GUC parameters (ssl_cert_files, ssl_key_files)
that allow loading multiple SSL certificate/key pairs of different key
types (e.g., RSA + ECDSA).  OpenSSL selects the appropriate certificate
during the TLS handshake based on the negotiated cipher suite.

When set, ssl_cert_files takes precedence over ssl_cert_file.  Each
entry in ssl_cert_files is paired positionally with the corresponding
entry in ssl_key_files.  Certificates are loaded via
SSL_CTX_use_certificate_chain_file() so intermediate CA chains are
included.

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.  Always use override=1 to handle TLS 1.3
HelloRetryRequest correctly (the callback may fire more than once).

Guard SSL_CTX_set_current_cert usage with #ifdef SSL_CERT_SET_FIRST
for LibreSSL compatibility.

Add ssl_cert_files/ssl_key_files to variable_is_guc_list_quote() in
dumputils.c for proper pg_dump handling.

Author: Renaud Métrich <rmetrich@redhat.com>
---
 doc/src/sgml/config.sgml                      |  48 +++
 doc/src/sgml/runtime.sgml                     |   7 +
 src/backend/libpq/be-secure-openssl.c         | 173 ++++++++++-
 src/backend/libpq/be-secure.c                 |   2 +
 src/backend/utils/misc/guc_parameters.dat     |  16 +
 src/backend/utils/misc/postgresql.conf.sample |   2 +
 src/bin/pg_dump/dumputils.c                   |   2 +
 src/include/libpq/libpq.h                     |   2 +
 src/test/ssl/meson.build                      |   1 +
 src/test/ssl/t/005_ssl_multi_cert.pl          | 280 ++++++++++++++++++
 10 files changed, 522 insertions(+), 11 deletions(-)
 create mode 100644 src/test/ssl/t/005_ssl_multi_cert.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index fa566c9e553..770b42e4349 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1455,6 +1455,35 @@ include_dir 'conf.d'
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-ssl-cert-files" xreflabel="ssl_cert_files">
+      <term><varname>ssl_cert_files</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>ssl_cert_files</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies a comma-separated list of SSL server certificate files to
+        load, each of a different key type (e.g., RSA, ECDSA, EdDSA).
+        <productname>OpenSSL</productname> selects the appropriate certificate
+        during the TLS handshake based on the negotiated cipher suite.
+        Each entry is paired positionally with the corresponding entry in
+        <xref linkend="guc-ssl-key-files"/>.  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.  When set, this takes precedence over
+        <xref linkend="guc-ssl-cert-file"/> for loading certificates.
+       </para>
+       <para>
+        This setting applies only to the default SSL configuration from
+        <filename>postgresql.conf</filename>.  Per-host certificate
+        configuration via <filename>pg_hosts.conf</filename> is not affected
+        by this parameter.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-ssl-ciphers" xreflabel="ssl_ciphers">
       <term><varname>ssl_ciphers</varname> (<type>string</type>)
       <indexterm>
@@ -12537,6 +12566,25 @@ dynamic_library_path = '/usr/local/lib/postgresql:$libdir'
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-ssl-key-files" xreflabel="ssl_key_files">
+      <term><varname>ssl_key_files</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>ssl_key_files</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies a comma-separated list of SSL server private key files,
+        each paired positionally with the corresponding entry in
+        <xref linkend="guc-ssl-cert-files"/>.
+        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-library" xreflabel="ssl_library">
       <term><varname>ssl_library</varname> (<type>string</type>)
       <indexterm>
diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml
index dfa292c2c3a..f8d0f425014 100644
--- a/doc/src/sgml/runtime.sgml
+++ b/doc/src/sgml/runtime.sgml
@@ -2451,6 +2451,13 @@ pg_dumpall -p 5432 | psql -d postgres -p 5433
       certificate owner is trustworthy</entry>
      </row>
 
+     <row>
+      <entry><xref linkend="guc-ssl-cert-files"/>, <xref linkend="guc-ssl-key-files"/></entry>
+      <entry>additional server certificates and keys</entry>
+      <entry>loaded alongside the primary certificate for multi key-type
+      support (e.g., ECDSA alongside RSA)</entry>
+     </row>
+
      <row>
       <entry><xref linkend="guc-ssl-ca-file"/></entry>
       <entry>trusted certificate authorities</entry>
diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c
index 7890e6c2de2..b196f9a1232 100644
--- a/src/backend/libpq/be-secure-openssl.c
+++ b/src/backend/libpq/be-secure-openssl.c
@@ -30,6 +30,7 @@
 #include "common/hashfn.h"
 #include "common/string.h"
 #include "libpq/libpq.h"
+#include "utils/varlena.h"
 #include "miscadmin.h"
 #include "pgstat.h"
 #include "storage/fd.h"
@@ -98,7 +99,7 @@ static bool initialize_dh(SSL_CTX *context, bool isServerStart);
 static bool initialize_ecdh(SSL_CTX *context, bool isServerStart);
 static const char *SSLerrmessageExt(unsigned long ecode, const char *replacement);
 static const char *SSLerrmessage(unsigned long ecode);
-static bool init_host_context(HostsLine *host, bool isServerStart);
+static bool init_host_context(HostsLine *host, bool isServerStart, bool is_default);
 static void host_context_cleanup_cb(void *arg);
 #ifdef HAVE_SSL_CTX_SET_CLIENT_HELLO_CB
 static int	sni_clienthello_cb(SSL *ssl, int *al, void *arg);
@@ -249,7 +250,7 @@ be_tls_init(bool isServerStart)
 		{
 			HostsLine  *host = lfirst(line);
 
-			if (!init_host_context(host, isServerStart))
+			if (!init_host_context(host, isServerStart, false))
 				goto error;
 
 			/*
@@ -344,7 +345,7 @@ be_tls_init(bool isServerStart)
 		pgconf->ssl_passphrase_cmd = ssl_passphrase_command;
 		pgconf->ssl_passphrase_reload = ssl_passphrase_command_supports_reload;
 
-		if (!init_host_context(pgconf, isServerStart))
+		if (!init_host_context(pgconf, isServerStart, true))
 			goto error;
 
 		/*
@@ -609,7 +610,7 @@ host_context_cleanup_cb(void *arg)
 }
 
 static bool
-init_host_context(HostsLine *host, bool isServerStart)
+init_host_context(HostsLine *host, bool isServerStart, bool is_default)
 {
 	SSL_CTX    *ctx = SSL_CTX_new(SSLv23_method());
 	static bool init_warned = false;
@@ -735,6 +736,123 @@ init_host_context(HostsLine *host, bool isServerStart)
 		goto error;
 	}
 
+	/*
+	 * Load additional certificates from ssl_cert_files/ssl_key_files.
+	 * These list-valued GUCs allow loading multiple certificate/key pairs
+	 * of different key types (e.g., RSA + ECDSA) into the same SSL_CTX.
+	 * OpenSSL selects the appropriate certificate during the TLS handshake.
+	 * Only load for the default host context (postgresql.conf), not for
+	 * per-host SNI entries from pg_hosts.conf.
+	 */
+	if (is_default && ssl_cert_files && ssl_cert_files[0])
+	{
+		char	   *rawcerts;
+		char	   *rawkeys;
+		List	   *certlist;
+		List	   *keylist;
+		ListCell   *clc;
+		ListCell   *klc;
+
+		if (!ssl_key_files || !ssl_key_files[0])
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("ssl_cert_files is set but ssl_key_files is not")));
+			goto error;
+		}
+
+		rawcerts = pstrdup(ssl_cert_files);
+		rawkeys = pstrdup(ssl_key_files);
+
+		if (!SplitGUCList(rawcerts, ',', &certlist))
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("invalid list syntax in ssl_cert_files")));
+			pfree(rawcerts);
+			pfree(rawkeys);
+			goto error;
+		}
+
+		if (!SplitGUCList(rawkeys, ',', &keylist))
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("invalid list syntax in ssl_key_files")));
+			pfree(rawcerts);
+			pfree(rawkeys);
+			goto error;
+		}
+
+		if (list_length(certlist) != list_length(keylist))
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("ssl_cert_files has %d entries but ssl_key_files has %d entries",
+							list_length(certlist), list_length(keylist))));
+			pfree(rawcerts);
+			pfree(rawkeys);
+			goto error;
+		}
+
+		forboth(clc, certlist, klc, keylist)
+		{
+			char	   *certfile = (char *) lfirst(clc);
+			char	   *keyfile = (char *) lfirst(klc);
+
+			if (SSL_CTX_use_certificate_chain_file(ctx, certfile) != 1)
+			{
+				ereport(isServerStart ? FATAL : LOG,
+						(errcode(ERRCODE_CONFIG_FILE_ERROR),
+						 errmsg("could not load server certificate file \"%s\": %s",
+								certfile, SSLerrmessage(ERR_get_error()))));
+				pfree(rawcerts);
+				pfree(rawkeys);
+				goto error;
+			}
+
+			if (!check_ssl_key_file_permissions(keyfile, isServerStart))
+			{
+				pfree(rawcerts);
+				pfree(rawkeys);
+				goto error;
+			}
+
+			if (SSL_CTX_use_PrivateKey_file(ctx, keyfile,
+											   SSL_FILETYPE_PEM) != 1)
+			{
+				ereport(isServerStart ? FATAL : LOG,
+						(errcode(ERRCODE_CONFIG_FILE_ERROR),
+						 errmsg("could not load private key file \"%s\": %s",
+								keyfile, SSLerrmessage(ERR_get_error()))));
+				pfree(rawcerts);
+				pfree(rawkeys);
+				goto error;
+			}
+
+			if (SSL_CTX_check_private_key(ctx) != 1)
+			{
+				ereport(isServerStart ? FATAL : LOG,
+						(errcode(ERRCODE_CONFIG_FILE_ERROR),
+						 errmsg("check of private key failed for \"%s\": %s",
+								keyfile, SSLerrmessage(ERR_get_error()))));
+				pfree(rawcerts);
+				pfree(rawkeys);
+				goto error;
+			}
+		}
+
+		pfree(rawcerts);
+		pfree(rawkeys);
+	}
+	else if (is_default && ssl_key_files && ssl_key_files[0])
+	{
+		ereport(isServerStart ? FATAL : LOG,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("ssl_key_files is set but ssl_cert_files is not")));
+		goto error;
+	}
+
 	/*
 	 * Load CA store, so we can verify client certificates if needed.
 	 */
@@ -1826,7 +1944,6 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config)
 
 	X509	   *cert;
 	EVP_PKEY   *key;
-
 	STACK_OF(X509) * chain;
 
 	Assert(ctx != NULL);
@@ -1836,26 +1953,60 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config)
 	 * beware -- it has very odd behavior:
 	 *
 	 *     https://github.com/openssl/openssl/issues/6109
+	 *
+	 * Instead, copy all certificate types from the SSL_CTX to the
+	 * per-connection SSL object.  Always use override=1 because this
+	 * callback may fire more than once per handshake (e.g. TLS 1.3
+	 * HelloRetryRequest).
+	 *
+	 * Fall back to single-cert copy when SSL_CTX_set_current_cert() is
+	 * not available (LibreSSL).
 	 */
+#ifdef SSL_CERT_SET_FIRST
+	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;
+
+		if (!SSL_CTX_get0_chain_certs(ctx, &chain)
+			|| !SSL_use_cert_and_key(ssl, cert, key, chain, 1))
+		{
+			ereport(COMMERROR,
+					errcode(ERRCODE_INTERNAL_ERROR),
+					errmsg_internal("could not update certificate chain: %s",
+									SSLerrmessage(ERR_get_error())));
+			return false;
+		}
+	} while (SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT));
+#else
 	cert = SSL_CTX_get0_certificate(ctx);
 	key = SSL_CTX_get0_privatekey(ctx);
 
 	Assert(cert && key);
 
 	if (!SSL_CTX_get0_chain_certs(ctx, &chain)
-		|| !SSL_use_cert_and_key(ssl, cert, key, chain, 1 /* override */ )
-		|| !SSL_check_private_key(ssl))
+		|| !SSL_use_cert_and_key(ssl, cert, key, chain, 1))
 	{
-		/*
-		 * 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",
 								SSLerrmessage(ERR_get_error())));
 		return false;
 	}
+#endif
+
+	if (!SSL_check_private_key(ssl))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg_internal("could not verify private key: %s",
+								SSLerrmessage(ERR_get_error())));
+		return false;
+	}
 
 	if (host_config->ssl_ca && host_config->ssl_ca[0])
 	{
diff --git a/src/backend/libpq/be-secure.c b/src/backend/libpq/be-secure.c
index 86ceea72e64..1111b27c7fc 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_cert_files;
+char	   *ssl_key_files;
 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..b6606b3d8db 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -2762,6 +2762,14 @@
   boot_val => '"server.crt"',
 },
 
+{ name => 'ssl_cert_files', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
+  short_desc => 'List of SSL server certificate files to load (comma-separated).',
+  long_desc => 'When set, takes precedence over ssl_cert_file. Each entry is paired with the corresponding entry in ssl_key_files.',
+  flags => 'GUC_LIST_INPUT | GUC_LIST_QUOTE',
+  variable => 'ssl_cert_files',
+  boot_val => '""',
+},
+
 { name => 'ssl_ciphers', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
   short_desc => 'Sets the list of allowed TLSv1.2 (and lower) ciphers.',
   flags => 'GUC_SUPERUSER_ONLY',
@@ -2803,6 +2811,14 @@
   boot_val => '"server.key"',
 },
 
+{ name => 'ssl_key_files', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
+  short_desc => 'List of SSL server private key files to load (comma-separated).',
+  long_desc => 'When set, takes precedence over ssl_key_file. Each entry is paired with the corresponding entry in ssl_cert_files.',
+  flags => 'GUC_LIST_INPUT | GUC_LIST_QUOTE',
+  variable => 'ssl_key_files',
+  boot_val => '""',
+},
+
 { name => 'ssl_library', type => 'string', context => 'PGC_INTERNAL', group => 'PRESET_OPTIONS',
   short_desc => 'Shows the name of the SSL library.',
   flags => 'GUC_NOT_IN_SAMPLE | GUC_DISALLOW_IN_FILE',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf9..b35a17c8549 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_cert_files = ''
+#ssl_key_files = ''
 #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/bin/pg_dump/dumputils.c b/src/bin/pg_dump/dumputils.c
index dfb1f603a43..0cb867f6f36 100644
--- a/src/bin/pg_dump/dumputils.c
+++ b/src/bin/pg_dump/dumputils.c
@@ -737,6 +737,8 @@ variable_is_guc_list_quote(const char *name)
 		pg_strcasecmp(name, "search_path") == 0 ||
 		pg_strcasecmp(name, "session_preload_libraries") == 0 ||
 		pg_strcasecmp(name, "shared_preload_libraries") == 0 ||
+		pg_strcasecmp(name, "ssl_cert_files") == 0 ||
+		pg_strcasecmp(name, "ssl_key_files") == 0 ||
 		pg_strcasecmp(name, "temp_tablespaces") == 0 ||
 		pg_strcasecmp(name, "unix_socket_directories") == 0)
 		return true;
diff --git a/src/include/libpq/libpq.h b/src/include/libpq/libpq.h
index d15073a0a93..624e04d8b62 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_cert_files;
+extern PGDLLIMPORT char *ssl_key_files;
 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/meson.build b/src/test/ssl/meson.build
index d7e7ce23433..72f5c6ca7e7 100644
--- a/src/test/ssl/meson.build
+++ b/src/test/ssl/meson.build
@@ -14,6 +14,7 @@ tests += {
       't/002_scram.pl',
       't/003_sslinfo.pl',
       't/004_sni.pl',
+      't/005_ssl_multi_cert.pl',
     ],
   },
 }
diff --git a/src/test/ssl/t/005_ssl_multi_cert.pl b/src/test/ssl/t/005_ssl_multi_cert.pl
new file mode 100644
index 00000000000..a008e2aa618
--- /dev/null
+++ b/src/test/ssl/t/005_ssl_multi_cert.pl
@@ -0,0 +1,280 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test multi-certificate support via ssl_cert_files/ssl_key_files
+
+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', );
+
+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 "
+	. "-CAserial $pgdata/ca.srl -CAcreateserial -out $ecdsa_crt -days 3650 2>/dev/null") == 0
+	or die "failed to sign ECDSA cert";
+chmod 0600, $ecdsa_key;
+unlink $ecdsa_csr;
+
+# Helper to rewrite sslconfig.conf from scratch
+sub write_sslconfig
+{
+	my ($node, %opts) = @_;
+	my $conf = $node->data_dir . '/sslconfig.conf';
+	unlink($conf);
+	$node->append_conf('sslconfig.conf', "ssl=on");
+	$node->append_conf('sslconfig.conf',
+		"ssl_ca_file='root+client_ca.crt'");
+	# Use singular ssl_cert_file/ssl_key_file as primary unless overridden
+	if (!exists $opts{ssl_cert_file})
+	{
+		$node->append_conf('sslconfig.conf',
+			"ssl_cert_file='server-cn-only.crt'");
+	}
+	if (!exists $opts{ssl_key_file})
+	{
+		$node->append_conf('sslconfig.conf',
+			"ssl_key_file='server-cn-only.key'");
+	}
+	foreach my $key (sort keys %opts)
+	{
+		$node->append_conf('sslconfig.conf', "$key=$opts{$key}");
+	}
+}
+
+#### Configure server with multi-cert via ssl_cert_files.
+
+note "configuring server with ssl_cert_files (RSA + ECDSA)";
+
+$ssl_server->switch_server_cert($node,
+	certfile => 'server-cn-only',
+	cafile => 'root+client_ca',
+	restart => 'no');
+
+$node->append_conf('sslconfig.conf',
+	"ssl_cert_files='$pgdata/server-cn-only.crt, $ecdsa_crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_key_files='$pgdata/server-cn-only.key, $ecdsa_key'");
+
+$node->restart;
+
+#### Tests.
+
+my $common_connstr = "sslrootcert=invalid hostaddr=$SERVERHOSTADDR host=localhost "
+	. "user=ssltestuser dbname=trustdb sslmode=require";
+
+# Test 1: Basic connectivity with multi-cert
+note "testing basic connectivity with multi-cert";
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"connect with multi-cert via default negotiation",
+	sql => "SELECT 1");
+
+# Test 2: Verify the GUC parameters are set
+my $result = $node->safe_psql('trustdb',
+	"SHOW ssl_cert_files",
+	connstr => "$common_connstr sslcert=invalid");
+like($result, qr/server-cn-only\.crt/, 'ssl_cert_files includes RSA cert');
+like($result, qr/server-ecdsa\.crt/, 'ssl_cert_files includes ECDSA cert');
+
+# Test 3: Verify both cipher types work via openssl s_client (TLS 1.2)
+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: TLS 1.3 connectivity with multi-cert
+note "testing TLS 1.3 connection with multi-cert";
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"connect via TLS 1.3 with multi-cert (default negotiation)",
+	sql => "SELECT 1");
+
+# Test 6: Mismatched list lengths
+note "testing mismatched ssl_cert_files/ssl_key_files lengths";
+
+write_sslconfig($node,
+	ssl_cert_files => "'$pgdata/server-cn-only.crt, $ecdsa_crt'",
+	ssl_key_files => "'$pgdata/server-cn-only.key'");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with mismatched list lengths');
+
+my $log = slurp_file($node->logfile);
+like($log, qr/ssl_cert_files has \d+ entries but ssl_key_files has \d+ entries/,
+	'log contains expected error for mismatched list lengths');
+
+# Test 7: ssl_cert_files without ssl_key_files
+note "testing ssl_cert_files without ssl_key_files";
+
+write_sslconfig($node,
+	ssl_cert_files => "'$pgdata/server-cn-only.crt'");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with ssl_cert_files set but ssl_key_files empty');
+
+$log = slurp_file($node->logfile);
+like($log, qr/ssl_cert_files is set but ssl_key_files is not/,
+	'log contains expected error for missing ssl_key_files');
+
+# Test 8: ssl_key_files without ssl_cert_files
+note "testing ssl_key_files without ssl_cert_files";
+
+write_sslconfig($node,
+	ssl_key_files => "'$ecdsa_key'");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with ssl_key_files set but ssl_cert_files empty');
+
+$log = slurp_file($node->logfile);
+like($log, qr/ssl_key_files is set but ssl_cert_files is not/,
+	'log contains expected error for missing ssl_cert_files');
+
+# Test 9: Bad certificate file path
+note "testing bad certificate file path in ssl_cert_files";
+
+write_sslconfig($node,
+	ssl_cert_files => "'/nonexistent/cert.crt'",
+	ssl_key_files => "'$ecdsa_key'");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with bad certificate file path');
+
+$log = slurp_file($node->logfile);
+like($log, qr/could not load server certificate file.*nonexistent/,
+	'log contains expected error for bad certificate path');
+
+# Test 10: Certificate/key type mismatch
+note "testing certificate/key type mismatch in ssl_cert_files";
+
+write_sslconfig($node,
+	ssl_cert_files => "'$pgdata/server-cn-only.crt'",
+	ssl_key_files => "'$ecdsa_key'");
+
+$result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with cert/key type mismatch');
+
+$log = slurp_file($node->logfile);
+like($log, qr/check of private key failed/,
+	'log contains expected error for cert/key mismatch');
+
+# Test 11: Single cert mode (no ssl_cert_files) still works
+note "testing single cert mode (no ssl_cert_files)";
+
+write_sslconfig($node);
+$node->start;
+
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"connect with single RSA cert (no ssl_cert_files)",
+	sql => "SELECT 1");
+
+# Test 12: ssl_cert_files takes precedence over ssl_cert_file
+note "testing ssl_cert_files takes precedence over ssl_cert_file";
+
+# ssl_cert_file points to RSA cert (server-cn-only), but ssl_cert_files
+# includes ECDSA.  Verify ECDSA is available, proving ssl_cert_files won.
+$node->append_conf('sslconfig.conf',
+	"ssl_cert_files='$pgdata/server-cn-only.crt, $ecdsa_crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_key_files='$pgdata/server-cn-only.key, $ecdsa_key'");
+$node->reload;
+sleep(1);
+
+my $openssl_precedence = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1`;
+like($openssl_precedence, qr/ECDHE-ECDSA-AES256-GCM-SHA384/,
+	'ssl_cert_files takes precedence: ECDSA available despite ssl_cert_file being RSA only');
+
+# Restore single cert for subsequent tests
+write_sslconfig($node);
+$node->reload;
+sleep(1);
+
+# Test 13: SIGHUP reload adds multi-cert
+note "testing SIGHUP reload adds multi-cert";
+
+$node->append_conf('sslconfig.conf',
+	"ssl_cert_files='$pgdata/server-cn-only.crt, $ecdsa_crt'");
+$node->append_conf('sslconfig.conf',
+	"ssl_key_files='$pgdata/server-cn-only.key, $ecdsa_key'");
+$node->reload;
+sleep(1);
+
+my $openssl_after_reload = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1`;
+like($openssl_after_reload, qr/ECDHE-ECDSA-AES256-GCM-SHA384/,
+	'ECDSA cipher works after SIGHUP reload');
+
+# Test 14: SIGHUP reload removes multi-cert
+note "testing SIGHUP reload removes multi-cert";
+
+write_sslconfig($node);
+$node->reload;
+sleep(1);
+
+my $openssl_after_remove = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1`;
+unlike($openssl_after_remove, qr/ECDHE-ECDSA-AES256-GCM-SHA384/,
+	'ECDSA cipher fails after multi-cert removed via SIGHUP');
+
+$node->connect_ok(
+	"$common_connstr sslcert=invalid",
+	"RSA connection works after multi-cert removed via SIGHUP",
+	sql => "SELECT 1");
+
+done_testing();
-- 
2.54.0

