This is an automated email from the ASF dual-hosted git repository. dsoumis pushed a commit to branch 11.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit c2f69f5e13a3186d38106f48e6c964f957060aa5 Author: Dimitris Soumis <[email protected]> AuthorDate: Wed Oct 28 15:23:07 2026 +0200 Use the certificate actually served after Windows-MY alias resolution The configured certificateKeyAlias can differ from the certificate actually served once the SunMSCAPI Windows-MY numbered-duplicate resolution has selected a different alias. Consumers that looked the certificate up by the configured alias therefore reported or monitored the wrong certificate. --- .../apache/catalina/manager/ManagerServlet.java | 7 +- .../apache/tomcat/util/net/AbstractEndpoint.java | 19 +++- .../apache/tomcat/util/net/LocalStrings.properties | 1 + java/org/apache/tomcat/util/net/SSLContext.java | 38 ++++++- java/org/apache/tomcat/util/net/SSLHostConfig.java | 6 +- .../tomcat/util/net/SSLHostConfigCertificate.java | 25 +++++ .../tomcat/util/net/jsse/JSSESSLContext.java | 17 +++ .../SSLUtilBaseCertificateSelectionTestBase.java | 125 +++++++++++++++++++-- .../TestSSLUtilBaseConfiguredAliasSelection.java | 59 ++++++++++ 9 files changed, 268 insertions(+), 29 deletions(-) diff --git a/java/org/apache/catalina/manager/ManagerServlet.java b/java/org/apache/catalina/manager/ManagerServlet.java index 71cdb510cb..8d899467dc 100644 --- a/java/org/apache/catalina/manager/ManagerServlet.java +++ b/java/org/apache/catalina/manager/ManagerServlet.java @@ -75,7 +75,6 @@ import org.apache.tomcat.util.modeler.Registry; import org.apache.tomcat.util.net.SSLContext; import org.apache.tomcat.util.net.SSLHostConfig; import org.apache.tomcat.util.net.SSLHostConfigCertificate; -import org.apache.tomcat.util.net.SSLUtilBase; import org.apache.tomcat.util.res.StringManager; import org.apache.tomcat.util.security.Escape; @@ -1690,14 +1689,10 @@ public class ManagerServlet extends HttpServlet implements ContainerServlet { sslHostConfigCert.getType(); List<String> certList = new ArrayList<>(); SSLContext sslContext = sslHostConfigCert.getSslContext(); - String alias = sslHostConfigCert.getCertificateKeyAlias(); - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; - } if (sslContext == null) { certList.add(smClient.getString("managerServlet.certsNotLoaded")); } else { - X509Certificate[] certs = sslContext.getCertificateChain(alias); + X509Certificate[] certs = sslContext.getCertificateChain(sslHostConfigCert); if (certs == null) { certList.add(smClient.getString("managerServlet.certsNotAvailable")); } else { diff --git a/java/org/apache/tomcat/util/net/AbstractEndpoint.java b/java/org/apache/tomcat/util/net/AbstractEndpoint.java index 18037a6fef..0c8e93fa16 100644 --- a/java/org/apache/tomcat/util/net/AbstractEndpoint.java +++ b/java/org/apache/tomcat/util/net/AbstractEndpoint.java @@ -613,6 +613,7 @@ public abstract class AbstractEndpoint<S, U> { */ protected void logCertificate(SSLHostConfigCertificate certificate) { SSLHostConfig sslHostConfig = certificate.getSSLHostConfig(); + SSLContext sslContext = certificate.getSslContext(); String certificateInfo; @@ -647,13 +648,19 @@ public abstract class AbstractEndpoint<S, U> { getLogCertificate().info(sm.getString("endpoint.tls.info", getName(), sslHostConfig.getHostName(), certificate.getType(), certificateInfo, trustStoreSource)); - SSLContext sslContext = certificate.getSslContext(); - if (getLogCertificate().isDebugEnabled() && sslContext != null) { - String alias = certificate.getCertificateKeyAlias(); - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; + // When a configured alias was resolved to a different alias that is actually served (e.g. a SunMSCAPI + // Windows-MY numbered duplicate), report it so the alias logged above is not mistaken for the one in use. + String configuredAlias = certificate.getCertificateKeyAlias(); + if (sslContext != null && configuredAlias != null) { + String servedAlias = sslContext.getServedAlias(certificate); + if (servedAlias != null && !servedAlias.equals(configuredAlias)) { + getLogCertificate() + .info(sm.getString("endpoint.tls.info.cert.aliasResolved", configuredAlias, servedAlias)); } - X509Certificate[] x509Certificates = sslContext.getCertificateChain(alias); + } + + if (getLogCertificate().isDebugEnabled() && sslContext != null) { + X509Certificate[] x509Certificates = sslContext.getCertificateChain(certificate); if (x509Certificates != null && x509Certificates.length > 0) { getLogCertificate().debug(generateCertificateDebug(x509Certificates[0])); } else { diff --git a/java/org/apache/tomcat/util/net/LocalStrings.properties b/java/org/apache/tomcat/util/net/LocalStrings.properties index ca9335dc43..b6fee25844 100644 --- a/java/org/apache/tomcat/util/net/LocalStrings.properties +++ b/java/org/apache/tomcat/util/net/LocalStrings.properties @@ -132,6 +132,7 @@ endpoint.tls.cert.encodingError=Certificate fingerprints not available endpoint.tls.cert.noCerts=Certificate details not available as the certificate chain returned from the SSLContext was empty endpoint.tls.enabledGroups=Configured named groups {0} for the TLS connector endpoint.tls.info=Connector [{0}], TLS virtual host [{1}], certificate type [{2}] configured from {3} with trust store [{4}] +endpoint.tls.info.cert.aliasResolved=The configured certificate alias [{0}] was resolved to alias [{1}] which is valid for TLS server authentication endpoint.tls.info.cert.keystore=keystore [{0}] using alias [{1}] endpoint.tls.info.cert.keystore.direct=Set directly - location unknown endpoint.tls.info.cert.pem=key [{0}], certificate [{1}] and certificate chain [{2}] diff --git a/java/org/apache/tomcat/util/net/SSLContext.java b/java/org/apache/tomcat/util/net/SSLContext.java index f51287aa80..25adf54622 100644 --- a/java/org/apache/tomcat/util/net/SSLContext.java +++ b/java/org/apache/tomcat/util/net/SSLContext.java @@ -90,13 +90,49 @@ public interface SSLContext { SSLParameters getSupportedSSLParameters(); /** - * Returns the certificate chain for the given alias. + * Returns the certificate chain for the given key store alias. This is a low-level lookup by exact alias; callers + * that want the certificate actually served for a configured {@link SSLHostConfigCertificate} should use + * {@link #getCertificateChain(SSLHostConfigCertificate)}, which resolves the served alias (it can differ from the + * configured alias, for example after SunMSCAPI Windows-MY numbered-duplicate resolution). * * @param alias The alias + * * @return The certificate chain */ X509Certificate[] getCertificateChain(String alias); + + /** + * Returns the alias the key manager would select to serve for the given certificate configuration during a + * handshake. This can differ from {@link SSLHostConfigCertificate#getCertificateKeyAlias()}, for example after + * SunMSCAPI Windows-MY numbered-duplicate resolution. The default returns the configured alias (or + * {@link SSLUtilBase#DEFAULT_KEY_ALIAS} when none is configured); implementations backed by a key manager override + * it to resolve the alias actually served. + * + * @param certificate The certificate configuration + * + * @return The served alias, never {@code null} + */ + default String getServedAlias(SSLHostConfigCertificate certificate) { + String alias = certificate.getCertificateKeyAlias(); + return alias == null ? SSLUtilBase.DEFAULT_KEY_ALIAS : alias; + } + + + /** + * Returns the certificate chain actually served for the given certificate configuration. Unlike + * {@link #getCertificateChain(String)}, which looks up a fixed alias, this uses {@link #getServedAlias}, so callers + * such as the certificate reload check observe the certificate that is really in use. This can differ from the + * configured alias, for example after SunMSCAPI Windows-MY duplicate resolution. + * + * @param certificate The certificate configuration + * + * @return The served certificate chain, or {@code null} if none can be determined + */ + default X509Certificate[] getCertificateChain(SSLHostConfigCertificate certificate) { + return getCertificateChain(getServedAlias(certificate)); + } + /** * Returns the accepted issuers. * diff --git a/java/org/apache/tomcat/util/net/SSLHostConfig.java b/java/org/apache/tomcat/util/net/SSLHostConfig.java index df0125ce86..e306cb8c62 100644 --- a/java/org/apache/tomcat/util/net/SSLHostConfig.java +++ b/java/org/apache/tomcat/util/net/SSLHostConfig.java @@ -1615,11 +1615,7 @@ public class SSLHostConfig implements Serializable { for (SSLHostConfigCertificate sslHostConfigCertificate : sslHostConfigCertificates) { SSLContext sslContext = sslHostConfigCertificate.getSslContext(); if (sslContext != null) { - String alias = sslHostConfigCertificate.getCertificateKeyAlias(); - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; - } - X509Certificate[] certificates = sslContext.getCertificateChain(alias); + X509Certificate[] certificates = sslContext.getCertificateChain(sslHostConfigCertificate); if (certificates != null && certificates.length > 0) { X509Certificate certificate = certificates[0]; Date expirationDate = certificate.getNotAfter(); diff --git a/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java b/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java index b7d67010bd..3e59bc541f 100644 --- a/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java +++ b/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java @@ -245,6 +245,31 @@ public class SSLHostConfigCertificate implements Serializable { } + /** + * Returns the alias the given key manager would choose to serve for this certificate, or {@code null} if it has no + * suitable entry. When {@link #getType()} is {@link Type#UNDEFINED} every key type is tried. This reflects the + * alias actually used in a handshake, which can differ from {@link #getCertificateKeyAlias()} - for example after + * the SunMSCAPI Windows-MY numbered-duplicate resolution performed by SSLUtilBase#getKeyManagers(). + * + * @param keyManager the key manager that will serve this certificate + * + * @return the served alias, or {@code null} if the key manager has no suitable entry + */ + public String chooseServerAlias(X509KeyManager keyManager) { + Type[] candidateTypes = Type.UNDEFINED.equals(type) ? Type.values() : new Type[] { type }; + for (Type candidateType : candidateTypes) { + if (candidateType == Type.UNDEFINED) { + continue; + } + String alias = keyManager.chooseServerAlias(candidateType.getKeyType(), null, null); + if (alias != null) { + return alias; + } + } + return null; + } + + /** * Returns the password for the certificate's private key. * diff --git a/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java b/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java index 088257110b..9679da9a1a 100644 --- a/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java +++ b/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java @@ -34,6 +34,7 @@ import javax.net.ssl.X509KeyManager; import javax.net.ssl.X509TrustManager; import org.apache.tomcat.util.net.SSLContext; +import org.apache.tomcat.util.net.SSLHostConfigCertificate; class JSSESSLContext implements SSLContext { @@ -89,6 +90,22 @@ class JSSESSLContext implements SSLContext { return result; } + + @Override + public String getServedAlias(SSLHostConfigCertificate certificate) { + if (kms != null) { + for (KeyManager km : kms) { + if (km instanceof X509KeyManager x509KeyManager) { + String alias = certificate.chooseServerAlias(x509KeyManager); + if (alias != null) { + return alias; + } + } + } + } + return SSLContext.super.getServedAlias(certificate); + } + @Override public X509Certificate[] getAcceptedIssuers() { Set<X509Certificate> certs = new HashSet<>(); diff --git a/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java b/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java index 8a9f9be7b6..c549e4603c 100644 --- a/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java +++ b/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java @@ -19,14 +19,18 @@ package org.apache.tomcat.util.net; import java.io.File; import java.io.Serial; +import java.security.Key; import java.security.KeyStore; import java.security.KeyStoreSpi; +import java.security.PrivateKey; import java.security.Provider; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Collections; +import java.util.Date; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import javax.net.ssl.X509KeyManager; @@ -140,11 +144,68 @@ public abstract class SSLUtilBaseCertificateSelectionTestBase extends TomcatBase } + /* + * Builds and registers an SSLContext for the given configured alias and key store, then returns the certificates + * that SSLHostConfig.certificatesExpiringBefore() - the input to the TLS certificate reload listener - would flag. + * The cut-off date is far enough in the future that every certificate qualifies, so the result is exactly the set + * of certificates the listener monitors for reload. + */ + protected Set<X509Certificate> certificatesMonitoredForReload(String alias, KeyStore keystore) throws Exception { + SSLHostConfigCertificate certificate = registeredCertificate(alias, keystore); + SSLContext context = sslUtil(certificate).createSSLContext(List.of()); + try { + certificate.setSslContext(context); + Date farFuture = new Date(System.currentTimeMillis() + 3650L * 24 * 60 * 60 * 1000); + return certificate.getSSLHostConfig().certificatesExpiringBefore(farFuture); + } finally { + context.destroy(); + } + } + + + /* + * Builds and registers an SSLContext for the given configured alias and key store, then returns the alias the + * context reports as actually served - which is what the certificate logging and reload paths resolve. + */ + protected String servedAlias(String alias, KeyStore keystore) throws Exception { + SSLHostConfigCertificate certificate = registeredCertificate(alias, keystore); + SSLContext context = sslUtil(certificate).createSSLContext(List.of()); + try { + certificate.setSslContext(context); + return context.getServedAlias(certificate); + } finally { + context.destroy(); + } + } + + + private static SSLHostConfigCertificate registeredCertificate(String alias, KeyStore keystore) { + SSLHostConfig hostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(hostConfig, Type.UNDEFINED); + certificate.setCertificateKeystore(keystore); + certificate.setCertificateKeystoreType("JKS"); + certificate.setCertificateKeystorePassword(TesterSupport.JKS_PASS); + if (alias != null) { + certificate.setCertificateKeyAlias(alias); + } + hostConfig.addCertificate(certificate); + return certificate; + } + + protected static KeyStore orderedKeystore(String malformedAlias, KeystoreEntry... entries) throws Exception { - return orderedKeystore(new Provider("SunMSCAPI", "1.0", "test") { - @Serial - private static final long serialVersionUID = -4141665633237722962L; - }, "Windows-MY", malformedAlias, entries); + return orderedKeystore(sunMSCAPIProvider(), "Windows-MY", true, malformedAlias, entries); + } + + + /* + * As orderedKeystore(String, KeystoreEntry...) but the private keys report a null format, like a non-exportable + * SunMSCAPI key. This routes SSLUtilBase.getKeyManagers() through its production path that keeps the original key + * store (ksUsed == ks) and wraps each key manager, rather than the in-memory-copy branch the exportable JKS-backed + * mock otherwise takes. + */ + protected static KeyStore nonExportableKeystore(KeystoreEntry... entries) throws Exception { + return orderedKeystore(sunMSCAPIProvider(), "Windows-MY", false, null, entries); } @@ -152,12 +213,20 @@ public abstract class SSLUtilBaseCertificateSelectionTestBase extends TomcatBase return orderedKeystore(new Provider("SUN", "1.0", "test") { @Serial private static final long serialVersionUID = 4434321038151233913L; - }, "JKS", null, entries); + }, "JKS", true, null, entries); } - protected static KeyStore orderedKeystore(Provider provider, String type, String malformedAlias, - KeystoreEntry... entries) throws Exception { + private static Provider sunMSCAPIProvider() { + return new Provider("SunMSCAPI", "1.0", "test") { + @Serial + private static final long serialVersionUID = -4141665633237722962L; + }; + } + + + protected static KeyStore orderedKeystore(Provider provider, String type, boolean exportableKeys, + String malformedAlias, KeystoreEntry... entries) throws Exception { File file = TesterKeystoreGenerator.generateKeystore(List.of(entries)); KeyStore delegate = KeyStore.getInstance(file, TesterSupport.JKS_PASS.toCharArray()); List<String> aliases = Stream.of(entries).map(KeystoreEntry::alias).toList(); @@ -180,11 +249,14 @@ public abstract class SSLUtilBaseCertificateSelectionTestBase extends TomcatBase }); EasyMock.expect(spi.engineGetCertificateChain(EasyMock.anyString())) .andStubAnswer(() -> delegate.getCertificateChain(EasyMock.getCurrentArgument(0))); - EasyMock.expect(spi.engineGetKey(EasyMock.anyString(), EasyMock.anyObject(char[].class))) - .andStubAnswer(() -> delegate.getKey(EasyMock.getCurrentArgument(0), EasyMock.getCurrentArgument(1))); + EasyMock.expect(spi.engineGetKey(EasyMock.anyString(), EasyMock.anyObject(char[].class))).andStubAnswer(() -> { + Key key = delegate.getKey(EasyMock.getCurrentArgument(0), EasyMock.getCurrentArgument(1)); + return exportableKeys ? key : nonExportableView(key); + }); EasyMock.replay(spi); - // A real JKS backs the mock, so keys report PKCS#8 and getKeyManagers() takes its in-memory-copy branch - // rather than SunMSCAPI's non-exportable-key path; the provider name and store type are overridden only so + // A real JKS backs the mock. With exportableKeys the keys report PKCS#8, so getKeyManagers() takes its + // in-memory-copy branch; with exportableKeys == false they report a null format (see nonExportableView), so it + // takes SunMSCAPI's non-exportable-key path instead. The provider name and store type are overridden only so // isSunMSCAPIWindowsMY() can be exercised off Windows. KeyStore result = new KeyStore(spi, provider, type) { }; result.load(null, null); @@ -192,6 +264,37 @@ public abstract class SSLUtilBaseCertificateSelectionTestBase extends TomcatBase } + /* + * A view of a private key that reports a null format, as a non-exportable SunMSCAPI key does, while staying usable + * by the key manager. getEncoded() returns null for the same reason; these selection tests never sign, so the key + * material itself is never needed. + */ + private static Key nonExportableView(Key key) { + if (!(key instanceof PrivateKey privateKey)) { + return key; + } + return new PrivateKey() { + @Serial + private static final long serialVersionUID = 1L; + + @Override + public String getAlgorithm() { + return privateKey.getAlgorithm(); + } + + @Override + public String getFormat() { + return null; + } + + @Override + public byte[] getEncoded() { + return null; + } + }; + } + + protected static void assertEku(X509Certificate certificate, String oid) throws Exception { List<String> extendedKeyUsage = certificate.getExtendedKeyUsage(); Assert.assertNotNull("Presented certificate has no Extended Key Usage", extendedKeyUsage); diff --git a/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java b/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java index 911180f643..05189fc0ef 100644 --- a/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java +++ b/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java @@ -20,8 +20,10 @@ package org.apache.tomcat.util.net; import java.io.IOException; import java.security.KeyStore; import java.security.cert.X509Certificate; +import java.util.Set; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -160,6 +162,27 @@ public class TestSSLUtilBaseConfiguredAliasSelection extends SSLUtilBaseCertific } + /* + * After the SunMSCAPI numbered-duplicate swap, the certificate served to clients is a numbered duplicate of the + * configured alias. The TLS certificate reload listener (via SSLHostConfig.certificatesExpiringBefore()) must + * monitor that served certificate, not the certificate at the configured alias. Non-exportable keys route + * getKeyManagers() through the production ksUsed == ks path a real SunMSCAPI store uses. Scoped to JSSE: for + * OpenSSL the served certificate is held in the native layer and resolved by a separate code path. + */ + @Test + public void testReloadListenerMonitorsTheServedCertificate() throws Exception { + Assume.assumeTrue("JSSE".equals(connectorName)); + KeyStore keystore = nonExportableKeystore(key(HOST, CLIENT_AUTH), key(HOST + " (1)", SERVER_AUTH)); + + Set<X509Certificate> monitored = certificatesMonitoredForReload(HOST, keystore); + + X509Certificate served = (X509Certificate) keystore.getCertificateChain(HOST + " (1)")[0]; + X509Certificate notServed = (X509Certificate) keystore.getCertificateChain(HOST)[0]; + Assert.assertTrue("The served certificate must be monitored for reload", monitored.contains(served)); + Assert.assertFalse("The unserved configured certificate must not be monitored", monitored.contains(notServed)); + } + + /* * In a SunMSCAPI Windows-MY store a certificate without a private key can take the bare friendly name while the * key-bearing certificate of the same name is exposed as a numbered duplicate. The configured alias then names a @@ -187,4 +210,40 @@ public class TestSSLUtilBaseConfiguredAliasSelection extends SSLUtilBaseCertific key("tomcat (2)", SERVER_AUTH)); assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); } + + + /* + * For an OpenSSL connector the served certificate is loaded into the native layer, but the reload check must still + * report it. With exportable keys getKeyManagers() copies only the resolved key into an in-memory store, so + * OpenSSLContext.getCertificateChain() finds nothing under the configured alias and already falls back to the served + * alias - no OpenSSL-specific change is needed. (Non-exportable SunMSCAPI keys cannot be loaded by OpenSSL at all, + * so this exportable case is the only one that runs.) + */ + @Test + public void testReloadResolvesServedCertificateWithOpenSSL() throws Exception { + Assume.assumeTrue(useOpenSSL); + KeyStore keystore = orderedKeystore(null, key(HOST, CLIENT_AUTH), key(HOST + " (1)", SERVER_AUTH)); + + Set<X509Certificate> monitored = certificatesMonitoredForReload(HOST, keystore); + + X509Certificate served = (X509Certificate) keystore.getCertificateChain(HOST + " (1)")[0]; + X509Certificate notServed = (X509Certificate) keystore.getCertificateChain(HOST)[0]; + Assert.assertTrue("OpenSSL reload must resolve the served certificate", monitored.contains(served)); + Assert.assertFalse("OpenSSL reload must not report the unserved configured certificate", + monitored.contains(notServed)); + } + + + /* + * After the SunMSCAPI numbered-duplicate swap the served alias is the numbered duplicate, not the configured alias. + * This is the alias that logCertificate reports and the reload check resolves. Non-exportable keys route + * getKeyManagers() through the production ksUsed == ks path. Scoped to JSSE: for OpenSSL the served alias is + * resolved from the native layer by a separate path. + */ + @Test + public void testServedAliasIsTheNumberedDuplicate() throws Exception { + Assume.assumeTrue("JSSE".equals(connectorName)); + KeyStore keystore = nonExportableKeystore(key(HOST, CLIENT_AUTH), key(HOST + " (1)", SERVER_AUTH)); + Assert.assertEquals(HOST + " (1)", servedAlias(HOST, keystore)); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
