From 9619bf7020f592d954d1a85e7c0c2133dc1230bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <rmetrich@redhat.com>
Date: Mon, 15 Jun 2026 09:03:04 +0200
Subject: [PATCH v2 7/8] Fix TLS 1.3 HelloRetryRequest with multiple
 certificate types
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

During a TLS 1.3 HelloRetryRequest, the ClientHello callback fires
twice.  On the second invocation, ssl_update_ssl() was using
SSL_use_cert_and_key() with override=0 for the second certificate type,
which failed because that key type was already loaded from the first
invocation, producing "could not update certificate chain: not
replacing certificate".

Fix by always using override=1 when loading certificates from the
SSL_CTX onto the per-connection SSL object.  Since we are loading a
complete set of certificates from a known-good context, replacing is
always the correct behavior.

Remove the TLS 1.2 protocol restriction from the test and add a TLS 1.3
connectivity test to exercise this code path.

Author: Renaud Métrich <rmetrich@redhat.com>
---
 src/backend/libpq/be-secure-openssl.c | 10 +++++-----
 src/test/ssl/t/004_ssl_alt_cert.pl    | 15 +++++++++++----
 2 files changed, 16 insertions(+), 9 deletions(-)

diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c
index 2b69be6b46b..0290c9b61e5 100644
--- a/src/backend/libpq/be-secure-openssl.c
+++ b/src/backend/libpq/be-secure-openssl.c
@@ -1977,15 +1977,16 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config)
 	X509	   *cert;
 	EVP_PKEY   *key;
 	STACK_OF(X509) *chain;
-	bool		first = true;
 
 	Assert(ctx != NULL);
 
 	/*
 	 * 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.
+	 * Always use override=1 because this callback may fire more than
+	 * once per handshake (e.g. TLS 1.3 HelloRetryRequest).  Using
+	 * override=0 for subsequent certs would fail on the second
+	 * invocation since those key types are already set.
 	 *
 	 * Fall back to single-cert copy when SSL_CTX_set_current_cert() is
 	 * not available (LibreSSL).
@@ -2001,7 +2002,7 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config)
 			continue;
 
 		if (!SSL_CTX_get0_chain_certs(ctx, &chain)
-			|| !SSL_use_cert_and_key(ssl, cert, key, chain, first ? 1 : 0))
+			|| !SSL_use_cert_and_key(ssl, cert, key, chain, 1))
 		{
 			ereport(COMMERROR,
 					errcode(ERRCODE_INTERNAL_ERROR),
@@ -2009,7 +2010,6 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config)
 									SSLerrmessage(ERR_get_error())));
 			return false;
 		}
-		first = false;
 	} while (SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT));
 #else
 	cert = SSL_CTX_get0_certificate(ctx);
diff --git a/src/test/ssl/t/004_ssl_alt_cert.pl b/src/test/ssl/t/004_ssl_alt_cert.pl
index dca4da34f19..a148e0a7fb1 100644
--- a/src/test/ssl/t/004_ssl_alt_cert.pl
+++ b/src/test/ssl/t/004_ssl_alt_cert.pl
@@ -124,14 +124,21 @@ 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
+# 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
 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
+# Test 7: 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()",
@@ -139,7 +146,7 @@ $result = $node->safe_psql('trustdb',
 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
+# Test 8: 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',
@@ -153,7 +160,7 @@ 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)
+# Test 9: 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'));
-- 
2.52.0

