From 992794a16430005adc9b688aa629ed020b1578df Mon Sep 17 00:00:00 2001
From: Ajit Awekar <ajit.awekar@enterprisedb.com>
Date: Tue, 16 Jun 2026 14:13:05 +0530
Subject: [PATCH 2/3] Add TLS client certificate expiry to credential
 validation

Register a method-specific validator for certificate-authenticated sessions
(CVT_CERT).  The client certificate presented at connection time is retained
on the Port, so its notAfter date can be re-checked locally with no network
round-trip; once the certificate has expired the session is terminated at the
next validation cycle.

be_tls_get_peer_cert_expired() reports whether the peer certificate's validity
period has passed, conservatively treating an absent or unparsable notAfter as
not expired.
---
 src/backend/libpq/auth-validate-methods.c     |  42 ++++-
 src/backend/libpq/be-secure-openssl.c         |  29 ++++
 src/include/libpq/libpq-be.h                  |   6 +
 src/test/ssl/meson.build                      |   1 +
 .../ssl/t/005_cert_continuous_validation.pl   | 159 ++++++++++++++++++
 5 files changed, 236 insertions(+), 1 deletion(-)
 create mode 100644 src/test/ssl/t/005_cert_continuous_validation.pl

diff --git a/src/backend/libpq/auth-validate-methods.c b/src/backend/libpq/auth-validate-methods.c
index f371a36906a..180d37263ad 100644
--- a/src/backend/libpq/auth-validate-methods.c
+++ b/src/backend/libpq/auth-validate-methods.c
@@ -21,17 +21,28 @@
 
 #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 the method-specific validators.  Role-level validity
+	 * (rolvaliduntil and role existence) is checked for every authenticated
+	 * session by ValidateRoleValidity(), so password-based methods need no
+	 * separate validator of their own.
+	 */
+	RegisterCredentialValidator(CVT_CERT, validate_cert_credentials);
 }
 
 /*
@@ -73,3 +84,32 @@ ValidateRoleValidity(void)
 	ReleaseSysCache(tuple);
 	return result;
 }
+
+/*
+ * Validate TLS client certificate credentials.
+ *
+ * The client certificate presented at connection time is retained on the
+ * Port for the lifetime of the session, so its validity period can be
+ * re-checked cheaply without any network round-trip.  Returns false if the
+ * certificate's notAfter date has passed, true otherwise.
+ *
+ * If the session is not using a client certificate (which should not happen
+ * for a cert-authenticated session), there is nothing certificate-specific to
+ * validate, so the credentials are considered 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))
+		return false;
+#endif
+
+	return true;
+}
diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c
index 7890e6c2de2..96eb20d184e 100644
--- a/src/backend/libpq/be-secure-openssl.c
+++ b/src/backend/libpq/be-secure-openssl.c
@@ -2265,6 +2265,35 @@ be_tls_get_peer_serial(Port *port, char *ptr, size_t len)
 		ptr[0] = '\0';
 }
 
+/*
+ * Report whether the client's certificate has passed its notAfter date.
+ *
+ * Returns true if a peer certificate is present and its validity period has
+ * already ended (i.e. the certificate is expired as of now), false otherwise.
+ * If no peer certificate is present, or the notAfter field cannot be parsed,
+ * the certificate is conservatively treated as not expired so that callers do
+ * not terminate a session on the basis of 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 if the supplied time is in the past
+	 * relative to now, 1 if it is in the future, and 0 on a parse error.  A
+	 * notAfter that lies in the past means the certificate has expired.
+	 */
+	return (X509_cmp_current_time(not_after) < 0);
+}
+
 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..aa007da958c 100644
--- a/src/include/libpq/libpq-be.h
+++ b/src/include/libpq/libpq-be.h
@@ -321,6 +321,12 @@ 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);
+
 /*
  * 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..31c495d48d4
--- /dev/null
+++ b/src/test/ssl/t/005_cert_continuous_validation.pl
@@ -0,0 +1,159 @@
+# 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" database; regardless of the base auth method passed here, the
+# generated HBA always serves "certdb" with "cert" (client-certificate)
+# authentication, which is what this test connects with.
+$ssl_server->configure_test_server_for_ssl($node, $SERVERHOSTADDR,
+	$SERVERHOSTCIDR, 'trust');
+$ssl_server->switch_server_cert($node, certfile => 'server-cn-only');
+
+# 3. Mint a short-lived client certificate for CN=ssltestuser, signed by the
+# committed test client CA.  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.
+my $tempdir = PostgreSQL::Test::Utils::tempdir();
+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');
+
+# 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");
+
+my $ca_config = <<EOF;
+[ short_client_ca ]
+certificate   = $client_ca_crt
+private_key   = $client_ca_key
+database      = $tempdir/index.txt
+serial        = $tempdir/serial.txt
+new_certs_dir = $tempdir/newcerts
+default_md    = sha256
+default_days  = 1
+policy        = policy_match
+email_in_dn   = no
+unique_subject = no
+
+[ policy_match ]
+commonName = supplied
+EOF
+PostgreSQL::Test::Utils::append_to_file("$tempdir/ca.config", $ca_config);
+
+# notBefore is set well in the past to avoid any clock-skew rejection at
+# connect time; notAfter is a few seconds out so the certificate expires while
+# the session is alive.
+my $now = time();
+my $startdate = strftime("%Y%m%d%H%M%SZ", gmtime($now - 300));
+my $enddate = strftime("%Y%m%d%H%M%SZ", gmtime($now + $cert_validity_secs));
+
+PostgreSQL::Test::Utils::system_or_bail(
+	'openssl', 'req', '-new',
+	'-key' => $client_key,
+	'-subj' => '/CN=ssltestuser',
+	'-out' => "$tempdir/short.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/short.csr",
+	'-out' => "$tempdir/short.crt");
+
+# libpq refuses a group/world-readable private key, so use a 0600 copy.
+copy($client_key, "$tempdir/short.key")
+  or die "could not copy client key: $!";
+chmod 0600, "$tempdir/short.key"
+  or die "could not chmod client key: $!";
+
+# 4. Open a persistent session authenticated with the short-lived certificate.
+my $connstr =
+	"host=$SERVERHOSTADDR port=" . $node->port . " dbname=certdb "
+  . "user=ssltestuser sslmode=verify-ca "
+  . "sslrootcert=ssl/root+server_ca.crt "
+  . "sslcert=$tempdir/short.crt sslkey=$tempdir/short.key";
+
+my $session = $node->background_psql(
+	'certdb',
+	connstr => $connstr,
+	on_error_stop => 0);
+
+# The certificate is still valid, so the session works normally.
+my ($stdout, $ret) = $session->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');
+
+# 5. Wait until the certificate has expired and at least one further
+# validation interval has elapsed, so the server detects the expiry.
+my $wait = $cert_validity_secs + $validation_interval + 2;
+note "waiting $wait seconds for the client certificate to expire...";
+sleep($wait);
+
+# 6. The next query should find the session terminated.
+eval { $session->query('SELECT 2 AS failure_expected;'); };
+
+my $log_contents = slurp_file($node->logfile);
+like(
+	$log_contents,
+	qr/FATAL:.*session credentials have expired/,
+	'cert session terminated after the client certificate expired');
+
+eval { $session->quit; };
+
+$node->stop;
+done_testing();
-- 
2.52.0

