diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java new file mode 100644 index 00000000..e51053ae --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java @@ -0,0 +1,175 @@ +/* + * 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.identityconnectors.framework.impl.api.remote; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; + +/** + * Matches a host name or IP address against the names in an X.509 server + * certificate the way RFC 6125 / RFC 2818 (and JSSE's "HTTPS" endpoint + * identification) do: IP addresses against subjectAltName iPAddress entries, + * host names against subjectAltName dNSName entries, falling back to the + * subject CN only when the certificate carries no dNSName at all. + *

+ * This is a diagnostic aid for deployments that switched hostname + * verification off; the enforcing check is done by JSSE during the handshake. + */ +final class CertificateHostnameMatcher { + + private static final int SAN_DNS_NAME = 2; + private static final int SAN_IP_ADDRESS = 7; + private static final Pattern IPV4_LITERAL = Pattern.compile("(\\d{1,3}\\.){3}\\d{1,3}"); + + private CertificateHostnameMatcher() { + } + + /** + * Returns whether {@code host} (a DNS name or an IP literal) is one of the + * names the certificate was issued for. + */ + static boolean matches(String host, X509Certificate certificate) { + if (isIpLiteral(host)) { + for (String address : subjectAltNames(certificate, SAN_IP_ADDRESS)) { + if (sameAddress(host, address)) { + return true; + } + } + return false; + } + List dnsNames = subjectAltNames(certificate, SAN_DNS_NAME); + if (dnsNames.isEmpty()) { + String commonName = commonName(certificate); + return commonName != null && matchesDnsName(host, commonName); + } + for (String dnsName : dnsNames) { + if (matchesDnsName(host, dnsName)) { + return true; + } + } + return false; + } + + /** + * Describes the subject and subjectAltName entries of the certificate for + * log messages, e.g. + * {@code subject 'CN=localhost', subjectAltName [dns:localhost, ip:127.0.0.1]}. + */ + static String describe(X509Certificate certificate) { + StringBuilder description = new StringBuilder("subject '") + .append(certificate.getSubjectX500Principal().getName(X500Principal.RFC2253)) + .append('\''); + List names = new ArrayList(); + for (String dnsName : subjectAltNames(certificate, SAN_DNS_NAME)) { + names.add("dns:" + dnsName); + } + for (String address : subjectAltNames(certificate, SAN_IP_ADDRESS)) { + names.add("ip:" + address); + } + if (names.isEmpty()) { + description.append(", no subjectAltName"); + } else { + description.append(", subjectAltName ").append(names); + } + return description.toString(); + } + + private static boolean isIpLiteral(String host) { + return host.indexOf(':') >= 0 || IPV4_LITERAL.matcher(host).matches(); + } + + private static boolean sameAddress(String host, String address) { + String literal = host; + if (literal.startsWith("[") && literal.endsWith("]")) { + literal = literal.substring(1, literal.length() - 1); + } + try { + // both operands are literals, so no name resolution happens here + return InetAddress.getByName(literal).equals(InetAddress.getByName(address)); + } catch (UnknownHostException e) { + return false; + } + } + + /** + * Case-insensitive comparison; a {@code *} in the leftmost label of the + * certificate name stands for exactly one label of the host. + */ + private static boolean matchesDnsName(String host, String name) { + String lowerHost = host.toLowerCase(Locale.ENGLISH); + String lowerName = name.toLowerCase(Locale.ENGLISH); + if (!lowerName.startsWith("*.")) { + return lowerHost.equals(lowerName); + } + String suffix = lowerName.substring(1); + if (suffix.indexOf('.', 1) < 0) { + // "*.com": a wildcard must not cover a whole top-level domain + return false; + } + int firstDot = lowerHost.indexOf('.'); + return firstDot > 0 && lowerHost.substring(firstDot).equals(suffix); + } + + private static List subjectAltNames(X509Certificate certificate, int type) { + List names = new ArrayList(); + Collection> entries; + try { + entries = certificate.getSubjectAlternativeNames(); + } catch (CertificateParsingException e) { + return names; + } + if (entries == null) { + return names; + } + for (List entry : entries) { + if (entry.size() >= 2 && Integer.valueOf(type).equals(entry.get(0)) + && entry.get(1) instanceof String) { + names.add((String) entry.get(1)); + } + } + return names; + } + + /** The most specific CN of the subject, or {@code null}. */ + private static String commonName(X509Certificate certificate) { + String subject = certificate.getSubjectX500Principal().getName(X500Principal.RFC2253); + try { + List rdns = new LdapName(subject).getRdns(); + // RFC 2253 lists the most specific RDN first, LdapName stores it last + for (int i = rdns.size() - 1; i >= 0; i--) { + Rdn rdn = rdns.get(i); + if ("CN".equalsIgnoreCase(rdn.getType()) && rdn.getValue() instanceof String) { + return (String) rdn.getValue(); + } + } + } catch (InvalidNameException e) { + // fall through: no usable CN + } + return null; + } +} diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java index e19c570d..3f723b4e 100644 --- a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java @@ -19,6 +19,7 @@ * enclosed by brackets [] replaced by your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" * ==================== + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.framework.impl.api.remote; @@ -28,9 +29,15 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; @@ -46,6 +53,21 @@ public class RemoteFrameworkConnection implements Closeable { private static final Log LOG = Log.getLog(RemoteFrameworkConnection.class); + + /** + * System property controlling whether the connector server certificate is + * checked against the configured host when {@code useSSL} is on + * (RFC 2818 / RFC 6125 "HTTPS" endpoint identification). Verification is + * on unless the property is set to exactly {@code false}; disabling it + * leaves the connection open to man-in-the-middle attacks by anyone + * holding a certificate the client trusts. + */ + public static final String HOSTNAME_VERIFICATION_PROPERTY = + "org.identityconnectors.framework.remote.hostnameVerification"; + + /** Servers already reported as mismatching while verification is off. */ + private static final Set REPORTED_MISMATCHES = ConcurrentHashMap.newKeySet(); + private Socket socket; private BinaryObjectSerializer encoder; private BinaryObjectDeserializer decoder; @@ -97,10 +119,22 @@ private void init(RemoteFrameworkConnectionInfo connectionInfo) throws Exception factory = context.getSocketFactory(); } - socket = - factory.createSocket(socket, connectionInfo.getHost(), connectionInfo - .getPort(), true); - ((SSLSocket) socket).startHandshake(); + SSLSocket sslSocket = + (SSLSocket) factory.createSocket(socket, connectionInfo.getHost(), + connectionInfo.getPort(), true); + // SSLSocket does not check the server certificate against the + // host on its own: have JSSE do it during the handshake. + boolean verifyHostname = isHostnameVerificationEnabled(); + if (verifyHostname) { + SSLParameters parameters = sslSocket.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + sslSocket.setSSLParameters(parameters); + } + sslSocket.startHandshake(); + if (!verifyHostname) { + reportCertificateMismatch(connectionInfo, sslSocket); + } + socket = sslSocket; } } catch (Exception e) { try { @@ -113,6 +147,48 @@ private void init(RemoteFrameworkConnectionInfo connectionInfo) throws Exception init(socket); } + /** + * Only the literal {@code false} disables verification, so that a typo in + * the property value can not silently weaken the connection. + */ + static boolean isHostnameVerificationEnabled() { + return !"false".equalsIgnoreCase(System.getProperty(HOSTNAME_VERIFICATION_PROPERTY)); + } + + /** + * With verification switched off, still tell the administrator (once per + * server) when the certificate would not have passed, so the certificate + * can be fixed and verification re-enabled. + */ + private static void reportCertificateMismatch(RemoteFrameworkConnectionInfo connectionInfo, + SSLSocket sslSocket) { + String server = connectionInfo.getHost() + ":" + connectionInfo.getPort(); + if (REPORTED_MISMATCHES.contains(server)) { + return; + } + String problem; + try { + Certificate[] chain = sslSocket.getSession().getPeerCertificates(); + if (chain.length == 0 || !(chain[0] instanceof X509Certificate)) { + problem = "presented no X.509 certificate"; + } else if (CertificateHostnameMatcher.matches(connectionInfo.getHost(), + (X509Certificate) chain[0])) { + return; + } else { + problem = "presented a certificate that does not match the host: " + + CertificateHostnameMatcher.describe((X509Certificate) chain[0]); + } + } catch (SSLPeerUnverifiedException e) { + problem = "presented no verifiable certificate"; + } + if (REPORTED_MISMATCHES.add(server)) { + LOG.warn("TLS hostname verification is disabled ({0}=false) and connector server {1} {2}." + + " The connection is exposed to man-in-the-middle attacks;" + + " fix the server certificate and re-enable verification.", + HOSTNAME_VERIFICATION_PROPERTY, server, problem); + } + } + private void init(Socket socket) throws Exception { this.socket = socket; InputStream inputStream = this.socket.getInputStream(); diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java index 94eae13c..f73aa9bf 100644 --- a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java @@ -19,6 +19,7 @@ * enclosed by brackets [] replaced by your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" * ==================== + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.framework.impl.api; @@ -201,10 +202,12 @@ protected ConnectorInfoManager getConnectorInfoManager() throws Exception { final int PORT = 8761; + // The client verifies the server certificate against the host it + // connects to, so the certificate must carry 127.0.0.1 as subjectAltName. TrustManager clientTrustManager = - new MyTrustManager("KeyStore.jks"); + new MyTrustManager("KeyStore-san.jks"); KeyManager serverKeyManager = - new MyKeyManager("KeyStore.jks"); + new MyKeyManager("KeyStore-san.jks"); synchronized (RemoteConnectorInfoManagerSSLTests.class) { if (null == _server) { diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java new file mode 100644 index 00000000..40a1725c --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java @@ -0,0 +1,116 @@ +/* + * 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.identityconnectors.framework.impl.api.remote; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import java.io.InputStream; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import org.testng.annotations.Test; + +/** + * Test certificates: + *

+ */ +public class CertificateHostnameMatcherTests { + + @Test + public void matchesCommonNameWhenCertificateHasNoSubjectAltName() throws Exception { + X509Certificate cert = keyStoreCertificate("KeyStore.jks"); + assertTrue(CertificateHostnameMatcher.matches("localhost", cert)); + assertTrue(CertificateHostnameMatcher.matches("LOCALHOST", cert)); + assertFalse(CertificateHostnameMatcher.matches("example.com", cert)); + } + + @Test + public void neverMatchesIpAddressAgainstCommonName() throws Exception { + X509Certificate cert = keyStoreCertificate("KeyStore.jks"); + assertFalse(CertificateHostnameMatcher.matches("127.0.0.1", cert)); + } + + @Test + public void matchesDnsSubjectAltName() throws Exception { + X509Certificate cert = keyStoreCertificate("KeyStore-san.jks"); + assertTrue(CertificateHostnameMatcher.matches("localhost", cert)); + assertFalse(CertificateHostnameMatcher.matches("localhost.localdomain", cert)); + } + + @Test + public void matchesIpSubjectAltName() throws Exception { + X509Certificate cert = keyStoreCertificate("KeyStore-san.jks"); + assertTrue(CertificateHostnameMatcher.matches("127.0.0.1", cert)); + assertTrue(CertificateHostnameMatcher.matches("::1", cert)); + assertTrue(CertificateHostnameMatcher.matches("0:0:0:0:0:0:0:1", cert)); + assertFalse(CertificateHostnameMatcher.matches("127.0.0.2", cert)); + } + + @Test + public void ignoresCommonNameWhenSubjectAltNameHasDnsNames() throws Exception { + X509Certificate cert = pemCertificate("wildcard-san.pem"); + assertFalse(CertificateHostnameMatcher.matches("cn.example.org", cert)); + assertTrue(CertificateHostnameMatcher.matches("example.net", cert)); + assertTrue(CertificateHostnameMatcher.matches("EXAMPLE.NET", cert)); + } + + @Test + public void matchesWildcardForExactlyOneLeftmostLabel() throws Exception { + X509Certificate cert = pemCertificate("wildcard-san.pem"); + assertTrue(CertificateHostnameMatcher.matches("www.example.com", cert)); + assertTrue(CertificateHostnameMatcher.matches("WWW.Example.COM", cert)); + assertFalse(CertificateHostnameMatcher.matches("example.com", cert)); + assertFalse(CertificateHostnameMatcher.matches("a.b.example.com", cert)); + assertFalse(CertificateHostnameMatcher.matches("wwwexample.com", cert)); + } + + @Test + public void describeListsSubjectAndSubjectAltNames() throws Exception { + String description = CertificateHostnameMatcher.describe(keyStoreCertificate("KeyStore-san.jks")); + assertTrue(description.contains("CN=localhost"), description); + assertTrue(description.contains("dns:localhost"), description); + assertTrue(description.contains("ip:127.0.0.1"), description); + + description = CertificateHostnameMatcher.describe(keyStoreCertificate("KeyStore.jks")); + assertTrue(description.contains("CN=localhost"), description); + assertFalse(description.contains("dns:"), description); + } + + private static X509Certificate keyStoreCertificate(String name) throws Exception { + try (InputStream in = CertificateHostnameMatcherTests.class.getResourceAsStream("/" + name)) { + assertNotNull(in, "missing test resource " + name); + KeyStore store = KeyStore.getInstance("JKS"); + store.load(in, "changeit".toCharArray()); + return (X509Certificate) store.getCertificate(store.aliases().nextElement()); + } + } + + private static X509Certificate pemCertificate(String name) throws Exception { + try (InputStream in = CertificateHostnameMatcherTests.class.getResourceAsStream("/" + name)) { + assertNotNull(in, "missing test resource " + name); + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(in); + } + } +} diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java new file mode 100644 index 00000000..bd7f70fc --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java @@ -0,0 +1,262 @@ +/* + * 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.identityconnectors.framework.impl.api.remote; + +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import org.identityconnectors.common.CollectionUtil; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.api.RemoteFrameworkConnectionInfo; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.testng.annotations.Test; + +/** + * Verifies that the legacy connector server client checks the server + * certificate against the configured host (TLS hostname verification). + * + * {@code KeyStore.jks} holds a certificate with {@code CN=localhost} and no + * subjectAltName; {@code KeyStore-san.jks} holds one with + * {@code SAN=dns:localhost,ip:127.0.0.1,ip:::1}. + */ +public class RemoteFrameworkConnectionSSLTests { + + private static final char[] PASSWORD = "changeit".toCharArray(); + private static final int TIMEOUT = 10000; + + @Test + public void rejectsServerCertificateWithoutMatchingName() throws Exception { + KeyStore store = loadKeyStore("KeyStore.jks"); + try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) { + try { + connect("127.0.0.1", server.getPort(), trustManagers(store)).close(); + fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1"); + } catch (ConnectorException e) { + assertHandshakeFailure(e); + } + } + } + + @Test + public void verifiesHostnameEvenWithPlainX509TrustManager() throws Exception { + KeyStore store = loadKeyStore("KeyStore.jks"); + try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) { + try { + connect("127.0.0.1", server.getPort(), + CollectionUtil. newList(new PinningTrustManager(store))) + .close(); + fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1"); + } catch (ConnectorException e) { + assertHandshakeFailure(e); + } + } + } + + @Test + public void acceptsServerCertificateWithMatchingSubjectAltName() throws Exception { + KeyStore store = loadKeyStore("KeyStore-san.jks"); + try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) { + connect("127.0.0.1", server.getPort(), trustManagers(store)).close(); + } + } + + @Test + public void fallsBackToCommonNameWhenCertificateHasNoSubjectAltName() throws Exception { + KeyStore store = loadKeyStore("KeyStore.jks"); + InetAddress localhost = InetAddress.getByName("localhost"); + try (TlsServer server = new TlsServer(store, localhost)) { + connect("localhost", server.getPort(), trustManagers(store)).close(); + } + } + + @Test + public void connectsWhenHostnameVerificationIsDisabled() throws Exception { + KeyStore store = loadKeyStore("KeyStore.jks"); + try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) { + withHostnameVerificationProperty("false", () -> + connect("127.0.0.1", server.getPort(), trustManagers(store)).close()); + } + } + + @Test + public void keepsVerificationUnlessPropertyIsExactlyFalse() throws Exception { + KeyStore store = loadKeyStore("KeyStore.jks"); + try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) { + withHostnameVerificationProperty("off", () -> { + try { + connect("127.0.0.1", server.getPort(), trustManagers(store)).close(); + fail("A value other than 'false' must not disable hostname verification"); + } catch (ConnectorException e) { + assertHandshakeFailure(e); + } + }); + } + } + + // ---- helpers --------------------------------------------------------- + + private interface Action { + void run() throws Exception; + } + + private static void withHostnameVerificationProperty(String value, Action action) + throws Exception { + String property = RemoteFrameworkConnection.HOSTNAME_VERIFICATION_PROPERTY; + String previous = System.getProperty(property); + System.setProperty(property, value); + try { + action.run(); + } finally { + if (previous == null) { + System.clearProperty(property); + } else { + System.setProperty(property, previous); + } + } + } + + private static void assertHandshakeFailure(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 RemoteFrameworkConnection connect(String host, int port, + List trustManagers) { + return new RemoteFrameworkConnection(new RemoteFrameworkConnectionInfo(host, port, + new GuardedString(PASSWORD), true, trustManagers, TIMEOUT)); + } + + private static KeyStore loadKeyStore(String name) throws Exception { + try (InputStream in = RemoteFrameworkConnectionSSLTests.class.getResourceAsStream("/" + name)) { + assertNotNull(in, "missing test resource " + name); + KeyStore store = KeyStore.getInstance("JKS"); + store.load(in, PASSWORD); + return store; + } + } + + private static List trustManagers(KeyStore store) throws Exception { + TrustManagerFactory factory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(store); + return Arrays.asList(factory.getTrustManagers()); + } + + /** + * A legacy-style trust manager (not an {@code X509ExtendedTrustManager}) + * that trusts exactly the certificates found in a key store, mirroring + * what integrators commonly plug into {@link RemoteFrameworkConnectionInfo}. + */ + private static final class PinningTrustManager implements X509TrustManager { + private final KeyStore store; + + PinningTrustManager(KeyStore store) { + this.store = store; + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + checkTrusted(chain); + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + checkTrusted(chain); + } + + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + private void checkTrusted(X509Certificate[] chain) throws CertificateException { + try { + if (store.getCertificateAlias(chain[0]) == null) { + throw new CertificateException("untrusted certificate"); + } + } catch (CertificateException e) { + throw e; + } catch (Exception e) { + throw new CertificateException(e); + } + } + } + + /** + * Minimal TLS server: completes the handshake for every accepted + * connection and then waits for the client to hang up. + */ + private static final class TlsServer implements AutoCloseable { + private final SSLServerSocket serverSocket; + + TlsServer(KeyStore store, InetAddress bindAddress) throws Exception { + KeyManagerFactory factory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(store, PASSWORD); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(factory.getKeyManagers(), null, null); + serverSocket = (SSLServerSocket) context.getServerSocketFactory() + .createServerSocket(0, 1, bindAddress); + Thread acceptor = new Thread(this::serve, "RemoteFrameworkConnectionSSLTests-server"); + acceptor.setDaemon(true); + acceptor.start(); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + private void serve() { + while (!serverSocket.isClosed()) { + try (SSLSocket socket = (SSLSocket) serverSocket.accept()) { + socket.setSoTimeout(TIMEOUT); + socket.startHandshake(); + socket.getInputStream().read(); + } catch (IOException e) { + // handshake rejected by the client or server closed: next connection + } + } + } + + public void close() throws IOException { + serverSocket.close(); + } + } +} diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks b/OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks new file mode 100644 index 00000000..04d6902c Binary files /dev/null and b/OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks differ diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem b/OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem new file mode 100644 index 00000000..094c5648 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLTCCAhWgAwIBAgIJAJiu6UyYAXyyMA0GCSqGSIb3DQEBCwUAMDAxFTATBgNV +BAoTDE9wZW5JQ0YgdGVzdDEXMBUGA1UEAxMOY24uZXhhbXBsZS5vcmcwIBcNMjYw +OTE4MDkyNzEyWhgPMjA1NjA5MTAwOTI3MTJaMDAxFTATBgNVBAoTDE9wZW5JQ0Yg +dGVzdDEXMBUGA1UEAxMOY24uZXhhbXBsZS5vcmcwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQDZMsfEKEiuvkf6d3tOmn6ndoMbAF/DGb3bnsvZ9yfgMPqq +chKHjZigLJwcSCYH84fMTbOJfOVIB+lynIiDcewSyYUzduUr8FYh0Nu2nnQqOFjc +o9UFZGdGWI5ou9XdSRRlfTzb/JXUiOmHPwQFSHoOWtJ5bQEyXcq4VaQKmHRqlrVN +UeoiEsoTxh9Hp/x0P/u1vlNOdP1YJioeF5qvSodpmwiuxOXNQsUe2mNcdl5c8pia +omAm7eznbbst72XF6P7zTt3MtegA8mcf4aCrTaGm8pNmcz/jxGufFqHqlEzxVarb +dkehZULH2pYMl7YS8NFmL9o0uYyTcI9RrplvR2jfAgMBAAGjSDBGMB0GA1UdDgQW +BBSMT3EPXPhvdQ0zQkyfu6D5S1Oj9DAlBgNVHREEHjAcgg0qLmV4YW1wbGUuY29t +ggtleGFtcGxlLm5ldDANBgkqhkiG9w0BAQsFAAOCAQEAZrRnTuCgh/bXQv8dv64t +fEU5xw9Mts39xi/+8/gC96njrK/ydeZGHQC7bSSgv2c7An7kXw5iESk3vLkg2wnX +SGGeYyjTDU6kQNo9VI2qSFdIPmgrzwu1Zk+M33PAZMk3X5XEtUaWEi1xBCQo9+K3 +01g9tRWElBpRgaI2WSUu31l1IBkIi7FymJi5KJdaVM0eCTRpFrf8wbuL+7H+bipY +N+/P5myrYBH1BDBWBco44GuEZe2O8vPngWf+MnLVVJkFHUO5MZueLL5e8Ju//DuB +7WsdR1yk4pk9VeHUQuWJxyug8JhusWQFzr0Z9UDeEVCe83p2myECCwokPVpmZ0bW +MA== +-----END CERTIFICATE----- diff --git a/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java b/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java index 21750764..a0f36591 100644 --- a/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java +++ b/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java @@ -20,6 +20,7 @@ * "Portions Copyrighted [year] [name of copyright owner]" * ==================== * Portions Copyrighted 2015 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.framework.api; @@ -69,7 +70,12 @@ public RemoteFrameworkConnectionInfo(String host, int port, GuardedString key) { * @param key * The remote framework key * @param useSSL - * Set to true if we are to connect via SSL. + * Set to true if we are to connect via SSL. The server + * certificate is then verified against {@code host}: it must + * list it as a subjectAltName dNSName or iPAddress entry (or as + * CN when it has no subjectAltName). Setting the system property + * {@code org.identityconnectors.framework.remote.hostnameVerification} + * to {@code false} disables this check (not recommended). * @param trustManagers * List of {@link TrustManager}'s to use for establising the SSL * connection. May be null or empty, in which case the default @@ -102,7 +108,12 @@ public RemoteFrameworkConnectionInfo(String host, int port, GuardedString key, b * @param key * The remote framework key * @param useSSL - * Set to true if we are to connect via SSL. + * Set to true if we are to connect via SSL. The server + * certificate is then verified against {@code host}: it must + * list it as a subjectAltName dNSName or iPAddress entry (or as + * CN when it has no subjectAltName). Setting the system property + * {@code org.identityconnectors.framework.remote.hostnameVerification} + * to {@code false} disables this check (not recommended). * @param trustManagers * List of {@link TrustManager}'s to use for establising the SSL * connection. May be null or empty, in which case the default