From f35e44d8d2be22286ff057bda1eff7d4446be2c8 Mon Sep 17 00:00:00 2001 From: Ajit Awekar Date: Tue, 29 Sep 2026 14:58:21 +0530 Subject: [PATCH v3 2/2] Add TLS client certificate expiry and revocation checks to credential validation Register a CVT_CERT validator so a certificate-authenticated session is re-validated against the certificate it originally connected with: the peer certificate is retained on the Port, so its notAfter date and CRL status can be re-checked locally, with no network round trip. be_tls_get_peer_cert_revoked() re-reads ssl_crl_file on demand, since the handshake-time check only ever sees the CRL loaded into the shared SSL_CTX at fork/last SIGHUP and never repeats. Scope is deliberately narrow: only the leaf certificate against ssl_crl_file, not the chain or ssl_crl_dir; any lookup failure fails open. A client certificate can also be required alongside a different primary auth method (clientcert=verify-full); the validator dispatch re-checks CVT_CERT in that case too, via a new Port field that survives past authentication, unlike the HbaLine it's copied from. --- doc/src/sgml/config.sgml | 14 + src/backend/libpq/auth-validate-methods.c | 45 +- src/backend/libpq/auth-validate.c | 21 + src/backend/libpq/auth.c | 7 + src/backend/libpq/be-secure-openssl.c | 112 +++++ src/include/libpq/libpq-be.h | 24 +- src/test/ssl/meson.build | 1 + .../ssl/t/005_cert_continuous_validation.pl | 396 ++++++++++++++++++ 8 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 src/test/ssl/t/005_cert_continuous_validation.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 6c2e5c90be1..ca6f14b905c 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1142,6 +1142,20 @@ include_dir 'conf.d' bearer token or of the client certificate. The default is off. + + For client-certificate sessions, this method-specific check also + re-checks certificate revocation on each cycle, independently of the + one-time check already performed at connection time (see + ). This periodic re-check has a + narrower scope than the connection-time check, however: it only + consults , not + , and it only checks the client's + own certificate, not any intermediate certificate authorities in + its chain. A certificate revoked only via + ssl_crl_dir, or an intermediate CA revoked after + the session was established, will therefore not cause an + already-open session to be terminated by this mechanism. + The re-validation period is controlled by . Validation is diff --git a/src/backend/libpq/auth-validate-methods.c b/src/backend/libpq/auth-validate-methods.c index 1db1896af0e..530af86c63b 100644 --- a/src/backend/libpq/auth-validate-methods.c +++ b/src/backend/libpq/auth-validate-methods.c @@ -20,17 +20,26 @@ #include "access/htup_details.h" #include "catalog/pg_authid.h" #include "libpq/auth-validate-methods.h" +#include "libpq/auth-validate.h" +#include "libpq/libpq-be.h" #include "miscadmin.h" #include "utils/syscache.h" #include "utils/timestamp.h" +/* Function declarations for internal use */ +static bool validate_cert_credentials(void); + /* * Initialize validation methods */ void InitializeValidationMethods(void) { - /* No method-specific validators are registered yet. */ + /* + * Register method-specific validators. Password methods need none -- + * ValidateRoleValidity() covers every session's baseline check. + */ + RegisterCredentialValidator(CVT_CERT, validate_cert_credentials); } /* @@ -79,3 +88,37 @@ ValidateRoleValidity(void) ReleaseSysCache(tuple); return result; } + +/* + * CVT_CERT validator: the peer cert is retained on Port, so its notAfter + * and revocation status can be re-checked locally, no round-trip needed. + * No cert on the session (shouldn't happen) is treated as valid. + */ +static bool +validate_cert_credentials(void) +{ +#ifdef USE_SSL + Port *port = MyProcPort; + + if (port == NULL || !port->ssl_in_use || port->peer == NULL) + return true; + + /* The session is no longer valid once the client certificate expires */ + if (be_tls_get_peer_cert_expired(port)) + { + SetCredentialValidationFailureDetail("client certificate check failed for user \"%s\": certificate has expired", + port->user_name); + return false; + } + + /* Nor is it valid once the certificate has been revoked via CRL */ + if (be_tls_get_peer_cert_revoked(port)) + { + SetCredentialValidationFailureDetail("client certificate check failed for user \"%s\": certificate has been revoked", + port->user_name); + return false; + } +#endif + + return true; +} diff --git a/src/backend/libpq/auth-validate.c b/src/backend/libpq/auth-validate.c index 1d6bd5eef0b..5119ecd4c3d 100644 --- a/src/backend/libpq/auth-validate.c +++ b/src/backend/libpq/auth-validate.c @@ -284,5 +284,26 @@ CheckCredentialValidity(void) (int) MyClientConnectionInfo.auth_method); } + /* + * A client cert can be required alongside a different primary method + * (clientcert=verify-full); re-check CVT_CERT here too in that case. + * Reads hba_clientcert, not hba->clientcert (freed by now; see libpq-be.h). + */ + if (result && + validation_type != CVT_CERT && + validators[CVT_CERT] != NULL && + MyProcPort != NULL && + MyProcPort->hba_clientcert != clientCertOff) + { + elog(DEBUG1, "credential validation: also rechecking CVT_CERT (clientcert required alongside auth method %d)", + (int) MyClientConnectionInfo.auth_method); + result = validators[CVT_CERT] (); + + if (!result && credential_validation_detail[0] == '\0') + SetCredentialValidationFailureDetail("client certificate re-check failed for user \"%s\" (auth method %d)", + MyProcPort->user_name, + (int) MyClientConnectionInfo.auth_method); + } + return result; } diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c index 12bf153d66f..d84e35e8955 100644 --- a/src/backend/libpq/auth.c +++ b/src/backend/libpq/auth.c @@ -356,6 +356,13 @@ set_authn_id(Port *port, const char *id) MyClientConnectionInfo.authn_id = MemoryContextStrdup(TopMemoryContext, id); MyClientConnectionInfo.auth_method = port->hba->auth_method; + /* + * Copy out what we need from "hba" while valid -- it's freed early in + * this backend's startup, well before the session ends (see + * hba_clientcert's comment in libpq-be.h). + */ + port->hba_clientcert = port->hba->clientcert; + if (log_connections & LOG_CONNECTION_AUTHENTICATION) { ereport(LOG, diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index 173623d1f6a..7d0fba41d3a 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -2277,6 +2277,118 @@ be_tls_get_peer_serial(Port *port, char *ptr, size_t len) ptr[0] = '\0'; } +/* + * Returns true if the peer cert's notAfter has already passed. An absent + * or unparsable notAfter is conservatively treated as not expired, so we + * never terminate a session over an unreadable field. + */ +bool +be_tls_get_peer_cert_expired(Port *port) +{ + const ASN1_TIME *not_after; + + if (port->peer == NULL) + return false; + + not_after = X509_get0_notAfter(port->peer); + if (not_after == NULL) + return false; + + /* + * X509_cmp_current_time() returns -1 for a past time (expired), 1 + * for future, 0 on parse error. + */ + return (X509_cmp_current_time(not_after) < 0); +} + +/* + * Re-checks the peer cert against ssl_crl_file on demand (cached by + * mtime), unlike the one-time handshake-time CRL check. Leaf cert and + * ssl_crl_file only (not chain/ssl_crl_dir); fails open on any error. + */ +bool +be_tls_get_peer_cert_revoked(Port *port) +{ + static char cached_path[MAXPGPATH] = {0}; + static time_t cached_mtime = 0; + static STACK_OF(X509_CRL) * cached_crls = NULL; + + struct stat st; + bool revoked = false; + int i; + + if (port->peer == NULL) + return false; + + if (ssl_crl_file[0] == '\0') + return false; + + if (stat(ssl_crl_file, &st) != 0) + return false; + + /* + * Reload when the path or mtime changed. A failed load leaves + * cached_crls as an empty non-NULL stack, so a broken file is + * retried only once per distinct mtime, not every call. + */ + if (cached_crls == NULL || + strcmp(cached_path, ssl_crl_file) != 0 || + cached_mtime != st.st_mtime) + { + X509_STORE *store; + STACK_OF(X509_CRL) * new_crls = NULL; + + if (cached_crls != NULL) + sk_X509_CRL_pop_free(cached_crls, X509_CRL_free); + + /* + * Load into a throwaway store and fetch only the peer's issuer's + * CRL(s), reusing the same OpenSSL primitives be_tls_init() uses + * at handshake time instead of hand-parsing. + */ + store = X509_STORE_new(); + if (store != NULL) + { + if (X509_STORE_load_locations(store, ssl_crl_file, NULL) == 1) + { + X509_STORE_CTX *storectx = X509_STORE_CTX_new(); + + if (storectx != NULL) + { + if (X509_STORE_CTX_init(storectx, store, NULL, NULL) == 1) + new_crls = X509_STORE_CTX_get1_crls(storectx, + X509_get_issuer_name(port->peer)); + X509_STORE_CTX_free(storectx); + } + } + X509_STORE_free(store); + } + + cached_crls = new_crls != NULL ? new_crls : sk_X509_CRL_new_null(); + cached_mtime = st.st_mtime; + strlcpy(cached_path, ssl_crl_file, sizeof(cached_path)); + } + + for (i = 0; i < sk_X509_CRL_num(cached_crls); i++) + { + X509_CRL *crl = sk_X509_CRL_value(cached_crls, i); + X509_REVOKED *r; + + /* + * 1 = revoked; 2 = removeFromCRL (delta-CRL-only, shouldn't + * appear in a base CRL). Treat either as revoked -- the safer, + * fail-closed reading. + */ + if (X509_CRL_get0_by_cert(crl, &r, port->peer) != 0) + { + revoked = true; + break; + } + } + + return revoked; +} + char * be_tls_get_certificate_hash(Port *port, size_t *len) { diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h index 921b2daa4ff..6e97c9e0ba4 100644 --- a/src/include/libpq/libpq-be.h +++ b/src/include/libpq/libpq-be.h @@ -160,10 +160,19 @@ typedef struct Port char *application_name; /* - * Information that needs to be held during the authentication cycle. + * Held during authentication only: "hba" points into a context every + * backend deletes early in startup. Anything needed later (like + * hba_clientcert below) must be copied out before that happens. */ HbaLine *hba; + /* + * Whether this session's HBA line required a client cert, copied out + * of hba->clientcert by set_authn_id() while "hba" is still valid. + * Safe to read for the session's lifetime, unlike "hba" itself. + */ + ClientCertMode hba_clientcert; + /* * TCP keepalive and user timeout settings. * @@ -321,6 +330,19 @@ 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); +/* + * Report whether the client certificate's validity period (notAfter) has + * already passed. Returns false when no peer certificate is present. + */ +extern bool be_tls_get_peer_cert_expired(Port *port); + +/* + * Report whether the client certificate appears on the CRL configured via + * ssl_crl_file. Best-effort addition to, not a replacement for, the + * revocation check already done at handshake time; fails open. + */ +extern bool be_tls_get_peer_cert_revoked(Port *port); + /* * Get the server certificate hash for SCRAM channel binding type * tls-server-end-point. diff --git a/src/test/ssl/meson.build b/src/test/ssl/meson.build index d7e7ce23433..cff390e1a9d 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_cert_continuous_validation.pl', ], }, } diff --git a/src/test/ssl/t/005_cert_continuous_validation.pl b/src/test/ssl/t/005_cert_continuous_validation.pl new file mode 100644 index 00000000000..d2c19a9ecd0 --- /dev/null +++ b/src/test/ssl/t/005_cert_continuous_validation.pl @@ -0,0 +1,396 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# Test continuous credential validation for TLS client certificate +# authentication: a session that authenticated with a client certificate +# must be terminated once that certificate passes its notAfter date, even +# though the certificate was valid at connection time. + +use strict; +use warnings FATAL => 'all'; +use Cwd qw(abs_path); +use POSIX qw(strftime); +use File::Copy qw(copy); +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(); + +# This is the hostname used to connect to the server. +my $SERVERHOSTADDR = '127.0.0.1'; +# This is the pattern to use in pg_hba.conf to match incoming connections. +my $SERVERHOSTCIDR = '127.0.0.1/32'; + +# How long the runtime-generated client certificate stays valid, and how +# often the server re-validates credentials. The certificate must outlive +# connection setup but expire well within the test's wait window. +my $cert_validity_secs = 15; +my $validation_interval = 5; # minimum allowed by the GUC + +# 1. Initialize and start the cluster with continuous validation enabled. +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf('postgresql.conf', + "credential_validation_enabled = on\n"); +$node->append_conf('postgresql.conf', + "credential_validation_interval = $validation_interval\n"); +$node->start; + +# 2. Configure the server for SSL. This creates the "ssltestuser" role and +# the "certdb"/"verifydb" databases. Regardless of the base auth method +# passed here, the generated HBA always serves "certdb" with plain "cert" +# (client-certificate-only) authentication; "verifydb" instead combines the +# base auth method with "clientcert=verify-full", which is what Test 2 below +# uses to exercise a certificate re-check alongside a non-cert primary auth +# method. ssltestuser's password is set here so scram-sha-256 works on +# "verifydb". +my $password = 'ssltestpass'; +$ssl_server->configure_test_server_for_ssl( + $node, $SERVERHOSTADDR, + $SERVERHOSTCIDR, 'scram-sha-256', + password => $password, + password_enc => 'scram-sha-256'); +$ssl_server->switch_server_cert($node, certfile => 'server-cn-only'); + +my $client_ca_crt = abs_path('ssl/client_ca.crt'); +my $client_ca_key = abs_path('ssl/client_ca.key'); +my $client_key = abs_path('ssl/client.key'); + +# Mint a short-lived client certificate for CN=ssltestuser, signed by the +# committed test client CA, into its own scratch directory. We use +# "openssl ca -startdate/-enddate" (rather than "x509 -req -not_after") +# because those options work back to OpenSSL 1.1.1, which core still +# supports. Each call gets a fresh tempdir/CA database, since two "openssl +# ca" invocations sharing one index/serial file would otherwise need careful +# sequencing. +sub mint_short_lived_client_cert +{ + my ($label) = @_; + my $tempdir = PostgreSQL::Test::Utils::tempdir(); + + # A throwaway "openssl ca" environment in the temp directory: the CA + # cert/key are the committed ones, but the index, serial and new-cert + # directory are scratch state we create here. + mkdir "$tempdir/newcerts" or die "could not create newcerts dir: $!"; + PostgreSQL::Test::Utils::append_to_file("$tempdir/index.txt", ''); + PostgreSQL::Test::Utils::append_to_file("$tempdir/serial.txt", "1000\n"); + + # OpenSSL's config parser treats backslashes as escape characters, so + # paths embedded in the config file below must use forward slashes; + # otherwise a Windows path such as "D:\a\..." is mangled (\a becomes a + # bell character) and "openssl ca" cannot find its + # database/new_certs_dir/certificate. Forward slashes work fine for + # file access on Windows too. + (my $ca_crt_fwd = $client_ca_crt) =~ s{\\}{/}g; + (my $ca_key_fwd = $client_ca_key) =~ s{\\}{/}g; + (my $tempdir_fwd = $tempdir) =~ s{\\}{/}g; + + my $ca_config = < $client_key, + '-subj' => '/CN=ssltestuser', + '-out' => "$tempdir/$label.csr"); + + PostgreSQL::Test::Utils::system_or_bail( + 'openssl', 'ca', '-batch', '-notext', + '-config' => "$tempdir/ca.config", + '-name' => 'short_client_ca', + '-startdate' => $startdate, + '-enddate' => $enddate, + '-in' => "$tempdir/$label.csr", + '-out' => "$tempdir/$label.crt"); + + # libpq refuses a group/world-readable private key, so use a 0600 copy. + copy($client_key, "$tempdir/$label.key") + or die "could not copy client key: $!"; + chmod 0600, "$tempdir/$label.key" + or die "could not chmod client key: $!"; + + return ("$tempdir/$label.crt", "$tempdir/$label.key"); +} + +############################################################################# +# Tests 1 and 2 both need "a session whose client certificate naturally +# expires mid-session, revalidated after one interval elapses". They use +# independent certificates/sessions (though the same underlying +# "ssltestuser" role on the wire), so both certificates are minted and both +# sessions opened up front, and they share a single sleep() for the +# certificate expiry + validation interval to elapse, rather than each test +# waiting out its own separate window. Because both use the exact same +# failure text ("certificate has expired" for "ssltestuser"), each assertion +# below is scoped to its own session's backend PID (via a log-line-prefix +# match), so Test 1's and Test 2's log lines can never satisfy each other's +# checks. +############################################################################# + +############################################################################# +# Test 1: a session authenticated purely with a client certificate ("cert" +# HBA method) is terminated once that certificate passes its notAfter date. +############################################################################# +note "=== Test 1: pure client-certificate authentication ==="; + +my ($cert1, $key1) = mint_short_lived_client_cert('short1'); + +my $connstr1 = + "host=$SERVERHOSTADDR port=" . $node->port . " dbname=certdb " + . "user=ssltestuser sslmode=verify-ca " + . "sslrootcert=ssl/root+server_ca.crt " + . "sslcert=$cert1 sslkey=$key1"; + +my $session1 = $node->background_psql( + 'certdb', + connstr => $connstr1, + on_error_stop => 0); + +# The certificate is still valid, so the session works normally. +my ($stdout, $ret) = $session1->query('SELECT 1 AS success;'); +like($stdout, qr/1/, 'cert session works while certificate is valid'); +is($ret, 0, 'no error on initial query for cert session'); + +my $pid1; +($stdout, $ret) = $session1->query('SELECT pg_backend_pid();'); +$pid1 = $1 if $stdout =~ /(\d+)/; +ok(defined $pid1, 'got session1 backend pid'); + +############################################################################# +# Test 2: a session authenticated with a *different* primary auth method +# (scram-sha-256) that also required a client certificate +# ("clientcert=verify-full") must still be terminated once that certificate +# expires. Regression test for a gap where CheckCredentialValidity() only +# ran the validator for the primary auth method (CVT_COUNT for scram-sha-256, +# i.e. none), so a certificate required only as a second factor was never +# rechecked, even though the session's continued trust still depended on it. +############################################################################# +note "=== Test 2: scram-sha-256 + clientcert=verify-full also rechecks the certificate ==="; + +my ($cert2, $key2) = mint_short_lived_client_cert('short2'); + +$ENV{PGPASSWORD} = $password; +my $connstr2 = + "host=$SERVERHOSTADDR port=" . $node->port . " dbname=verifydb " + . "user=ssltestuser sslmode=verify-ca " + . "sslrootcert=ssl/root+server_ca.crt " + . "sslcert=$cert2 sslkey=$key2"; + +my $session2 = $node->background_psql( + 'verifydb', + connstr => $connstr2, + on_error_stop => 0); + +($stdout, $ret) = $session2->query('SELECT 1 AS success;'); +like($stdout, qr/1/, + '2FA (scram + clientcert) session works while certificate is valid'); +is($ret, 0, 'no error on initial query for 2FA session'); + +my $pid2; +($stdout, $ret) = $session2->query('SELECT pg_backend_pid();'); +$pid2 = $1 if $stdout =~ /(\d+)/; +ok(defined $pid2, 'got session2 backend pid'); + +# Both certificates were minted moments apart with the same validity period, +# so a single wait covers both expiring plus one further validation cycle. +# Credential validation now also runs while a session sits idle waiting for +# the next message (not only once one is sent), so the FATAL for each can +# already be logged during this sleep, before either session is queried +# again below. +my $wait = $cert_validity_secs + $validation_interval + 2; +note "waiting $wait seconds for both client certificates to expire..."; +sleep($wait); + +############################################################################# +# Test 1 checks +############################################################################# +eval { $session1->query('SELECT 2 AS failure_expected;'); }; + +my $log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/backend\[$pid1\].*FATAL:.*session credentials have expired/, + 'cert session terminated after the client certificate expired'); + +# The DETAIL line (server log only, never sent to the client) should name +# the exact user and reason, so an admin doesn't have to guess which +# session/user this PID belonged to or which validator rejected it. +like( + $log_contents, + qr/backend\[$pid1\].*DETAIL:.*client certificate check failed for user "ssltestuser": certificate has expired/, + 'server log DETAIL identifies the user and the certificate-expiry reason'); + +eval { $session1->quit; }; + +############################################################################# +# Test 2 checks +############################################################################# +eval { $session2->query('SELECT 2 AS failure_expected;'); }; + +$log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/backend\[$pid2\].*FATAL:.*session credentials have expired/, + '2FA session terminated after the client certificate expired, despite scram-sha-256 being the primary auth method' +); +like( + $log_contents, + qr/backend\[$pid2\].*DETAIL:.*client certificate check failed for user "ssltestuser": certificate has expired/, + 'server log DETAIL identifies the certificate-expiry reason, even though scram-sha-256 was the primary auth method' +); + +eval { $session2->quit; }; + +############################################################################# +# Test 3: a session authenticated with a client certificate must be +# terminated once that certificate is revoked via CRL, even though it was +# never expired and even though nothing forces the already-forked backend +# serving this session to reload the CRL that was (or wasn't) in effect +# when it started -- only the postmaster and freshly-forked backends reload +# ssl_crl_file on SIGHUP. This exercises be_tls_get_peer_cert_revoked()'s +# own direct, on-demand re-read of ssl_crl_file, not the handshake-time CRL +# check that already existed. +############################################################################# +note "=== Test 3: client certificate revoked via CRL mid-session ==="; + +my $crl_tempdir = PostgreSQL::Test::Utils::tempdir(); +mkdir "$crl_tempdir/newcerts" or die "could not create newcerts dir: $!"; +PostgreSQL::Test::Utils::append_to_file("$crl_tempdir/index.txt", ''); +PostgreSQL::Test::Utils::append_to_file("$crl_tempdir/serial.txt", "2000\n"); + +(my $crl_ca_crt_fwd = $client_ca_crt) =~ s{\\}{/}g; +(my $crl_ca_key_fwd = $client_ca_key) =~ s{\\}{/}g; +(my $crl_tempdir_fwd = $crl_tempdir) =~ s{\\}{/}g; + +my $crl_ca_config = <background_psql( + 'certdb', + connstr => $connstr3, + on_error_stop => 0); + +($stdout, $ret) = $session3->query('SELECT 1 AS success;'); +like($stdout, qr/1/, 'cert session works before the certificate is revoked'); +is($ret, 0, 'no error on initial query for the revocation test session'); + +# 3b. Revoke that certificate and publish a fresh CRL. +PostgreSQL::Test::Utils::system_or_bail( + 'openssl', 'ca', + '-config' => "$crl_tempdir/ca.config", + '-name' => 'myca', + '-revoke' => 'ssl/client.crt', + '-cert' => $client_ca_crt, + '-keyfile' => $client_ca_key); + +PostgreSQL::Test::Utils::system_or_bail( + 'openssl', 'ca', + '-config' => "$crl_tempdir/ca.config", + '-name' => 'myca', + '-gencrl', + '-cert' => $client_ca_crt, + '-keyfile' => $client_ca_key, + '-out' => "$crl_tempdir/root.crl"); + +$node->append_conf('postgresql.conf', + "ssl_crl_file = '$crl_tempdir_fwd/root.crl'\n"); +$node->reload; + +# Only inspect log content generated from this point on, so this assertion +# can't be satisfied by Test 1's/Test 2's already-logged FATAL/DETAIL lines. +# Taken *before* the sleep: credential validation now also runs while the +# session sits idle waiting for the next message, so the FATAL can already +# be logged during the sleep below, before another command is ever sent. +my $test3_log_offset = -s $node->logfile; + +# 3c. Wait for the next credential re-validation cycle to notice. +note + "waiting $validation_interval seconds for credential validation to notice the revocation..."; +sleep($validation_interval + 2); + +eval { $session3->query('SELECT 2 AS failure_expected;'); }; + +$log_contents = slurp_file($node->logfile, $test3_log_offset); +like( + $log_contents, + qr/FATAL:.*session credentials have expired/, + 'cert session terminated after the client certificate was revoked via CRL' +); +like( + $log_contents, + qr/DETAIL:.*client certificate check failed for user "ssltestuser": certificate has been revoked/, + 'server log DETAIL distinguishes revocation from expiry'); + +eval { $session3->quit; }; + +$node->stop; +done_testing(); -- 2.52.0