To: [email protected]Subject: Re: [PATCH v2] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support
In-Reply-To: <[email protected]>
Hi Zsolt, Thanks for the thorough review. Here is v2 addressing all six issues you raised. The fixes are in separate commits so each one can be reviewed independently: v2-0001: original patch (unchanged from v1) v2-0002: reject alternate certificate with same key type as primary (#5) v2-0003: load full certificate chain for alternate certificate (#4) v2-0004: load alternate certificates only for the default host context (#2) v2-0005: add compatibility guards for LibreSSL and older OpenSSL (#6) v2-0006: document ssl_alt_cert_file limitations (#3) v2-0007: fix TLS 1.3 HelloRetryRequest with multiple certificate types (#1) v2-0008: expand test coverage (same-type rejection, SIGHUP reload, single-cert regression, TLS 1.3 actually exercised) Specific answers: 1) TLS 1.3 HelloRetryRequest: the root cause was using override=0 for the second certificate. During HRR the ClientHello callback fires twice; on the second invocation the key type was already loaded from the first, so SSL_use_cert_and_key() refused to replace it. Fixed by always using override=1 — since we load a complete set from the SSL_CTX, replacing is always correct. Added a TLS 1.3 connectivity test and removed the TLS 1.2 protocol restriction. 2) Global GUCs leaking into SNI contexts: added an is_default parameter to init_host_context(). Alt certs are now loaded only for the default host context (postgresql.conf), not for per-host entries from pg_hosts.conf. 3) Per-host SNI support: documented that ssl_alt_cert_file applies only to the default SSL configuration from postgresql.conf. 4) Full chain loading: switched from SSL_CTX_use_certificate_file() to SSL_CTX_use_certificate_chain_file(), matching how the primary certificate is loaded. 5) Same-type detection: after loading the alt cert, we iterate with SSL_CTX_set_current_cert(FIRST/NEXT) and compare EVP_PKEY_get_base_id() on both. Produces a FATAL error if they match. 6) LibreSSL/older OpenSSL: guarded SSL_CTX_set_current_cert() and SSL_CERT_SET_FIRST/NEXT with #ifdef SSL_CERT_SET_FIRST. When not available, ssl_alt_cert_file produces a clear error, ssl_update_ssl() falls back to the original single-cert copy, and cert types caching is skipped. Verified by building with #undef SSL_CERT_SET_FIRST to exercise the fallback code paths. Test results: 452/452 SSL tests pass (29 alt cert subtests including TLS 1.3, same-type rejection, SIGHUP reload add/remove, single-cert regression, plus the existing 423), no regressions. RHEL 9.8 / OpenSSL 3.5.5. --- Looking ahead, I want to flag an alternative design direction worth considering. The ssl_alt_cert_file approach is inherently limited to two certificates (primary + one alternate), but OpenSSL supports up to three key types (RSA, ECDSA, EdDSA). It also introduces new GUC names that don't generalize well. An approach that other projects have converged on is to make ssl_cert_file and ssl_key_file multi-valued — httpd and nginx already work this way, and I am doing the same for MariaDB [1] where we went through a similar evolution: we started with --ssl-alt-cert, then redesigned to allow repeated --ssl-cert/--ssl-key options and let OpenSSL handle cert/key matching and type verification internally. 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' The server would load each pair via SSL_CTX_use_certificate_chain_file() / SSL_CTX_use_PrivateKey_file() and let OpenSSL sort out the type matching — no same-type detection needed, no "alt" naming, and natural support for all three key types. The ssl_update_ssl() fix from this patch (iterating all cert types) would still be needed regardless. I'm happy to go either direction — the current v2 is functional and complete, but if the community prefers the multi-valued approach, I can rework it. [1] https://github.com/MariaDB/server/pull/5178 Thanks, Renaud Métrich Red Hat Le 12/06/2026 à 10:39 PM, Zsolt Parragi a écrit :
Hello! The problem the patch tries to solve is real, but I see several gaps/problems with the current implementation with some testing: 1. it seems to break TLS 1.3 HelloRetryRequest as it tries to add the second certificate with override=0. Connection then fails with "SSL error: tlsv1 alert internal error", server log shows "could not update certificate chain: not replacing certificate" / "failed to switch to SSL configuration for host, terminating connection" 2. The global ssl_alt_* GUCs are loaded into every pg_hosts context. If the SNI cert is a different type, it loads the alternative certificates as alternatives, if it's the same type, it replaces the hosts entry. 3. pg_hosts/SNI has no support for the new GUCs, there's no way to configure per host versions of the feature. Shouldn't the patch include proper support for SNI? 4. Shouldn't alternative certificates load the entire chain, not just the first block? 5. If both have the same type, the alternate certificate silently replaces the primary one. Shouldn't that result in a startup error instead? 6. Won't this cause build failure with LibreSSL, or older OpenSSL?
From a230d54069b1615520ce2c5c78731041076ff1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 8 Jun 2026 17:38:32 +0200 Subject: [PATCH v2 1/8] Add ssl_alt_cert_file/ssl_alt_key_file for dual RSA+ECDSA certificate support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new GUC parameters (ssl_alt_cert_file, ssl_alt_key_file) that allow loading an alternate SSL certificate alongside the primary one. OpenSSL selects the appropriate certificate during the TLS handshake based on the negotiated cipher suite. Fix ssl_update_ssl() to iterate all certificate types in the SSL_CTX using SSL_CTX_set_current_cert(FIRST/NEXT) and copy each to the per-connection SSL object, preserving multi-cert state across the SNI architecture. Add ssl_server_cert_type()/ssl_server_cert_types() to the sslinfo extension (v1.3) for observability of which certificate types are loaded and which was selected for the current connection. Author: Renaud Métrich <[email protected]> --- contrib/sslinfo/Makefile | 2 +- contrib/sslinfo/meson.build | 2 + contrib/sslinfo/sslinfo--1.2--1.3.sql | 12 ++ contrib/sslinfo/sslinfo--1.3.sql | 56 +++++ contrib/sslinfo/sslinfo.c | 42 ++++ contrib/sslinfo/sslinfo.control | 2 +- doc/src/sgml/config.sgml | 43 +++- doc/src/sgml/runtime.sgml | 13 ++ doc/src/sgml/sslinfo.sgml | 34 +++ src/backend/libpq/be-secure-openssl.c | 199 ++++++++++++++++-- src/backend/libpq/be-secure.c | 2 + src/backend/utils/misc/guc_parameters.dat | 12 ++ src/backend/utils/misc/postgresql.conf.sample | 2 + src/include/libpq/libpq-be.h | 2 + src/include/libpq/libpq.h | 2 + src/test/ssl/t/004_ssl_alt_cert.pl | 188 +++++++++++++++++ 16 files changed, 591 insertions(+), 22 deletions(-) create mode 100644 contrib/sslinfo/sslinfo--1.2--1.3.sql create mode 100644 contrib/sslinfo/sslinfo--1.3.sql create mode 100644 src/test/ssl/t/004_ssl_alt_cert.pl diff --git a/contrib/sslinfo/Makefile b/contrib/sslinfo/Makefile index 14305594e2d..2d3ee10bf1e 100644 --- a/contrib/sslinfo/Makefile +++ b/contrib/sslinfo/Makefile @@ -6,7 +6,7 @@ OBJS = \ sslinfo.o EXTENSION = sslinfo -DATA = sslinfo--1.2.sql sslinfo--1.1--1.2.sql sslinfo--1.0--1.1.sql +DATA = sslinfo--1.3.sql sslinfo--1.2--1.3.sql sslinfo--1.2.sql sslinfo--1.1--1.2.sql sslinfo--1.0--1.1.sql PGFILEDESC = "sslinfo - information about client SSL certificate" ifdef USE_PGXS diff --git a/contrib/sslinfo/meson.build b/contrib/sslinfo/meson.build index 6e9cb96430a..e3c578415de 100644 --- a/contrib/sslinfo/meson.build +++ b/contrib/sslinfo/meson.build @@ -25,6 +25,8 @@ contrib_targets += sslinfo install_data( 'sslinfo--1.0--1.1.sql', 'sslinfo--1.1--1.2.sql', + 'sslinfo--1.3.sql', + 'sslinfo--1.2--1.3.sql', 'sslinfo--1.2.sql', 'sslinfo.control', kwargs: contrib_data_args, diff --git a/contrib/sslinfo/sslinfo--1.2--1.3.sql b/contrib/sslinfo/sslinfo--1.2--1.3.sql new file mode 100644 index 00000000000..87af04b34de --- /dev/null +++ b/contrib/sslinfo/sslinfo--1.2--1.3.sql @@ -0,0 +1,12 @@ +/* contrib/sslinfo/sslinfo--1.2--1.3.sql */ + +-- complain if script is sourced in psql, rather than via ALTER EXTENSION +\echo Use "ALTER EXTENSION sslinfo UPDATE TO '1.3'" to load this file. \quit + +CREATE FUNCTION ssl_server_cert_type() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_server_cert_type' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_server_cert_types() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_server_cert_types' +LANGUAGE C STRICT PARALLEL RESTRICTED; diff --git a/contrib/sslinfo/sslinfo--1.3.sql b/contrib/sslinfo/sslinfo--1.3.sql new file mode 100644 index 00000000000..55c53137ff0 --- /dev/null +++ b/contrib/sslinfo/sslinfo--1.3.sql @@ -0,0 +1,56 @@ +/* contrib/sslinfo/sslinfo--1.3.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION sslinfo" to load this file. \quit + +CREATE FUNCTION ssl_client_serial() RETURNS numeric +AS 'MODULE_PATHNAME', 'ssl_client_serial' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_is_used() RETURNS boolean +AS 'MODULE_PATHNAME', 'ssl_is_used' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_version() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_version' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_cipher() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_cipher' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_client_cert_present() RETURNS boolean +AS 'MODULE_PATHNAME', 'ssl_client_cert_present' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_client_dn_field(text) RETURNS text +AS 'MODULE_PATHNAME', 'ssl_client_dn_field' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_issuer_field(text) RETURNS text +AS 'MODULE_PATHNAME', 'ssl_issuer_field' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_client_dn() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_client_dn' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_issuer_dn() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_issuer_dn' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION +ssl_extension_info(OUT name text, + OUT value text, + OUT critical boolean +) RETURNS SETOF record +AS 'MODULE_PATHNAME', 'ssl_extension_info' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_server_cert_type() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_server_cert_type' +LANGUAGE C STRICT PARALLEL RESTRICTED; + +CREATE FUNCTION ssl_server_cert_types() RETURNS text +AS 'MODULE_PATHNAME', 'ssl_server_cert_types' +LANGUAGE C STRICT PARALLEL RESTRICTED; diff --git a/contrib/sslinfo/sslinfo.c b/contrib/sslinfo/sslinfo.c index c4ae847880d..96b2907312a 100644 --- a/contrib/sslinfo/sslinfo.c +++ b/contrib/sslinfo/sslinfo.c @@ -474,3 +474,45 @@ ssl_extension_info(PG_FUNCTION_ARGS) /* All done */ SRF_RETURN_DONE(funcctx); } + + +/* + * Returns the key type of the server certificate used in the current + * SSL connection (e.g. "RSA", "ECDSA", "EdDSA"). + */ +PG_FUNCTION_INFO_V1(ssl_server_cert_type); +Datum +ssl_server_cert_type(PG_FUNCTION_ARGS) +{ + const char *cert_type; + + if (!MyProcPort->ssl_in_use) + PG_RETURN_NULL(); + + cert_type = be_tls_get_server_cert_type(MyProcPort); + if (cert_type == NULL) + PG_RETURN_NULL(); + + PG_RETURN_TEXT_P(cstring_to_text(cert_type)); +} + + +/* + * Returns a comma-separated list of all certificate key types loaded + * by the server (e.g. "RSA, ECDSA"). + */ +PG_FUNCTION_INFO_V1(ssl_server_cert_types); +Datum +ssl_server_cert_types(PG_FUNCTION_ARGS) +{ + const char *cert_types; + + if (!MyProcPort->ssl_in_use) + PG_RETURN_NULL(); + + cert_types = be_tls_get_server_cert_types(); + if (cert_types == NULL || cert_types[0] == '\0') + PG_RETURN_NULL(); + + PG_RETURN_TEXT_P(cstring_to_text(cert_types)); +} diff --git a/contrib/sslinfo/sslinfo.control b/contrib/sslinfo/sslinfo.control index c7754f924cf..b53e95b7da8 100644 --- a/contrib/sslinfo/sslinfo.control +++ b/contrib/sslinfo/sslinfo.control @@ -1,5 +1,5 @@ # sslinfo extension comment = 'information about SSL certificates' -default_version = '1.2' +default_version = '1.3' module_pathname = '$libdir/sslinfo' relocatable = true diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index fa566c9e553..4d712ed852b 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1327,7 +1327,48 @@ include_dir 'conf.d' </listitem> </varlistentry> - <varlistentry id="guc-ssl-ca-file" xreflabel="ssl_ca_file"> + <varlistentry id="guc-ssl-alt-cert-file" xreflabel="ssl_alt_cert_file"> + <term><varname>ssl_alt_cert_file</varname> (<type>string</type>) + <indexterm> + <primary><varname>ssl_alt_cert_file</varname> configuration parameter</primary> + </indexterm> + </term> + <listitem> + <para> + Specifies the name of the file containing an alternate SSL server + certificate of a different key type (e.g., ECDSA if the primary is + RSA). <productname>OpenSSL</productname> selects the appropriate + certificate during the TLS handshake based on the negotiated cipher + suite. Relative paths are relative to the data directory. + This parameter can only be set in the <filename>postgresql.conf</filename> + file or on the server command line. + The default is empty, meaning no alternate certificate is loaded. + Both <varname>ssl_alt_cert_file</varname> and + <xref linkend="guc-ssl-alt-key-file"/> must be set together. + </para> + </listitem> + </varlistentry> + + <varlistentry id="guc-ssl-alt-key-file" xreflabel="ssl_alt_key_file"> + <term><varname>ssl_alt_key_file</varname> (<type>string</type>) + <indexterm> + <primary><varname>ssl_alt_key_file</varname> configuration parameter</primary> + </indexterm> + </term> + <listitem> + <para> + Specifies the name of the file containing the private key for the + alternate SSL server certificate specified by + <xref linkend="guc-ssl-alt-cert-file"/>. + Relative paths are relative to the data directory. + This parameter can only be set in the <filename>postgresql.conf</filename> + file or on the server command line. + The default is empty. + </para> + </listitem> + </varlistentry> + + <varlistentry id="guc-ssl-ca-file" xreflabel="ssl_ca_file"> <term><varname>ssl_ca_file</varname> (<type>string</type>) <indexterm> <primary><varname>ssl_ca_file</varname> configuration parameter</primary> diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml index dfa292c2c3a..cc2ab2168d3 100644 --- a/doc/src/sgml/runtime.sgml +++ b/doc/src/sgml/runtime.sgml @@ -2451,6 +2451,19 @@ pg_dumpall -p 5432 | psql -d postgres -p 5433 certificate owner is trustworthy</entry> </row> + <row> + <entry><xref linkend="guc-ssl-alt-cert-file"/></entry> + <entry>alternate server certificate</entry> + <entry>loaded alongside the primary certificate for dual key-type + support (e.g., ECDSA alongside RSA)</entry> + </row> + + <row> + <entry><xref linkend="guc-ssl-alt-key-file"/></entry> + <entry>alternate server private key</entry> + <entry>proves alternate server certificate was sent by the owner</entry> + </row> + <row> <entry><xref linkend="guc-ssl-ca-file"/></entry> <entry>trusted certificate authorities</entry> diff --git a/doc/src/sgml/sslinfo.sgml b/doc/src/sgml/sslinfo.sgml index 85d49f66537..4ad3fecbf02 100644 --- a/doc/src/sgml/sslinfo.sgml +++ b/doc/src/sgml/sslinfo.sgml @@ -240,6 +240,40 @@ emailAddress </para> </listitem> </varlistentry> + <varlistentry> + <term> + <function>ssl_server_cert_type() returns text</function> + <indexterm> + <primary>ssl_server_cert_type</primary> + </indexterm> + </term> + <listitem> + <para> + Returns the key type of the server certificate selected for the current + SSL connection (e.g., <literal>RSA</literal>, + <literal>ECDSA</literal>, or <literal>EdDSA</literal>). + </para> + </listitem> + </varlistentry> + + <varlistentry> + <term> + <function>ssl_server_cert_types() returns text</function> + <indexterm> + <primary>ssl_server_cert_types</primary> + </indexterm> + </term> + <listitem> + <para> + Returns a comma-separated list of all certificate key types loaded by + the server (e.g., <literal>RSA, ECDSA</literal>). This reflects all + certificates configured via + <xref linkend="guc-ssl-cert-file"/> and + <xref linkend="guc-ssl-alt-cert-file"/>. + </para> + </listitem> + </varlistentry> + </variablelist> </sect2> diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index 7890e6c2de2..cf7d981b697 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -125,7 +125,9 @@ static struct hosts */ HostsLine *default_host; } *SSL_hosts; +static char ssl_cert_types_cached[64]; +static const char *evp_pkey_type_name(int type); static bool dummy_ssl_passwd_cb_called = false; static bool ssl_is_server_start; @@ -548,6 +550,50 @@ be_tls_init(bool isServerStart) if (SSLPreferServerCiphers) SSL_CTX_set_options(context, SSL_OP_CIPHER_SERVER_PREFERENCE); + + /* + * Cache the loaded certificate types for be_tls_get_server_cert_types(). + * This must be done before the host memory context is replaced, since + * child backends will inherit this cached value via fork() and the host + * contexts will be freed when PostmasterContext is deleted. + */ + ssl_cert_types_cached[0] = '\0'; + if (new_hosts->default_host && new_hosts->default_host->ssl_ctx) + { + SSL_CTX *cache_ctx = new_hosts->default_host->ssl_ctx; + int cpos = 0; + + SSL_CTX_set_current_cert(cache_ctx, SSL_CERT_SET_FIRST); + do + { + X509 *cert = SSL_CTX_get0_certificate(cache_ctx); + + if (cert) + { + EVP_PKEY *pkey = X509_get0_pubkey(cert); + + if (pkey) + { + const char *name = evp_pkey_type_name(EVP_PKEY_get_base_id(pkey)); + size_t nlen = strlen(name); + + if (cpos > 0 && cpos + 2 < (int) sizeof(ssl_cert_types_cached)) + { + ssl_cert_types_cached[cpos++] = ','; + ssl_cert_types_cached[cpos++] = ' '; + } + if (cpos + nlen < sizeof(ssl_cert_types_cached)) + { + memcpy(ssl_cert_types_cached + cpos, name, nlen); + cpos += (int) nlen; + } + } + } + } while (SSL_CTX_set_current_cert(cache_ctx, SSL_CERT_SET_NEXT)); + + ssl_cert_types_cached[cpos] = '\0'; + } + /* * Success! Replace any existing SSL_context and host configurations. */ @@ -735,6 +781,60 @@ init_host_context(HostsLine *host, bool isServerStart) goto error; } + /* + * Load alternate certificate (e.g. ECDSA alongside RSA) into the same + * context. OpenSSL supports one certificate per key type and selects + * the appropriate one during the TLS handshake. + */ + if (ssl_alt_cert_file[0] && !ssl_alt_key_file[0]) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("ssl_alt_cert_file is set but ssl_alt_key_file is not"))); + goto error; + } + if (!ssl_alt_cert_file[0] && ssl_alt_key_file[0]) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("ssl_alt_key_file is set but ssl_alt_cert_file is not"))); + goto error; + } + if (ssl_alt_cert_file[0] && ssl_alt_key_file[0]) + { + if (SSL_CTX_use_certificate_file(ctx, ssl_alt_cert_file, + SSL_FILETYPE_PEM) != 1) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load alternate server certificate file \"%s\": %s", + ssl_alt_cert_file, SSLerrmessage(ERR_get_error())))); + goto error; + } + + if (!check_ssl_key_file_permissions(ssl_alt_key_file, isServerStart)) + goto error; + + if (SSL_CTX_use_PrivateKey_file(ctx, ssl_alt_key_file, + SSL_FILETYPE_PEM) != 1) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load alternate private key file \"%s\": %s", + ssl_alt_key_file, SSLerrmessage(ERR_get_error())))); + goto error; + } + + if (SSL_CTX_check_private_key(ctx) != 1) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("check of alternate private key failed: %s", + SSLerrmessage(ERR_get_error())))); + goto error; + } + } + /* * Load CA store, so we can verify client certificates if needed. */ @@ -1826,33 +1926,43 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config) X509 *cert; EVP_PKEY *key; - - STACK_OF(X509) * chain; + STACK_OF(X509) *chain; + bool first = true; Assert(ctx != NULL); - /*- - * Make use of the already-loaded certificate chain and key. At first - * glance, SSL_set_SSL_CTX() looks like the easiest way to do this, but - * beware -- it has very odd behavior: - * - * https://github.com/openssl/openssl/issues/6109 + + /* + * Iterate over all certificate types loaded in the SSL_CTX (e.g. RSA + * and ECDSA) and copy each onto the per-connection SSL object. + * SSL_use_cert_and_key with override=1 replaces; override=0 adds a + * cert for a different key type without wiping existing ones. */ - cert = SSL_CTX_get0_certificate(ctx); - key = SSL_CTX_get0_privatekey(ctx); + SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_FIRST); + do + { + cert = SSL_CTX_get0_certificate(ctx); + key = SSL_CTX_get0_privatekey(ctx); + + if (!cert || !key) + continue; - Assert(cert && key); + if (!SSL_CTX_get0_chain_certs(ctx, &chain) + || !SSL_use_cert_and_key(ssl, cert, key, chain, first ? 1 : 0)) + { + ereport(COMMERROR, + errcode(ERRCODE_INTERNAL_ERROR), + errmsg_internal("could not update certificate chain: %s", + SSLerrmessage(ERR_get_error()))); + return false; + } + first = false; + } while (SSL_CTX_set_current_cert(ctx, SSL_CERT_SET_NEXT)); - if (!SSL_CTX_get0_chain_certs(ctx, &chain) - || !SSL_use_cert_and_key(ssl, cert, key, chain, 1 /* override */ ) - || !SSL_check_private_key(ssl)) + if (!SSL_check_private_key(ssl)) { - /* - * This shouldn't really be possible, since the inputs came from a - * SSL_CTX that was already populated by OpenSSL. - */ ereport(COMMERROR, errcode(ERRCODE_INTERNAL_ERROR), - errmsg_internal("could not update certificate chain: %s", + errmsg_internal("could not verify private key: %s", SSLerrmessage(ERR_get_error()))); return false; } @@ -2194,6 +2304,29 @@ SSLerrmessage(unsigned long ecode) return errbuf; } + +static const char * +evp_pkey_type_name(int type) +{ + switch (type) + { + case EVP_PKEY_RSA: + return "RSA"; + case EVP_PKEY_EC: + return "ECDSA"; +#ifdef EVP_PKEY_ED25519 + case EVP_PKEY_ED25519: + return "EdDSA"; +#endif +#ifdef EVP_PKEY_ED448 + case EVP_PKEY_ED448: + return "EdDSA"; +#endif + default: + return "unknown"; + } +} + int be_tls_get_cipher_bits(Port *port) { @@ -2226,6 +2359,34 @@ be_tls_get_cipher(Port *port) return NULL; } +const char * +be_tls_get_server_cert_type(Port *port) +{ + if (port->ssl) + { + X509 *cert = SSL_get_certificate(port->ssl); + + if (cert) + { + EVP_PKEY *pkey = X509_get0_pubkey(cert); + + if (pkey) + return evp_pkey_type_name(EVP_PKEY_get_base_id(pkey)); + } + } + return NULL; +} + +const char * +be_tls_get_server_cert_types(void) +{ + if (ssl_cert_types_cached[0] == '\0') + return NULL; + + return ssl_cert_types_cached; +} + + void be_tls_get_peer_subject_name(Port *port, char *ptr, size_t len) { diff --git a/src/backend/libpq/be-secure.c b/src/backend/libpq/be-secure.c index 86ceea72e64..51e479c463d 100644 --- a/src/backend/libpq/be-secure.c +++ b/src/backend/libpq/be-secure.c @@ -37,6 +37,8 @@ char *ssl_library; char *ssl_cert_file; char *ssl_key_file; +char *ssl_alt_cert_file; +char *ssl_alt_key_file; char *ssl_ca_file; char *ssl_crl_file; char *ssl_crl_dir; diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index afaa058b046..2cfed2d42a4 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2750,6 +2750,18 @@ check_hook => 'check_ssl', }, +{ name => 'ssl_alt_cert_file', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL', + short_desc => 'Location of the alternate SSL server certificate file.', + variable => 'ssl_alt_cert_file', + boot_val => '""', +}, + +{ name => 'ssl_alt_key_file', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL', + short_desc => 'Location of the alternate SSL server private key file.', + variable => 'ssl_alt_key_file', + boot_val => '""', +}, + { name => 'ssl_ca_file', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL', short_desc => 'Location of the SSL certificate authority file.', variable => 'ssl_ca_file', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index ac38cddaaf9..d367a096079 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -115,6 +115,8 @@ #ssl_crl_file = '' #ssl_crl_dir = '' #ssl_key_file = 'server.key' +#ssl_alt_cert_file = '' +#ssl_alt_key_file = '' #ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed TLSv1.2 ciphers #ssl_tls13_ciphers = '' # allowed TLSv1.3 cipher suites, blank for default #ssl_prefer_server_ciphers = on diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h index 921b2daa4ff..cf4fd1d282f 100644 --- a/src/include/libpq/libpq-be.h +++ b/src/include/libpq/libpq-be.h @@ -320,6 +320,8 @@ extern const char *be_tls_get_cipher(Port *port); extern void be_tls_get_peer_subject_name(Port *port, char *ptr, size_t len); extern void be_tls_get_peer_issuer_name(Port *port, char *ptr, size_t len); extern void be_tls_get_peer_serial(Port *port, char *ptr, size_t len); +extern const char *be_tls_get_server_cert_type(Port *port); +extern const char *be_tls_get_server_cert_types(void); /* * Get the server certificate hash for SCRAM channel binding type diff --git a/src/include/libpq/libpq.h b/src/include/libpq/libpq.h index d15073a0a93..5e7705adba7 100644 --- a/src/include/libpq/libpq.h +++ b/src/include/libpq/libpq.h @@ -108,6 +108,8 @@ extern PGDLLIMPORT char *ssl_cert_file; extern PGDLLIMPORT char *ssl_crl_file; extern PGDLLIMPORT char *ssl_crl_dir; extern PGDLLIMPORT char *ssl_key_file; +extern PGDLLIMPORT char *ssl_alt_cert_file; +extern PGDLLIMPORT char *ssl_alt_key_file; extern PGDLLIMPORT int ssl_min_protocol_version; extern PGDLLIMPORT int ssl_max_protocol_version; extern PGDLLIMPORT char *ssl_passphrase_command; diff --git a/src/test/ssl/t/004_ssl_alt_cert.pl b/src/test/ssl/t/004_ssl_alt_cert.pl new file mode 100644 index 00000000000..dca4da34f19 --- /dev/null +++ b/src/test/ssl/t/004_ssl_alt_cert.pl @@ -0,0 +1,188 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test dual RSA + ECDSA certificate support via ssl_alt_cert_file/ssl_alt_key_file + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +use FindBin; +use lib $FindBin::RealBin; + +use SSL::Server; + +if ($ENV{with_ssl} ne 'openssl') +{ + plan skip_all => 'OpenSSL not supported by this build'; +} +if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bssl\b/) +{ + plan skip_all => + 'Potentially unsafe test SSL not enabled in PG_TEST_EXTRA'; +} + +my $ssl_server = SSL::Server->new(); +my $SERVERHOSTADDR = '127.0.0.1'; +my $SERVERHOSTCIDR = '127.0.0.1/32'; + +#### Set up the server. + +note "setting up data directory"; +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init; + +$ENV{PGHOST} = $node->host; +$ENV{PGPORT} = $node->port; +$node->start; + +$ssl_server->configure_test_server_for_ssl($node, $SERVERHOSTADDR, + $SERVERHOSTCIDR, 'trust', extensions => [qw(sslinfo)]); + +my $pgdata = $node->data_dir; + +#### Generate ECDSA cert signed by the test server CA. + +my $ssl_dir = "$FindBin::RealBin/../ssl"; +my $ecdsa_key = "$pgdata/server-ecdsa.key"; +my $ecdsa_csr = "$pgdata/server-ecdsa.csr"; +my $ecdsa_crt = "$pgdata/server-ecdsa.crt"; + +note "generating ECDSA server certificate"; + +system("openssl ecparam -genkey -name prime256v1 -out $ecdsa_key 2>/dev/null") == 0 + or die "failed to generate ECDSA key"; +system("openssl req -new -key $ecdsa_key -out $ecdsa_csr -subj '/CN=localhost' -batch 2>/dev/null") == 0 + or die "failed to generate ECDSA CSR"; +system("openssl x509 -req -in $ecdsa_csr -CA $ssl_dir/server_ca.crt -CAkey $ssl_dir/server_ca.key " + . "-CAcreateserial -out $ecdsa_crt -days 3650 2>/dev/null") == 0 + or die "failed to sign ECDSA cert"; +chmod 0600, $ecdsa_key; +unlink $ecdsa_csr; + +#### Configure server with dual certs (RSA primary + ECDSA alternate). + +note "configuring server with dual RSA + ECDSA certificates"; + +# Use the standard RSA cert as primary via switch_server_cert +$ssl_server->switch_server_cert($node, + certfile => 'server-cn-only', + cafile => 'root+client_ca', + restart => 'no'); + +# Add alt cert configuration +$node->append_conf('sslconfig.conf', + "ssl_alt_cert_file='$ecdsa_crt'"); +$node->append_conf('sslconfig.conf', + "ssl_alt_key_file='$ecdsa_key'"); + +# Force TLS 1.2 so we can control cipher selection +$node->append_conf('sslconfig.conf', + "ssl_max_protocol_version='TLSv1.2'"); + +$node->restart; + +#### Tests. + +my $common_connstr = "sslrootcert=invalid hostaddr=$SERVERHOSTADDR host=localhost " + . "user=ssltestuser dbname=trustdb sslmode=require"; + +# Test 1: Connect with RSA cipher +note "testing RSA cipher connection"; +$node->connect_ok( + "$common_connstr sslcert=invalid", + "connect with RSA cipher via default negotiation", + sql => "SELECT 1"); + +# Test 2: Verify the GUC parameters are set +my $result = $node->safe_psql('trustdb', + "SHOW ssl_alt_cert_file", + connstr => "$common_connstr sslcert=invalid"); +like($result, qr/server-ecdsa\.crt/, 'ssl_alt_cert_file is set'); + +$result = $node->safe_psql('trustdb', + "SHOW ssl_alt_key_file", + connstr => "$common_connstr sslcert=invalid"); +like($result, qr/server-ecdsa\.key/, 'ssl_alt_key_file is set'); + +# Test 3: Verify both cipher types work via openssl s_client +note "testing RSA cipher via openssl s_client"; +my $openssl_rsa = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-RSA-AES256-GCM-SHA384 2>&1`; +like($openssl_rsa, qr/ECDHE-RSA-AES256-GCM-SHA384/, 'RSA cipher negotiates successfully'); + +note "testing ECDSA cipher via openssl s_client"; +my $openssl_ecdsa = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1`; +like($openssl_ecdsa, qr/ECDHE-ECDSA-AES256-GCM-SHA384/, 'ECDSA cipher negotiates successfully'); + +# Test 4: Verify correct cert type is served for each cipher +note "verifying RSA cert served for RSA cipher"; +my $rsa_cert = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-RSA-AES256-GCM-SHA384 2>&1 | openssl x509 -noout -text 2>/dev/null`; +like($rsa_cert, qr/rsaEncryption/, 'RSA cert served for RSA cipher'); + +note "verifying ECDSA cert served for ECDSA cipher"; +my $ecdsa_cert = `echo "QUIT" | openssl s_client -connect $SERVERHOSTADDR:${\$node->port} -starttls postgres -tls1_2 -cipher ECDHE-ECDSA-AES256-GCM-SHA384 2>&1 | openssl x509 -noout -text 2>/dev/null`; +like($ecdsa_cert, qr/id-ecPublicKey/, 'ECDSA cert served for ECDSA cipher'); + +# Test 5: Verify ssl_server_cert_type() returns correct type per connection +note "testing ssl_server_cert_type() via default negotiation"; +$result = $node->safe_psql('trustdb', + "SELECT ssl_server_cert_type()", + connstr => "$common_connstr sslcert=invalid"); +like($result, qr/^(RSA|ECDSA)$/, 'ssl_server_cert_type() returns valid cert type'); + +# Test 6: Verify ssl_server_cert_types() shows all loaded types +note "testing ssl_server_cert_types() shows all loaded cert types"; +$result = $node->safe_psql('trustdb', + "SELECT ssl_server_cert_types()", + connstr => "$common_connstr sslcert=invalid"); +like($result, qr/RSA/, 'ssl_server_cert_types() includes RSA'); +like($result, qr/ECDSA/, 'ssl_server_cert_types() includes ECDSA'); + +# Test 7: Verify server rejects mismatched alt cert/key configuration +note "testing server rejects ssl_alt_cert_file without ssl_alt_key_file"; + +$node->append_conf('sslconfig.conf', + "ssl_alt_key_file=''"); + +$result = $node->restart(fail_ok => 1); +is($result, 0, 'restart fails with ssl_alt_cert_file set but ssl_alt_key_file empty'); + +# Check the log for the expected error message +my $log = slurp_file($node->logfile); +like($log, qr/ssl_alt_cert_file is set but ssl_alt_key_file is not/, + 'log contains expected error for missing ssl_alt_key_file'); + +# Test 8: Verify the reverse mismatch (key without cert) +note "testing server rejects ssl_alt_key_file without ssl_alt_cert_file"; + +ok(unlink($node->data_dir . '/sslconfig.conf')); +$node->append_conf('sslconfig.conf', "ssl=on"); +$node->append_conf('sslconfig.conf', + "ssl_ca_file='root+client_ca.crt'"); +$node->append_conf('sslconfig.conf', + "ssl_cert_file='server-cn-only.crt'"); +$node->append_conf('sslconfig.conf', + "ssl_key_file='server-cn-only.key'"); +$node->append_conf('sslconfig.conf', + "ssl_alt_key_file='$ecdsa_key'"); + +$result = $node->restart(fail_ok => 1); +is($result, 0, 'restart fails with ssl_alt_key_file set but ssl_alt_cert_file empty'); + +$log = slurp_file($node->logfile); +like($log, qr/ssl_alt_key_file is set but ssl_alt_cert_file is not/, + 'log contains expected error for missing ssl_alt_cert_file'); + +# Restore valid config so the node can be stopped cleanly +ok(unlink($node->data_dir . '/sslconfig.conf')); +$node->append_conf('sslconfig.conf', "ssl=on"); +$node->append_conf('sslconfig.conf', + "ssl_ca_file='root+client_ca.crt'"); +$node->append_conf('sslconfig.conf', + "ssl_cert_file='server-cn-only.crt'"); +$node->append_conf('sslconfig.conf', + "ssl_key_file='server-cn-only.key'"); +$node->start; + +done_testing(); -- 2.52.0
From e613fb1d5b6d09661004ec0f65627b2549af5634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 15 Jun 2026 08:14:09 +0200 Subject: [PATCH v2 2/8] Reject alternate certificate with same key type as primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If ssl_alt_cert_file specifies a certificate of the same key type as the primary ssl_cert_file (e.g., both RSA), the alternate silently replaces the primary, which is not useful. Detect this by comparing EVP_PKEY_get_base_id() on both certificates after loading, and report a configuration error at startup or reload. Author: Renaud Métrich <[email protected]> --- src/backend/libpq/be-secure-openssl.c | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index cf7d981b697..bfbce535ac8 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -833,6 +833,39 @@ init_host_context(HostsLine *host, bool isServerStart) SSLerrmessage(ERR_get_error())))); goto error; } + + /* + * 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) as the primary certificate", + evp_pkey_type_name(alt_type)))); + goto error; + } + } + } } /* -- 2.52.0
From bff0d5d0bb295824a0c7136903f07cabfe1d8668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 15 Jun 2026 08:14:56 +0200 Subject: [PATCH v2 3/8] Load full certificate chain for alternate certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use SSL_CTX_use_certificate_chain_file() instead of SSL_CTX_use_certificate_file() for the alternate certificate, matching how the primary certificate is loaded. This ensures that intermediate CA certificates included in the PEM file are loaded as the chain for the alternate certificate. Author: Renaud Métrich <[email protected]> --- src/backend/libpq/be-secure-openssl.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index bfbce535ac8..fa9f551e22a 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -802,8 +802,7 @@ init_host_context(HostsLine *host, bool isServerStart) } if (ssl_alt_cert_file[0] && ssl_alt_key_file[0]) { - if (SSL_CTX_use_certificate_file(ctx, ssl_alt_cert_file, - SSL_FILETYPE_PEM) != 1) + if (SSL_CTX_use_certificate_chain_file(ctx, ssl_alt_cert_file) != 1) { ereport(isServerStart ? FATAL : LOG, (errcode(ERRCODE_CONFIG_FILE_ERROR), -- 2.52.0
From c0966084b91818bdf3d35e87666399c7e59298d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 15 Jun 2026 08:15:50 +0200 Subject: [PATCH v2 4/8] Load alternate certificates only for the default host context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global ssl_alt_cert_file/ssl_alt_key_file GUCs were being loaded into every host context, including per-host SNI entries from pg_hosts.conf. This caused unintended behavior: if an SNI host used a different key type, the alt cert was added as an alternative; if the same type, it silently replaced the host cert. Add an is_default parameter to init_host_context() and only load the alternate certificate for the default host context (postgresql.conf), not for per-host SNI entries. Author: Renaud Métrich <[email protected]> --- src/backend/libpq/be-secure-openssl.c | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index fa9f551e22a..6964830c5af 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -98,7 +98,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); @@ -251,7 +251,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; /* @@ -346,7 +346,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; /* @@ -655,7 +655,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; @@ -784,23 +784,25 @@ init_host_context(HostsLine *host, bool isServerStart) /* * Load alternate certificate (e.g. ECDSA alongside RSA) into the same * context. OpenSSL supports one certificate per key type and selects - * the appropriate one during the TLS handshake. + * the appropriate one during the TLS handshake. Only load for the + * default host context (postgresql.conf), not for per-host SNI entries + * from pg_hosts.conf. */ - if (ssl_alt_cert_file[0] && !ssl_alt_key_file[0]) + if (is_default && ssl_alt_cert_file[0] && !ssl_alt_key_file[0]) { ereport(isServerStart ? FATAL : LOG, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("ssl_alt_cert_file is set but ssl_alt_key_file is not"))); goto error; } - if (!ssl_alt_cert_file[0] && ssl_alt_key_file[0]) + if (is_default && !ssl_alt_cert_file[0] && ssl_alt_key_file[0]) { ereport(isServerStart ? FATAL : LOG, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("ssl_alt_key_file is set but ssl_alt_cert_file is not"))); goto error; } - if (ssl_alt_cert_file[0] && ssl_alt_key_file[0]) + if (is_default && ssl_alt_cert_file[0] && ssl_alt_key_file[0]) { if (SSL_CTX_use_certificate_chain_file(ctx, ssl_alt_cert_file) != 1) { -- 2.52.0
From 6662750b8e6bc2c9c3fcd5ae727559912a64757d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 15 Jun 2026 08:17:29 +0200 Subject: [PATCH v2 5/8] Add compatibility guards for LibreSSL and older OpenSSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard SSL_CTX_set_current_cert() and SSL_CERT_SET_FIRST/NEXT usage with #ifdef SSL_CERT_SET_FIRST, since these are available in OpenSSL 1.0.2+ but not in LibreSSL. When not available: - ssl_alt_cert_file produces a clear configuration error - ssl_update_ssl() falls back to the original single-cert copy - cert types caching is skipped (ssl_server_cert_types() returns NULL) Author: Renaud Métrich <[email protected]> --- src/backend/libpq/be-secure-openssl.c | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index 6964830c5af..2b69be6b46b 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -558,6 +558,7 @@ be_tls_init(bool isServerStart) * contexts will be freed when PostmasterContext is deleted. */ ssl_cert_types_cached[0] = '\0'; +#ifdef SSL_CERT_SET_FIRST if (new_hosts->default_host && new_hosts->default_host->ssl_ctx) { SSL_CTX *cache_ctx = new_hosts->default_host->ssl_ctx; @@ -593,6 +594,7 @@ be_tls_init(bool isServerStart) ssl_cert_types_cached[cpos] = '\0'; } +#endif /* SSL_CERT_SET_FIRST */ /* * Success! Replace any existing SSL_context and host configurations. @@ -787,7 +789,11 @@ init_host_context(HostsLine *host, bool isServerStart, bool is_default) * the appropriate one during the TLS handshake. Only load for the * default host context (postgresql.conf), not for per-host SNI entries * from pg_hosts.conf. + * + * Requires SSL_CTX_set_current_cert() for same-type detection, which + * is available in OpenSSL 1.0.2+ but not in LibreSSL. */ +#ifdef SSL_CERT_SET_FIRST if (is_default && ssl_alt_cert_file[0] && !ssl_alt_key_file[0]) { ereport(isServerStart ? FATAL : LOG, @@ -868,6 +874,16 @@ init_host_context(HostsLine *host, bool isServerStart, bool is_default) } } } +#else + if (is_default && ssl_alt_cert_file[0]) + { + ereport(isServerStart ? FATAL : LOG, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("ssl_alt_cert_file is not supported by this build"), + errhint("Alternate certificate support requires OpenSSL 1.0.2 or later."))); + goto error; + } +#endif /* SSL_CERT_SET_FIRST */ /* * Load CA store, so we can verify client certificates if needed. @@ -1970,7 +1986,11 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config) * 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. + * + * 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 { @@ -1991,6 +2011,22 @@ ssl_update_ssl(SSL *ssl, HostsLine *host_config) } first = 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)) + { + 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)) { -- 2.52.0
From 0fab5ad14f25f2458c0847880cca4d8dc175db2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 15 Jun 2026 08:18:02 +0200 Subject: [PATCH v2 6/8] Document ssl_alt_cert_file limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note that the alternate certificate must use a different key type than the primary, and that this setting applies only to the default SSL configuration from postgresql.conf, not to per-host entries in pg_hosts.conf. Author: Renaud Métrich <[email protected]> --- doc/src/sgml/config.sgml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 4d712ed852b..a684523aeb4 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1345,6 +1345,14 @@ include_dir 'conf.d' The default is empty, meaning no alternate certificate is loaded. Both <varname>ssl_alt_cert_file</varname> and <xref linkend="guc-ssl-alt-key-file"/> must be set together. + The alternate certificate must use a different key type than the + primary certificate; specifying the same type is an error. + </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> -- 2.52.0
From 9619bf7020f592d954d1a85e7c0c2133dc1230bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> 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 <[email protected]> --- 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
From 724917cd546d24ac99f3d07c87c2c29a36884616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renaud=20M=C3=A9trich?= <[email protected]> Date: Mon, 15 Jun 2026 09:41:58 +0200 Subject: [PATCH v2 8/8] Expand test coverage for dual certificate support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for: - Same-type alt cert rejection (both RSA -> startup error) - Single cert mode regression (no alt cert still works) - SIGHUP reload adds alt cert (ECDSA becomes available) - SIGHUP reload removes alt cert (ECDSA no longer available) Remove the global ssl_max_protocol_version=TLSv1.2 restriction so TLS 1.3 connections are actually exercised (the openssl s_client tests already force -tls1_2 explicitly). Refactor config rewriting into a write_sslconfig() helper. 29 subtests total, up from 22. Author: Renaud Métrich <[email protected]> --- src/test/ssl/t/004_ssl_alt_cert.pl | 111 ++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/src/test/ssl/t/004_ssl_alt_cert.pl b/src/test/ssl/t/004_ssl_alt_cert.pl index a148e0a7fb1..b4973bfc996 100644 --- a/src/test/ssl/t/004_ssl_alt_cert.pl +++ b/src/test/ssl/t/004_ssl_alt_cert.pl @@ -77,10 +77,6 @@ $node->append_conf('sslconfig.conf', $node->append_conf('sslconfig.conf', "ssl_alt_key_file='$ecdsa_key'"); -# Force TLS 1.2 so we can control cipher selection -$node->append_conf('sslconfig.conf', - "ssl_max_protocol_version='TLSv1.2'"); - $node->restart; #### Tests. @@ -146,16 +142,34 @@ $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'); +# 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'"); + $node->append_conf('sslconfig.conf', + "ssl_cert_file='server-cn-only.crt'"); + $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}"); + } +} + # 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', - "ssl_alt_key_file=''"); +write_sslconfig($node, + ssl_alt_cert_file => "'$ecdsa_crt'"); $result = $node->restart(fail_ok => 1); is($result, 0, 'restart fails with ssl_alt_cert_file set but ssl_alt_key_file empty'); -# Check the log for the expected error message my $log = slurp_file($node->logfile); like($log, qr/ssl_alt_cert_file is set but ssl_alt_key_file is not/, 'log contains expected error for missing ssl_alt_key_file'); @@ -163,16 +177,8 @@ like($log, qr/ssl_alt_cert_file is set but ssl_alt_key_file is not/, # 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')); -$node->append_conf('sslconfig.conf', "ssl=on"); -$node->append_conf('sslconfig.conf', - "ssl_ca_file='root+client_ca.crt'"); -$node->append_conf('sslconfig.conf', - "ssl_cert_file='server-cn-only.crt'"); -$node->append_conf('sslconfig.conf', - "ssl_key_file='server-cn-only.key'"); -$node->append_conf('sslconfig.conf', - "ssl_alt_key_file='$ecdsa_key'"); +write_sslconfig($node, + ssl_alt_key_file => "'$ecdsa_key'"); $result = $node->restart(fail_ok => 1); is($result, 0, 'restart fails with ssl_alt_key_file set but ssl_alt_cert_file empty'); @@ -181,15 +187,70 @@ $log = slurp_file($node->logfile); like($log, qr/ssl_alt_key_file is set but ssl_alt_cert_file is not/, 'log contains expected error for missing ssl_alt_cert_file'); -# Restore valid config so the node can be stopped cleanly -ok(unlink($node->data_dir . '/sslconfig.conf')); -$node->append_conf('sslconfig.conf', "ssl=on"); -$node->append_conf('sslconfig.conf', - "ssl_ca_file='root+client_ca.crt'"); +# Test 10: Verify server rejects same-type alt cert (both RSA) +note "testing server rejects same-type alternate certificate"; + +write_sslconfig($node, + ssl_alt_cert_file => "'server-cn-only.crt'", + ssl_alt_key_file => "'server-cn-only.key'"); + +$result = $node->restart(fail_ok => 1); +is($result, 0, 'restart fails with same-type alt cert'); + +$log = slurp_file($node->logfile); +like($log, qr/alternate certificate has the same key type/, + 'log contains expected error for same-type alt cert'); + +# Test 11: Verify single cert still works (no alt cert regression) +note "testing single cert mode (no alt cert)"; + +write_sslconfig($node); +$node->start; + +$node->connect_ok( + "$common_connstr sslcert=invalid", + "connect with single RSA cert (no alt cert)", + sql => "SELECT 1"); + +$result = $node->safe_psql('trustdb', + "SELECT ssl_server_cert_type()", + connstr => "$common_connstr sslcert=invalid"); +is($result, 'RSA', 'ssl_server_cert_type() returns RSA with single cert'); + +# Test 12: Verify SIGHUP reload adds alt cert +note "testing SIGHUP reload adds alt certificate"; + $node->append_conf('sslconfig.conf', - "ssl_cert_file='server-cn-only.crt'"); + "ssl_alt_cert_file='$ecdsa_crt'"); $node->append_conf('sslconfig.conf', - "ssl_key_file='server-cn-only.key'"); -$node->start; + "ssl_alt_key_file='$ecdsa_key'"); +$node->reload; + +# Wait briefly for reload to take effect +sleep(1); + +# Verify ECDSA cipher now works after reload +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 13: Verify SIGHUP reload removes alt cert +note "testing SIGHUP reload removes alt certificate"; + +write_sslconfig($node); +$node->reload; + +sleep(1); + +# Verify ECDSA cipher no longer works after removing alt cert +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 alt cert removed via SIGHUP'); + +# RSA still works +$node->connect_ok( + "$common_connstr sslcert=invalid", + "RSA connection works after alt cert removed via SIGHUP", + sql => "SELECT 1"); done_testing(); -- 2.52.0
OpenPGP_signature.asc
Description: OpenPGP digital signature
