diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactory.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactory.java index 8ce9e32aa92..088e0dbf687 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactory.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactory.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.security; import javax.net.SocketFactory; +import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import java.io.IOException; import java.net.InetAddress; @@ -18,6 +19,14 @@ public static SocketFactory getDefault() { return instance; } + /** + * TLS endpoint identification algorithm for sockets from this factory, or {@code null} to + * disable hostname verification (as {@link SkipSslLdapSocketFactory} does). + */ + protected String getEndpointIdentificationAlgorithm() { + return "LDAPS"; + } + @Override public String[] getDefaultCipherSuites() { return delegate.getDefaultCipherSuites(); @@ -28,33 +37,40 @@ public String[] getSupportedCipherSuites() { return delegate.getSupportedCipherSuites(); } + /** + * Ensures the target hostname is attached before the handshake; see {@link HostnameAwareSslSocket}. + */ + private Socket wrap(Socket socket, String knownHost) { + return new HostnameAwareSslSocket((SSLSocket) socket, getEndpointIdentificationAlgorithm(), knownHost); + } + @Override public Socket createSocket() throws IOException { - return delegate.createSocket(); + return wrap(delegate.createSocket(), null); } @Override public Socket createSocket(Socket socket, String s, int i, boolean b) throws IOException { - return delegate.createSocket(socket, s, i, b); + return wrap(delegate.createSocket(socket, s, i, b), s); } @Override public Socket createSocket(String s, int i) throws IOException { - return delegate.createSocket(s, i); + return wrap(delegate.createSocket(s, i), s); } @Override public Socket createSocket(String s, int i, InetAddress inetAddress, int i1) throws IOException { - return delegate.createSocket(s, i, inetAddress, i1); + return wrap(delegate.createSocket(s, i, inetAddress, i1), s); } @Override public Socket createSocket(InetAddress inetAddress, int i) throws IOException { - return delegate.createSocket(inetAddress, i); + return wrap(delegate.createSocket(inetAddress, i), HostnameAwareSslSocket.targetHost(inetAddress)); } @Override public Socket createSocket(InetAddress inetAddress, int i, InetAddress inetAddress1, int i1) throws IOException { - return delegate.createSocket(inetAddress, i, inetAddress1, i1); + return wrap(delegate.createSocket(inetAddress, i, inetAddress1, i1), HostnameAwareSslSocket.targetHost(inetAddress)); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactory.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactory.java index 5b2d41d0502..1e250a9f8a8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactory.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactory.java @@ -4,6 +4,7 @@ import javax.net.SocketFactory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import java.io.IOException; import java.net.InetAddress; @@ -29,6 +30,7 @@ */ public class CaCertAwareLdapSocketFactory extends SSLSocketFactory { + private static final String ENDPOINT_ID_ALGORITHM = "LDAPS"; private static final SocketFactory instance = new CaCertAwareLdapSocketFactory(); private static final IdpOutboundTrustCache TRUST_CACHE = new IdpOutboundTrustCache(); @@ -62,6 +64,17 @@ private static SSLSocketFactory resolveDelegate() { return sslContext != null ? sslContext.getSocketFactory() : (SSLSocketFactory) LdapSocketFactory.getDefault(); } + /** + * Same hostname wrapping as {@link BaseLdapSocketFactory}; default-trust fallback sockets are + * already wrapped, so they are not wrapped twice. + */ + private static Socket wrap(Socket socket, String knownHost) { + if (socket instanceof HostnameAwareSslSocket) { + return socket; + } + return new HostnameAwareSslSocket((SSLSocket) socket, ENDPOINT_ID_ALGORITHM, knownHost); + } + @Override public String[] getDefaultCipherSuites() { return resolveDelegate().getDefaultCipherSuites(); @@ -74,32 +87,32 @@ public String[] getSupportedCipherSuites() { @Override public Socket createSocket() throws IOException { - return resolveDelegate().createSocket(); + return wrap(resolveDelegate().createSocket(), null); } @Override public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException { - return resolveDelegate().createSocket(socket, host, port, autoClose); + return wrap(resolveDelegate().createSocket(socket, host, port, autoClose), host); } @Override public Socket createSocket(String host, int port) throws IOException { - return resolveDelegate().createSocket(host, port); + return wrap(resolveDelegate().createSocket(host, port), host); } @Override public Socket createSocket(String host, int port, InetAddress localAddress, int localPort) throws IOException { - return resolveDelegate().createSocket(host, port, localAddress, localPort); + return wrap(resolveDelegate().createSocket(host, port, localAddress, localPort), host); } @Override public Socket createSocket(InetAddress address, int port) throws IOException { - return resolveDelegate().createSocket(address, port); + return wrap(resolveDelegate().createSocket(address, port), HostnameAwareSslSocket.targetHost(address)); } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { - return resolveDelegate().createSocket(address, port, localAddress, localPort); + return wrap(resolveDelegate().createSocket(address, port, localAddress, localPort), HostnameAwareSslSocket.targetHost(address)); } private record ZoneLdapTrust(List caCertificates, boolean skipSslValidation) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/HostnameAwareSslSocket.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/HostnameAwareSslSocket.java new file mode 100644 index 00000000000..563af58dceb --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/HostnameAwareSslSocket.java @@ -0,0 +1,415 @@ +package org.cloudfoundry.identity.uaa.security; + +import org.bouncycastle.jsse.BCSSLSocket; + +import javax.net.ssl.HandshakeCompletedListener; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.channels.SocketChannel; +import java.util.List; +import java.util.function.BiFunction; +import java.util.regex.Pattern; + +/** + * Delegating {@link SSLSocket} that attaches the target hostname (peer host, SNI and endpoint + * identification algorithm) before the TLS handshake. Needed because JNDI's LDAP provider, when + * {@code com.sun.jndi.ldap.connect.timeout} is set, uses the no-arg {@code createSocket()} and only + * supplies the host later via {@code connect(SocketAddress, int)}; Bouncy Castle FIPS JSSE does not + * infer the host from that call and fails with "No hostname specified for HTTPS endpoint ID check". + */ +class HostnameAwareSslSocket extends SSLSocket { + + private final SSLSocket delegate; + private final String endpointIdentificationAlgorithm; + private static final Pattern IPV4_LITERAL = Pattern.compile("\\d{1,3}(\\.\\d{1,3}){3}"); + + private boolean hostnameApplied; + + /** + * @param endpointIdentificationAlgorithm e.g. {@code "LDAPS"}, or {@code null} to disable endpoint identification + * @param knownHost the target host if known now, or {@code null} to take it from {@code connect(...)} + */ + HostnameAwareSslSocket(SSLSocket delegate, String endpointIdentificationAlgorithm, String knownHost) { + this.delegate = delegate; + this.endpointIdentificationAlgorithm = endpointIdentificationAlgorithm; + if (knownHost != null) { + applyHostname(knownHost); + } + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + applyHostnameFromEndpoint(endpoint); + delegate.connect(endpoint); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + applyHostnameFromEndpoint(endpoint); + delegate.connect(endpoint, timeout); + } + + private void applyHostnameFromEndpoint(SocketAddress endpoint) { + if (!hostnameApplied && endpoint instanceof InetSocketAddress inetSocketAddress) { + applyHostname(inetSocketAddress.getHostString()); + } + } + + /** + * The hostname the address was created with, else its IP literal. Unlike + * {@link InetAddress#getHostName()}, never does a reverse DNS lookup. + */ + static String targetHost(InetAddress address) { + String text = address.toString(); + int slash = text.lastIndexOf('/'); + String hostname = slash > 0 ? text.substring(0, slash) : ""; + return hostname.isEmpty() ? address.getHostAddress() : hostname; + } + + private void applyHostname(String host) { + hostnameApplied = true; + if (delegate instanceof BCSSLSocket bcSocket && host != null && !host.isEmpty()) { + // BCJSSE checks the endpoint against the peer host (not SNI) and doesn't take it from connect() + bcSocket.setHost(host); + } + SSLParameters params = delegate.getSSLParameters(); + params.setEndpointIdentificationAlgorithm(endpointIdentificationAlgorithm); + if (endpointIdentificationAlgorithm != null && host != null && !host.isEmpty() && !IPV4_LITERAL.matcher(host).matches()) { + try { + params.setServerNames(List.of(new SNIHostName(host))); + } catch (IllegalArgumentException _) { + // not a valid SNI hostname (e.g. IPv6 literal); endpoint identification still applies + } + } + delegate.setSSLParameters(params); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public String[] getEnabledCipherSuites() { + return delegate.getEnabledCipherSuites(); + } + + @Override + public void setEnabledCipherSuites(String[] suites) { + delegate.setEnabledCipherSuites(suites); + } + + @Override + public String[] getSupportedProtocols() { + return delegate.getSupportedProtocols(); + } + + @Override + public String[] getEnabledProtocols() { + return delegate.getEnabledProtocols(); + } + + @Override + public void setEnabledProtocols(String[] protocols) { + delegate.setEnabledProtocols(protocols); + } + + @Override + public SSLSession getSession() { + return delegate.getSession(); + } + + @Override + public SSLSession getHandshakeSession() { + return delegate.getHandshakeSession(); + } + + @Override + public void addHandshakeCompletedListener(HandshakeCompletedListener listener) { + delegate.addHandshakeCompletedListener(listener); + } + + @Override + public void removeHandshakeCompletedListener(HandshakeCompletedListener listener) { + delegate.removeHandshakeCompletedListener(listener); + } + + @Override + public void startHandshake() throws IOException { + delegate.startHandshake(); + } + + @Override + public void setUseClientMode(boolean mode) { + delegate.setUseClientMode(mode); + } + + @Override + public boolean getUseClientMode() { + return delegate.getUseClientMode(); + } + + @Override + public void setNeedClientAuth(boolean need) { + delegate.setNeedClientAuth(need); + } + + @Override + public boolean getNeedClientAuth() { + return delegate.getNeedClientAuth(); + } + + @Override + public void setWantClientAuth(boolean want) { + delegate.setWantClientAuth(want); + } + + @Override + public boolean getWantClientAuth() { + return delegate.getWantClientAuth(); + } + + @Override + public void setEnableSessionCreation(boolean flag) { + delegate.setEnableSessionCreation(flag); + } + + @Override + public boolean getEnableSessionCreation() { + return delegate.getEnableSessionCreation(); + } + + @Override + public SSLParameters getSSLParameters() { + return delegate.getSSLParameters(); + } + + @Override + public void setSSLParameters(SSLParameters params) { + delegate.setSSLParameters(params); + } + + @Override + public String getApplicationProtocol() { + return delegate.getApplicationProtocol(); + } + + @Override + public String getHandshakeApplicationProtocol() { + return delegate.getHandshakeApplicationProtocol(); + } + + @Override + public void setHandshakeApplicationProtocolSelector(BiFunction, String> selector) { + delegate.setHandshakeApplicationProtocolSelector(selector); + } + + @Override + public BiFunction, String> getHandshakeApplicationProtocolSelector() { + return delegate.getHandshakeApplicationProtocolSelector(); + } + + @Override + public void bind(SocketAddress bindpoint) throws IOException { + delegate.bind(bindpoint); + } + + @Override + public InetAddress getInetAddress() { + return delegate.getInetAddress(); + } + + @Override + public InetAddress getLocalAddress() { + return delegate.getLocalAddress(); + } + + @Override + public int getPort() { + return delegate.getPort(); + } + + @Override + public int getLocalPort() { + return delegate.getLocalPort(); + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return delegate.getRemoteSocketAddress(); + } + + @Override + public SocketAddress getLocalSocketAddress() { + return delegate.getLocalSocketAddress(); + } + + @Override + public SocketChannel getChannel() { + return delegate.getChannel(); + } + + @Override + public InputStream getInputStream() throws IOException { + return delegate.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return delegate.getOutputStream(); + } + + @Override + public void setTcpNoDelay(boolean on) throws SocketException { + delegate.setTcpNoDelay(on); + } + + @Override + public boolean getTcpNoDelay() throws SocketException { + return delegate.getTcpNoDelay(); + } + + @Override + public void setSoLinger(boolean on, int linger) throws SocketException { + delegate.setSoLinger(on, linger); + } + + @Override + public int getSoLinger() throws SocketException { + return delegate.getSoLinger(); + } + + @Override + public void sendUrgentData(int data) throws IOException { + delegate.sendUrgentData(data); + } + + @Override + public void setOOBInline(boolean on) throws SocketException { + delegate.setOOBInline(on); + } + + @Override + public boolean getOOBInline() throws SocketException { + return delegate.getOOBInline(); + } + + @Override + public void setSoTimeout(int timeout) throws SocketException { + delegate.setSoTimeout(timeout); + } + + @Override + public int getSoTimeout() throws SocketException { + return delegate.getSoTimeout(); + } + + @Override + public void setSendBufferSize(int size) throws SocketException { + delegate.setSendBufferSize(size); + } + + @Override + public int getSendBufferSize() throws SocketException { + return delegate.getSendBufferSize(); + } + + @Override + public void setReceiveBufferSize(int size) throws SocketException { + delegate.setReceiveBufferSize(size); + } + + @Override + public int getReceiveBufferSize() throws SocketException { + return delegate.getReceiveBufferSize(); + } + + @Override + public void setKeepAlive(boolean on) throws SocketException { + delegate.setKeepAlive(on); + } + + @Override + public boolean getKeepAlive() throws SocketException { + return delegate.getKeepAlive(); + } + + @Override + public void setTrafficClass(int tc) throws SocketException { + delegate.setTrafficClass(tc); + } + + @Override + public int getTrafficClass() throws SocketException { + return delegate.getTrafficClass(); + } + + @Override + public void setReuseAddress(boolean on) throws SocketException { + delegate.setReuseAddress(on); + } + + @Override + public boolean getReuseAddress() throws SocketException { + return delegate.getReuseAddress(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public void shutdownInput() throws IOException { + delegate.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + delegate.shutdownOutput(); + } + + @Override + public String toString() { + return delegate.toString(); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public boolean isBound() { + return delegate.isBound(); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public boolean isInputShutdown() { + return delegate.isInputShutdown(); + } + + @Override + public boolean isOutputShutdown() { + return delegate.isOutputShutdown(); + } + + @Override + public void setPerformancePreferences(int connectionTime, int latency, int bandwidth) { + delegate.setPerformancePreferences(connectionTime, latency, bandwidth); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/SkipSslLdapSocketFactory.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/SkipSslLdapSocketFactory.java index 8a80c963bdd..3c462cfae02 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/security/SkipSslLdapSocketFactory.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/SkipSslLdapSocketFactory.java @@ -58,4 +58,11 @@ public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws Certi } } + @Override + protected String getEndpointIdentificationAlgorithm() { + // Hostname verification is intentionally skipped in this mode (disableSslVerification=true), + // so no endpoint identification algorithm should be requested. + return null; + } + } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManager.java index 6d5613ad0a7..285121b645b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManager.java @@ -14,16 +14,34 @@ package org.cloudfoundry.identity.uaa.security; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509TrustManager; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; +import java.util.Locale; -public class X509ExpiryCheckingTrustManager implements X509TrustManager { +/** + * Default-trust manager that also rejects expired certificates. Extends + * {@link X509ExtendedTrustManager} so the delegate still performs endpoint identification. + */ +public class X509ExpiryCheckingTrustManager extends X509ExtendedTrustManager { private X509TrustManager delegate; @@ -51,20 +69,171 @@ protected void setDelegate(X509TrustManager delegate) { @Override public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { - if (delegate == null) { - throw new CertificateException(); + requireDelegate().checkClientTrusted(x509Certificates, s); + } + + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s, Socket socket) throws CertificateException { + X509TrustManager tm = requireDelegate(); + if (tm instanceof X509ExtendedTrustManager extended) { + extended.checkClientTrusted(x509Certificates, s, socket); } else { - delegate.checkClientTrusted(x509Certificates, s); + tm.checkClientTrusted(x509Certificates, s); + } + } + + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s, SSLEngine engine) throws CertificateException { + X509TrustManager tm = requireDelegate(); + if (tm instanceof X509ExtendedTrustManager extended) { + extended.checkClientTrusted(x509Certificates, s, engine); + } else { + tm.checkClientTrusted(x509Certificates, s); } } @Override public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + requireDelegate().checkServerTrusted(x509Certificates, s); + checkValidity(x509Certificates); + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s, Socket socket) throws CertificateException { + X509TrustManager tm = requireDelegate(); + if (tm instanceof X509ExtendedTrustManager extended) { + extended.checkServerTrusted(x509Certificates, s, socket); + } else { + verifyHostname(x509Certificates, socket instanceof SSLSocket ss ? ss.getSSLParameters() : null, peerHost(socket)); + tm.checkServerTrusted(x509Certificates, s); + } + checkValidity(x509Certificates); + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s, SSLEngine engine) throws CertificateException { + X509TrustManager tm = requireDelegate(); + if (tm instanceof X509ExtendedTrustManager extended) { + extended.checkServerTrusted(x509Certificates, s, engine); + } else { + verifyHostname(x509Certificates, engine == null ? null : engine.getSSLParameters(), peerHost(engine)); + tm.checkServerTrusted(x509Certificates, s); + } + checkValidity(x509Certificates); + } + + // A non-extended delegate does not check hostnames (SunJSSE used to do it for plain X509TrustManagers), + // so do it here when endpoint identification is requested. + private static void verifyHostname(X509Certificate[] chain, SSLParameters params, String host) throws CertificateException { + String algorithm = params == null ? null : params.getEndpointIdentificationAlgorithm(); + if (algorithm == null || algorithm.isEmpty()) { + return; + } + if (!"HTTPS".equalsIgnoreCase(algorithm) && !"LDAPS".equalsIgnoreCase(algorithm)) { + throw new CertificateException("Unsupported endpoint identification algorithm: " + algorithm); + } + if (host == null || chain == null || chain.length == 0) { + throw new CertificateException("Unable to determine peer host for hostname verification"); + } + String name = host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host; + if (!matches(chain[0], name)) { + throw new CertificateException("No subject alternative names matching host " + host); + } + } + + private static String peerHost(Socket socket) { + if (socket instanceof SSLSocket ssl) { + SSLSession session = ssl.getHandshakeSession(); + if (session != null && session.getPeerHost() != null) { + return session.getPeerHost(); + } + } + return socket == null || socket.getInetAddress() == null ? null : socket.getInetAddress().getHostName(); + } + + private static String peerHost(SSLEngine engine) { + if (engine == null) { + return null; + } + SSLSession session = engine.getHandshakeSession(); + return session != null && session.getPeerHost() != null ? session.getPeerHost() : engine.getPeerHost(); + } + + private static boolean matches(X509Certificate cert, String host) throws CertificateException { + boolean isIp = InetAddressUtils.isIpLiteral(host); + Collection> sans = cert.getSubjectAlternativeNames(); + boolean hasTypedSan = false; + if (sans != null) { + for (List san : sans) { + int type = (Integer) san.get(0); + if (isIp && type == 7) { + hasTypedSan = true; + if (InetAddressUtils.sameIp(host, (String) san.get(1))) { + return true; + } + } else if (!isIp && type == 2) { + hasTypedSan = true; + if (matchesDns((String) san.get(1), host)) { + return true; + } + } + } + } + if (hasTypedSan || isIp) { + return false; + } + // No DNS SANs: fall back to the most specific common name + try { + String cn = null; + for (Rdn rdn : new LdapName(cert.getSubjectX500Principal().getName()).getRdns()) { + if ("CN".equalsIgnoreCase(rdn.getType())) { + cn = rdn.getValue().toString(); + break; + } + } + return cn != null && matchesDns(cn, host); + } catch (InvalidNameException e) { + throw new CertificateException(e); + } + } + + private static boolean matchesDns(String pattern, String host) { + String p = pattern.toLowerCase(Locale.ROOT); + String h = host.toLowerCase(Locale.ROOT); + if (p.endsWith(".")) { + p = p.substring(0, p.length() - 1); + } + if (h.endsWith(".")) { + h = h.substring(0, h.length() - 1); + } + if (p.startsWith("*.")) { + int dot = h.indexOf('.'); + return dot > 0 && p.substring(1).equals(h.substring(dot)) && p.indexOf('.', 2) > 0; + } + return p.equals(h); + } + + private static final class InetAddressUtils { + static boolean isIpLiteral(String host) { + return host.indexOf(':') >= 0 || host.matches("\\d{1,3}(\\.\\d{1,3}){3}"); + } + + static boolean sameIp(String a, String b) { + try { + return InetAddress.getByName(a).equals(InetAddress.getByName(b)); + } catch (UnknownHostException e) { + return false; + } + } + } + private X509TrustManager requireDelegate() throws CertificateException { if (delegate == null) { throw new CertificateException(); - } else { - delegate.checkServerTrusted(x509Certificates, s); } + return delegate; + } + + private static void checkValidity(X509Certificate[] x509Certificates) throws CertificateException { for (X509Certificate certificate : x509Certificates) { certificate.checkValidity(); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactoryTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactoryTest.java new file mode 100644 index 00000000000..ac29422d9b7 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/BaseLdapSocketFactoryTest.java @@ -0,0 +1,79 @@ +package org.cloudfoundry.identity.uaa.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BaseLdapSocketFactoryTest { + + @Mock + private SSLSocketFactory delegateFactory; + @Mock + private SSLSocket sslSocket; + + private SSLParameters params; + + @BeforeEach + void setUp() { + params = new SSLParameters(); + lenient().when(sslSocket.getSSLParameters()).thenReturn(params); + } + + private BaseLdapSocketFactory factory(String algorithm) { + BaseLdapSocketFactory factory = new BaseLdapSocketFactory() { + @Override + protected String getEndpointIdentificationAlgorithm() { + return algorithm; + } + }; + factory.delegate = delegateFactory; + return factory; + } + + @Test + void createSocketWithHostAppliesHostImmediately() throws IOException { + when(delegateFactory.createSocket("ldap.example.com", 636)).thenReturn(sslSocket); + + Socket socket = factory("LDAPS").createSocket("ldap.example.com", 636); + + assertThat(socket).isInstanceOf(HostnameAwareSslSocket.class); + assertThat(params.getEndpointIdentificationAlgorithm()).isEqualTo("LDAPS"); + assertThat(params.getServerNames()).containsExactly(new SNIHostName("ldap.example.com")); + } + + @Test + void createSocketWithAddressOnlyInetAddressDoesNotSendSni() throws IOException { + InetAddress address = InetAddress.getByAddress(new byte[]{10, 1, 2, 3}); + when(delegateFactory.createSocket(address, 636)).thenReturn(sslSocket); + + factory("LDAPS").createSocket(address, 636); + + assertThat(params.getEndpointIdentificationAlgorithm()).isEqualTo("LDAPS"); + assertThat(params.getServerNames()).isNull(); + } + + @Test + void nullAlgorithmIsPropagatedToSocket() throws IOException { + params.setEndpointIdentificationAlgorithm("HTTPS"); + when(delegateFactory.createSocket("ldap.example.com", 636)).thenReturn(sslSocket); + + factory(null).createSocket("ldap.example.com", 636); + + assertThat(params.getEndpointIdentificationAlgorithm()).isNull(); + } +} \ No newline at end of file diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactoryTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactoryTest.java index 7b104e6eaf6..77ef2d15f42 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactoryTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/CaCertAwareLdapSocketFactoryTest.java @@ -15,6 +15,8 @@ import javax.net.ssl.SSLServerSocketFactory; import javax.net.ssl.SSLSocket; import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; @@ -54,7 +56,7 @@ void createSocket_resolvesPerZoneTrust_twoZonesSameHostDifferentCertsDoNotInterf KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); KeyPair serverKeyPair = keyPairGenerator.generateKeyPair(); - X509Certificate serverCert = getSelfCertificate(serverKeyPair, "UAA Test", "UAA Test Unit", "server-cert", + X509Certificate serverCert = getSelfCertificate(serverKeyPair, "UAA Test", "UAA Test Unit", "localhost", new Date(), 60L * 60, "SHA256withRSA"); String serverCertPem = pemEncode(serverCert); @@ -104,6 +106,47 @@ void createSocket_noRegisteredTrustForZone_fallsBackToDefaultTrust() throws Exce .isInstanceOf(IOException.class); } + @Test + void createSocket_noArg_returnsHostnameAwareSocketThatHandshakesAfterConnect() throws Exception { + TestCert cert = newTestCert(); + try (ServerHandle server = startTlsServer(cert.keyPair(), cert.certificate())) { + IdentityZoneHolder.set(zone("zone-noarg")); + CaCertAwareLdapSocketFactory.registerZoneTrust("zone-noarg", List.of(pemEncode(cert.certificate())), false); + + try (Socket socket = CaCertAwareLdapSocketFactory.getDefault().createSocket()) { + assertThat(socket).isInstanceOf(HostnameAwareSslSocket.class); + socket.connect(new InetSocketAddress("localhost", server.port()), 5000); + SSLSocket sslSocket = (SSLSocket) socket; + assertThat(sslSocket.getSSLParameters().getEndpointIdentificationAlgorithm()).isEqualTo("LDAPS"); + sslSocket.startHandshake(); + assertThat(sslSocket.getSession().isValid()).isTrue(); + } + } + } + + @Test + void createSocket_noRegisteredTrust_doesNotDoubleWrap() throws Exception { + TestCert cert = newTestCert(); + try (ServerHandle server = startTlsServer(cert.keyPair(), cert.certificate())) { + IdentityZoneHolder.set(zone("zone-unregistered-wrap")); + try (Socket socket = CaCertAwareLdapSocketFactory.getDefault().createSocket("localhost", server.port())) { + assertThat(socket).isInstanceOf(HostnameAwareSslSocket.class); + assertThat(socket).extracting("delegate").isNotInstanceOf(HostnameAwareSslSocket.class); + } + } + } + + private record TestCert(KeyPair keyPair, X509Certificate certificate) { + } + + private static TestCert newTestCert() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + return new TestCert(keyPair, getSelfCertificate(keyPair, "UAA Test", "UAA Test Unit", "localhost", + new Date(), 60L * 60, "SHA256withRSA")); + } + private static IdentityZone zone(String id) { IdentityZone zone = new IdentityZone(); zone.setId(id); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/HostnameAwareSslSocketTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/HostnameAwareSslSocketTest.java new file mode 100644 index 00000000000..3322dc15164 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/HostnameAwareSslSocketTest.java @@ -0,0 +1,109 @@ +package org.cloudfoundry.identity.uaa.security; + +import org.bouncycastle.jsse.BCSSLSocket; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +@ExtendWith(MockitoExtension.class) +class HostnameAwareSslSocketTest { + + @Mock + private SSLSocket delegate; + + private SSLParameters params; + + @BeforeEach + void setUp() { + params = new SSLParameters(); + lenient().when(delegate.getSSLParameters()).thenReturn(params); + } + + @Test + void connectAppliesHostnameFromEndpoint() throws IOException { + HostnameAwareSslSocket socket = new HostnameAwareSslSocket(delegate, "LDAPS", null); + SocketAddress endpoint = InetSocketAddress.createUnresolved("ldap.example.com", 636); + + socket.connect(endpoint, 1000); + + verify(delegate).connect(endpoint, 1000); + assertThat(params.getEndpointIdentificationAlgorithm()).isEqualTo("LDAPS"); + assertThat(params.getServerNames()).containsExactly(new SNIHostName("ldap.example.com")); + } + + @Test + void connectDoesNotOverrideAlreadyKnownHost() throws IOException { + HostnameAwareSslSocket socket = new HostnameAwareSslSocket(delegate, "LDAPS", "known.example.com"); + + socket.connect(InetSocketAddress.createUnresolved("other.example.com", 636)); + + assertThat(params.getServerNames()).containsExactly(new SNIHostName("known.example.com")); + } + + @Test + void bcJsseSocketReceivesPeerHostOnConnect() throws IOException { + SSLSocket bcDelegate = mock(SSLSocket.class, withSettings().extraInterfaces(BCSSLSocket.class)); + when(bcDelegate.getSSLParameters()).thenReturn(params); + HostnameAwareSslSocket socket = new HostnameAwareSslSocket(bcDelegate, "LDAPS", null); + + socket.connect(InetSocketAddress.createUnresolved("ldap.example.com", 636), 1000); + + verify((BCSSLSocket) bcDelegate).setHost("ldap.example.com"); + } + + @Test + void nullAlgorithmDisablesEndpointIdentificationAndSni() { + params.setEndpointIdentificationAlgorithm("HTTPS"); + + new HostnameAwareSslSocket(delegate, null, "ldap.example.com"); + + assertThat(params.getEndpointIdentificationAlgorithm()).isNull(); + assertThat(params.getServerNames()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"10.1.2.3", "::1", "bad_host!"}) + @EmptySource + void invalidSniHostsStillSetEndpointIdentification(String host) { + new HostnameAwareSslSocket(delegate, "LDAPS", host); + + assertThat(params.getEndpointIdentificationAlgorithm()).isEqualTo("LDAPS"); + assertThat(params.getServerNames()).isNull(); + } + + @Test + void targetHostReturnsSuppliedHostName() throws UnknownHostException { + InetAddress address = InetAddress.getByAddress("ldap.example.com", new byte[]{10, 1, 2, 3}); + + assertThat(HostnameAwareSslSocket.targetHost(address)).isEqualTo("ldap.example.com"); + } + + @ParameterizedTest + @ValueSource(strings = {"10.1.2.3", "::1"}) + void targetHostReturnsLiteralForAddressOnlyInetAddress(String literal) throws UnknownHostException { + InetAddress address = InetAddress.getByAddress(InetAddress.getByName(literal).getAddress()); + + assertThat(HostnameAwareSslSocket.targetHost(address)).isEqualTo(address.getHostAddress()); + } +} \ No newline at end of file diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/LdapSocketFactoryHandshakeTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/LdapSocketFactoryHandshakeTest.java new file mode 100644 index 00000000000..f0049598780 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/LdapSocketFactoryHandshakeTest.java @@ -0,0 +1,184 @@ +package org.cloudfoundry.identity.uaa.security; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +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 java.io.IOException; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Real handshakes through {@link BaseLdapSocketFactory} with {@link X509ExpiryCheckingTrustManager} + * (as in {@link LdapSocketFactory}) on both SunJSSE and BCJSSE. The server certificate is trusted, so + * a handshake failure can only come from endpoint identification (hostname verification). + */ +class LdapSocketFactoryHandshakeTest { + + private static final String SUN_JSSE = "SunJSSE"; + private static final String BC_JSSE = "BCJSSE"; + + private static Provider bcJsse; + private static X509Certificate certificate; + private static SSLServerSocket serverSocket; + private static ExecutorService executor; + + @BeforeAll + static void startServer() throws Exception { + Provider bcFips = Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + if (bcFips == null) { + bcFips = new BouncyCastleFipsProvider(); + Security.addProvider(bcFips); + } + bcJsse = new BouncyCastleJsseProvider(bcFips); + + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + // BCJSSE (unlike SunJSSE) does not fall back to the CN, so the certificate needs a SAN + X500Name subject = new X500Name("CN=localhost"); + Instant now = Instant.now(); + certificate = new JcaX509CertificateConverter().setProvider(bcFips).getCertificate( + new JcaX509v3CertificateBuilder(subject, BigInteger.valueOf(now.toEpochMilli()), Date.from(now), + Date.from(now.plus(1, ChronoUnit.HOURS)), subject, keyPair.getPublic()) + .addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, "localhost"))) + .build(new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcFips).build(keyPair.getPrivate()))); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setKeyEntry("server", keyPair.getPrivate(), new char[0], new X509Certificate[]{certificate}); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, new char[0]); + SSLContext serverContext = SSLContext.getInstance("TLS"); + serverContext.init(kmf.getKeyManagers(), null, null); + serverSocket = (SSLServerSocket) serverContext.getServerSocketFactory().createServerSocket(0); + + executor = Executors.newSingleThreadExecutor(); + executor.submit(() -> { + while (!serverSocket.isClosed()) { + try (SSLSocket socket = (SSLSocket) serverSocket.accept()) { + socket.startHandshake(); + } catch (IOException _) { + // server socket closed, or the client rejected the handshake + } + } + }); + } + + @AfterAll + static void stopServer() throws IOException { + serverSocket.close(); + executor.shutdownNow(); + } + + @ParameterizedTest + @ValueSource(strings = {SUN_JSSE, BC_JSSE}) + void noArgCreateSocket_connectByHostname_handshakeSucceeds(String provider) throws Exception { + try (Socket socket = factory(provider).createSocket()) { + socket.connect(new InetSocketAddress("localhost", serverSocket.getLocalPort()), 5000); + SSLSocket sslSocket = (SSLSocket) socket; + sslSocket.startHandshake(); + // BCJSSE reports non-resumable TLS 1.3 sessions as !isValid(), so check negotiation instead + assertThat(sslSocket.getSession().getCipherSuite()).isNotEqualTo("SSL_NULL_WITH_NULL_NULL"); + } + } + + @ParameterizedTest + @ValueSource(strings = {SUN_JSSE, BC_JSSE}) + void noArgCreateSocket_hostnameMismatch_failsHandshake(String provider) throws Exception { + BaseLdapSocketFactory factory = factory(provider); + assertThatThrownBy(() -> { + try (Socket socket = factory.createSocket()) { + socket.connect(new InetSocketAddress("127.0.0.1", serverSocket.getLocalPort()), 5000); + ((SSLSocket) socket).startHandshake(); + } + }).satisfies(LdapSocketFactoryHandshakeTest::isHostnameMismatch); + } + + @ParameterizedTest + @ValueSource(strings = {SUN_JSSE, BC_JSSE}) + void createSocketWithHost_hostnameMismatch_failsHandshake(String provider) throws Exception { + BaseLdapSocketFactory factory = factory(provider); + assertThatThrownBy(() -> { + try (Socket socket = factory.createSocket("127.0.0.1", serverSocket.getLocalPort())) { + ((SSLSocket) socket).startHandshake(); + } + }).satisfies(LdapSocketFactoryHandshakeTest::isHostnameMismatch); + } + + @Test + void skipSslFactory_hostnameMismatch_handshakeSucceeds() throws Exception { + try (Socket socket = new SkipSslLdapSocketFactory().createSocket()) { + socket.connect(new InetSocketAddress("127.0.0.1", serverSocket.getLocalPort()), 5000); + ((SSLSocket) socket).startHandshake(); + } + } + + /** + * SunJSSE throws SSLHandshakeException, BCJSSE TlsFatalAlert; both are rooted in a CertificateException. + * Rejects "No hostname specified" so a missing peer host can't make a mismatch test pass. + */ + private static void isHostnameMismatch(Throwable thrown) { + assertThat(thrown).isInstanceOf(IOException.class) + .rootCause() + .isInstanceOf(CertificateException.class) + .hasMessageNotContaining("No hostname specified"); + } + + private static BaseLdapSocketFactory factory(String providerName) throws Exception { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("server", certificate); + + boolean bc = BC_JSSE.equals(providerName); + TrustManagerFactory tmf = bc + ? TrustManagerFactory.getInstance("PKIX", bcJsse) + : TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + X509ExpiryCheckingTrustManager trustManager = new X509ExpiryCheckingTrustManager(); + trustManager.setDelegate((X509TrustManager) tmf.getTrustManagers()[0]); + + SSLContext sslContext = bc ? SSLContext.getInstance("TLS", bcJsse) : SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{trustManager}, null); + + BaseLdapSocketFactory factory = new BaseLdapSocketFactory() { + }; + factory.delegate = sslContext.getSocketFactory(); + return factory; + } +} \ No newline at end of file diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManagerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManagerTest.java index e33e981a0e1..1112fe51834 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManagerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/X509ExpiryCheckingTrustManagerTest.java @@ -15,15 +15,31 @@ package org.cloudfoundry.identity.uaa.security; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mockito; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509TrustManager; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.concurrent.CompletableFuture; import java.security.cert.CertificateExpiredException; import java.security.cert.X509Certificate; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.fail; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; class X509ExpiryCheckingTrustManagerTest { @@ -70,4 +86,92 @@ void checkAcceptedIssuers_callsDelegate() { manager.getAcceptedIssuers(); verify(mockedDelegate).getAcceptedIssuers(); } -} + + @Test + void defaultDelegateIsExtendedSoEndpointIdentificationIsNotLost() { + assertThat(new X509ExpiryCheckingTrustManager()).isInstanceOf(X509ExtendedTrustManager.class) + .extracting("delegate").isInstanceOf(X509ExtendedTrustManager.class); + } + + @Test + void checkServerTrusted_withSocket_callsExtendedDelegateAndChecksExpiry() throws Exception { + X509ExpiryCheckingTrustManager manager = new X509ExpiryCheckingTrustManager(); + X509ExtendedTrustManager mockedDelegate = Mockito.mock(X509ExtendedTrustManager.class); + manager.setDelegate(mockedDelegate); + X509Certificate certificate = Mockito.mock(X509Certificate.class); + X509Certificate[] x509Certificates = {certificate}; + Socket socket = Mockito.mock(Socket.class); + + manager.checkServerTrusted(x509Certificates, "RSA", socket); + + verify(mockedDelegate).checkServerTrusted(x509Certificates, "RSA", socket); + verify(mockedDelegate, never()).checkServerTrusted(x509Certificates, "RSA"); + verify(certificate).checkValidity(); + } + + @Test + void handshake_withNonExtendedDelegate_failsWhenHostnameDoesNotMatch(@TempDir Path tempDir) throws Exception { + assertThatThrownBy(() -> handshake(tempDir, "HTTPS", "CN=other.example.com", "san=dns:other.example.com")) + .hasRootCauseInstanceOf(java.security.cert.CertificateException.class) + .hasStackTraceContaining("No subject alternative names matching host"); + } + + @Test + void handshake_withNonExtendedDelegate_succeedsWhenHostnameVerificationNotRequested(@TempDir Path tempDir) throws Exception { + handshake(tempDir, null, "CN=other.example.com", "san=dns:other.example.com"); + } + + @Test + void handshake_withNonExtendedDelegate_succeedsWhenHostnameMatches(@TempDir Path tempDir) throws Exception { + handshake(tempDir, "HTTPS", "CN=localhost", "san=ip:127.0.0.1"); + } + + // Client connects to 127.0.0.1; the non-extended delegate trusts any chain, so only hostname checks can reject + private static void handshake(Path tempDir, String endpointIdAlgorithm, String dn, String san) throws Exception { + Path keystore = tempDir.resolve("server.p12"); + Process keytool = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "keytool").toString(), + "-genkeypair", "-alias", "server", "-keyalg", "RSA", "-keysize", "2048", + "-dname", dn, "-ext", san, "-validity", "2", + "-storetype", "PKCS12", "-keystore", keystore.toString(), "-storepass", "changeit") + .redirectErrorStream(true).start(); + try (InputStream out = keytool.getInputStream()) { + out.readAllBytes(); + } + assertThat(keytool.waitFor()).isZero(); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream in = java.nio.file.Files.newInputStream(keystore)) { + ks.load(in, "changeit".toCharArray()); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "changeit".toCharArray()); + SSLContext serverContext = SSLContext.getInstance("TLS"); + serverContext.init(kmf.getKeyManagers(), null, null); + + X509ExpiryCheckingTrustManager manager = new X509ExpiryCheckingTrustManager(); + manager.setDelegate(Mockito.mock(X509TrustManager.class)); + SSLContext clientContext = SSLContext.getInstance("TLS"); + clientContext.init(null, new javax.net.ssl.TrustManager[]{manager}, null); + + try (SSLServerSocket server = (SSLServerSocket) serverContext.getServerSocketFactory() + .createServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + CompletableFuture serverSide = CompletableFuture.runAsync(() -> { + try (SSLSocket s = (SSLSocket) server.accept()) { + s.startHandshake(); + } catch (Exception ignored) { + // client-side failure is asserted + } + }); + try (SSLSocket client = (SSLSocket) clientContext.getSocketFactory() + .createSocket(InetAddress.getLoopbackAddress(), server.getLocalPort())) { + SSLParameters params = client.getSSLParameters(); + params.setEndpointIdentificationAlgorithm(endpointIdAlgorithm); + client.setSSLParameters(params); + client.startHandshake(); + } finally { + serverSide.cancel(true); + } + } + } +} \ No newline at end of file