diff --git a/OpenICF-java-framework/connector-framework-internal/pom.xml b/OpenICF-java-framework/connector-framework-internal/pom.xml index 8e17cdc97..7b3c88bd5 100644 --- a/OpenICF-java-framework/connector-framework-internal/pom.xml +++ b/OpenICF-java-framework/connector-framework-internal/pom.xml @@ -140,6 +140,16 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.identityconnectors.common.logging.CapturingLogSpi + + + maven-dependency-plugin 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 000000000..3653b93a1 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java @@ -0,0 +1,243 @@ +/* + * 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.IDN; +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.net.ssl.SNIHostName; +import javax.security.auth.x500.X500Principal; + +/** + * Matches a host name or IP address against the names in an X.509 server + * certificate, approximating RFC 2818 and JSSE's "HTTPS" endpoint + * identification: 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. As in JSSE, + * one trailing dot of the host is ignored and a host name that is not a valid + * DNS name matches nothing. Wildcards + * follow JSSE's rules for certificates from a private CA; the public suffix + * check JSSE adds for public CAs is not replicated. + *

+ * This is a diagnostic aid for deployments that switched hostname + * verification off, so a mismatch reported here is advisory; 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("((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"); + + 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) { + host = stripTrailingDot(host); + if (isIpLiteral(host)) { + for (String address : subjectAltNames(certificate, SAN_IP_ADDRESS)) { + if (sameAddress(host, address)) { + return true; + } + } + return false; + } + if (!isVerifiableHost(host)) { + 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; + } + + /** + * Returns whether JSSE can verify a certificate for {@code host} at all: + * an IP literal, or a valid DNS name once one trailing dot is removed. + * JSSE rejects any other host name (an underscore, an empty label, a + * leading or trailing hyphen) before it looks at the certificate, so no + * certificate can pass hostname verification for it. + */ + static boolean isVerifiableHost(String host) { + host = stripTrailingDot(host); + if (isIpLiteral(host)) { + return true; + } + try { + // the check sun.security.util.HostnameChecker.matchDNS runs first + new SNIHostName(host); + return true; + } catch (IllegalArgumentException e) { + 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(); + } + + /** JSSE drops the dot of a fully qualified name before checking it. */ + private static String stripTrailingDot(String host) { + return host.endsWith(".") ? host.substring(0, host.length() - 1) : host; + } + + 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 of IDN-normalized names; a {@code *} in the + * leftmost label of the certificate name (e.g. {@code *.example.com} or + * {@code w*.example.com}) matches within exactly one label of the host. + */ + private static boolean matchesDnsName(String host, String name) { + String lowerHost = normalize(host); + String lowerName = normalize(name); + int lastWildcard = lowerName.lastIndexOf('*'); + if (lastWildcard < 0) { + return lowerHost.equals(lowerName); + } + // "*", "*." and "*com" are not wildcard names; a '*' outside the + // leftmost label is compared literally, as JSSE does + if (lowerName.equals("*.") || lowerName.indexOf('.', lastWildcard) < 0) { + return false; + } + String[] hostLabels = lowerHost.split("\\.", -1); + String[] nameLabels = lowerName.split("\\.", -1); + if (hostLabels.length != nameLabels.length) { + return false; + } + for (int i = 1; i < nameLabels.length; i++) { + if (!hostLabels[i].equals(nameLabels[i])) { + return false; + } + } + return hostLabels[0].matches(wildcardLabel(nameLabels[0])); + } + + private static String wildcardLabel(String label) { + StringBuilder regex = new StringBuilder(); + int start = 0; + for (int star = label.indexOf('*'); star >= 0; star = label.indexOf('*', start)) { + regex.append(Pattern.quote(label.substring(start, star))).append("[^.]*"); + start = star + 1; + } + return regex.append(Pattern.quote(label.substring(start))).toString(); + } + + private static String normalize(String name) { + try { + name = IDN.toUnicode(IDN.toASCII(name)); + } catch (IllegalArgumentException e) { + // not a valid IDN: compare as is + } + return name.toLowerCase(Locale.ENGLISH); + } + + 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 e19c570de..21bf686de 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. */ + 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,66 @@ 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".equals(System.getProperty(HOSTNAME_VERIFICATION_PROPERTY)); + } + + /** + * With verification switched off, still tell the administrator (once per + * server) when the connection would not have passed, so the certificate + * or the configured host 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; + } + Certificate[] chain; + try { + chain = sslSocket.getSession().getPeerCertificates(); + } catch (SSLPeerUnverifiedException e) { + chain = null; + } + String mismatch = describeMismatch(connectionInfo.getHost(), chain); + if (mismatch != null && REPORTED_MISMATCHES.add(server)) { + LOG.warn("TLS hostname verification is disabled ({0}=false) and connector server {1} {2}", + HOSTNAME_VERIFICATION_PROPERTY, server, mismatch); + } + } + + /** + * Says why a connection to {@code host} that presented {@code chain} + * ({@code null} when no certificate could be verified) would fail + * hostname verification and what to do about it, or returns {@code null} + * when it would pass. + */ + static String describeMismatch(String host, Certificate[] chain) { + String problem; + String remedy = "fix the server certificate and re-enable verification"; + if (!CertificateHostnameMatcher.isVerifiableHost(host)) { + problem = "is configured by a name that is not a valid DNS host name," + + " so no certificate can pass hostname verification for it"; + remedy = "configure the server by a valid host name or by its IP address" + + " and re-enable verification"; + } else if (chain == null) { + problem = "presented no verifiable certificate"; + } else if (chain.length == 0 || !(chain[0] instanceof X509Certificate)) { + problem = "presented no X.509 certificate"; + } else if (CertificateHostnameMatcher.matches(host, (X509Certificate) chain[0])) { + return null; + } else { + problem = "presented a certificate that may not match the host: " + + CertificateHostnameMatcher.describe((X509Certificate) chain[0]); + } + return problem + ". The connection is exposed to man-in-the-middle attacks; " + + remedy + "."; + } + 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/common/logging/CapturingLogSpi.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/common/logging/CapturingLogSpi.java new file mode 100644 index 000000000..b83a261a6 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/common/logging/CapturingLogSpi.java @@ -0,0 +1,75 @@ +/* + * 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.common.logging; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Logging SPI of this module's tests, selected by the surefire configuration + * through {@link Log#LOGSPI_PROP}: it logs to standard out like the default + * {@link StdOutLogger} and also records every message, so that a test can + * check what was logged. + */ +public class CapturingLogSpi extends StdOutLogger { + + private static final List ENTRIES = new CopyOnWriteArrayList(); + + private static final class Entry { + final Class clazz; + final Log.Level level; + final String message; + + Entry(Class clazz, Log.Level level, String message) { + this.clazz = clazz; + this.level = level; + this.message = message; + } + } + + /** + * Returns whether this SPI is the one the framework logs through. + */ + public static boolean isActive() { + return Log.getSpiClass() == CapturingLogSpi.class; + } + + /** Forgets every message recorded so far. */ + public static void clear() { + ENTRIES.clear(); + } + + /** + * Returns the messages logged so far for {@code clazz} at {@code level}. + */ + public static List messages(Class clazz, Log.Level level) { + List messages = new ArrayList(); + for (Entry entry : ENTRIES) { + if (entry.clazz == clazz && entry.level == level) { + messages.add(entry.message); + } + } + return messages; + } + + @Override + public void log(Class clazz, String methodName, Log.Level level, String message, + Throwable ex) { + ENTRIES.add(new Entry(clazz, level, message)); + super.log(clazz, methodName, level, message, ex); + } +} 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 94eae13cb..f73aa9bf5 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 000000000..5bcce6cce --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java @@ -0,0 +1,187 @@ +/* + * 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: + *

+ * The PEM files are self-signed, generated with e.g. + * {@code openssl req -x509 -newkey rsa:2048 -nodes -keyout /dev/null -days 3650 + * -subj "/O=OpenICF test" -addext "subjectAltName=DNS:*.com,DNS:w*.example.org"}. + */ +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 acceptsWildcardFormsJsseAcceptsFromPrivateCa() throws Exception { + X509Certificate cert = pemCertificate("private-wildcard-san.pem"); + assertTrue(CertificateHostnameMatcher.matches("example.com", cert)); + assertFalse(CertificateHostnameMatcher.matches("com", cert)); + assertFalse(CertificateHostnameMatcher.matches("www.example.com", cert)); + assertTrue(CertificateHostnameMatcher.matches("www.example.org", cert)); + assertTrue(CertificateHostnameMatcher.matches("w.example.org", cert)); + assertFalse(CertificateHostnameMatcher.matches("x.example.org", cert)); + } + + @Test + public void usesMostSpecificCommonName() throws Exception { + X509Certificate cert = pemCertificate("multi-cn.pem"); + assertTrue(CertificateHostnameMatcher.matches("right.example", cert)); + assertFalse(CertificateHostnameMatcher.matches("wrong.example", cert)); + } + + @Test + public void matchesNothingWithoutCommonNameOrSubjectAltName() throws Exception { + X509Certificate cert = pemCertificate("no-names.pem"); + assertFalse(CertificateHostnameMatcher.matches("localhost", cert)); + assertFalse(CertificateHostnameMatcher.matches("127.0.0.1", cert)); + } + + @Test + public void treatsOutOfRangeOctetsAsHostName() throws Exception { + // 300.1.1.1 is not an IPv4 literal: it is matched against dNSName + // entries instead of being resolved and compared as an address + X509Certificate cert = pemCertificate("private-wildcard-san.pem"); + assertTrue(CertificateHostnameMatcher.matches("300.1.1.1", cert)); + } + + @Test + public void verifiesOnlyIpLiteralsAndValidDnsNames() { + for (String host : new String[] { "localhost", "connector-server", "icf.local", + "WWW.Example.COM", "www.example.com.", "127.0.0.1", "::1", "[::1]", "300.1.1.1" }) { + assertTrue(CertificateHostnameMatcher.isVerifiableHost(host), host); + } + for (String host : new String[] { "my_host.example", "my_host.example.", + "a..example.com", "-bad-.example.com", "localhost.." }) { + assertFalse(CertificateHostnameMatcher.isVerifiableHost(host), host); + } + } + + @Test + public void ignoresTrailingDotOfHost() throws Exception { + assertTrue(CertificateHostnameMatcher.matches("localhost.", keyStoreCertificate("KeyStore.jks"))); + assertTrue(CertificateHostnameMatcher.matches("localhost.", keyStoreCertificate("KeyStore-san.jks"))); + assertTrue(CertificateHostnameMatcher.matches("www.example.com.", pemCertificate("wildcard-san.pem"))); + } + + @Test + public void neverMatchesHostThatIsNotAValidDnsName() throws Exception { + // JSSE rejects these host names before it looks at the certificate, + // even when the certificate carries exactly that name + X509Certificate cert = pemCertificate("invalid-host-san.pem"); + assertFalse(CertificateHostnameMatcher.matches("my_host.example", cert)); + assertFalse(CertificateHostnameMatcher.matches("-bad-.example.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 000000000..259dfa9ac --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java @@ -0,0 +1,396 @@ +/* + * 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.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +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.logging.CapturingLogSpi; +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.api.RemoteFrameworkConnectionInfo; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.testng.SkipException; +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"))) { + for (String value : new String[] { "off", "FALSE" }) { + withHostnameVerificationProperty(value, () -> { + try { + connect("127.0.0.1", server.getPort(), trustManagers(store)).close(); + fail("'" + value + "' must not disable hostname verification"); + } catch (ConnectorException e) { + assertHandshakeFailure(e); + } + }); + } + } + } + + @Test + public void reportsMismatchOnceAndOnlyForNonMatchingCertificateWhenVerificationIsDisabled() + throws Exception { + assertCapturingLog(); + KeyStore cnOnly = loadKeyStore("KeyStore.jks"); + KeyStore san = loadKeyStore("KeyStore-san.jks"); + InetAddress loopback = InetAddress.getByName("127.0.0.1"); + RemoteFrameworkConnection.REPORTED_MISMATCHES.clear(); + CapturingLogSpi.clear(); + // both servers are bound at once, so their ports differ + try (TlsServer cnOnlyServer = new TlsServer(cnOnly, loopback); + TlsServer sanServer = new TlsServer(san, loopback)) { + withHostnameVerificationProperty("false", () -> { + for (int i = 0; i < 2; i++) { + connect("127.0.0.1", cnOnlyServer.getPort(), trustManagers(cnOnly)).close(); + connect("127.0.0.1", sanServer.getPort(), trustManagers(san)).close(); + } + }); + List warnings = mismatchWarnings("127.0.0.1:" + cnOnlyServer.getPort()); + assertEquals(warnings.size(), 1, "one warning per server: " + warnings); + assertTrue(warnings.get(0).contains("may not match the host"), warnings.get(0)); + assertEquals(mismatchWarnings("127.0.0.1:" + sanServer.getPort()).size(), 0); + } + } + + /** + * JSSE drops the trailing dot of a fully qualified host name before + * checking it, so {@code localhost.} matches {@code dns:localhost}, and + * with verification off no warning is logged for it. + */ + @Test + public void acceptsFullyQualifiedHostWithTrailingDot() throws Exception { + assertCapturingLog(); + String host = "localhost."; + InetAddress address; + try { + address = InetAddress.getByName(host); + } catch (UnknownHostException e) { + throw new SkipException(host + " does not resolve here"); + } + KeyStore san = loadKeyStore("KeyStore-san.jks"); + CapturingLogSpi.clear(); + try (TlsServer server = new TlsServer(san, address)) { + connect(host, server.getPort(), trustManagers(san)).close(); + withHostnameVerificationProperty("false", () -> + connect(host, server.getPort(), trustManagers(san)).close()); + assertEquals(mismatchWarnings(host + ":" + server.getPort()).size(), 0); + } + } + + @Test + public void describesWhyVerificationWouldFail() throws Exception { + Certificate[] cnOnly = { certificate("KeyStore.jks") }; + Certificate[] san = { certificate("KeyStore-san.jks") }; + assertNull(RemoteFrameworkConnection.describeMismatch("localhost", san)); + assertNull(RemoteFrameworkConnection.describeMismatch("127.0.0.1", san)); + String cnOnlyByIp = RemoteFrameworkConnection.describeMismatch("127.0.0.1", cnOnly); + assertTrue(cnOnlyByIp.contains("may not match the host: subject 'CN=localhost"), cnOnlyByIp); + assertTrue(cnOnlyByIp.contains("no subjectAltName"), cnOnlyByIp); + assertTrue(cnOnlyByIp.contains("fix the server certificate"), cnOnlyByIp); + assertTrue(RemoteFrameworkConnection.describeMismatch("localhost", null) + .contains("presented no verifiable certificate")); + // JSSE rejects the name itself, so no certificate can fix it + String invalidHost = RemoteFrameworkConnection.describeMismatch("my_host.example", san); + assertTrue(invalidHost.contains("not a valid DNS host name"), invalidHost); + assertTrue(invalidHost.contains("by its IP address"), invalidHost); + } + + /** + * Without trust managers the connection trusts the JVM default trust + * store ({@code javax.net.ssl.trustStore}), the typical deployment; a + * PKIX failure instead of the hostname failure would mean the store was + * not used. + */ + @Test + public void verifiesHostnameWithDefaultTrustStore() throws Exception { + KeyStore store = loadKeyStore("KeyStore.jks"); + String trustStore = new File(RemoteFrameworkConnectionSSLTests.class + .getResource("/KeyStore.jks").toURI()).getPath(); + withSystemProperties(new String[][] { + { "javax.net.ssl.trustStore", trustStore }, + { "javax.net.ssl.trustStorePassword", new String(PASSWORD) }, + { "javax.net.ssl.trustStoreType", "JKS" } }, () -> { + try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) { + connect("127.0.0.1", server.getPort(), null).close(); + fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1"); + } catch (ConnectorException e) { + assertHandshakeFailure(e); + } + }); + } + + // ---- helpers --------------------------------------------------------- + + private interface Action { + void run() throws Exception; + } + + private static void withHostnameVerificationProperty(String value, Action action) + throws Exception { + withSystemProperties(new String[][] { + { RemoteFrameworkConnection.HOSTNAME_VERIFICATION_PROPERTY, value } }, action); + } + + private static void withSystemProperties(String[][] properties, Action action) + throws Exception { + String[] previous = new String[properties.length]; + for (int i = 0; i < properties.length; i++) { + previous[i] = System.setProperty(properties[i][0], properties[i][1]); + } + try { + action.run(); + } finally { + for (int i = 0; i < properties.length; i++) { + if (previous[i] == null) { + System.clearProperty(properties[i][0]); + } else { + System.setProperty(properties[i][0], previous[i]); + } + } + } + } + + private static void assertCapturingLog() { + assertTrue(CapturingLogSpi.isActive(), "the surefire configuration must select " + + CapturingLogSpi.class.getName() + " through " + Log.LOGSPI_PROP); + } + + /** WARN messages about the given server logged since the last clear. */ + private static List mismatchWarnings(String server) { + List warnings = new ArrayList(); + for (String message : CapturingLogSpi.messages(RemoteFrameworkConnection.class, + Log.Level.WARN)) { + if (message.contains("connector server " + server + " ")) { + warnings.add(message); + } + } + return warnings; + } + + 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 Certificate certificate(String keyStore) throws Exception { + KeyStore store = loadKeyStore(keyStore); + return store.getCertificate(store.aliases().nextElement()); + } + + 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 000000000..04d6902c2 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/invalid-host-san.pem b/OpenICF-java-framework/connector-framework-internal/src/test/resources/invalid-host-san.pem new file mode 100644 index 000000000..a9b5ab015 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/resources/invalid-host-san.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIUSJ5Q3UpbICey7bdRFNkF0sPDxeAwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UECgwMT3BlbklDRiB0ZXN0MB4XDTI2MDkzMDA5MjIzMFoXDTM2 +MDkyNzA5MjIzMFowFzEVMBMGA1UECgwMT3BlbklDRiB0ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAulLfyH7eA6VleuccfjFtv3uSqaCAEh2Vw0ZJ +pAtVyWsV682FwV52sueiJviIl2W31AQrM1NRIScb9+31Zg1At+bia5FwtnxpFNC/ +PxCb6FLrB4rpcMuPV+nEE8Kw93Le9JU3+p0zkx0Lb3wcKuHIOjKd+9EYtiS4tq9+ +z7IzfOpOdjCPJD+yGhPUKm4oOMd79vzTH4g/cUfPBAgYEzlPBDCKH9sh+U0uwtkl +luJsUcHn71JIehL+f0JB+MfvwoeHBIqVE7imQC7x9Db2i46ywGQQYxDUWvN0h/1f +jrGlumc76Dmcxwo25IaEDHgEGrXKUy2hhX4WRAl8kj55cB6UnQIDAQABo4GDMIGA +MB0GA1UdDgQWBBS7EGoeqbFNAgTaVcNcUT6hNY+u6DAfBgNVHSMEGDAWgBS7EGoe +qbFNAgTaVcNcUT6hNY+u6DAPBgNVHRMBAf8EBTADAQH/MC0GA1UdEQQmMCSCD215 +X2hvc3QuZXhhbXBsZYIRLWJhZC0uZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQAD +ggEBAKALqNcrve91PZHNekTwQRaEPuzdQXbkV1mIwa5bgjOXIrb+RWmexU3Swvmc +DuN3n1yfNwlD7RkmIGqWg+juW2stqki/g0lEubOOyPouK1kg45C3Dd0JAly1Zkvy +STL6Wqc2MuXAtdPWP6Y4miJaScwKXVOk283LWs1W293ZCKjl90Jz2OgTvGf/sjQb +L20V9s1o6tAtk/icLboGaCEEaWwDlDuF7mRuZgbpZSGWvCBeR4qTN/L3Co0dLoVJ +NzMbFWuQL2PMivTzYFb3FNONN1u5f2Fury8kz55Qh6PaaTYRzN9vNJLkN/eW2B6D +Rkhs3CLMc5flsx5i0RIhCKZJgN8= +-----END CERTIFICATE----- diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/multi-cn.pem b/OpenICF-java-framework/connector-framework-internal/src/test/resources/multi-cn.pem new file mode 100644 index 000000000..2a077bcf6 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/resources/multi-cn.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWTCCAkGgAwIBAgIUGCZSTwgAsTuWuXKko/e2R+1cLjowDQYJKoZIhvcNAQEL +BQAwPDEWMBQGA1UEAwwNd3JvbmcuZXhhbXBsZTEKMAgGA1UECwwBeDEWMBQGA1UE +AwwNcmlnaHQuZXhhbXBsZTAeFw0yNjA5MjkxNjE5MTZaFw0zNjA5MjYxNjE5MTZa +MDwxFjAUBgNVBAMMDXdyb25nLmV4YW1wbGUxCjAIBgNVBAsMAXgxFjAUBgNVBAMM +DXJpZ2h0LmV4YW1wbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCy +vswae14YvCsk/jXStk63u0MHCUPYV6xA4NNei65SSei4Bptpbpo2EhKzfOtpIarD +dWQcxa304xuitNZKMN1TrToeoq+6oSIeiDAy+GHTrS7B42/rF+3e9NSjZpbFIHBY +OdPpLfNo1qKOTJPfe4A8pgzeO3/aj2JY9dwnDWx6spCeZvEMDaJiHAYasY/AeO/7 +b9hylV5kqEk+kbM4D9S8PoA4xnbBKZdHDDD7KgFWJOlyLGb+SF7s2m6JiM9jGgky +kqqOREN8Nop9ZG75jreY6gEUyDy2kVN51pldntoBgdaGDpOuw2BefuCCOV+mklGY +FHlS1E3Fc1iBN4+kcT71AgMBAAGjUzBRMB0GA1UdDgQWBBR3paXcd+WVQDM+jWYx +xiCb60OAUjAfBgNVHSMEGDAWgBR3paXcd+WVQDM+jWYxxiCb60OAUjAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB4k/rCTudn80UlEixe/DCEKPZD +AFiGXvYPiMfMTmVSFuXUmHCd/prgMbCVyYAz4Nq0nWNY96TYOVpw1kHW8gDbHimm +o4UDVWZnADyIAdnqx2ncKy5cx9/Xr8rauHy0IF7YcRUzk6GAbWdZY7njpjifi0Np +RegM1VKgwxzdmYglcaaeK4kZLb6hJtHtEfxvt4XLASHowwPVVABFl+bYtNxe7VCq +VAE84BYRrNkpH5jDMV3QqE2/CEsKvkpQY6tuSqVzJ4YNftF+G8CANvuAHsw41D10 +n66ayDkRUrf089pW7YYCHYZ1HhJqjEULG7PysXAopXzViZ3TQpFAL6UEPVXs +-----END CERTIFICATE----- diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/no-names.pem b/OpenICF-java-framework/connector-framework-internal/src/test/resources/no-names.pem new file mode 100644 index 000000000..1e46254ea --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/resources/no-names.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDzCCAfegAwIBAgIUJ1YUsmEh5B4YVwSLKc1CEUgMhnwwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UECgwMT3BlbklDRiB0ZXN0MB4XDTI2MDkyOTE2MTkyMFoXDTM2 +MDkyNjE2MTkyMFowFzEVMBMGA1UECgwMT3BlbklDRiB0ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAobDNjy63J8dVWy04APSKjSXOAW6mfc3fJ7N5 +/aNLTpnstq41Ng4YaSDvHPikF0cBy2XUwhF8jf4s/WpSOhy1JkNzaiiGKNZM58LA +WkAFc3vWM5uWmw25JgSp5W6jLaGD/qHXWQe8XidrAikqVqfK4UChYuwQklaW85mA +RXpuZKr+5DubCVwY9yzcSEqbDyzQKvPoW38fHIoT8qXt5hhvxR8xn01gEg131q4g +5ssu/D60ZM2Q41Mk5yVvMvlHiaY6+zjO+ibgYMScRev+bbGc6+r2VeGS58GlqRNK +VX4Aq5pslOYDMkbGnp7u9vpoK91X2tkeL6J0xjnzQAjnLpAGxQIDAQABo1MwUTAd +BgNVHQ4EFgQUy0jmqf1IA+r6XncaA8wUQZoNDlswHwYDVR0jBBgwFoAUy0jmqf1I +A+r6XncaA8wUQZoNDlswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEARISPRJtJ4V80aGeT+A2bt3r2vletd+XyQ/ajaXQM9OpWiFgOcf8eJFHM2y0G +XguYWsZWVfm6vmE0G+KYukAdR8OcwhILhUgXMQUWs+ffipdzkoqsX1Fpz0ySsL+E +DlHyX9UbTbIbaIUblhgFkIq2V/FDE+tb2gKQ/EECUQAPx3lUrcddTKIYbqmvEM7v +1bu5pHiBRkncgyhhrA4wfVrYS7/xMJhiPWFoCYklE57/vL+dfwc2enEifUMWRqnj +lwYf3JhJHTeLCHavGno5OR6tx5QAdRQLQA0IDhvU1TSJM2Z2/GzyZpeTQ5SN+g7P +iXLPAW7NwPOuzhLf1KjLKx9DAw== +-----END CERTIFICATE----- diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/private-wildcard-san.pem b/OpenICF-java-framework/connector-framework-internal/src/test/resources/private-wildcard-san.pem new file mode 100644 index 000000000..552b7923c --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/resources/private-wildcard-san.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPTCCAiWgAwIBAgIUGnBzUa31EE9tgsO77U7NLd2HAyUwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UECgwMT3BlbklDRiB0ZXN0MB4XDTI2MDkyOTE2MTk1OFoXDTM2 +MDkyNjE2MTk1OVowFzEVMBMGA1UECgwMT3BlbklDRiB0ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6CrkeqUW98xE6yHU+thzoyy7vV11bikHwy1f +9PCaE6eFe+QXu7NeLF65KD18jv+dYKKfwl4yW0bMuFP0ujPXFSwsgJVD4HT0AGG+ +kw9ygUW+N12hL80lWCVBuLlgELZoZfIpyen9KD/iG+dTnLlEs3Ii/M6ap8Stbrdf +AKeOqyzwwnkh2orj4Yf37t2kjOjqct5Tf2w69iwRiOwl2gON3HiNnQ7Gl22HQSQL +FTlwR0I/yVPoujD7UA88DSBueEhthg/FtF3okeHSM7wePdzCcD5w7tWi1Huujref +/N2vc9NAMc3pqB5Y91qYrvvx9DGHyJ3Obd1IHYIJfNZqpSpKvQIDAQABo4GAMH4w +HQYDVR0OBBYEFHBj83oYEuOLX7eRbQtqQFhNV5zMMB8GA1UdIwQYMBaAFHBj83oY +EuOLX7eRbQtqQFhNV5zMMA8GA1UdEwEB/wQFMAMBAf8wKwYDVR0RBCQwIoIFKi5j +b22CDncqLmV4YW1wbGUub3JnggkzMDAuMS4xLjEwDQYJKoZIhvcNAQELBQADggEB +AB6xBIZBJdB8RRHki57OhmRYzTq+DUxf40Tw1B51QLEso6Oa/GL/xS9T0YHJDr4j +Q9U6MSRt1J51jcaDyctdPDd96ud8FNtPK3h5HuSMqESNeSNr8u10sVGVovuQT3UU +TlJr9Cu7pEIX2RlBsuEn6H+WXPB0cTYtfUEJ9HAliH/KhxxFphA9jYN5KhgHK2U7 +9zrUPlpfZDCtUZpCgN75ucnAnxUZmcrf5YsYOuiljl12nVnmSY6aaWp8ezMVZm+o +EwAyXGavCDXpRm390scCFs/5hKbmvXIK62vxWQDUopBco4BwHfrKREy1HeAEYgf6 ++kaXe5gLjpSJSyomM7908KE= +-----END CERTIFICATE----- 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 000000000..094c5648b --- /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 21750764e..a73b08534 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,13 @@ 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}: an IP + * address must be a subjectAltName iPAddress entry, a host name + * a subjectAltName dNSName entry (or the subject CN when the + * certificate has no dNSName entry). 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 +109,13 @@ 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}: an IP + * address must be a subjectAltName iPAddress entry, a host name + * a subjectAltName dNSName entry (or the subject CN when the + * certificate has no dNSName entry). 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 diff --git a/OpenICF-maven-plugin/src/main/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverter.java b/OpenICF-maven-plugin/src/main/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverter.java index 26edf428b..f5ab88a5a 100644 --- a/OpenICF-maven-plugin/src/main/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverter.java +++ b/OpenICF-maven-plugin/src/main/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverter.java @@ -20,15 +20,19 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openicf.maven; +import java.net.Socket; import java.util.Arrays; import java.util.List; +import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; +import javax.net.ssl.X509ExtendedTrustManager; import org.codehaus.plexus.component.configurator.ComponentConfigurationException; import org.codehaus.plexus.component.configurator.ConfigurationListener; @@ -131,21 +135,51 @@ public Object fromConfiguration(final ConverterLookup converterLookup, /** * Create a trust manager that trusts all certificates It is not using a - * particular keyStore + * particular keyStore. + *

+ * It is an {@link X509ExtendedTrustManager} so that JSSE does not wrap it: + * neither the "HTTPS" hostname check the framework enables for the + * connection nor the {@code jdk.certpath.disabledAlgorithms} check on the + * server's certificate chain applies. With every certificate trusted, + * neither check adds security; the hostname check would only reject + * connector servers whose certificate does not name the configured host. */ protected List getTrustManager() { - return Arrays.asList((TrustManager) new X509TrustManager() { + return Arrays.asList((TrustManager) new X509ExtendedTrustManager() { + @Override public java.security.cert.X509Certificate[] getAcceptedIssuers() { return null; } + @Override public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { } + @Override public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { } + + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, + String authType, Socket socket) { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, + String authType, Socket socket) { + } + + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, + String authType, SSLEngine engine) { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, + String authType, SSLEngine engine) { + } }); } } diff --git a/OpenICF-maven-plugin/src/test/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverterTests.java b/OpenICF-maven-plugin/src/test/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverterTests.java new file mode 100644 index 000000000..823e388af --- /dev/null +++ b/OpenICF-maven-plugin/src/test/java/org/forgerock/openicf/maven/RemoteFrameworkConnectionInfoConverterTests.java @@ -0,0 +1,111 @@ +/* + * 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.maven; + +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.security.KeyStore; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; + +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.api.RemoteFrameworkConnectionInfo; +import org.identityconnectors.framework.impl.api.remote.RemoteFrameworkConnection; +import org.testng.annotations.Test; + +/** + * The plugin trusts every connector server certificate, so the framework's + * hostname verification must not reject a server whose certificate does not + * name the configured host. {@code KeyStore.jks} holds a certificate with + * {@code CN=localhost} and no subjectAltName, which JSSE never matches against + * {@code 127.0.0.1}. + */ +public class RemoteFrameworkConnectionInfoConverterTests { + + private static final char[] PASSWORD = "changeit".toCharArray(); + private static final int TIMEOUT = 10000; + + @Test + public void trustAllConnectionSkipsHostnameVerification() throws Exception { + assertNull(System.getProperty(RemoteFrameworkConnection.HOSTNAME_VERIFICATION_PROPERTY), + "hostname verification must be on for this test"); + try (TlsServer server = new TlsServer(loadKeyStore("KeyStore.jks"), + InetAddress.getByName("127.0.0.1"))) { + new RemoteFrameworkConnection(new RemoteFrameworkConnectionInfo("127.0.0.1", + server.getPort(), new GuardedString(PASSWORD), true, + new RemoteFrameworkConnectionInfoConverter().getTrustManager(), TIMEOUT)) + .close(); + } + } + + private static KeyStore loadKeyStore(String name) throws Exception { + try (InputStream in = + RemoteFrameworkConnectionInfoConverterTests.class.getResourceAsStream("/" + name)) { + assertNotNull(in, "missing test resource " + name); + KeyStore store = KeyStore.getInstance("JKS"); + store.load(in, PASSWORD); + return store; + } + } + + /** + * 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, "RemoteFrameworkConnectionInfoConverterTests-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-maven-plugin/src/test/resources/KeyStore.jks b/OpenICF-maven-plugin/src/test/resources/KeyStore.jks new file mode 100644 index 000000000..c317f2557 Binary files /dev/null and b/OpenICF-maven-plugin/src/test/resources/KeyStore.jks differ