diff --git a/handler/src/main/java/io/netty/handler/ssl/JdkSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/JdkSslClientContext.java index fde06a6023b..6a066005a45 100644 --- a/handler/src/main/java/io/netty/handler/ssl/JdkSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/JdkSslClientContext.java @@ -20,6 +20,7 @@ import java.security.Provider; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSessionContext; @@ -176,8 +177,8 @@ public JdkSslClientContext( long sessionCacheSize, long sessionTimeout) throws SSLException { super(newSSLContext(provider, toX509CertificatesInternal(trustCertCollectionFile), trustManagerFactory, null, null, - null, null, sessionCacheSize, sessionTimeout, null, KeyStore.getDefaultType(), null), true, - ciphers, cipherFilter, apn, ClientAuth.NONE, null, false); + null, null, sessionCacheSize, sessionTimeout, null, KeyStore.getDefaultType(), + null, false), true, ciphers, cipherFilter, apn, ClientAuth.NONE, null, false); } /** @@ -260,7 +261,7 @@ public JdkSslClientContext(File trustCertCollectionFile, TrustManagerFactory tru trustCertCollectionFile), trustManagerFactory, toX509CertificatesInternal(keyCertChainFile), toPrivateKeyInternal(keyFile, keyPassword), keyPassword, keyManagerFactory, sessionCacheSize, sessionTimeout, - null, KeyStore.getDefaultType(), null), true, + null, KeyStore.getDefaultType(), null, false), true, ciphers, cipherFilter, apn, ClientAuth.NONE, null, false); } @@ -270,11 +271,11 @@ public JdkSslClientContext(File trustCertCollectionFile, TrustManagerFactory tru KeyManagerFactory keyManagerFactory, Iterable ciphers, CipherSuiteFilter cipherFilter, ApplicationProtocolConfig apn, String[] protocols, long sessionCacheSize, long sessionTimeout, SecureRandom secureRandom, String keyStoreType, String endpointIdentificationAlgorithm, - ResumptionController resumptionController) + ResumptionController resumptionController, boolean sanPeerIdentityLookup) throws SSLException { super(newSSLContext(sslContextProvider, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, sessionCacheSize, - sessionTimeout, secureRandom, keyStoreType, resumptionController), + sessionTimeout, secureRandom, keyStoreType, resumptionController, sanPeerIdentityLookup), true, ciphers, cipherFilter, toNegotiator(apn, false), ClientAuth.NONE, protocols, false, endpointIdentificationAlgorithm, resumptionController); } @@ -285,7 +286,8 @@ private static SSLContext newSSLContext(Provider sslContextProvider, PrivateKey key, String keyPassword, KeyManagerFactory keyManagerFactory, long sessionCacheSize, long sessionTimeout, SecureRandom secureRandom, String keyStore, - ResumptionController resumptionController) throws SSLException { + ResumptionController resumptionController, + boolean sanPeerIdentityLookup) throws SSLException { try { if (trustCertCollection != null) { trustManagerFactory = buildTrustManagerFactory(trustCertCollection, trustManagerFactory, keyStore); @@ -297,8 +299,8 @@ private static SSLContext newSSLContext(Provider sslContextProvider, SSLContext ctx = sslContextProvider == null ? SSLContext.getInstance(PROTOCOL) : SSLContext.getInstance(PROTOCOL, sslContextProvider); ctx.init(keyManagerFactory == null ? null : keyManagerFactory.getKeyManagers(), - trustManagerFactory == null ? null : - wrapIfNeeded(trustManagerFactory.getTrustManagers(), resumptionController), + trustManagerFactory == null ? null : wrapIfNeeded( + trustManagerFactory.getTrustManagers(), resumptionController, sanPeerIdentityLookup), secureRandom); SSLSessionContext sessCtx = ctx.getClientSessionContext(); @@ -317,9 +319,11 @@ private static SSLContext newSSLContext(Provider sslContextProvider, } } - private static TrustManager[] wrapIfNeeded(TrustManager[] tms, ResumptionController resumptionController) { - if (resumptionController != null) { - for (int i = 0; i < tms.length; i++) { + private static TrustManager[] wrapIfNeeded(TrustManager[] tms, ResumptionController resumptionController, + boolean sanPeerIdentityLookup) { + for (int i = 0; i < tms.length; i++) { + tms[i] = SanPeerIdentityTrustManager.wrapIfNeeded(tms[i], sanPeerIdentityLookup); + if (resumptionController != null) { tms[i] = resumptionController.wrapIfNeeded(tms[i]); } } diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java index 57152de1fbc..358e8752ad8 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java @@ -196,7 +196,8 @@ public OpenSslClientContext(File trustCertCollectionFile, TrustManagerFactory tr OpenSslKeyMaterialProvider.validateKeyMaterialSupported(keyCertChain, key, keyPassword); sessionContext = newSessionContext(this, ctx, engineMap, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, keyStore, - sessionCacheSize, sessionTimeout, resumptionController); + sessionCacheSize, sessionTimeout, endpointIdentificationAlgorithm, + resumptionController, options); success = true; } finally { if (!success) { diff --git a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java index 6d4b21b7ce7..24e19613f74 100644 --- a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java @@ -73,7 +73,8 @@ public final class ReferenceCountedOpenSslClientContext extends ReferenceCounted try { sessionContext = newSessionContext(this, ctx, engineMap, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, keyStore, - sessionCacheSize, sessionTimeout, resumptionController); + sessionCacheSize, sessionTimeout, endpointIdentificationAlgorithm, + resumptionController, options); success = true; } finally { if (!success) { @@ -94,7 +95,9 @@ static OpenSslSessionContext newSessionContext(ReferenceCountedOpenSslContext th X509Certificate[] keyCertChain, PrivateKey key, String keyPassword, KeyManagerFactory keyManagerFactory, String keyStore, long sessionCacheSize, long sessionTimeout, - ResumptionController resumptionController) + String endpointIdentificationAlgorithm, + ResumptionController resumptionController, + Map.Entry, Object>... options) throws SSLException { if (key == null && keyCertChain != null || key != null && keyCertChain == null) { throw new IllegalArgumentException( @@ -155,8 +158,11 @@ static OpenSslSessionContext newSessionContext(ReferenceCountedOpenSslContext th TrustManagerFactory.getDefaultAlgorithm()); trustManagerFactory.init((KeyStore) null); } - final X509TrustManager manager = chooseTrustManager( - trustManagerFactory.getTrustManagers(), resumptionController); + final boolean sanPeerIdentityLookup = isSanPeerIdentityLookupEnabled( + endpointIdentificationAlgorithm, options); + final X509TrustManager manager = wrapTrustManagerIfNeeded( + chooseTrustManager(trustManagerFactory.getTrustManagers(), resumptionController), + sanPeerIdentityLookup); // IMPORTANT: The callbacks set for verification must be static to prevent memory leak as // otherwise the context can never be collected. This is because the JNI code holds @@ -204,6 +210,37 @@ private static void setVerifyCallback(long ctx, OpenSslEngineMap engineMap, X509 } } + private static boolean isSanPeerIdentityLookupEnabled(String endpointIdentificationAlgorithm, + Map.Entry, Object>[] options) { + if (endpointIdentificationAlgorithm == null) { + return false; + } + if (options == null) { + return false; + } + for (Map.Entry, Object> option : options) { + if (option == null) { + continue; + } + if (SanPeerIdentityConfig.SAN_PEER_IDENTITY_LOOKUP.equals(option.getKey())) { + return Boolean.TRUE.equals(option.getValue()); + } + } + return false; + } + + @SuppressJava6Requirement(reason = "Usage guarded by java version check") + private static X509TrustManager wrapTrustManagerIfNeeded(X509TrustManager manager, + boolean sanPeerIdentityLookup) { + if (!sanPeerIdentityLookup) { + return manager; + } + if (useExtendedTrustManager(manager)) { + return new SanPeerIdentityTrustManager((X509ExtendedTrustManager) manager); + } + return manager; + } + static final class OpenSslClientSessionContext extends OpenSslSessionContext { OpenSslClientSessionContext(ReferenceCountedOpenSslContext context, OpenSslKeyMaterialProvider provider) { super(context, provider, SSL.SSL_SESS_CACHE_CLIENT, new OpenSslClientSessionCache(context.engineMap)); diff --git a/handler/src/main/java/io/netty/handler/ssl/SanPeerIdentityConfig.java b/handler/src/main/java/io/netty/handler/ssl/SanPeerIdentityConfig.java new file mode 100644 index 00000000000..f52e9c0cd29 --- /dev/null +++ b/handler/src/main/java/io/netty/handler/ssl/SanPeerIdentityConfig.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package io.netty.handler.ssl; + +final class SanPeerIdentityConfig { + static final SslContextOption SAN_PEER_IDENTITY_LOOKUP = + new SslContextOption("SAN_PEER_IDENTITY_LOOKUP"); + + private SanPeerIdentityConfig() { + } +} + diff --git a/handler/src/main/java/io/netty/handler/ssl/SanPeerIdentityTrustManager.java b/handler/src/main/java/io/netty/handler/ssl/SanPeerIdentityTrustManager.java new file mode 100644 index 00000000000..5a457230106 --- /dev/null +++ b/handler/src/main/java/io/netty/handler/ssl/SanPeerIdentityTrustManager.java @@ -0,0 +1,347 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package io.netty.handler.ssl; + +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedTrustManager; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; + +final class SanPeerIdentityTrustManager extends X509ExtendedTrustManager { + private static final InternalLogger logger = + InternalLoggerFactory.getInstance(SanPeerIdentityTrustManager.class); + private static final int DNS_SAN_TYPE = 2; + private static final int IP_SAN_TYPE = 7; + + private final X509ExtendedTrustManager delegate; + + SanPeerIdentityTrustManager(X509ExtendedTrustManager delegate) { + this.delegate = delegate; + } + + static TrustManager wrapIfNeeded(TrustManager trustManager, boolean sanPeerIdentityLookup) { + if (sanPeerIdentityLookup && trustManager instanceof X509ExtendedTrustManager) { + return new SanPeerIdentityTrustManager((X509ExtendedTrustManager) trustManager); + } + return trustManager; + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + delegate.checkClientTrusted(chain, authType, socket); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + delegate.checkServerTrusted(chain, authType, socket); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + delegate.checkClientTrusted(chain, authType, engine); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + if (engine == null || chain == null || chain.length == 0 || chain[0] == null) { + logger.debug("SAN peer identity lookup skipped: engine={}, chainPresent={}, leafPresent={}", + engine != null, chain != null, chain != null && chain.length > 0 && chain[0] != null); + delegate.checkServerTrusted(chain, authType, engine); + return; + } + + String peerIdentity = resolvePeerIdentity(engine.getPeerHost(), chain[0]); + if (peerIdentity == null) { + logger.debug("SAN peer identity override not applied for peerHost={}", engine.getPeerHost()); + delegate.checkServerTrusted(chain, authType, engine); + return; + } + + logger.debug("Overriding peerHost from {} to {} for SAN peer identity verification", + engine.getPeerHost(), peerIdentity); + delegate.checkServerTrusted(chain, authType, new DelegatingPeerHostEngine(engine, peerIdentity)); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + delegate.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + delegate.checkServerTrusted(chain, authType); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return delegate.getAcceptedIssuers(); + } + + static String resolvePeerIdentity(String peerHost, X509Certificate leafCertificate) throws CertificateException { + logger.debug("Resolving SAN peer identity for peerHost={}", peerHost); + if (peerHost == null || peerHost.isEmpty()) { + logger.debug("SAN peer identity lookup aborted because peerHost is empty"); + return null; + } + + SanTypes sanTypes = readSanTypes(leafCertificate); + logger.debug("SAN types for peerHost={}: hasDnsSans={}, hasIpSans={}", + peerHost, sanTypes.hasDnsSans, sanTypes.hasIpSans); + if (!sanTypes.hasDnsSans && !sanTypes.hasIpSans) { + logger.debug("SAN peer identity lookup aborted for peerHost={} because certificate has no DNS/IP SANs", + peerHost); + return null; + } + + if (sanTypes.hasIpSans && !sanTypes.hasDnsSans) { + logger.debug("Using original peerHost={} because certificate only has IP SANs", peerHost); + return peerHost; + } + + if (sanTypes.hasDnsSans && !isIpAddress(peerHost)) { + logger.debug("Using original peerHost={} because it is already a hostname and certificate has DNS SANs", + peerHost); + return peerHost; + } + + String canonicalHost = reverseLookup(peerHost); + logger.debug("Reverse lookup for peerHost={} returned canonicalHost={}", peerHost, canonicalHost); + if (canonicalHost != null && !isIpAddress(canonicalHost)) { + logger.debug("Using reverse-looked-up canonicalHost={} for peerHost={}", canonicalHost, peerHost); + return canonicalHost; + } + + return peerHost; + } + + private static SanTypes readSanTypes(X509Certificate certificate) throws CertificateException { + boolean hasDnsSans = false; + boolean hasIpSans = false; + try { + Collection> subjectAlternativeNames = certificate.getSubjectAlternativeNames(); + if (subjectAlternativeNames == null) { + return new SanTypes(false, false); + } + + for (List entry : subjectAlternativeNames) { + if (entry == null || entry.size() < 2 || !(entry.get(0) instanceof Integer)) { + continue; + } + + int type = ((Integer) entry.get(0)).intValue(); + if (type == DNS_SAN_TYPE) { + hasDnsSans = true; + } else if (type == IP_SAN_TYPE) { + hasIpSans = true; + } + } + return new SanTypes(hasDnsSans, hasIpSans); + } catch (CertificateException e) { + throw e; + } catch (Exception e) { + throw new CertificateException("Failed to inspect certificate SAN entries", e); + } + } + + private static boolean isIpAddress(String value) { + return value.indexOf(':') >= 0 || value.matches("\\d+\\.\\d+\\.\\d+\\.\\d+"); + } + + private static String reverseLookup(String peerHost) { + try { + return InetAddress.getByName(peerHost).getCanonicalHostName(); + } catch (UnknownHostException e) { + return null; + } + } + + private static final class SanTypes { + final boolean hasDnsSans; + final boolean hasIpSans; + + SanTypes(boolean hasDnsSans, boolean hasIpSans) { + this.hasDnsSans = hasDnsSans; + this.hasIpSans = hasIpSans; + } + } + + private static final class DelegatingPeerHostEngine extends JdkSslEngine { + private final String peerHost; + + DelegatingPeerHostEngine(SSLEngine engine, String peerHost) { + super(engine); + this.peerHost = peerHost; + } + + @Override + public String getPeerHost() { + return peerHost; + } + + @Override + public SSLSession getHandshakeSession() { + final SSLSession session = super.getHandshakeSession(); + if (session == null) { + return null; + } + return new DelegatingPeerHostSession(session, peerHost); + } + + @Override + public SSLSession getSession() { + final SSLSession session = super.getSession(); + if (session == null) { + return null; + } + return new DelegatingPeerHostSession(session, peerHost); + } + } + + private static final class DelegatingPeerHostSession implements SSLSession { + private final SSLSession delegate; + private final String peerHost; + + DelegatingPeerHostSession(SSLSession delegate, String peerHost) { + this.delegate = delegate; + this.peerHost = peerHost; + } + + @Override + public byte[] getId() { + return delegate.getId(); + } + + @Override + public javax.net.ssl.SSLSessionContext getSessionContext() { + return delegate.getSessionContext(); + } + + @Override + public long getCreationTime() { + return delegate.getCreationTime(); + } + + @Override + public long getLastAccessedTime() { + return delegate.getLastAccessedTime(); + } + + @Override + public void invalidate() { + delegate.invalidate(); + } + + @Override + public boolean isValid() { + return delegate.isValid(); + } + + @Override + public void putValue(String name, Object value) { + delegate.putValue(name, value); + } + + @Override + public Object getValue(String name) { + return delegate.getValue(name); + } + + @Override + public void removeValue(String name) { + delegate.removeValue(name); + } + + @Override + public String[] getValueNames() { + return delegate.getValueNames(); + } + + @Override + public java.security.cert.Certificate[] getPeerCertificates() + throws javax.net.ssl.SSLPeerUnverifiedException { + return delegate.getPeerCertificates(); + } + + @Override + public java.security.cert.Certificate[] getLocalCertificates() { + return delegate.getLocalCertificates(); + } + + @Override + public javax.security.cert.X509Certificate[] getPeerCertificateChain() + throws javax.net.ssl.SSLPeerUnverifiedException { + return delegate.getPeerCertificateChain(); + } + + @Override + public java.security.Principal getPeerPrincipal() + throws javax.net.ssl.SSLPeerUnverifiedException { + return delegate.getPeerPrincipal(); + } + + @Override + public java.security.Principal getLocalPrincipal() { + return delegate.getLocalPrincipal(); + } + + @Override + public String getCipherSuite() { + return delegate.getCipherSuite(); + } + + @Override + public String getProtocol() { + return delegate.getProtocol(); + } + + @Override + public String getPeerHost() { + return peerHost; + } + + @Override + public int getPeerPort() { + return delegate.getPeerPort(); + } + + @Override + public int getPacketBufferSize() { + return delegate.getPacketBufferSize(); + } + + @Override + public int getApplicationBufferSize() { + return delegate.getApplicationBufferSize(); + } + } +} + diff --git a/handler/src/main/java/io/netty/handler/ssl/SslContext.java b/handler/src/main/java/io/netty/handler/ssl/SslContext.java index af13a4d9114..d9f0ec68521 100644 --- a/handler/src/main/java/io/netty/handler/ssl/SslContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/SslContext.java @@ -808,7 +808,7 @@ public static SslContext newClientContext( toX509Certificates(keyCertChainFile), toPrivateKey(keyFile, keyPassword), keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, null, sessionCacheSize, sessionTimeout, false, - null, KeyStore.getDefaultType(), null); + null, KeyStore.getDefaultType(), null, false, null); } catch (Exception e) { if (e instanceof SSLException) { throw (SSLException) e; @@ -825,7 +825,7 @@ static SslContext newClientContextInternal( Iterable ciphers, CipherSuiteFilter cipherFilter, ApplicationProtocolConfig apn, String[] protocols, long sessionCacheSize, long sessionTimeout, boolean enableOcsp, SecureRandom secureRandom, String keyStoreType, String endpointIdentificationAlgorithm, - Map.Entry, Object>... options) throws SSLException { + boolean sanPeerIdentityLookup, Map.Entry, Object>... options) throws SSLException { if (provider == null) { provider = defaultClientProvider(); } @@ -841,7 +841,7 @@ static SslContext newClientContextInternal( trustCert, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, protocols, sessionCacheSize, sessionTimeout, secureRandom, keyStoreType, endpointIdentificationAlgorithm, - resumptionController); + resumptionController, sanPeerIdentityLookup); case OPENSSL: verifyNullSslContextProvider(provider, sslContextProvider); OpenSsl.ensureAvailability(); diff --git a/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java b/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java index 2238881a68d..6592a564fbb 100644 --- a/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java +++ b/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java @@ -210,6 +210,7 @@ public static SslContextBuilder forServer(KeyManager keyManager) { private SecureRandom secureRandom; private String keyStoreType = KeyStore.getDefaultType(); private String endpointIdentificationAlgorithm; + private boolean sanPeerIdentityLookup; private final Map, Object> options = new HashMap, Object>(); private SslContextBuilder(boolean forServer) { @@ -220,9 +221,13 @@ private SslContextBuilder(boolean forServer) { * Configure a {@link SslContextOption}. */ public SslContextBuilder option(SslContextOption option, T value) { + if (SanPeerIdentityConfig.SAN_PEER_IDENTITY_LOOKUP.equals(option)) { + sanPeerIdentityLookup = value != null && Boolean.TRUE.equals(value); + } if (value == null) { options.remove(option); } else { + option.validate(value); options.put(option, value); } return this; @@ -633,6 +638,17 @@ public SslContextBuilder endpointIdentificationAlgorithm(String algorithm) { return this; } + /** + * Enables SAN-driven peer identity lookup for client-side endpoint verification. + *

+ * When enabled and hostname verification is configured, Netty inspects the peer certificate SAN entries and may + * preserve the original host or reverse-resolve an IP literal to a canonical hostname before delegating to the + * underlying trust manager. + */ + public SslContextBuilder sanPeerIdentityLookup(boolean enabled) { + return option(SanPeerIdentityConfig.SAN_PEER_IDENTITY_LOOKUP, Boolean.valueOf(enabled)); + } + /** * Create new {@code SslContext} instance with configured settings. *

If {@link #sslProvider(SslProvider)} is set to {@link SslProvider#OPENSSL_REFCNT} then the caller is @@ -649,7 +665,7 @@ public SslContext build() throws SSLException { trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, protocols, sessionCacheSize, sessionTimeout, enableOcsp, secureRandom, keyStoreType, endpointIdentificationAlgorithm, - toArray(options.entrySet(), EMPTY_ENTRIES)); + sanPeerIdentityLookup, toArray(options.entrySet(), EMPTY_ENTRIES)); } } diff --git a/handler/src/test/java/io/netty/handler/ssl/SanPeerIdentityTrustManagerTest.java b/handler/src/test/java/io/netty/handler/ssl/SanPeerIdentityTrustManagerTest.java new file mode 100644 index 00000000000..b428889d35c --- /dev/null +++ b/handler/src/test/java/io/netty/handler/ssl/SanPeerIdentityTrustManagerTest.java @@ -0,0 +1,249 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package io.netty.handler.ssl; + +import io.netty.util.internal.EmptyArrays; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import java.math.BigInteger; +import java.security.Principal; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SanPeerIdentityTrustManagerTest { + + // Verifies the method exits early when there is no usable peer host + @Test + public void testResolvePeerIdentityReturnsNullForEmptyPeerHost() throws Exception { + assertEquals(null, SanPeerIdentityTrustManager.resolvePeerIdentity( + "", certificateWithSans(dnsSan("node1.example.com")))); + assertEquals(null, SanPeerIdentityTrustManager.resolvePeerIdentity( + null, certificateWithSans(dnsSan("node1.example.com")))); + } + + // Verifies the method does not try to choose a peer identity when the certificate has no relevant SAN entries + @Test + public void testResolvePeerIdentityReturnsNullWhenCertificateHasNoDnsOrIpSans() throws Exception { + assertEquals(null, SanPeerIdentityTrustManager.resolvePeerIdentity( + "10.0.0.1", certificateWithSans(Arrays.asList(1, "ignored")))); + } + + // Verifies that when the certificate only contains IP SANs, the method keeps using the original peer host + @Test + public void testResolvePeerIdentityKeepsOriginalPeerHostForIpOnlySans() throws Exception { + assertEquals("10.0.0.1", SanPeerIdentityTrustManager.resolvePeerIdentity( + "10.0.0.1", certificateWithSans(ipSan("10.0.0.1")))); + } + + // Verifies that if the peer host is already a hostname and the certificate has DNS SANs, no rewrite is needed + @Test + public void testResolvePeerIdentityKeepsOriginalPeerHostForDnsSansWhenPeerIsHostname() throws Exception { + assertEquals("node1.example.com", SanPeerIdentityTrustManager.resolvePeerIdentity( + "node1.example.com", certificateWithSans(dnsSan("node1.example.com")))); + } + + // Verifies the fallback path after attempting reverse lookup for an IP peer host with DNS SANs. + @Test + public void testResolvePeerIdentityFallsBackToOriginalPeerHostWhenReverseLookupDoesNotYieldHostname() + throws Exception { + assertEquals("192.0.2.10", SanPeerIdentityTrustManager.resolvePeerIdentity( + "192.0.2.10", certificateWithSans(dnsSan("node1.example.com")))); + } + + @Test + public void testWrapIfNeededWrapsOnlyExtendedTrustManagersWhenEnabled() { + TrustManager wrapped = SanPeerIdentityTrustManager.wrapIfNeeded(new EmptyExtendedX509TrustManager(), true); + assertTrue(wrapped instanceof SanPeerIdentityTrustManager); + + TrustManager notWrappedWhenDisabled = + SanPeerIdentityTrustManager.wrapIfNeeded(new EmptyExtendedX509TrustManager(), false); + assertTrue(notWrappedWhenDisabled instanceof EmptyExtendedX509TrustManager); + + TrustManager plainTrustManager = new TrustManager() { }; + assertSame(plainTrustManager, SanPeerIdentityTrustManager.wrapIfNeeded(plainTrustManager, true)); + } + + private static X509Certificate certificateWithSans(List... sans) { + final Collection> subjectAlternativeNames = sans.length == 0 ? Collections.>emptyList() + : Arrays.>asList(sans); + return new TestX509Certificate(subjectAlternativeNames); + } + + private static List dnsSan(String value) { + return Arrays.asList(Integer.valueOf(2), value); + } + + private static List ipSan(String value) { + return Arrays.asList(Integer.valueOf(7), value); + } + + private static final class TestX509Certificate extends X509Certificate { + private final Collection> subjectAlternativeNames; + + TestX509Certificate(Collection> subjectAlternativeNames) { + this.subjectAlternativeNames = subjectAlternativeNames; + } + + @Override + public Collection> getSubjectAlternativeNames() { + return subjectAlternativeNames; + } + + @Override + public void checkValidity() { + // NOOP + } + + @Override + public void checkValidity(Date date) { + // NOOP + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public BigInteger getSerialNumber() { + return null; + } + + @Override + public Principal getIssuerDN() { + return null; + } + + @Override + public Principal getSubjectDN() { + return null; + } + + @Override + public Date getNotBefore() { + return null; + } + + @Override + public Date getNotAfter() { + return null; + } + + @Override + public byte[] getTBSCertificate() { + return EmptyArrays.EMPTY_BYTES; + } + + @Override + public byte[] getSignature() { + return EmptyArrays.EMPTY_BYTES; + } + + @Override + public String getSigAlgName() { + return null; + } + + @Override + public String getSigAlgOID() { + return null; + } + + @Override + public byte[] getSigAlgParams() { + return EmptyArrays.EMPTY_BYTES; + } + + @Override + public boolean[] getIssuerUniqueID() { + return new boolean[0]; + } + + @Override + public boolean[] getSubjectUniqueID() { + return new boolean[0]; + } + + @Override + public boolean[] getKeyUsage() { + return new boolean[0]; + } + + @Override + public int getBasicConstraints() { + return 0; + } + + @Override + public byte[] getEncoded() { + return EmptyArrays.EMPTY_BYTES; + } + + @Override + public void verify(PublicKey key) { + // NOOP + } + + @Override + public void verify(PublicKey key, String sigProvider) { + // NOOP + } + + @Override + public String toString() { + return "TestX509Certificate"; + } + + @Override + public PublicKey getPublicKey() { + return null; + } + + @Override + public boolean hasUnsupportedCriticalExtension() { + return false; + } + + @Override + public Set getCriticalExtensionOIDs() { + return null; + } + + @Override + public Set getNonCriticalExtensionOIDs() { + return null; + } + + @Override + public byte[] getExtensionValue(String oid) { + return EmptyArrays.EMPTY_BYTES; + } + } +} +