diff --git a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ClientRemoteConnectorInfoManager.java b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ClientRemoteConnectorInfoManager.java index 286ab681..3e26ee15 100644 --- a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ClientRemoteConnectorInfoManager.java +++ b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ClientRemoteConnectorInfoManager.java @@ -184,8 +184,15 @@ public void onClosed(Closeable closeable, ICloseType type) throws IOException { tryReleaseConnectionPermit(); if (!socket.connectPromise.isDone()) { - socket.connectPromise.handleException(new ConnectorIOException( - "Connection is closed before WebSocket is established")); + final Throwable handshakeFailure = + ConnectionManager.getHandshakeFailure(conn); + socket.connectPromise.handleException(handshakeFailure != null + ? new ConnectorIOException( + "TLS handshake failed before WebSocket is established: " + + handshakeFailure.getMessage(), + handshakeFailure) + : new ConnectorIOException( + "Connection is closed before WebSocket is established")); } // Immediately try to reconnect if (System.currentTimeMillis() - lastConnectithenOnException.get() > 30000) { diff --git a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManager.java b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManager.java index 9737418a..0f68d194 100644 --- a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManager.java +++ b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManager.java @@ -11,6 +11,7 @@ * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. * ==================== * Portions Copyrighted 2015 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems, LLC */ /** @@ -32,6 +33,8 @@ import java.util.logging.Logger; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; import org.forgerock.openicf.framework.remote.ReferenceCountedObject; import org.forgerock.openicf.framework.remote.rpc.OperationMessageListener; @@ -64,6 +67,7 @@ import org.glassfish.grizzly.http.util.MimeHeaders; import org.glassfish.grizzly.nio.transport.TCPNIOTransport; import org.glassfish.grizzly.nio.transport.TCPNIOTransportBuilder; +import org.glassfish.grizzly.ssl.SSLConnectionContext; import org.glassfish.grizzly.ssl.SSLContextConfigurator; import org.glassfish.grizzly.ssl.SSLEngineConfigurator; import org.glassfish.grizzly.ssl.SSLFilter; @@ -308,7 +312,9 @@ public void onTimeout(Connection connection) { } final SSLEngineConfigurator configurator = new SSLEngineConfigurator(context, true, false, false); - final SwitchingSSLFilter filter = new SwitchingSSLFilter(configurator, defaultSecState); + final SwitchingSSLFilter filter = + new SwitchingSSLFilter(configurator, defaultSecState, clientConfig + .isHostnameVerification()); fcb.add(filter); final AsyncHttpClientEventFilter eventFilter = @@ -476,6 +482,15 @@ public String getAuthority() { return connectionInfo.getRemoteURI().getAuthority(); } + /** The host of the remote URI, without the brackets of an IPv6 literal. */ + String getHost() { + String host = connectionInfo.getRemoteURI().getHost(); + if (host != null && host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + return host; + } + boolean isGracefullyFinishResponseOnClose() { final HttpResponsePacket response = responsePacket; return !response.getProcessingState().isKeepAlive() && !response.isChunked() @@ -860,23 +875,71 @@ public Object type() { } } // END GracefulCloseEvent + /** + * The TLS handshake failure recorded on a connection by + * {@link SwitchingSSLFilter}, so that the reason (an untrusted or + * mismatching server certificate, say) can be reported to whoever waits + * for that connection instead of a bare "connection closed". + */ + static final Attribute HANDSHAKE_FAILURE = Grizzly.DEFAULT_ATTRIBUTE_BUILDER + .createAttribute(SwitchingSSLFilter.class.getName() + ".handshakeFailure"); + + static Throwable getHandshakeFailure(final Connection connection) { + return HANDSHAKE_FAILURE.get(connection); + } + static final class SwitchingSSLFilter extends SSLFilter { private final boolean secureByDefault; + private final boolean hostnameVerification; final Attribute CONNECTION_IS_SECURE = Grizzly.DEFAULT_ATTRIBUTE_BUILDER .createAttribute(SwitchingSSLFilter.class.getName()); // -------------------------------------------------------- Constructors - SwitchingSSLFilter(final SSLEngineConfigurator clientConfig, final boolean secureByDefault) { + SwitchingSSLFilter(final SSLEngineConfigurator clientConfig, final boolean secureByDefault, + final boolean hostnameVerification) { super(null, clientConfig); this.secureByDefault = secureByDefault; + this.hostnameVerification = hostnameVerification; } // ---------------------------------------------- Methods from SSLFilter + /** + * Creates the engine for the server named in the remote URI (not for + * the proxy the socket may be connected to) and, unless switched off, + * has JSSE check the server certificate against that host during the + * handshake: an {@code SSLEngine} does not do this on its own. + */ + @Override + protected SSLEngine createClientSSLEngine(final SSLConnectionContext sslCtx, + final SSLEngineConfigurator sslEngineConfigurator) { + final RemoteConnectionContext context = + RemoteConnectionContext.get(sslCtx.getConnection()); + final String host = context != null ? context.getHost() : null; + final SSLEngine sslEngine = + host != null ? sslEngineConfigurator.createSSLEngine(host, -1) : super + .createClientSSLEngine(sslCtx, sslEngineConfigurator); + if (hostnameVerification) { + final SSLParameters parameters = sslEngine.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + sslEngine.setSSLParameters(parameters); + } + return sslEngine; + } + + @Override + protected void notifyHandshakeFailed(final Connection connection, final Throwable t) { + HANDSHAKE_FAILURE.set(connection, t); + final RemoteConnectionContext context = RemoteConnectionContext.get(connection); + logger.warn("TLS handshake with connector server {0} failed: {1}", + context != null ? context.getAuthority() : connection.getPeerAddress(), t); + super.notifyHandshakeFailed(connection, t); + } + @Override public NextAction handleEvent(FilterChainContext ctx, FilterChainEvent event) throws IOException { diff --git a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManagerConfig.java b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManagerConfig.java index d6e26709..b32683bc 100644 --- a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManagerConfig.java +++ b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/client/ConnectionManagerConfig.java @@ -20,6 +20,7 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * Portions Copyrighted 2026 3A Systems, LLC */ package org.forgerock.openicf.framework.client; @@ -32,6 +33,15 @@ public class ConnectionManagerConfig { + /** + * System property that turns off TLS hostname verification for every + * client unless a {@link ConnectionManagerConfig} says otherwise. Shared + * with the legacy connector server client. Only the literal {@code false} + * disables verification, so a typo in the value can not weaken it. + */ + public static final String HOSTNAME_VERIFICATION_PROPERTY = + "org.identityconnectors.framework.remote.hostnameVerification"; + // SSL Config private String trustStoreProvider; @@ -72,6 +82,9 @@ public class ConnectionManagerConfig { protected int maxConnectionLifeTimeInMs; + protected boolean hostnameVerification = + !"false".equalsIgnoreCase(System.getProperty(HOSTNAME_VERIFICATION_PROPERTY)); + public int getScheduledThreadPoolSize() { return 5; } @@ -175,6 +188,21 @@ public static Builder newBuilder() { return new Builder(); } + /** + * Whether the connector server certificate is checked against the host of + * the remote URI during the TLS handshake (RFC 2818 / RFC 6125 "HTTPS" + * endpoint identification). On by default; switching it off leaves the + * connection open to man-in-the-middle attacks by anyone holding a + * certificate the client trusts. + */ + public boolean isHostnameVerification() { + return hostnameVerification; + } + + public void setHostnameVerification(boolean hostnameVerification) { + this.hostnameVerification = hostnameVerification; + } + public static class Builder { } diff --git a/OpenICF-java-framework/connector-server-grizzly/src/test/java/org/forgerock/openicf/framework/server/ClientHostnameVerificationTest.java b/OpenICF-java-framework/connector-server-grizzly/src/test/java/org/forgerock/openicf/framework/server/ClientHostnameVerificationTest.java new file mode 100644 index 00000000..c52204f9 --- /dev/null +++ b/OpenICF-java-framework/connector-server-grizzly/src/test/java/org/forgerock/openicf/framework/server/ClientHostnameVerificationTest.java @@ -0,0 +1,185 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openicf.framework.server; + +import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.JSK_PASSWORD; +import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.KEY_HASH; +import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.TEST_CONNECTOR_KEY; +import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.buildRemoteWSFrameworkConnectionInfo; +import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.findFreePort; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.net.URL; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLHandshakeException; + +import org.forgerock.openicf.framework.ConnectorFramework; +import org.forgerock.openicf.framework.ConnectorFrameworkFactory; +import org.forgerock.openicf.framework.client.ClientRemoteConnectorInfoManager; +import org.forgerock.openicf.framework.client.ConnectionManager; +import org.forgerock.openicf.framework.client.ConnectionManagerConfig; +import org.forgerock.openicf.framework.client.RemoteWSFrameworkConnectionInfo; +import org.forgerock.openicf.framework.remote.ReferenceCountedObject; +import org.glassfish.grizzly.http.server.NetworkListener; +import org.glassfish.grizzly.ssl.SSLContextConfigurator; +import org.identityconnectors.framework.api.ConnectorInfo; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.testconnector.TstConnector; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +/** + * The WebSocket client must check the connector server certificate against + * the host of the remote URI. {@code serverKeystore.jks} names + * {@code localhost}, {@code 127.0.0.1} and {@code ::1} as subjectAltName; + * {@code serverKeystore-cn-only.jks} only has {@code CN=localhost}. Both are + * trusted by {@code truststore.jks}, which the client picks up through the + * {@code javax.net.ssl.trustStore} system properties. + */ +public class ClientHostnameVerificationTest { + + private static final long TIMEOUT_SECONDS = 30; + + private final int sanPort = findFreePort(); + private final int cnOnlyPort = findFreePort(); + + private ConnectorServer connectorServer; + + /** + * Like the other tests of this module, the server lives for the whole + * run: every {@link ConnectorServer} registers with the JVM-wide Grizzly + * {@code WebSocketEngine}, and unregistering one while other test + * servers are still in use leaves their WebSocket upgrades unanswered. + */ + @BeforeTest + public void startServer() throws Exception { + String truststore = resourcePath("truststore.jks"); + System.setProperty(SSLContextConfigurator.TRUST_STORE_FILE, truststore); + System.setProperty(SSLContextConfigurator.TRUST_STORE_PASSWORD, JSK_PASSWORD); + + connectorServer = new ConnectorServer(); + connectorServer.setConnectorFrameworkFactory(new ConnectorFrameworkFactory()); + connectorServer.setConnectorBundleURLs(Arrays.asList(TstConnector.class + .getProtectionDomain().getCodeSource().getLocation())); + connectorServer.init(); + connectorServer.addListener("san", NetworkListener.DEFAULT_NETWORK_HOST, sanPort, + serverSSLContext("serverKeystore.jks", truststore)); + connectorServer.addListener("cn-only", NetworkListener.DEFAULT_NETWORK_HOST, cnOnlyPort, + serverSSLContext("serverKeystore-cn-only.jks", truststore)); + connectorServer.setKeyHash(KEY_HASH); + connectorServer.start(); + } + + @AfterTest + public void stopServer() throws Exception { + connectorServer.stop(); + connectorServer.destroy(); + } + + @Test + public void rejectsServerCertificateWithoutMatchingName() throws Exception { + try (Client client = new Client(new ConnectionManagerConfig())) { + try { + client.connect(cnOnlyPort); + fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1"); + } catch (ConnectorException e) { + assertHostnameVerificationFailure(e); + } + } + } + + @Test + public void acceptsServerCertificateWithMatchingSubjectAltName() throws Exception { + try (Client client = new Client(new ConnectionManagerConfig())) { + assertNotNull(client.connect(sanPort)); + } + } + + @Test + public void connectsToMismatchedCertificateWhenVerificationIsDisabled() throws Exception { + ConnectionManagerConfig config = new ConnectionManagerConfig(); + config.setHostnameVerification(false); + try (Client client = new Client(config)) { + assertNotNull(client.connect(cnOnlyPort)); + } + } + + // ---- helpers --------------------------------------------------------- + + private static void assertHostnameVerificationFailure(ConnectorException e) { + Throwable cause = e; + while (cause != null && !(cause instanceof SSLHandshakeException)) { + cause = cause.getCause(); + } + assertNotNull(cause, "Expected an SSLHandshakeException in the cause chain of: " + e); + String message = String.valueOf(cause.getMessage()); + assertTrue(message.contains("subject alternative") || message.contains("No name matching"), + "Expected a hostname verification failure, got: " + message); + } + + private static SSLContextConfigurator serverSSLContext(String keystore, String truststore) + throws Exception { + SSLContextConfigurator configurator = new SSLContextConfigurator(false); + configurator.setKeyStoreFile(resourcePath(keystore)); + configurator.setKeyStorePass(JSK_PASSWORD); + configurator.setTrustStoreFile(truststore); + configurator.setTrustStorePass(JSK_PASSWORD); + configurator.setSecurityProtocol("TLS"); + return configurator; + } + + private static String resourcePath(String name) throws Exception { + URL url = ClientHostnameVerificationTest.class.getClassLoader().getResource(name); + assertNotNull(url, "missing test resource " + name); + return URLDecoder.decode(url.getFile(), "UTF-8"); + } + + /** A client-side framework with its own connection manager configuration. */ + private static final class Client implements AutoCloseable { + private final ReferenceCountedObject.Reference framework; + + Client(ConnectionManagerConfig config) { + framework = new ConnectorFrameworkFactory().acquire(); + framework.get().setConnectionManagerConfig(config); + } + + /** + * Opens the WebSocket to the server on {@code port} and, once it is + * up, looks up the test connector over it, so that the initial + * exchange has completed by the time the client is closed. + */ + ConnectorInfo connect(int port) throws Exception { + RemoteWSFrameworkConnectionInfo info = + buildRemoteWSFrameworkConnectionInfo(true, port, null); + ConnectionManager connectionManager = + (ConnectionManager) framework.get().getRemoteConnectionInfoManagerFactory(); + ClientRemoteConnectorInfoManager manager = connectionManager.connect(info); + manager.connect().getOrThrow(TIMEOUT_SECONDS, TimeUnit.SECONDS); + return manager.getAsyncConnectorInfoManager().findConnectorInfoAsync( + TEST_CONNECTOR_KEY).getOrThrow(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + public void close() { + framework.release(); + } + } +} diff --git a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/README.txt b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/README.txt index 2a683881..8d29ca70 100644 --- a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/README.txt +++ b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/README.txt @@ -1,17 +1,25 @@ -#Sample keytool commands to generate two self-signed certificate and export them into a trust store. +# keytool commands used to generate the self-signed test certificates and the trust store. +# +# The client verifies the server certificate against the host it connects to (the tests use +# wss://127.0.0.1), so the server certificate carries the loopback names as subjectAltName. +# serverKeystore-cn-only.jks has no subjectAltName and is used to test that such a certificate +# is rejected. -echo changeit > keystore.pin +keytool -genkeypair -alias openicf-server -keyalg RSA -keysize 2048 -sigalg SHA256withRSA \ + -dname "CN=localhost, O=OpenICF Self-Signed Certificate" \ + -ext "SAN=dns:localhost,ip:127.0.0.1,ip:::1" -validity 10950 \ + -storetype JKS -keystore serverKeystore.jks -storepass Passw0rd -keypass Passw0rd +keytool -genkeypair -alias openicf-server-cn-only -keyalg RSA -keysize 2048 -sigalg SHA256withRSA \ + -dname "CN=localhost, O=OpenICF Self-Signed Certificate" -validity 10950 \ + -storetype JKS -keystore serverKeystore-cn-only.jks -storepass Passw0rd -keypass Passw0rd +keytool -genkeypair -alias openicf-client -keyalg RSA -keysize 2048 -sigalg SHA256withRSA \ + -dname "CN=client, O=OpenICF Self-Signed Certificate" -validity 10950 \ + -storetype JKS -keystore clientKeystore.jks -storepass Passw0rd -keypass Passw0rd +keytool -exportcert -rfc -alias openicf-server -keystore serverKeystore.jks -storepass Passw0rd > openicf-server.pem +keytool -exportcert -rfc -alias openicf-server-cn-only -keystore serverKeystore-cn-only.jks -storepass Passw0rd > openicf-server-cn-only.pem +keytool -exportcert -rfc -alias openicf-client -keystore clientKeystore.jks -storepass Passw0rd > openicf-client.pem -keytool -genkey -alias openicf-client -keyalg rsa -dname "CN=client, O=OpenICF Self-Signed Certificate" -keystore clientKeystore.jks -keytool -genkey -alias openicf-server -keyalg rsa -dname "CN=localhost, O=OpenICF Self-Signed Certificate" -keystore serverKeystore.jks - -keytool -selfcert -alias openicf-client -validity 3653 -keystore clientKeystore.jks -keytool -selfcert -alias openicf-server -validity 3653 -keystore serverKeystore.jks - -keytool -export -alias openicf-client -file openicf-client-cert.txt -rfc -keystore clientKeystore.jks -keytool -export -alias openicf-server -file openicf-localhost-cert.txt -rfc -keystore serverKeystore.jks - - -keytool -import -alias openicf-client -file openicf-client-cert.txt -trustcacerts -keystore truststore.jks -storetype JKS -keytool -import -alias openicf-server -file openicf-localhost-cert.txt -trustcacerts -keystore truststore.jks -storetype JKS +keytool -importcert -noprompt -alias openicf-client -file openicf-client.pem -storetype JKS -keystore truststore.jks -storepass Passw0rd +keytool -importcert -noprompt -alias openicf-server -file openicf-server.pem -storetype JKS -keystore truststore.jks -storepass Passw0rd +keytool -importcert -noprompt -alias openicf-server-cn-only -file openicf-server-cn-only.pem -storetype JKS -keystore truststore.jks -storepass Passw0rd diff --git a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/clientKeystore.jks b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/clientKeystore.jks index 5d6a4911..9e19ebd3 100644 Binary files a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/clientKeystore.jks and b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/clientKeystore.jks differ diff --git a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore-cn-only.jks b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore-cn-only.jks new file mode 100644 index 00000000..87de2294 Binary files /dev/null and b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore-cn-only.jks differ diff --git a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore.jks b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore.jks index fc1f4630..e1288440 100644 Binary files a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore.jks and b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/serverKeystore.jks differ diff --git a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/truststore.jks b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/truststore.jks index 0466fd4b..e8706e7e 100644 Binary files a/OpenICF-java-framework/connector-server-grizzly/src/test/resources/truststore.jks and b/OpenICF-java-framework/connector-server-grizzly/src/test/resources/truststore.jks differ