Hi Zsolt,I'm very sorry, I messed up my internal GIT when building the patch, causing "TLS 1.3 HRR test" to be lost, I've added it back to v4, please search for "Test 6: TLS 1.3 HelloRetryRequest with multi-cert".
---Regarding "ssl_cert_files", it takes precedence but actually was not taking full precedence, in the sense that if there was a cert of some type in "ssl_cert_file" and in "ssl_cert_files" as well, then the one in "ssl_cert_files" would be used, but if the types were different, then they were added.
Now with v4 if "ssl_cert_files" is present, then "ssl_cert_file" is ignored entirely and a warning shows up when starting.
IMHO failing if both "ssl_cert_file" and "ssl_cert_files" are present is overkill, a warning is sufficient (search for "Test 13: ssl_cert_files takes precedence over ssl_cert_file").
---For LibreSSL, the code was weak indeed. It's actually better to fail when LibreSSL is used and more than one cert is provided.
But actually I don't have LibreSSL at all (I'm on RHEL), so all I did is set a guard to simulate it.
Now I installed libressl-4.3.1-1.el9 packages and built to confirm all is fine with it as well.
Test 13, which verifies that "ssl_cert_files" takes precedence over "ssl_cert_file" now fails with LibreSSL with errors/hints below:
FATAL: ssl_cert_files with multiple entries is not supported by this buildHINT: This build lacks SSL_CTX_set_current_cert() support (e.g. LibreSSL). Only one certificate can be served.
With such failure, users are aware that using "ssl_cert_files" is mostly useless for them.
If LibreSSL later adds the missing code, the feature will then work automagically (the doc will have to be updated however since I added the sentence "Builds using <productname>LibreSSL</productname> support only a single entry; ...").
Renaud. Le 22/06/2026 à 8:40 PM, Zsolt Parragi a écrit :
When set, ssl_cert_files takes precedence over ssl_cert_file.Are you sure? ssl_cert_files gets loaded after ssl_cert_file was already, it seems additive to me. Shouldn't specifying both result in an error instead?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.I don't see it? The string secp384r1 doesn't appear in the patch at all.LibreSSL fallback paths verified via #undef SSL_CERT_SET_FIRST build.I think the fallback part needs at least a proper documentation / description specifying what's the expected behavior. Currently if I follow it correctly it serves the last loaded certificate, silently ignoring others? I don't think that's a behavior I would expect from a security-focused feature. But note that I did not try to build the patch with libressl and run tests with it yet.
From 04c6d25de4807656499fd46110e7b9f7b33de449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Wed, 24 Jun 2026 15:18:59 +0200 Subject: [PATCH v4] 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 | 54 ++++ doc/src/sgml/runtime.sgml | 7 + src/backend/libpq/be-secure-openssl.c | 201 +++++++++++- 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 | 304 ++++++++++++++++++ 10 files changed, 579 insertions(+), 12 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..dbc1a16afb3 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1455,6 +1455,41 @@ 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> + <para> + Multiple entries require <productname>OpenSSL</productname>. + Builds using <productname>LibreSSL</productname> support only a + single entry; specifying more than one will result in a startup + error. + </para> + </listitem> + </varlistentry> + <varlistentry id="guc-ssl-ciphers" xreflabel="ssl_ciphers"> <term><varname>ssl_ciphers</varname> (<type>string</type>) <indexterm> @@ -12537,6 +12572,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..c50ff426d42 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>server certificates and keys</entry> + <entry>when set, replaces ssl_cert_file/ssl_key_file; allows loading + multiple key types (e.g., RSA and ECDSA simultaneously)</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..ee16fc4b897 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; @@ -686,8 +687,17 @@ init_host_context(HostsLine *host, bool isServerStart) } /* - * Load and verify server's certificate and private key + * Load and verify server's certificate and private key. + * Skip when ssl_cert_files is set for the default host — those + * take precedence and will be loaded below instead. */ + if (is_default && ssl_cert_files && ssl_cert_files[0]) + { + ereport(LOG, + (errmsg("ssl_cert_file and ssl_key_file are overridden by ssl_cert_files and ssl_key_files"))); + goto load_cert_files; + } + if (SSL_CTX_use_certificate_chain_file(ctx, host->ssl_cert) != 1) { ereport(isServerStart ? FATAL : LOG, @@ -735,6 +745,140 @@ init_host_context(HostsLine *host, bool isServerStart) goto error; } +load_cert_files: + + /* + * Load certificates from ssl_cert_files/ssl_key_files. When set, + * these take precedence over ssl_cert_file/ssl_key_file (the primary + * cert/key loading above is skipped). 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; + } + +#ifndef SSL_CERT_SET_FIRST + if (list_length(certlist) > 1) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("ssl_cert_files with multiple entries is not supported by this build"), + errhint("This build lacks SSL_CTX_set_current_cert() support (e.g. LibreSSL). Only one certificate can be served."))); + pfree(rawcerts); + pfree(rawkeys); + goto error; + } +#endif + + 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 +1970,6 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config) X509 *cert; EVP_PKEY *key; - STACK_OF(X509) * chain; Assert(ctx != NULL); @@ -1836,26 +1979,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..b42b49464c5 --- /dev/null +++ b/src/test/ssl/t/005_ssl_multi_cert.pl @@ -0,0 +1,304 @@ +# 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: TLS 1.3 HelloRetryRequest with multi-cert +# Force HRR by configuring the server to accept only secp384r1 while +# the client offers X25519 first. The server sends HelloRetryRequest +# asking for secp384r1, and the client retries. This exercises the +# ssl_update_ssl() code path being called twice in one handshake. +note "testing TLS 1.3 HelloRetryRequest with multi-cert"; + +$node->append_conf('sslconfig.conf', "ssl_groups='secp384r1'"); +$node->reload; +sleep(1); + +my $openssl_hrr = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_3 -groups X25519:secp384r1 2>&1`; +like($openssl_hrr, qr/TLSv1\.3/, + 'TLS 1.3 connection succeeds after HelloRetryRequest with multi-cert'); + +# Restore default groups +$node->append_conf('sslconfig.conf', "ssl_groups='X25519:prime256v1:secp384r1:secp521r1:ffdhe2048'"); +$node->reload; +sleep(1); + +# Test 7: 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 8: 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 9: 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 10: 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 11: 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 12: 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 13: 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'); + +$log = slurp_file($node->logfile); +like($log, qr/ssl_cert_file and ssl_key_file are overridden by ssl_cert_files and ssl_key_files/, + 'log contains warning that ssl_cert_file is overridden'); + +# Restore single cert for subsequent tests +write_sslconfig($node); +$node->reload; +sleep(1); + +# Test 14: 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 15: 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.52.0
OpenPGP_signature.asc
Description: OpenPGP digital signature
