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 286ab681c..3e26ee150 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 9737418a4..9a75db01d 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,8 @@ 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); fcb.add(filter); final AsyncHttpClientEventFilter eventFilter = @@ -476,6 +481,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 +874,90 @@ 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 ConnectionManagerConfig managerConfig; 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 ConnectionManagerConfig managerConfig) { super(null, clientConfig); this.secureByDefault = secureByDefault; + this.managerConfig = managerConfig; } // ---------------------------------------------- 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 (managerConfig.isHostnameVerification()) { + final SSLParameters parameters = sslEngine.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + sslEngine.setSSLParameters(parameters); + } + return sslEngine; + } + + /** + * Grizzly's transport wrapper gives a connection without an + * {@code SSLEngine} a server-mode one on its first read. Through a + * proxy that first read is the plain answer to CONNECT, and the client + * handshake then waits for a ClientHello nobody sends; so reads bypass + * the wrapper until the connection is switched to TLS. + */ + @Override + protected SSLTransportFilterWrapper createOptimizedTransportFilter( + final TransportFilter childFilter) { + return new SSLTransportFilterWrapper(childFilter, this) { + @Override + public NextAction handleRead(final FilterChainContext ctx) throws IOException { + return isSecure(ctx.getConnection()) ? super.handleRead(ctx) : wrappedFilter + .handleRead(ctx); + } + }; + } + + @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 d6e267098..9c2a91f98 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,17 @@ 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 and, like there, read on every + * connection, so changing it takes effect without rebuilding the + * framework. Only exactly {@code false} disables verification, so a typo + * or another spelling of 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 +84,9 @@ public class ConnectionManagerConfig { protected int maxConnectionLifeTimeInMs; + /** Set by {@link #setHostnameVerification}; {@code null} follows the system property. */ + protected Boolean hostnameVerification; + public int getScheduledThreadPoolSize() { return 5; } @@ -175,6 +190,25 @@ 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. Unless set explicitly, follows + * {@link #HOSTNAME_VERIFICATION_PROPERTY} at the time of the call. + */ + public boolean isHostnameVerification() { + final Boolean explicit = hostnameVerification; + return explicit != null + ? explicit + : !"false".equals(System.getProperty(HOSTNAME_VERIFICATION_PROPERTY)); + } + + public void setHostnameVerification(boolean hostnameVerification) { + this.hostnameVerification = hostnameVerification; + } + public static class Builder { } diff --git a/OpenICF-java-framework/connector-framework-server/src/test/java/org/forgerock/openicf/framework/client/ConnectionManagerConfigTest.java b/OpenICF-java-framework/connector-framework-server/src/test/java/org/forgerock/openicf/framework/client/ConnectionManagerConfigTest.java new file mode 100644 index 000000000..10fc0208e --- /dev/null +++ b/OpenICF-java-framework/connector-framework-server/src/test/java/org/forgerock/openicf/framework/client/ConnectionManagerConfigTest.java @@ -0,0 +1,83 @@ +/* + * 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.client; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class ConnectionManagerConfigTest { + + private static final String KEY = ConnectionManagerConfig.HOSTNAME_VERIFICATION_PROPERTY; + + private String saved; + + @BeforeMethod + public void saveProperty() { + saved = System.getProperty(KEY); + System.clearProperty(KEY); + } + + @AfterMethod + public void restoreProperty() { + if (saved == null) { + System.clearProperty(KEY); + } else { + System.setProperty(KEY, saved); + } + } + + @Test + public void hostnameVerificationDefaultsFromSystemProperty() { + assertTrue(new ConnectionManagerConfig().isHostnameVerification()); + System.setProperty(KEY, "false"); + assertFalse(new ConnectionManagerConfig().isHostnameVerification()); + // exactly "false", as in the legacy client + System.setProperty(KEY, "FALSE"); + assertTrue(new ConnectionManagerConfig().isHostnameVerification()); + System.setProperty(KEY, "flase"); + assertTrue(new ConnectionManagerConfig().isHostnameVerification()); + } + + /** + * The legacy client reads the property on every connection; an existing + * configuration must follow a later change of it the same way. + */ + @Test + public void hostnameVerificationFollowsLaterPropertyChanges() { + ConnectionManagerConfig config = new ConnectionManagerConfig(); + assertTrue(config.isHostnameVerification()); + System.setProperty(KEY, "false"); + assertFalse(config.isHostnameVerification()); + System.clearProperty(KEY); + assertTrue(config.isHostnameVerification()); + } + + @Test + public void explicitSettingOverridesSystemProperty() { + ConnectionManagerConfig config = new ConnectionManagerConfig(); + config.setHostnameVerification(false); + assertFalse(config.isHostnameVerification()); + + System.setProperty(KEY, "false"); + config = new ConnectionManagerConfig(); + config.setHostnameVerification(true); + assertTrue(config.isHostnameVerification()); + } +} 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 000000000..cbe699db7 --- /dev/null +++ b/OpenICF-java-framework/connector-server-grizzly/src/test/java/org/forgerock/openicf/framework/server/ClientHostnameVerificationTest.java @@ -0,0 +1,389 @@ +/* + * 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 java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.Collections.singleton; +import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.DEFAULT_GUARDED_PASSWORD; +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.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +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)); + } + } + + /** + * The first connection builds the client's filter chain with verification + * on; a later change of the system property must still reach the next + * handshake, as it does for the legacy client. + */ + @Test + public void followsLaterChangeOfTheSystemProperty() throws Exception { + final String key = ConnectionManagerConfig.HOSTNAME_VERIFICATION_PROPERTY; + final String saved = System.getProperty(key); + System.clearProperty(key); + try (Client client = new Client(new ConnectionManagerConfig())) { + assertNotNull(client.connect(sanPort)); + System.setProperty(key, "false"); + assertNotNull(client.open(buildRemoteWSFrameworkConnectionInfo(true, cnOnlyPort, null))); + } finally { + if (saved == null) { + System.clearProperty(key); + } else { + System.setProperty(key, saved); + } + } + } + + @Test + public void rejectsServerCertificateWithoutMatchingNameThroughProxy() throws Exception { + try (ConnectProxy proxy = new ConnectProxy(); + Client client = new Client(new ConnectionManagerConfig())) { + RemoteWSFrameworkConnectionInfo info = + RemoteWSFrameworkConnectionInfo.newBuilder().setRemoteURI( + URI.create("wss://127.0.0.1:" + cnOnlyPort + "/openicf")) + .setPrincipal("secure").setPassword(DEFAULT_GUARDED_PASSWORD) + .setProxyHost("127.0.0.1").setProxyPort(proxy.getPort()).build(); + try { + client.connect(info); + fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1"); + } catch (ConnectorException e) { + assertHostnameVerificationFailure(e); + } + // the handshake did go through the tunnel + assertTrue(proxy.getTargets().contains("127.0.0.1:" + cnOnlyPort), + "Expected a CONNECT to 127.0.0.1:" + cnOnlyPort + ", got " + proxy.getTargets()); + } + } + + /** + * Through a CONNECT proxy the socket peer is the proxy, not the connector + * server, and the certificate must still be checked against the host of + * the remote URI: {@code CN=localhost} passes for {@code localhost} (with + * no subjectAltName JSSE falls back to the CN for a DNS name), while the + * proxy's {@code 127.0.0.1} would need an IP subjectAltName and fail. + */ + @Test + public void verifiesRemoteUriHostRatherThanProxy() throws Exception { + try (ConnectProxy proxy = new ConnectProxy(); + Client client = new Client(new ConnectionManagerConfig())) { + RemoteWSFrameworkConnectionInfo info = + RemoteWSFrameworkConnectionInfo.newBuilder().setRemoteURI( + URI.create("wss://localhost:" + cnOnlyPort + "/openicf")) + .setPrincipal("secure").setPassword(DEFAULT_GUARDED_PASSWORD) + .setProxyHost("127.0.0.1").setProxyPort(proxy.getPort()).build(); + assertNotNull(client.connect(info)); + // the client keeps more than one connection, each through its own tunnel + assertEquals(new HashSet<>(proxy.getTargets()), singleton("localhost:" + 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 { + return connect(buildRemoteWSFrameworkConnectionInfo(true, port, null)); + } + + ConnectorInfo connect(RemoteWSFrameworkConnectionInfo info) throws Exception { + return open(info).getAsyncConnectorInfoManager().findConnectorInfoAsync( + TEST_CONNECTOR_KEY).getOrThrow(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + /** + * Only opens the WebSocket, which needs the TLS handshake to pass. A + * second connection to the same server session joins the connection + * group of the first one and gets no connector lookup of its own. + */ + ClientRemoteConnectorInfoManager open(RemoteWSFrameworkConnectionInfo info) + throws Exception { + ConnectionManager connectionManager = + (ConnectionManager) framework.get().getRemoteConnectionInfoManagerFactory(); + ClientRemoteConnectorInfoManager manager = connectionManager.connect(info); + manager.connect().getOrThrow(TIMEOUT_SECONDS, TimeUnit.SECONDS); + return manager; + } + + public void close() { + framework.release(); + } + } + + /** + * A minimal HTTP CONNECT proxy on {@code 127.0.0.1} that records the + * authority of every tunnel it opens. + */ + private static final class ConnectProxy implements AutoCloseable { + private final ServerSocket serverSocket = + new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")); + private final List targets = new CopyOnWriteArrayList<>(); + private final List sockets = new CopyOnWriteArrayList<>(); + + ConnectProxy() throws IOException { + start("connect-proxy", this::accept); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + List getTargets() { + return targets; + } + + private void accept() { + while (!serverSocket.isClosed()) { + try { + final Socket client = serverSocket.accept(); + sockets.add(client); + start("connect-proxy-tunnel", () -> tunnel(client)); + } catch (IOException e) { + return; + } + } + } + + private void tunnel(Socket client) { + try { + String[] requestLine = readRequestLine(client.getInputStream()).split(" "); + if (requestLine.length < 2 || !"CONNECT".equals(requestLine[0])) { + client.close(); + return; + } + String authority = requestLine[1]; + targets.add(authority); + int colon = authority.lastIndexOf(':'); + final Socket server = new Socket(authority.substring(0, colon), + Integer.parseInt(authority.substring(colon + 1))); + sockets.add(server); + client.getOutputStream().write( + "HTTP/1.0 200 Connection established\r\n\r\n".getBytes(US_ASCII)); + start("connect-proxy-pump", () -> pump(server, client)); + pump(client, server); + } catch (IOException e) { + closeQuietly(client); + } + } + + /** Reads the request up to the empty line and returns its first line. */ + private static String readRequestLine(InputStream in) throws IOException { + ByteArrayOutputStream header = new ByteArrayOutputStream(); + int matched = 0; + while (matched < 4) { + int b = in.read(); + if (b < 0) { + throw new IOException("Connection closed inside the request header"); + } + header.write(b); + matched = b == "\r\n\r\n".charAt(matched) ? matched + 1 : (b == '\r' ? 1 : 0); + } + String text = header.toString("US-ASCII"); + return text.substring(0, text.indexOf("\r\n")); + } + + private static void pump(Socket from, Socket to) { + try { + InputStream in = from.getInputStream(); + OutputStream out = to.getOutputStream(); + byte[] buffer = new byte[8192]; + for (int n; (n = in.read(buffer)) >= 0;) { + out.write(buffer, 0, n); + out.flush(); + } + } catch (IOException ignored) { + // either side went away + } finally { + closeQuietly(from); + closeQuietly(to); + } + } + + private static void start(String name, Runnable task) { + Thread thread = new Thread(task, name); + thread.setDaemon(true); + thread.start(); + } + + private static void closeQuietly(Socket socket) { + try { + socket.close(); + } catch (IOException ignored) { + // nothing to do + } + } + + public void close() throws IOException { + serverSocket.close(); + for (Socket socket : sockets) { + closeQuietly(socket); + } + } + } +} 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 2a683881c..8d29ca703 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 5d6a49116..9e19ebd3b 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 000000000..87de22948 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 fc1f46307..e1288440f 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 0466fd4b0..e8706e7e0 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