Hi, Here is v3, redesigned based on Zsolt's v2 feedback and the proxy workaround discussion with Jacob.
The main change: the ssl_alt_cert_file/ssl_alt_key_file approach is dropped entirely. Instead, v3 introduces two new list-valued GUCs: ssl_cert_files = 'server-rsa.crt, server-ecdsa.crt' ssl_key_files = 'server-rsa.key, server-ecdsa.key' This follows the same pattern as unix_socket_directories and shared_preload_libraries (GUC_LIST_INPUT | GUC_LIST_QUOTE). When set, ssl_cert_files takes precedence over ssl_cert_file. Each entry is paired positionally with the corresponding entry in ssl_key_files. Certificates are loaded via SSL_CTX_use_certificate_chain_file() (full chain) and OpenSSL handles key-type matching internally — no same-type detection needed, no ordering validation, and natural support for all three key types (RSA, ECDSA, EdDSA). Addressing each v2 review point: 1+4) Same-type detection / EVP_PKEY_get_base_id — dropped entirely. With ssl_cert_files, OpenSSL handles duplicate key types internally (last wins), so no application-level detection is needed. This also eliminates the fragile SSL_CTX_set_current_cert(NEXT) logic. 2) TLS 1.3 HRR test — added a proper test that forces HelloRetryRequest by setting ssl_groups='secp384r1' on the server and connecting with -groups X25519:secp384r1. The ssl_update_ssl() fix (override=1 always) is carried over from v2. 3) Test in meson.build — the new test is t/005_ssl_multi_cert.pl, added to both Makefile and src/test/ssl/meson.build. 5) SNI limitation — documented that ssl_cert_files applies to the default SSL configuration from postgresql.conf only. Per-host support in pg_hosts.conf is left as future work per Zsolt's suggestion. 6) Multi-valued GUC design — implemented as new ssl_cert_files / ssl_key_files GUCs rather than making ssl_cert_file list-valued, avoiding any dump/restore implications. Added to variable_is_guc_list_quote() in dumputils.c. Verified with pg_dumpall: the GUCs are PGC_SIGHUP context so they never appear in dumps (only in postgresql.conf / postgresql.auto.conf). The observability functions (ssl_server_cert_type/ssl_server_cert_types via sslinfo extension) that were in v1/v2 have been split out for a separate submission, to keep this patch focused on the core multi-cert loading. Test results: 30 subtests in 005_ssl_multi_cert.pl covering dual cert negotiation (TLS 1.2 + 1.3), HelloRetryRequest, mismatched list lengths, missing cert/key files, bad certificate path, cert/key type mismatch, single cert regression, ssl_cert_files precedence over ssl_cert_file, and SIGHUP reload add/remove. Full SSL suite passes with no regressions. RHEL 9.8 / OpenSSL 3.5.5. LibreSSL fallback paths verified via #undef SSL_CERT_SET_FIRST build. 10 files changed, 522 insertions(+), 11 deletions(-) Thanks, Renaud Métrich Red Hat Le 16/06/2026 à 2:16 PM, Renaud Métrich a écrit :
Hi Zsolt, Thanks for the thorough review, I will take a look at all this. Best regards, Renaud Métrich Red Hat Le 15/06/2026 à 11:13 PM, Zsolt Parragi a écrit :+ primary_type = EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert)); + alt_type = EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));+ Isn't EVP_PKEY_get_base_id also a function introduced by OpenSSL 3.0?-# Test 5: Verify ssl_server_cert_type() returns correct type per connection +# Test 5: Verify TLS 1.3 connection works (exercises HelloRetryRequest path)+note "testing TLS 1.3 connection with dual certs"; +$node->connect_ok( + "$common_connstr sslcert=invalid", + "connect via TLS 1.3 with dual certs (default negotiation)", + sql => "SELECT 1"); ++# Test 6: Verify ssl_server_cert_type() returns correct type per connectionI don't think this properly tests HelloRetryRequest, as there's no key-share group mismatch in the testcase. Also, the testcase file should be included in src/test/ssl/meson.build, currently it is only executed by make. + + /*+ * Verify that the alternate certificate uses a different key type + * than the primary. If both are the same type (e.g. both RSA), + * the alternate silently replaces the primary, which is not useful.+ */ + { + X509 *primary_cert; + X509 *alt_cert; + int primary_type; + int alt_type; + + SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_FIRST); + primary_cert = SSL_CTX_get0_certificate(ctx); + + SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT); + alt_cert = SSL_CTX_get0_certificate(ctx); + + if (primary_cert && alt_cert) + {+ primary_type = EVP_PKEY_get_base_id(X509_get0_pubkey(primary_cert)); + alt_type = EVP_PKEY_get_base_id(X509_get0_pubkey(alt_cert));+ + if (primary_type == alt_type) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR),+ errmsg("alternate certificate has the same key type (%s) asthe primary certificate", + evp_pkey_type_name(alt_type)))); + goto error; + } + } + } Are you sure about this code? The comment says "the alternate silently replaces the primary, which is not useful." - which was also my observation, but replacing means that there's no alt certificate really. I think this check works by accident because if there's no NEXT certificate, OpenSSL returns the first certificate again, but this behavior is undocumented.3) Per-host SNI support: documented that ssl_alt_cert_file applies only to the default SSL configuration from postgresql.conf.I don't think documenting the limitation is a good approach for this feature, it should be supported uniformly everywhere. The question of how it could fit into pg_hosts is a different question, so I am not suggesting that you should simply add a few more columns there, but I would at least keep it as a TODO item. The format/extensibility of hosts and other configuration files is a bigger question I want to start a discussion about soon, and this could fit there.For PostgreSQL, since GUCs are strings, this could take the form of comma-separated paths: ssl_cert_file = 'server-rsa.crt, server-ecdsa.crt' ssl_key_file = 'server-rsa.key, server-ecdsa.key'List style GUCs already exist (for example unix_socket_directories), so there's a good precedent for this. This could also fit into pg_hosts, where the host part already accepts a list of hosts. Also I'm not sure if changing the single string GUC to a list could cause any issues with dump/restore. I didn't check this in detail, but there's a comment about this in src/backend/utils/misc/guc_parameters.dat and dumputils.c. Another option would be to add new GUCs ending with _files, and make them mutually exclusive?and let OpenSSL sort out the type matching — no same-type detection needed, no "alt" naming, and natural support for all three key types.I'm quite sure we would still have to do some validation, but that's a minor detail.
From fc596782f6dcda3410738c6d3cd73a7b4b1dfee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> 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 <[email protected]> --- 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
OpenPGP_signature.asc
Description: OpenPGP digital signature
