From 32d7c59f16eac665800e5f131b06c14e76fac248 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Fri, 13 Nov 2026 12:47:42 +0000
Subject: [PATCH] [#1287] Complete large TLS handshakes without blocking writes (#1290)

---
 opendj-core/src/main/java/org/forgerock/opendj/ldap/LDAPClientContext.java                            |    4 
 opendj-grizzly/src/test/java/org/forgerock/opendj/grizzly/GrizzlyLDAPListenerTestCase.java            |  623 ++++++++++++++++++++++++++++++++++++++++++++
 opendj-grizzly/src/main/java/org/forgerock/opendj/grizzly/LDAPServerFilter.java                       |  117 ++++++++
 opendj-server-legacy/src/messages/org/opends/messages/protocol.properties                             |    4 
 opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-log-messages.adoc                       |    9 
 opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java           |    3 
 opendj-server-legacy/src/test/java/org/opends/server/protocols/ldap/LDAPConnectionLimitsTestCase.java |   60 ++++
 7 files changed, 809 insertions(+), 11 deletions(-)

diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldap/LDAPClientContext.java b/opendj-core/src/main/java/org/forgerock/opendj/ldap/LDAPClientContext.java
index c1511c3..27e6298 100644
--- a/opendj-core/src/main/java/org/forgerock/opendj/ldap/LDAPClientContext.java
+++ b/opendj-core/src/main/java/org/forgerock/opendj/ldap/LDAPClientContext.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2010 Sun Microsystems, Inc.
  * Portions copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 
 package org.forgerock.opendj.ldap;
@@ -146,7 +147,8 @@
      *            Must be {@code true} if the TLS filter has to be installed as a consequence of a StartTLS request
      *            performed by a client. When {@code true} the TLS filter will be installed atomically after the first
      *            message sent to prevent race-condition.
-     * @return {@code true} if the TLS filter has been enabled, {@code false} if it was already enabled.
+     * @return {@code true} if the TLS filter has been enabled, or {@code false} if it was already enabled, an upgrade
+     *         is pending, or the connection is closed.
      * @throws NullPointerException
      *             if sslEngine is null
      */
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-log-messages.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-log-messages.adoc
index 546eebb..4e3205d 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-log-messages.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-log-messages.adoc
@@ -12,7 +12,7 @@
   information: "Portions copyright [year] [name of copyright owner]".
  
   Copyright 2017 ForgeRock AS.
-  Portions Copyright 2024-2026 3A Systems LLC.
+  Portions Copyright 2024-2026 3A Systems, LLC.
 ////
 
 :figure-caption!:
@@ -12093,6 +12093,13 @@
 +
 Message: Use of the proxied authorization V2 control for user %s is not allowed: the account's password is expired.
 
+[#log-ref-log-ref-ERR_LDAP_TLS_UPGRADE_UNAVAILABLE_1544]
+ID: 1544::
+Severity: ERROR
+
++
+Message: StartTLS cannot be enabled because the connection is closed, TLS is already enabled, or a TLS upgrade is pending.
+
 --
 
 
diff --git a/opendj-grizzly/src/main/java/org/forgerock/opendj/grizzly/LDAPServerFilter.java b/opendj-grizzly/src/main/java/org/forgerock/opendj/grizzly/LDAPServerFilter.java
index 12ff173..697a049 100644
--- a/opendj-grizzly/src/main/java/org/forgerock/opendj/grizzly/LDAPServerFilter.java
+++ b/opendj-grizzly/src/main/java/org/forgerock/opendj/grizzly/LDAPServerFilter.java
@@ -31,10 +31,13 @@
 import java.util.List;
 import java.util.Properties;
 import java.util.concurrent.CancellationException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 
 import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLEngineResult.HandshakeStatus;
 import javax.net.ssl.SSLSession;
 import javax.security.sasl.Sasl;
 import javax.security.sasl.SaslServer;
@@ -65,6 +68,7 @@
 import org.forgerock.util.promise.PromiseImpl;
 import org.forgerock.util.promise.ResultHandler;
 import org.forgerock.util.promise.RuntimeExceptionHandler;
+import org.glassfish.grizzly.Buffer;
 import org.glassfish.grizzly.Connection;
 import org.glassfish.grizzly.EmptyCompletionHandler;
 import org.glassfish.grizzly.filterchain.BaseFilter;
@@ -73,6 +77,7 @@
 import org.glassfish.grizzly.filterchain.FilterChainBuilder;
 import org.glassfish.grizzly.filterchain.FilterChainContext;
 import org.glassfish.grizzly.filterchain.NextAction;
+import org.glassfish.grizzly.ssl.SSLBaseFilter.HandshakeListener;
 import org.glassfish.grizzly.ssl.SSLContextConfigurator;
 import org.glassfish.grizzly.ssl.SSLFilter;
 import org.glassfish.grizzly.ssl.SSLUtils;
@@ -124,6 +129,21 @@
 
     private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
+    /** One daemon timer for handshakes on all listeners; completed connections leave its queue immediately. */
+    private static final ScheduledThreadPoolExecutor HANDSHAKE_TIMER = newHandshakeTimer();
+
+    private static ScheduledThreadPoolExecutor newHandshakeTimer() {
+        final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1, task -> {
+            final Thread thread = new Thread(task, "OpenDJ LDAP TLS handshake timer");
+            thread.setDaemon(true);
+            return thread;
+        });
+        timer.setRemoveOnCancelPolicy(true);
+        timer.setKeepAliveTime(30, TimeUnit.SECONDS);
+        timer.allowCoreThreadTimeOut(true);
+        return timer;
+    }
+
     private final int maxConcurrentRequests;
     private final Options connectionOptions;
 
@@ -312,6 +332,10 @@
 
         private final Connection<?> connection;
         private volatile boolean isClosed;
+        /** Guarded by this context, including the race between handshake completion and expiry. */
+        private ScheduledFuture<?> handshakeDeadline;
+        private long handshakeDeadlineGeneration;
+        private boolean initialHandshakePending;
         private final List<LDAPClientContextEventListener> connectionEventListeners = new LinkedList<>();
         private GrizzlyBackpressureSubscription downstream;
 
@@ -339,6 +363,7 @@
         @Override
         public NextAction handleClose(final FilterChainContext ctx) {
             isClosed = true;
+            cancelHandshakeDeadline();
             // downstream can be null if the connection is closed before handleAccept() completion.
             final GrizzlyBackpressureSubscription immutableRef = downstream;
             if (immutableRef != null) {
@@ -386,7 +411,7 @@
         public boolean enableTLS(final SSLEngine sslEngine, final boolean startTls) {
             Reject.ifNull(sslEngine, "sslEngine must not be null");
             synchronized (this) {
-                if (filterExists(SSLFilter.class)) {
+                if (!connection.isOpen() || filterExists(SSLFilter.class) || filterExists(StartTLSFilter.class)) {
                     return false;
                 }
                 SSLUtils.setSSLEngine(connection, sslEngine);
@@ -399,13 +424,97 @@
                 	System.setProperty(SSLContextConfigurator.TRUST_STORE_FILE, "NONE");
                 }
 
-                SSLFilter sslFilter = new SSLFilter();
-                sslFilter.setHandshakeTimeout(getLongProperty("org.forgerock.opendj.grizzly.handshakeTimeout", 10000), TimeUnit.MILLISECONDS);
-                installFilter(startTls ? new StartTLSFilter(sslFilter) : sslFilter);
+                // Grizzly otherwise sees this externally supplied engine as NOT_HANDSHAKING on the first read,
+                // starts it implicitly while unwrapping, and enters its blocking re-handshake path. A partial write
+                // can then wait for the selector thread that is itself blocked in that handshake.
+                final long timeout = getLongProperty("org.forgerock.opendj.grizzly.handshakeTimeout", 10000);
+                initialHandshakePending = true;
+                final SSLFilter sslFilter = new SSLFilter() {
+                    private boolean firstRead = true;
+                    private boolean receivedFirstRecord;
+
+                    @Override
+                    public NextAction handleRead(final FilterChainContext ctx) throws IOException {
+                        if (firstRead) {
+                            firstRead = false;
+                            if (sslEngine.getHandshakeStatus() == HandshakeStatus.NOT_HANDSHAKING) {
+                                sslEngine.beginHandshake();
+                            }
+                        }
+                        if (timeout > 0) {
+                            if (!receivedFirstRecord) {
+                                final Buffer input = ctx.getMessage();
+                                final int packetSize = SSLUtils.getSSLPacketSize(input);
+                                receivedFirstRecord = packetSize > 0 && input.remaining() >= packetSize;
+                            }
+                            if (receivedFirstRecord) {
+                                renewHandshakeDeadline(timeout);
+                            }
+                        }
+                        return super.handleRead(ctx);
+                    }
+                };
+                // A negative Grizzly timeout selects non-blocking handshake processing. Preserve its configured
+                // read timeout separately, starting when the first complete TLS record arrives and renewing on reads.
+                sslFilter.setHandshakeTimeout(-1, TimeUnit.MILLISECONDS);
+                sslFilter.addHandshakeListener(new HandshakeListener() {
+                    @Override
+                    public void onStart(Connection<?> ignored) {
+                        // The externally supplied engine is started on the first read.
+                    }
+
+                    @Override
+                    public void onComplete(Connection<?> ignored) {
+                        cancelHandshakeDeadline();
+                        // Grizzly also uses this timeout for later re-handshakes. They still take its blocking
+                        // path, so restore their read timeout once the initial, asynchronous handshake is done.
+                        sslFilter.setHandshakeTimeout(timeout, TimeUnit.MILLISECONDS);
+                    }
+
+                    @Override
+                    public void onFailure(Connection<?> ignored, Throwable error) {
+                        cancelHandshakeDeadline();
+                    }
+                });
+                try {
+                    installFilter(startTls ? new StartTLSFilter(sslFilter) : sslFilter);
+                } catch (RuntimeException | Error e) {
+                    cancelHandshakeDeadline();
+                    throw e;
+                }
                 return true;
             }
         }
 
+        private synchronized void renewHandshakeDeadline(final long timeout) {
+            if (!initialHandshakePending) {
+                return;
+            }
+            if (handshakeDeadline != null) {
+                handshakeDeadline.cancel(false);
+            }
+            final long generation = ++handshakeDeadlineGeneration;
+            handshakeDeadline = HANDSHAKE_TIMER.schedule(() -> {
+                synchronized (ClientConnectionImpl.this) {
+                    if (!initialHandshakePending || generation != handshakeDeadlineGeneration) {
+                        return;
+                    }
+                    initialHandshakePending = false;
+                    handshakeDeadline = null;
+                }
+                connection.terminateSilently();
+            }, timeout, TimeUnit.MILLISECONDS);
+        }
+
+        private synchronized void cancelHandshakeDeadline() {
+            initialHandshakePending = false;
+            handshakeDeadlineGeneration++;
+            if (handshakeDeadline != null) {
+                handshakeDeadline.cancel(false);
+                handshakeDeadline = null;
+            }
+        }
+
         @Override
         public SSLSession getSSLSession() {
             final SSLEngine sslEngine = SSLUtils.getSSLEngine(connection);
diff --git a/opendj-grizzly/src/test/java/org/forgerock/opendj/grizzly/GrizzlyLDAPListenerTestCase.java b/opendj-grizzly/src/test/java/org/forgerock/opendj/grizzly/GrizzlyLDAPListenerTestCase.java
index 3d378c0..3a4317d 100644
--- a/opendj-grizzly/src/test/java/org/forgerock/opendj/grizzly/GrizzlyLDAPListenerTestCase.java
+++ b/opendj-grizzly/src/test/java/org/forgerock/opendj/grizzly/GrizzlyLDAPListenerTestCase.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
- * Portions Copyright 2017-2026 3A Systems, LLC
+ * Portions Copyright 2017-2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.grizzly;
 
@@ -28,18 +28,48 @@
 import static org.forgerock.util.Options.defaultOptions;
 import static org.mockito.Mockito.mock;
 
+import java.io.DataInputStream;
 import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.lang.management.ThreadInfo;
 import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.math.BigInteger;
 import java.net.InetSocketAddress;
 import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Date;
 import java.util.List;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.logging.Level;
 
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLEngineResult;
+import javax.net.ssl.SSLSocket;
+import javax.security.auth.x500.X500Principal;
+
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
 import org.forgerock.opendj.ldap.Connection;
 import org.forgerock.opendj.ldap.ConnectionException;
 import org.forgerock.opendj.ldap.ConnectionFactory;
@@ -55,9 +85,11 @@
 import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.SdkTestCase;
 import org.forgerock.opendj.ldap.SearchResultHandler;
+import org.forgerock.opendj.ldap.SSLContextBuilder;
 import org.forgerock.opendj.ldap.ServerConnection;
 import org.forgerock.opendj.ldap.ServerConnectionFactory;
 import org.forgerock.opendj.ldap.TestCaseUtils;
+import org.forgerock.opendj.ldap.TrustManagers;
 import org.forgerock.opendj.ldap.requests.AbandonRequest;
 import org.forgerock.opendj.ldap.requests.AddRequest;
 import org.forgerock.opendj.ldap.requests.BindRequest;
@@ -77,8 +109,10 @@
 import org.forgerock.util.promise.PromiseImpl;
 import org.glassfish.grizzly.nio.transport.TCPNIOTransport;
 import org.glassfish.grizzly.nio.transport.TCPNIOTransportBuilder;
+import org.glassfish.grizzly.strategies.SameThreadIOStrategy;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import com.forgerock.reactive.ServerConnectionFactoryAdapter;
@@ -374,6 +408,593 @@
         }
     }
 
+    @DataProvider
+    public Object[][] tlsProtocols() {
+        return new Object[][] { { "TLSv1.2", false, 180 }, { "TLSv1.3", false, 180 },
+                { "TLSv1.2", true, 180 }, { "TLSv1.3", true, 180 },
+                { "TLSv1.2", false, 600 }, { "TLSv1.3", true, 600 } };
+    }
+
+    /** A TLS flight larger than the configured write buffer must reach the client before it waits for a reply. */
+    @Test(dataProvider = "tlsProtocols", timeOut = 20000)
+    public void testLargeCertificateHandshakeWithSmallWriteBuffer(final String protocol, final boolean startTls,
+            final int nameCount) throws Exception {
+        final SSLContext serverSSLContext = largeCertificateContext(nameCount);
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        try {
+            final MockServerConnection serverConnection = new MockServerConnection() {
+                @Override
+                public <R extends ExtendedResult> void handleExtendedRequest(final Integer requestContext,
+                        final ExtendedRequest<R> request, final IntermediateResponseHandler intermediateResponseHandler,
+                        final LdapResultHandler<R> resultHandler) {
+                    try {
+                        enableServerTLS(context.get(10, TimeUnit.SECONDS), serverSSLContext, protocol, true);
+                        resultHandler.handleResult(request.getResultDecoder().newExtendedErrorResult(
+                                ResultCode.SUCCESS, "", ""));
+                    } catch (Exception e) {
+                        resultHandler.handleException(newLdapException(ResultCode.OTHER, e.getMessage(), e));
+                    }
+                }
+            };
+            final MockServerConnectionFactory serverFactory = new MockServerConnectionFactory(serverConnection) {
+                @Override
+                public ServerConnection<Integer> handleAccept(final LDAPClientContext context) throws LdapException {
+                    if (!startTls) {
+                        enableServerTLS(context, serverSSLContext, protocol, false);
+                    }
+                    return super.handleAccept(context);
+                }
+            };
+            final Options options = defaultOptions().set(GrizzlyLDAPListener.GRIZZLY_TRANSPORT, transport);
+            final LDAPListener listener = new LDAPListener(Collections.singleton(loopbackWithDynamicPort()),
+                    new ServerConnectionFactoryAdapter(options.get(LDAP_DECODE_OPTIONS), serverFactory), options);
+            try {
+                final InetSocketAddress address = listener.firstSocketAddress();
+                final SSLContext clientContext = new SSLContextBuilder().setTrustManager(TrustManagers.trustAll())
+                        .getSSLContext();
+                try (Socket plain = new Socket(address.getAddress(), address.getPort())) {
+                    plain.setSoTimeout(7000);
+                    if (startTls) {
+                        startTLS(plain, (byte) 1);
+                    }
+                    try (SSLSocket socket = (SSLSocket) clientContext.getSocketFactory().createSocket(
+                            plain, "localhost", address.getPort(), false)) {
+                        socket.setEnabledProtocols(new String[] { protocol });
+                        socket.setSoTimeout(7000);
+                        socket.startHandshake();
+                        bindAnonymously(socket);
+                    }
+                }
+            } finally {
+                listener.close();
+            }
+        } finally {
+            transport.shutdownNow();
+        }
+    }
+
+    private static boolean enableServerTLS(LDAPClientContext context, SSLContext sslContext, String protocol,
+            boolean startTls) {
+        final SSLEngine engine = sslContext.createSSLEngine();
+        engine.setUseClientMode(false);
+        engine.setEnabledProtocols(new String[] { protocol });
+        return context.enableTLS(engine, startTls);
+    }
+
+    /** The asynchronous handshake must not leave a client which sends only part of its ClientHello connected. */
+    @Test(timeOut = 20000)
+    public void testIncompleteHandshakeIsClosedAfterTheConfiguredTimeout() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "1200");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            final long connected = System.nanoTime();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                serverConnection.context.get(5, TimeUnit.SECONDS);
+                // A complete TLS record containing an incomplete ClientHello.
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x01, 0x00 });
+                socket.setSoTimeout(5000);
+                org.testng.Assert.assertEquals(socket.getInputStream().read(), -1, "incomplete handshake stayed open");
+                org.testng.Assert.assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - connected) >= 800,
+                        "the handshake was closed before its timeout");
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    /** The timeout must not start before the first complete TLS record is received. */
+    @Test(timeOut = 20000)
+    public void testDelayedClientHelloCanCompleteHandshake() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "1200");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            try (Socket plain = new Socket(address.getAddress(), address.getPort())) {
+                serverConnection.context.get(5, TimeUnit.SECONDS);
+                Thread.sleep(2000);
+                final SSLContext client = new SSLContextBuilder().setTrustManager(TrustManagers.trustAll())
+                        .getSSLContext();
+                try (SSLSocket socket = (SSLSocket) client.getSocketFactory().createSocket(
+                        plain, address.getHostString(), address.getPort(), true)) {
+                    socket.setSoTimeout(5000);
+                    socket.startHandshake();
+                    bindAnonymously(socket);
+                }
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    /** A partially received first record does not start Grizzly's handshake read timeout. */
+    @Test(timeOut = 20000)
+    public void testPartialFirstRecordDoesNotStartHandshakeTimeout() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "1200");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                serverConnection.context.get(5, TimeUnit.SECONDS);
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x04, 0x01 });
+                Thread.sleep(2000);
+                socket.getOutputStream().write(new byte[] { 0x00, 0x01, 0x00 });
+                socket.setSoTimeout(5000);
+                final long lastWrite = System.nanoTime();
+                org.testng.Assert.assertEquals(socket.getInputStream().read(), -1, "idle handshake stayed open");
+                org.testng.Assert.assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastWrite) >= 800,
+                        "the timeout started before the first complete TLS record");
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    /** Progress on a fragmented handshake renews the timeout for the next read. */
+    @Test(timeOut = 20000)
+    public void testHandshakeTimeoutIsRenewedByIncomingRecords() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "2000");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                serverConnection.context.get(5, TimeUnit.SECONDS);
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x01, 0x00 });
+                Thread.sleep(750);
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x01, 0x03 });
+                Thread.sleep(750);
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x01, 0x03 });
+                socket.setSoTimeout(1000);
+                try {
+                    socket.getInputStream().read();
+                    fail("the progressing handshake was closed prematurely");
+                } catch (SocketTimeoutException expected) {
+                    // The total elapsed time exceeds the timeout, but the last read was recent.
+                }
+                socket.setSoTimeout(5000);
+                org.testng.Assert.assertEquals(socket.getInputStream().read(), -1, "idle handshake stayed open");
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    /** An expiry which has started before a renewal must not close the renewed handshake. */
+    @Test(timeOut = 20000)
+    public void testStartedHandshakeExpiryDoesNotCloseRenewedConnection() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance().build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                final LDAPClientContext context = serverConnection.context.get(5, TimeUnit.SECONDS);
+                final Class<?> contextClass = context.getClass();
+                final Method renew = contextClass.getDeclaredMethod("renewHandshakeDeadline", long.class);
+                final Field deadlineField = contextClass.getDeclaredField("handshakeDeadline");
+                final Field connectionField = contextClass.getDeclaredField("connection");
+                renew.setAccessible(true);
+                deadlineField.setAccessible(true);
+                connectionField.setAccessible(true);
+                final org.glassfish.grizzly.Connection<?> connection =
+                        (org.glassfish.grizzly.Connection<?>) connectionField.get(context);
+                final Thread expiry;
+                synchronized (context) {
+                    // Run the actual scheduled task early, then hold it at the context monitor while renewing.
+                    renew.invoke(context, 60000L);
+                    final ScheduledFuture<?> oldDeadline = (ScheduledFuture<?>) deadlineField.get(context);
+                    expiry = new Thread((Runnable) oldDeadline, "test-started-handshake-expiry");
+                    expiry.setDaemon(true);
+                    expiry.start();
+                    final long waitUntil = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+                    while (expiry.getState() != Thread.State.BLOCKED && System.nanoTime() < waitUntil) {
+                        Thread.sleep(5);
+                    }
+                    org.testng.Assert.assertEquals(expiry.getState(), Thread.State.BLOCKED,
+                            "the expiry did not start and wait for the context monitor");
+                    final ThreadInfo blocked = ManagementFactory.getThreadMXBean().getThreadInfo(expiry.getId());
+                    org.testng.Assert.assertNotNull(blocked.getLockInfo(), "the expiry is not waiting on a monitor");
+                    org.testng.Assert.assertEquals(blocked.getLockInfo().getIdentityHashCode(),
+                            System.identityHashCode(context), "the expiry is blocked on a different monitor");
+                    renew.invoke(context, 5000L);
+                    org.testng.Assert.assertTrue(oldDeadline.isCancelled(), "the previous deadline was not cancelled");
+                }
+                expiry.join(5000);
+                org.testng.Assert.assertFalse(expiry.isAlive(), "the previous expiry did not finish");
+                org.testng.Assert.assertTrue(connection.isOpen(), "the previous expiry closed the renewed handshake");
+                socket.setSoTimeout(10000);
+                org.testng.Assert.assertEquals(socket.getInputStream().read(), -1, "the renewed deadline did not fire");
+            }
+        } finally {
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    @DataProvider
+    public Object[][] disabledHandshakeTimeouts() {
+        return new Object[][] { { "0" }, { "-1" } };
+    }
+
+    /** Grizzly's zero and negative timeout values leave a pending handshake without a deadline. */
+    @Test(dataProvider = "disabledHandshakeTimeouts", timeOut = 20000)
+    public void testDisabledHandshakeTimeoutKeepsAnIncompleteHandshake(final String timeout) throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, timeout);
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                serverConnection.context.get(5, TimeUnit.SECONDS);
+                // A complete record with an incomplete ClientHello reaches the deadline code.
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x01, 0x00 });
+                socket.setSoTimeout(1500);
+                try {
+                    socket.getInputStream().read();
+                    fail("the incomplete handshake closed with timeout " + timeout);
+                } catch (SocketTimeoutException expected) {
+                    // The connection is still open.
+                }
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    @DataProvider
+    public Object[][] completedHandshakeProtocols() {
+        return new Object[][] { { "TLSv1.2" }, { "TLSv1.3" } };
+    }
+
+    /** Completing the handshake cancels its timeout, so it cannot close a subsequently active LDAP connection. */
+    @Test(dataProvider = "completedHandshakeProtocols", timeOut = 30000)
+    public void testCompletedHandshakeIsNotClosedByItsFormerTimeout(final String protocol) throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection, protocol);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "5000");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            final SSLContext clientContext = new SSLContextBuilder().setTrustManager(TrustManagers.trustAll())
+                    .getSSLContext();
+            try (SSLSocket socket = (SSLSocket) clientContext.getSocketFactory().createSocket(
+                    address.getAddress(), address.getPort())) {
+                socket.setSoTimeout(8000);
+                socket.startHandshake();
+                socket.setSoTimeout(6000);
+                try {
+                    socket.getInputStream().read();
+                    fail("the completed connection was closed by the handshake timeout");
+                } catch (SocketTimeoutException expected) {
+                    // The connection remained open past the deadline without any LDAP traffic.
+                }
+                bindAnonymously(socket);
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    /** A TLS upgrade must not be accepted after the client connection has closed. */
+    @Test(timeOut = 20000)
+    public void testEnableTlsRefusesAClosedConnection() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance().build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final Options options = defaultOptions().set(GrizzlyLDAPListener.GRIZZLY_TRANSPORT, transport);
+        final LDAPListener listener = new LDAPListener(Collections.singleton(loopbackWithDynamicPort()),
+                new ServerConnectionFactoryAdapter(options.get(LDAP_DECODE_OPTIONS),
+                        new MockServerConnectionFactory(serverConnection)), options);
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            final LDAPClientContext clientContext;
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                clientContext = serverConnection.context.get(5, TimeUnit.SECONDS);
+            }
+            final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+            while (!clientContext.isClosed() && System.nanoTime() < deadline) {
+                Thread.sleep(10);
+            }
+            org.testng.Assert.assertTrue(clientContext.isClosed(), "the server did not see the client close");
+            org.testng.Assert.assertFalse(enableServerTLS(clientContext, serverContext, "TLSv1.2", true),
+                    "enableTLS accepted a closed connection");
+        } finally {
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    /** A stalled TLS 1.2 renegotiation must retain the configured Grizzly read timeout. */
+    @Test(timeOut = 30000)
+    public void testIncompleteRenegotiationTimesOut() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection();
+        final LDAPListener listener = secureListener(transport, serverContext, serverConnection);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "1200");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            final SSLContext clientContext = new SSLContextBuilder().setTrustManager(TrustManagers.trustAll())
+                    .getSSLContext();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                socket.setSoTimeout(5000);
+                final SSLEngine engine = clientContext.createSSLEngine("localhost", address.getPort());
+                engine.setUseClientMode(true);
+                engine.setEnabledProtocols(new String[] { "TLSv1.2" });
+                finishClientHandshake(engine, socket);
+
+                engine.beginHandshake();
+                final ByteBuffer hello = ByteBuffer.allocate(engine.getSession().getPacketBufferSize());
+                final SSLEngineResult result = engine.wrap(ByteBuffer.allocate(0), hello);
+                org.testng.Assert.assertEquals(result.getStatus(), SSLEngineResult.Status.OK);
+                org.testng.Assert.assertTrue(hello.position() > 0, "no renegotiation ClientHello was produced");
+                final long started = System.nanoTime();
+                socket.getOutputStream().write(hello.array(), 0, hello.position());
+                // Drain the server's flight, but do not send the rest of the client's handshake.
+                int received = 0;
+                while (socket.getInputStream().read() != -1) {
+                    received++;
+                }
+                org.testng.Assert.assertTrue(received > 0, "the server did not start renegotiation");
+                org.testng.Assert.assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) >= 800,
+                        "the server closed before the renegotiation timeout");
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    private static void finishClientHandshake(SSLEngine engine, Socket socket) throws Exception {
+        final ByteBuffer input = ByteBuffer.allocate(65536);
+        final ByteBuffer output = ByteBuffer.allocate(65536);
+        final ByteBuffer application = ByteBuffer.allocate(65536);
+        final ByteBuffer empty = ByteBuffer.allocate(0);
+        final DataInputStream in = new DataInputStream(socket.getInputStream());
+        engine.beginHandshake();
+        while (engine.getHandshakeStatus() != SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING) {
+            switch (engine.getHandshakeStatus()) {
+            case NEED_TASK:
+                Runnable task;
+                while ((task = engine.getDelegatedTask()) != null) {
+                    task.run();
+                }
+                break;
+            case NEED_WRAP:
+                output.clear();
+                final SSLEngineResult wrapped = engine.wrap(empty, output);
+                org.testng.Assert.assertEquals(wrapped.getStatus(), SSLEngineResult.Status.OK);
+                socket.getOutputStream().write(output.array(), 0, output.position());
+                break;
+            case NEED_UNWRAP:
+                final byte[] header = new byte[5];
+                in.readFully(header);
+                final int length = (header[3] & 0xff) * 256 + (header[4] & 0xff);
+                input.put(header);
+                in.readFully(input.array(), input.position(), length);
+                input.position(input.position() + length);
+                input.flip();
+                final SSLEngineResult unwrapped = engine.unwrap(input, application);
+                input.compact();
+                org.testng.Assert.assertTrue(unwrapped.getStatus() == SSLEngineResult.Status.OK
+                        || unwrapped.getStatus() == SSLEngineResult.Status.BUFFER_UNDERFLOW);
+                break;
+            default:
+                throw new AssertionError("Unexpected handshake status: " + engine.getHandshakeStatus());
+            }
+        }
+    }
+
+    /** StartTLS after an LDAP request still needs a timeout once the TLS handshake has started. */
+    @Test(timeOut = 20000)
+    public void testStartTlsHandshakeTimesOutAfterAnEarlierLdapRequest() throws Exception {
+        final SSLContext serverContext = largeCertificateContext();
+        final TCPNIOTransport transport = TCPNIOTransportBuilder.newInstance()
+                .setIOStrategy(SameThreadIOStrategy.getInstance()).setWriteBufferSize(4096).build();
+        transport.start();
+        final MockServerConnection serverConnection = new MockServerConnection() {
+            @Override
+            public <R extends ExtendedResult> void handleExtendedRequest(final Integer requestContext,
+                    final ExtendedRequest<R> request, final IntermediateResponseHandler intermediateResponseHandler,
+                    final LdapResultHandler<R> resultHandler) {
+                try {
+                    final LDAPClientContext clientContext = context.get(5, TimeUnit.SECONDS);
+                    org.testng.Assert.assertTrue(enableServerTLS(clientContext, serverContext, "TLSv1.2", true));
+                    org.testng.Assert.assertFalse(enableServerTLS(clientContext, serverContext, "TLSv1.2", true),
+                            "a second StartTLS was accepted while the first was pending");
+                    resultHandler.handleResult(request.getResultDecoder().newExtendedErrorResult(
+                            ResultCode.SUCCESS, "", ""));
+                } catch (Exception e) {
+                    resultHandler.handleException(newLdapException(ResultCode.OTHER, e.getMessage(), e));
+                }
+            }
+        };
+        final Options options = defaultOptions().set(GrizzlyLDAPListener.GRIZZLY_TRANSPORT, transport);
+        final LDAPListener listener = new LDAPListener(Collections.singleton(loopbackWithDynamicPort()),
+                new ServerConnectionFactoryAdapter(options.get(LDAP_DECODE_OPTIONS),
+                        new MockServerConnectionFactory(serverConnection)), options);
+        final String property = "org.forgerock.opendj.grizzly.handshakeTimeout";
+        final String previous = System.getProperty(property);
+        System.setProperty(property, "1200");
+        try {
+            final InetSocketAddress address = listener.firstSocketAddress();
+            try (Socket socket = new Socket(address.getAddress(), address.getPort())) {
+                socket.setSoTimeout(5000);
+                bindAnonymously(socket);
+                startTLS(socket, (byte) 2);
+                socket.getOutputStream().write(new byte[] { 0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x01, 0x00 });
+                org.testng.Assert.assertEquals(socket.getInputStream().read(), -1, "StartTLS handshake stayed open");
+            }
+        } finally {
+            restoreProperty(property, previous);
+            listener.close();
+            transport.shutdownNow();
+        }
+    }
+
+    private static void bindAnonymously(Socket socket) throws IOException {
+        socket.getOutputStream().write(new byte[] {
+                0x30, 0x0c, 0x02, 0x01, 0x01, 0x60, 0x07, 0x02, 0x01, 0x03, 0x04, 0x00, (byte) 0x80, 0x00 });
+        final byte[] response = new byte[14];
+        new DataInputStream(socket.getInputStream()).readFully(response);
+        org.testng.Assert.assertEquals(response, new byte[] {
+                0x30, 0x0c, 0x02, 0x01, 0x01, 0x61, 0x07, 0x0a, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00 });
+    }
+
+    private static void startTLS(Socket socket, byte messageId) throws IOException {
+        socket.getOutputStream().write(new byte[] {
+                0x30, 0x1d, 0x02, 0x01, messageId, 0x77, 0x18, (byte) 0x80, 0x16 });
+        socket.getOutputStream().write("1.3.6.1.4.1.1466.20037".getBytes(StandardCharsets.US_ASCII));
+        final byte[] response = new byte[14];
+        new DataInputStream(socket.getInputStream()).readFully(response);
+        org.testng.Assert.assertEquals(response, new byte[] {
+                0x30, 0x0c, 0x02, 0x01, messageId, 0x78, 0x07, 0x0a, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00 });
+    }
+
+    private static LDAPListener secureListener(TCPNIOTransport transport, SSLContext serverContext,
+            MockServerConnection serverConnection) throws IOException {
+        return secureListener(transport, serverContext, serverConnection, "TLSv1.2");
+    }
+
+    private static LDAPListener secureListener(TCPNIOTransport transport, SSLContext serverContext,
+            MockServerConnection serverConnection, String protocol) throws IOException {
+        final MockServerConnectionFactory factory = new MockServerConnectionFactory(serverConnection) {
+            @Override
+            public ServerConnection<Integer> handleAccept(final LDAPClientContext context) throws LdapException {
+                enableServerTLS(context, serverContext, protocol, false);
+                return super.handleAccept(context);
+            }
+        };
+        final Options options = defaultOptions().set(GrizzlyLDAPListener.GRIZZLY_TRANSPORT, transport);
+        return new LDAPListener(Collections.singleton(loopbackWithDynamicPort()),
+                new ServerConnectionFactoryAdapter(options.get(LDAP_DECODE_OPTIONS), factory), options);
+    }
+
+    private static void restoreProperty(String name, String value) {
+        if (value == null) {
+            System.clearProperty(name);
+        } else {
+            System.setProperty(name, value);
+        }
+    }
+
+    private static SSLContext largeCertificateContext() throws Exception {
+        return largeCertificateContext(180);
+    }
+
+    private static SSLContext largeCertificateContext(int nameCount) throws Exception {
+        final KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+        generator.initialize(2048);
+        final KeyPair pair = generator.generateKeyPair();
+        final X500Principal subject = new X500Principal("CN=localhost");
+        final Instant now = Instant.now();
+        final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(subject, BigInteger.ONE,
+                Date.from(now.minus(1, ChronoUnit.DAYS)), Date.from(now.plus(1, ChronoUnit.DAYS)), subject,
+                pair.getPublic());
+        final GeneralName[] names = new GeneralName[nameCount];
+        for (int i = 0; i < names.length; i++) {
+            names[i] = new GeneralName(GeneralName.dNSName, String.format("host-%03d.diagnostic-1287.example.test", i));
+        }
+        builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(names));
+        final X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(
+                builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
+        org.testng.Assert.assertTrue(certificate.getEncoded().length > 6144, "certificate is too small for this test");
+        if (nameCount >= 600) {
+            org.testng.Assert.assertTrue(certificate.getEncoded().length > 16384,
+                    "certificate does not exceed one TLS record");
+        }
+        final KeyStore keyStore = KeyStore.getInstance("JKS");
+        keyStore.load(null, null);
+        final char[] password = "test-password".toCharArray();
+        keyStore.setKeyEntry("server", pair.getPrivate(), password, new X509Certificate[] { certificate });
+        final KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+        factory.init(keyStore, password);
+        final SSLContext context = SSLContext.getInstance("TLS");
+        context.init(factory.getKeyManagers(), null, null);
+        return context;
+    }
+
     /**
      * Tests LDAP listener which attempts to open a connection to a remote
      * offline server at the point when the listener accepts the client
diff --git a/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java b/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java
index 78427f9..533baa7 100644
--- a/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java
+++ b/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java
@@ -44,7 +44,6 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
 
-import javax.net.ssl.SSLEngine;
 import javax.net.ssl.SSLException;
 import javax.net.ssl.SSLPeerUnverifiedException;
 import javax.net.ssl.SSLSession;
@@ -1665,7 +1664,7 @@
         }
         try {
             if (!clientContext.enableTLS(connectionHandler.createSSLEngine(), true)) {
-                unavailableReason.append(ERR_LDAP_TLS_EXISTING_SECURITY_PROVIDER.get(SSLEngine.class.getName()));
+                unavailableReason.append(ERR_LDAP_TLS_UPGRADE_UNAVAILABLE.get());
                 return false;
             }
         } catch (DirectoryException de) {
diff --git a/opendj-server-legacy/src/messages/org/opends/messages/protocol.properties b/opendj-server-legacy/src/messages/org/opends/messages/protocol.properties
index 20cabd6..78efb03 100644
--- a/opendj-server-legacy/src/messages/org/opends/messages/protocol.properties
+++ b/opendj-server-legacy/src/messages/org/opends/messages/protocol.properties
@@ -13,7 +13,7 @@
 # Copyright 2006-2009 Sun Microsystems, Inc.
 # Portions Copyright 2013-2016 ForgeRock AS.
 # Portions copyright 2013-2014 Manuel Gaupp
-# Portions Copyright 2026 3A Systems LLC.
+# Portions Copyright 2026 3A Systems, LLC.
 
 
 #
@@ -453,6 +453,8 @@
 ERR_LDAP_TLS_CANNOT_CREATE_TLS_PROVIDER_273=An error occurred while \
  attempting to create a TLS connection security provider for this client \
  connection for use with StartTLS: %s
+ERR_LDAP_TLS_UPGRADE_UNAVAILABLE_1544=StartTLS cannot be enabled because the \
+ connection is closed, TLS is already enabled, or a TLS upgrade is pending
 NOTE_CONNHANDLER_STARTED_LISTENING_276=Started listening for new \
  connections on %s
 NOTE_CONNHANDLER_STOPPED_LISTENING_277=Stopped listening for new \
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/protocols/ldap/LDAPConnectionLimitsTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/protocols/ldap/LDAPConnectionLimitsTestCase.java
index e89e63d..df5ed8f 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/protocols/ldap/LDAPConnectionLimitsTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/protocols/ldap/LDAPConnectionLimitsTestCase.java
@@ -48,6 +48,7 @@
 import javax.net.ssl.X509TrustManager;
 
 import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.i18n.LocalizableMessageBuilder;
 import org.forgerock.opendj.config.DefinedDefaultBehaviorProvider;
 import org.forgerock.opendj.config.PropertyDefinition;
 import org.forgerock.opendj.ldap.DN;
@@ -512,6 +513,53 @@
     }
   }
 
+  /** A refused TLS upgrade must not claim that TLS is already enabled on a closed connection. */
+  @Test
+  public void closedConnectionHasAnAccurateStartTlsRefusal() throws Exception
+  {
+    final int port = TestCaseUtils.findFreePort();
+    final LDAPConnectionHandler2 handler = start(configuration(port, true));
+    try
+    {
+      final LDAPClientConnection2 connection;
+      try (Socket client = new Socket("127.0.0.1", port))
+      {
+        awaitClientConnections(handler, 1);
+        connection = (LDAPClientConnection2) handler.getClientConnections().iterator().next();
+      }
+      awaitClientConnections(handler, 0);
+      final LocalizableMessageBuilder reason = new LocalizableMessageBuilder();
+      assertFalse(connection.prepareTLS(reason));
+      assertTrue(reason.toString().contains("closed"), reason.toString());
+    }
+    finally
+    {
+      stop(handler);
+    }
+  }
+
+  /** A second StartTLS attempt while the first upgrade is pending is also reported accurately. */
+  @Test
+  public void pendingUpgradeHasAnAccurateStartTlsRefusal() throws Exception
+  {
+    final int port = TestCaseUtils.findFreePort();
+    final LDAPConnectionHandler2 handler = start(configuration(port, true));
+    try (Socket client = new Socket("127.0.0.1", port))
+    {
+      awaitClientConnections(handler, 1);
+      final LDAPClientConnection2 connection =
+          (LDAPClientConnection2) handler.getClientConnections().iterator().next();
+      assertTrue(connection.prepareTLS(new LocalizableMessageBuilder()));
+      final LocalizableMessageBuilder reason = new LocalizableMessageBuilder();
+      assertFalse(connection.prepareTLS(reason));
+      assertTrue(reason.toString().contains("pending"), reason.toString());
+    }
+    finally
+    {
+      stop(handler);
+    }
+  }
+
   /** Connections are counted whatever the limit, so a limit set on a running handler covers those already open. */
   @Test
   public void limitPerAddressSetAtRuntimeCoversTheOpenConnections() throws Exception
@@ -870,6 +918,12 @@
 
   private static LDAPConnectionHandlerCfg configuration(int port, String... limits) throws Exception
   {
+    return configuration(port, false, limits);
+  }
+
+  private static LDAPConnectionHandlerCfg configuration(int port, boolean allowStartTls, String... limits)
+      throws Exception
+  {
     final List<String> lines = new ArrayList<>();
     lines.add("dn: cn=Limits Test Handler,cn=Connection Handlers,cn=config");
     lines.add("objectClass: top");
@@ -882,7 +936,11 @@
     lines.add("ds-cfg-listen-port: " + port);
     lines.add("ds-cfg-keep-stats: false");
     lines.add("ds-cfg-use-ssl: false");
-    lines.add("ds-cfg-allow-start-tls: false");
+    lines.add("ds-cfg-allow-start-tls: " + allowStartTls);
+    if (allowStartTls)
+    {
+      lines.add("ds-cfg-key-manager-provider: cn=JKS,cn=Key Manager Providers,cn=config");
+    }
     lines.add("ds-cfg-allow-ldap-v2: false");
     lines.add("ds-cfg-send-rejection-notice: true");
     for (String limit : limits)

--
Gitblit v1.10.0