From a58e5cbfe5eb7924d16a4b0160e22bd702be64c2 Mon Sep 17 00:00:00 2001 From: Ajit Awekar 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 = < $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