diff --git a/java/org/apache/catalina/manager/ManagerServlet.java b/java/org/apache/catalina/manager/ManagerServlet.java index 71cdb510cb0f..7a4c77450adf 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.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,11 @@ protected Map> getConnectorCerts(StringManager smClient) { sslHostConfigCert.getType(); List 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( + sslContext.getServedAlias(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 522efbee7672..29724d3368a8 100644 --- a/java/org/apache/tomcat/util/net/AbstractEndpoint.java +++ b/java/org/apache/tomcat/util/net/AbstractEndpoint.java @@ -613,6 +613,7 @@ protected void createSSLContext(SSLHostConfig sslHostConfig) throws IllegalArgum */ protected void logCertificate(SSLHostConfigCertificate certificate) { SSLHostConfig sslHostConfig = certificate.getSSLHostConfig(); + SSLContext sslContext = certificate.getSslContext(); String certificateInfo; @@ -647,17 +648,24 @@ protected void logCertificate(SSLHostConfigCertificate certificate) { 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; + if (sslContext != null) { + String servedAlias = sslContext.getServedAlias(certificate); + + // When a configured alias was resolved to a different alias than 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 (configuredAlias != null && servedAlias != null && !servedAlias.equalsIgnoreCase(configuredAlias)) { + getLogCertificate() + .info(sm.getString("endpoint.tls.info.cert.aliasResolved", configuredAlias, servedAlias)); } - X509Certificate[] x509Certificates = sslContext.getCertificateChain(alias); - if (x509Certificates != null && x509Certificates.length > 0) { - getLogCertificate().debug(generateCertificateDebug(x509Certificates[0])); - } else { - getLogCertificate().debug(sm.getString("endpoint.tls.cert.noCerts")); + + if (getLogCertificate().isDebugEnabled()) { + X509Certificate[] x509Certificates = sslContext.getCertificateChain(servedAlias); + if (x509Certificates != null && x509Certificates.length > 0) { + getLogCertificate().debug(generateCertificateDebug(x509Certificates[0])); + } else { + getLogCertificate().debug(sm.getString("endpoint.tls.cert.noCerts")); + } } } } diff --git a/java/org/apache/tomcat/util/net/Constants.java b/java/org/apache/tomcat/util/net/Constants.java index 1669adbfb3b6..28fb7140f45c 100644 --- a/java/org/apache/tomcat/util/net/Constants.java +++ b/java/org/apache/tomcat/util/net/Constants.java @@ -68,6 +68,24 @@ public class Constants { */ public static final String SSL_PROTO_SSLv2Hello = "SSLv2Hello"; + /** + * EKU OIDs. + */ + public static final String SERVER_AUTH_EXTENDED_KEY_USAGE_OID = "1.3.6.1.5.5.7.3.1"; + public static final String ANY_EXTENDED_KEY_USAGE_OID = "2.5.29.37.0"; + + /** + * SunMSCAPI provider name. + */ + public static final String SUNMSCAPI_PROVIDER_NAME = "SunMSCAPI"; + + /** + * Windows-MY key store types. + */ + public static final String WINDOWS_MY_KEYSTORE_TYPE = "WINDOWS-MY"; + public static final String WINDOWS_MY_CURRENTUSER_KEYSTORE_TYPE = "WINDOWS-MY-CURRENTUSER"; + public static final String WINDOWS_MY_LOCALMACHINE_KEYSTORE_TYPE = "WINDOWS-MY-LOCALMACHINE"; + /** * Private constructor to prevent instantiation. */ diff --git a/java/org/apache/tomcat/util/net/LocalStrings.properties b/java/org/apache/tomcat/util/net/LocalStrings.properties index 6bc78573bdfc..a3290d4eee80 100644 --- a/java/org/apache/tomcat/util/net/LocalStrings.properties +++ b/java/org/apache/tomcat/util/net/LocalStrings.properties @@ -125,6 +125,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}] @@ -178,9 +179,12 @@ sslUtilBase.keystore_load_failed=Failed to load keystore type [{0}] with path [{ sslUtilBase.noCertFile=SSLHostConfig attribute certificateFile must be defined when using an SSL connector sslUtilBase.noCrlSupport=The truststoreProvider [{0}] does not support the certificateRevocationFile configuration option sslUtilBase.noKeys=No aliases for private keys found in key store +sslUtilBase.noServerAuthKey=The key store has private key entries but none has a certificate that is valid for TLS server authentication. Enable debug logging to see the skipped aliases, use a certificate whose Extended Key Usage permits server authentication, or configure certificateKeyAlias explicitly. sslUtilBase.noVerificationDepth=The truststoreProvider [{0}] does not support the certificateVerificationDepth configuration option sslUtilBase.noneSupported=None of the [{0}] specified are supported by the SSL engine : [{1}] +sslUtilBase.selectedServerAuthCert=Selected the certificate with alias [{0}] for TLS server authentication as the configured alias [{1}] is not valid for that purpose and the key store contains more than one certificate under that name sslUtilBase.skipped=Tomcat interprets the [{0}] attribute in a manner consistent with the latest OpenSSL development branch. Some of the specified [{0}] are not supported by the configured SSL engine for this connector (which may use JSSE or an older OpenSSL version) and have been skipped: [{1}] +sslUtilBase.skippedKeyUsage=Skipping certificate with alias [{0}] as it is not valid for TLS server authentication sslUtilBase.ssl3=SSLv3 has been explicitly enabled. This protocol is known to be insecure. sslUtilBase.tls13.auth=The JSSE TLS 1.3 implementation does not support post handshake authentication (PHA) and is therefore incompatible with optional certificate authentication sslUtilBase.trustedCertNotChecked=The validity dates of the trusted certificate with alias [{0}] were not checked as the certificate was of an unknown type diff --git a/java/org/apache/tomcat/util/net/SSLContext.java b/java/org/apache/tomcat/util/net/SSLContext.java index f51287aa800b..0f282f3b2d67 100644 --- a/java/org/apache/tomcat/util/net/SSLContext.java +++ b/java/org/apache/tomcat/util/net/SSLContext.java @@ -26,6 +26,7 @@ import javax.net.ssl.SSLServerSocketFactory; import javax.net.ssl.SSLSessionContext; import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; /** * This interface is needed to override the default SSLContext class to allow SSL implementation pluggability without @@ -93,10 +94,51 @@ default SSLEngine createSSLEngine(boolean clientMode) { * Returns the certificate chain for the given alias. * * @param alias The alias + * * @return The certificate chain */ X509Certificate[] getCertificateChain(String alias); + + /** + * Returns the key managers that may serve the given certificate configuration, in preference order. Used by + * {@link #getServedAlias} to resolve the alias actually served. The default returns an empty array (no key manager + * known); implementations backed by one or more key managers override it to expose them. + * + * @param certificate The certificate configuration + * + * @return The key managers that may serve the certificate, possibly empty, never {@code null} + */ + default X509KeyManager[] getServerKeyManagers(SSLHostConfigCertificate certificate) { + return new X509KeyManager[0]; + } + + + /** + * 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. Each key manager from {@link #getServerKeyManagers} is asked + * in turn; if none selects an alias the configured alias is used (or {@link SSLUtilBase#DEFAULT_KEY_ALIAS} when + * none is configured). + * + * @param certificate The certificate configuration + * + * @return The served alias, never {@code null} + */ + default String getServedAlias(SSLHostConfigCertificate certificate) { + for (X509KeyManager keyManager : getServerKeyManagers(certificate)) { + if (keyManager != null) { + String alias = certificate.chooseServerAlias(keyManager); + if (alias != null) { + return alias; + } + } + } + String configuredAlias = certificate.getCertificateKeyAlias(); + return configuredAlias == null ? SSLUtilBase.DEFAULT_KEY_ALIAS : configuredAlias; + } + + /** * Returns the accepted issuers. * diff --git a/java/org/apache/tomcat/util/net/SSLContextWrapper.java b/java/org/apache/tomcat/util/net/SSLContextWrapper.java index 280d4dbbcecb..5c75ad3764df 100644 --- a/java/org/apache/tomcat/util/net/SSLContextWrapper.java +++ b/java/org/apache/tomcat/util/net/SSLContextWrapper.java @@ -80,6 +80,22 @@ public X509Certificate[] getCertificateChain(String alias) { return keyManager.getCertificateChain(alias); } + + /** + * {@inheritDoc} + *

+ * The wrapped {@code javax.net.ssl.SSLContext} and its key manager are supplied pre-configured, so Tomcat cannot + * reliably predict which alias a handshake will serve: the socket-based {@link X509KeyManager#chooseServerAlias} + * callback used elsewhere to probe the served alias may disagree with the {@code chooseEngineServerAlias} callback + * that an {@code SSLEngine} handshake actually uses. The alias the operator configured is therefore the only + * dependable answer for certificate monitoring. + */ + @Override + public String getServedAlias(SSLHostConfigCertificate certificate) { + String configuredAlias = certificate.getCertificateKeyAlias(); + return configuredAlias == null ? SSLUtilBase.DEFAULT_KEY_ALIAS : configuredAlias; + } + @Override public X509Certificate[] getAcceptedIssuers() { return trustManager.getAcceptedIssuers(); diff --git a/java/org/apache/tomcat/util/net/SSLHostConfig.java b/java/org/apache/tomcat/util/net/SSLHostConfig.java index 94582622d082..5da5973e0be9 100644 --- a/java/org/apache/tomcat/util/net/SSLHostConfig.java +++ b/java/org/apache/tomcat/util/net/SSLHostConfig.java @@ -1587,11 +1587,8 @@ public Set certificatesExpiringBefore(Date date) { 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( + sslContext.getServedAlias(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 edd52cda0fad..0f0c7b17632e 100644 --- a/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java +++ b/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java @@ -244,6 +244,31 @@ public Type getType() { } + /** + * 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/SSLUtilBase.java b/java/org/apache/tomcat/util/net/SSLUtilBase.java index 68ac2ecbb899..c88c09d9c99c 100644 --- a/java/org/apache/tomcat/util/net/SSLUtilBase.java +++ b/java/org/apache/tomcat/util/net/SSLUtilBase.java @@ -25,6 +25,7 @@ import java.security.DomainLoadStoreParameter; import java.security.Key; import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.cert.CRL; import java.security.cert.CRLException; import java.security.cert.CertPathParameters; @@ -36,6 +37,7 @@ import java.security.cert.CertificateExpiredException; import java.security.cert.CertificateFactory; import java.security.cert.CertificateNotYetValidException; +import java.security.cert.CertificateParsingException; import java.security.cert.CollectionCertStoreParameters; import java.security.cert.PKIXBuilderParameters; import java.security.cert.PKIXRevocationChecker; @@ -393,22 +395,49 @@ public KeyManager[] getKeyManagers() throws Exception { ksUsed.setKeyEntry(keyAlias, privateKeyFile.getPrivateKey(), keyPassArray, chain.toArray(new Certificate[0])); } else { - if (keyAlias != null && !ks.isKeyEntry(keyAlias)) { - throw new IOException(sm.getString("sslUtilBase.alias_no_key_entry", keyAlias)); - } else if (keyAlias == null) { + if (keyAlias == null) { Enumeration aliases = ks.aliases(); if (!aliases.hasMoreElements()) { throw new IOException(sm.getString("sslUtilBase.noKeys")); } + boolean skippedForServerAuth = false; while (aliases.hasMoreElements() && keyAlias == null) { - keyAlias = aliases.nextElement(); - if (!ks.isKeyEntry(keyAlias)) { - keyAlias = null; + String candidate = aliases.nextElement(); + if (!ks.isKeyEntry(candidate)) { + continue; + } + Certificate cert = ks.getCertificate(candidate); + if (cert instanceof X509Certificate x509 && !isServerAuthCapable(x509)) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("sslUtilBase.skippedKeyUsage", candidate)); + } + skippedForServerAuth = true; + continue; } + keyAlias = candidate; } if (keyAlias == null) { + if (skippedForServerAuth) { + throw new IOException(sm.getString("sslUtilBase.noServerAuthKey")); + } throw new IOException(sm.getString("sslUtilBase.alias_no_key_entry", (Object) null)); } + } else { + if (isSunMSCAPIWindowsMY(ks)) { + Certificate configuredCert = ks.isKeyEntry(keyAlias) ? ks.getCertificate(keyAlias) : null; + if (!(configuredCert instanceof X509Certificate x509) || !isServerAuthCapable(x509)) { + String duplicateAlias = findServerAuthAliasInWindowsMYFamily(ks, keyAlias); + if (duplicateAlias != null) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("sslUtilBase.selectedServerAuthCert", duplicateAlias, keyAlias)); + } + keyAlias = duplicateAlias; + } + } + } + if (!ks.isKeyEntry(keyAlias)) { + throw new IOException(sm.getString("sslUtilBase.alias_no_key_entry", keyAlias)); + } } Key k = ks.getKey(keyAlias, keyPassArray); @@ -450,6 +479,98 @@ public KeyManager[] getKeyManagers() throws Exception { } + /** + * Searches a SunMSCAPI Windows-MY key store for a key entry whose certificate is valid for TLS server + * authentication, among the numbered duplicates of {@code baseAlias}. When several certificates share a Windows + * "friendly name", that provider appends {@code " (1)"}, {@code " (2)"}, etc. to give them unique aliases, as the + * key store API requires. + * + * @param ks the SunMSCAPI Windows-MY key store to search + * @param baseAlias the configured alias. Not itself re-checked: the caller has already determined that its + * certificate is not valid for TLS server authentication. + * + * @return the alias of the first duplicate in the contiguous run (in increasing numeric order starting at 1) whose + * certificate is valid for TLS server authentication, or {@code null} if there is no such duplicate + * + * @throws KeyStoreException if the key store has not been initialized + */ + private static String findServerAuthAliasInWindowsMYFamily(KeyStore ks, String baseAlias) throws KeyStoreException { + int suffix = 1; + while (true) { + String candidateAlias = baseAlias + " (" + suffix + ")"; + Certificate candidateCert = ks.getCertificate(candidateAlias); + if (candidateCert == null) { + // The first missing number ends the contiguous run of numbered duplicates. + return null; + } + if (candidateCert instanceof X509Certificate x509 && ks.isKeyEntry(candidateAlias) && + isServerAuthCapable(x509)) { + return candidateAlias; + } + suffix++; + } + } + + + /** + * Determines whether the given certificate may be used for TLS server authentication, based on its Extended Key + * Usage (EKU) extension. Used by {@link #getKeyManagers()} when auto-selecting a key entry so that certificates + * which are not valid for server authentication are ignored. + *

+ * The policy is deliberately lenient about absent usage, strict about declared usage and tolerant of a malformed + * extension: + *

+ * + * @param cert the certificate to test + * + * @return {@code true} if the certificate may be used for TLS server authentication + */ + private static boolean isServerAuthCapable(X509Certificate cert) { + List objectIdentifiers; + try { + objectIdentifiers = cert.getExtendedKeyUsage(); + } catch (CertificateParsingException | RuntimeException e) { + return false; + } + + if (objectIdentifiers == null) { + return true; + } + + for (String objectIdentifier : objectIdentifiers) { + if (objectIdentifier.equals(Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID) || + objectIdentifier.equals(Constants.ANY_EXTENDED_KEY_USAGE_OID)) { + return true; + } + } + + return false; + } + + private static boolean isSunMSCAPIWindowsMY(KeyStore ks) { + if (ks == null || !Constants.SUNMSCAPI_PROVIDER_NAME.equals(ks.getProvider().getName())) { + return false; + } + + //Windows-ROOT is excluded because it is a keyless trust store + return switch (ks.getType().toUpperCase(Locale.ROOT)) { + case Constants.WINDOWS_MY_KEYSTORE_TYPE, + Constants.WINDOWS_MY_CURRENTUSER_KEYSTORE_TYPE, + Constants.WINDOWS_MY_LOCALMACHINE_KEYSTORE_TYPE -> true; + default -> false; + }; + } + @Override public String[] getEnabledProtocols() { return enabledProtocols; diff --git a/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java b/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java index 088257110b1e..29b07ed12b2c 100644 --- a/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java +++ b/java/org/apache/tomcat/util/net/jsse/JSSESSLContext.java @@ -20,8 +20,10 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Set; import javax.net.ssl.KeyManager; @@ -34,6 +36,7 @@ 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 +92,21 @@ public X509Certificate[] getCertificateChain(String alias) { return result; } + + @Override + public X509KeyManager[] getServerKeyManagers(SSLHostConfigCertificate certificate) { + if (kms == null) { + return new X509KeyManager[0]; + } + List x509KeyManagers = new ArrayList<>(); + for (KeyManager km : kms) { + if (km instanceof X509KeyManager x509KeyManager) { + x509KeyManagers.add(x509KeyManager); + } + } + return x509KeyManagers.toArray(new X509KeyManager[0]); + } + @Override public X509Certificate[] getAcceptedIssuers() { Set certs = new HashSet<>(); diff --git a/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties b/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties index dc73269ae3cd..3819c025c013 100644 --- a/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties +++ b/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties @@ -45,6 +45,7 @@ openssl.doubleInit=SSL context already initialized, ignoring openssl.errApplyConf=Could not apply OpenSSLConf to SSL context openssl.errCheckConf=Error during OpenSSLConf check openssl.errMakeConf=Could not create OpenSSLConf context +openssl.errorLoadingCertificate=Error loading certificate: [{0}] openssl.errorSSLCtxInit=Error initializing SSL context openssl.failSslContextMake=Unable to create SSLContext. Check that SSLEngine is enabled in the AprLifecycleListener, the AprLifecycleListener has initialised correctly and that a valid SSLProtocol has been specified openssl.invalidSslProtocol=An invalid value [{0}] was provided for the SSLProtocol attribute diff --git a/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java b/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java index 619fc78b4cf3..ba223897837c 100644 --- a/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java +++ b/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java @@ -29,9 +29,7 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; -import java.util.Arrays; import java.util.Base64; -import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.concurrent.locks.Lock; @@ -60,7 +58,6 @@ import org.apache.tomcat.util.net.SSLHostConfigCertificate; import org.apache.tomcat.util.net.SSLHostConfigCertificate.Type; import org.apache.tomcat.util.net.SSLHostConfigPreSharedKey; -import org.apache.tomcat.util.net.SSLUtilBase; import org.apache.tomcat.util.res.StringManager; /** @@ -578,17 +575,13 @@ public void addCertificate(SSLHostConfigCertificate certificate) throws Exceptio SSLContext.setCertificateChainFile(state.ctx, SSLHostConfig.adjustRelativePath(certificate.getCertificateChainFile()), false); } else { - String alias = certificate.getCertificateKeyAlias(); X509KeyManager x509KeyManager = certificate.getCertificateKeyManager(); - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; - } + String alias = getServedAlias(certificate); X509Certificate[] chain = x509KeyManager.getCertificateChain(alias); - if (chain == null) { - alias = findAlias(x509KeyManager, certificate); - chain = x509KeyManager.getCertificateChain(alias); - } PrivateKey key = x509KeyManager.getPrivateKey(alias); + if (key == null || key.getEncoded() == null || chain == null || chain.length == 0) { + throw new SSLException(sm.getString("openssl.errorLoadingCertificate", alias)); + } String encodedKey = BEGIN_KEY + Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(key.getEncoded()) + END_KEY; SSLContext.setCertificateRaw(state.ctx, chain[0].getEncoded(), @@ -621,32 +614,6 @@ private static int getCertificateIndex(SSLHostConfigCertificate certificate) { } - /* - * Find a valid alias when none was specified in the config. - */ - private static String findAlias(X509KeyManager keyManager, SSLHostConfigCertificate certificate) { - - Type type = certificate.getType(); - String result = null; - - List candidateTypes = new ArrayList<>(); - if (Type.UNDEFINED.equals(type)) { - // Try all types to find an suitable alias - candidateTypes.addAll(Arrays.asList(Type.values())); - candidateTypes.remove(Type.UNDEFINED); - } else { - // Look for the specific type to find a suitable alias - candidateTypes.add(type); - } - - Iterator iter = candidateTypes.iterator(); - while (result == null && iter.hasNext()) { - result = keyManager.chooseServerAlias(iter.next().getKeyType(), null, null); - } - - return result; - } - private static X509TrustManager chooseTrustManager(TrustManager[] managers) { for (TrustManager m : managers) { if (m instanceof X509TrustManager) { @@ -695,20 +662,17 @@ public SSLParameters getSupportedSSLParameters() { @Override public X509Certificate[] getCertificateChain(String alias) { - X509Certificate[] chain = null; X509KeyManager x509KeyManager = certificate.getCertificateKeyManager(); - if (x509KeyManager != null) { - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; - } - chain = x509KeyManager.getCertificateChain(alias); - if (chain == null) { - alias = findAlias(x509KeyManager, certificate); - chain = x509KeyManager.getCertificateChain(alias); - } + if (x509KeyManager == null) { + return null; } + return x509KeyManager.getCertificateChain(alias); + } - return chain; + @Override + public X509KeyManager[] getServerKeyManagers(SSLHostConfigCertificate certificate) { + X509KeyManager x509KeyManager = certificate.getCertificateKeyManager(); + return x509KeyManager == null ? new X509KeyManager[0] : new X509KeyManager[] { x509KeyManager }; } @Override diff --git a/java/org/apache/tomcat/util/net/openssl/panama/OpenSSLContext.java b/java/org/apache/tomcat/util/net/openssl/panama/OpenSSLContext.java index d093bba4d744..7f1d492bc763 100644 --- a/java/org/apache/tomcat/util/net/openssl/panama/OpenSSLContext.java +++ b/java/org/apache/tomcat/util/net/openssl/panama/OpenSSLContext.java @@ -30,7 +30,6 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -58,7 +57,6 @@ import org.apache.tomcat.util.net.SSLHostConfigCertificate; import org.apache.tomcat.util.net.SSLHostConfigCertificate.Type; import org.apache.tomcat.util.net.SSLHostConfigPreSharedKey; -import org.apache.tomcat.util.net.SSLUtilBase; import org.apache.tomcat.util.net.openssl.OpenSSLConf; import org.apache.tomcat.util.net.openssl.OpenSSLConfCmd; import org.apache.tomcat.util.net.openssl.OpenSSLPreSharedKeySelector; @@ -1195,16 +1193,9 @@ public boolean addCertificate(SSLHostConfigCertificate certificate, Arena localA } } } else { - String alias = certificate.getCertificateKeyAlias(); X509KeyManager x509KeyManager = certificate.getCertificateKeyManager(); - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; - } + String alias = getServedAlias(certificate); X509Certificate[] chain = x509KeyManager.getCertificateChain(alias); - if (chain == null) { - alias = findAlias(x509KeyManager, certificate); - chain = x509KeyManager.getCertificateChain(alias); - } // Convert the in-memory key manager entry to native objects with // the shared loader (PKCS#8 key via d2i_AutoPrivateKey, // certificates via d2i_X509) @@ -1317,32 +1308,6 @@ private static MemorySegment createMemoryBio(Arena arena, byte[] bytes) { } - /* - * Find a valid alias when none was specified in the config. - */ - private static String findAlias(X509KeyManager keyManager, SSLHostConfigCertificate certificate) { - - Type type = certificate.getType(); - String result = null; - - List candidateTypes = new ArrayList<>(); - if (Type.UNDEFINED.equals(type)) { - // Try all types to find a suitable alias - candidateTypes.addAll(Arrays.asList(Type.values())); - candidateTypes.remove(Type.UNDEFINED); - } else { - // Look for the specific type to find a suitable alias - candidateTypes.add(type); - } - - Iterator iter = candidateTypes.iterator(); - while (result == null && iter.hasNext()) { - result = keyManager.chooseServerAlias(iter.next().getKeyType(), null, null); - } - - return result; - } - private static X509TrustManager chooseTrustManager(TrustManager[] managers) { for (TrustManager m : managers) { if (m instanceof X509TrustManager) { @@ -1402,25 +1367,20 @@ public SSLParameters getSupportedSSLParameters() { @Override public X509Certificate[] getCertificateChain(String alias) { - X509Certificate[] chain = null; X509KeyManager x509KeyManager = certificate.getCertificateKeyManager(); if (x509KeyManager != null) { - if (alias == null) { - alias = SSLUtilBase.DEFAULT_KEY_ALIAS; - } - chain = x509KeyManager.getCertificateChain(alias); - if (chain == null) { - alias = findAlias(x509KeyManager, certificate); - chain = x509KeyManager.getCertificateChain(alias); - } - } else { - // No key manager (e.g. a PEM or PKCS#12 file configuration): read - // the chain from the configured source with the shared loader so - // the certificate data is available via JMX and for expiry checks - chain = CertificateLoader.getCertificateChain(certificate); + return x509KeyManager.getCertificateChain(alias); } + // No key manager (e.g. a PEM or PKCS#12 file configuration): read the chain from the configured source with + // the shared loader so the certificate data is available via JMX and for expiry checks + return CertificateLoader.getCertificateChain(certificate); + } + - return chain; + @Override + public X509KeyManager[] getServerKeyManagers(SSLHostConfigCertificate certificate) { + X509KeyManager x509KeyManager = certificate.getCertificateKeyManager(); + return x509KeyManager == null ? new X509KeyManager[0] : new X509KeyManager[] { x509KeyManager }; } @Override diff --git a/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java b/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java new file mode 100644 index 000000000000..fb90d22679e6 --- /dev/null +++ b/test/org/apache/tomcat/util/net/SSLUtilBaseCertificateSelectionTestBase.java @@ -0,0 +1,437 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://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 org.apache.tomcat.util.net; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +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.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import javax.net.ssl.X509KeyManager; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.tomcat.util.net.SSLHostConfigCertificate.Type; +import org.easymock.EasyMock; + +/** + * Shared fixtures for the {@link SSLUtilBase#getKeyManagers()} certificate-selection tests. The two behaviors are + * split across the concrete subclasses: {@link TestSSLUtilBaseAutomaticCertificateSelection} covers Extended Key + * Usage based selection when no alias is configured, and {@link TestSSLUtilBaseConfiguredAliasSelection} covers a + * configured alias, including the SunMSCAPI Windows-MY numbered-duplicate resolution. + *

+ * {@link #orderedKeystore(String, KeystoreEntry...)} returns a key store with a controlled alias enumeration order + * that presents as a SunMSCAPI Windows-MY store; {@link #nonSunMSCAPIKeystore(KeystoreEntry...)} presents as a plain + * JKS store, so that the SunMSCAPI-only duplicate search is skipped. + */ +public abstract class SSLUtilBaseCertificateSelectionTestBase extends TomcatBaseTest { + + @Parameterized.Parameters(name = "{0}") + public static Collection parameters() { + return List.of(new Object[] { "JSSE", Boolean.FALSE, + "org.apache.tomcat.util.net.jsse.JSSEImplementation" }, + new Object[] { "OpenSSL", Boolean.TRUE, + "org.apache.tomcat.util.net.openssl.OpenSSLImplementation" }, + new Object[] { "OpenSSL-FFM", Boolean.TRUE, + "org.apache.tomcat.util.net.openssl.panama.OpenSSLImplementation" }); + } + + @Parameter(0) + public String connectorName; + + @Parameter(1) + public boolean useOpenSSL; + + @Parameter(2) + public String sslImplementationName; + + + @Before + public void configureSSLImplementation() throws Exception { + TesterSupport.configureSSLImplementation(getTomcatInstance(), sslImplementationName, useOpenSSL); + getTomcatInstance().getServer().init(); + } + + protected static final String HOST = "localhost"; + + protected static final String CLIENT_AUTH_EXTENDED_KEY_USAGE_OID = "1.3.6.1.5.5.7.3.2"; + + private static final String SERVER_AUTH_KEYSTORE = TesterSupport.SSL_DIR + "localhost-rsa-serverauth.jks"; + private static final String SERVER_AUTH_KEYSTORE_2 = TesterSupport.SSL_DIR + "localhost-rsa-serverauth2.jks"; + private static final String CLIENT_AUTH_KEYSTORE = TesterSupport.SSL_DIR + "localhost-rsa-clientauth.jks"; + private static final String ANY_AUTH_KEYSTORE = TesterSupport.SSL_DIR + "localhost-rsa-anyauth.jks"; + private static final String NO_EKU_KEYSTORE = TesterSupport.SSL_DIR + "localhost-rsa-noeku.jks"; + + protected enum CertType { + SERVER_AUTH, + CLIENT_AUTH, + ANY_AUTH, + NO_EKU + } + + private static Map> certificatePool; + + private record CertAndKey(PrivateKey key, X509Certificate certificate) { + } + + /** + * An entry to place into a test key store: the alias, which pre-generated certificate to use, and whether it is a + * private key entry or a trusted (certificate-only) entry. + */ + protected record KeystoreEntry(String alias, CertType certType, boolean keyEntry) { + + protected static KeystoreEntry trusted(String alias) { + return new KeystoreEntry(alias, CertType.SERVER_AUTH, false); + } + } + + protected static KeystoreEntry key(String alias, CertType certType) { + return new KeystoreEntry(alias, certType, true); + } + + + protected X509Certificate present(String alias, KeystoreEntry... entries) throws Exception { + return present(alias, orderedKeystore(null, entries)); + } + + + protected X509Certificate present(String alias, KeyStore keystore) throws Exception { + X509KeyManager keyManager = (X509KeyManager) sslUtil(alias, keystore).getKeyManagers()[0]; + X509Certificate[] chain = keyManager.getCertificateChain(keyManager.chooseServerAlias("RSA", null, null)); + Assert.assertNotNull("Key manager returned no certificate chain", chain); + return chain[0]; + } + + + protected SSLUtil sslUtil(String alias, KeyStore keystore) throws Exception { + + 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); + } + + return sslUtil(certificate); + } + + + protected SSLUtil sslUtil(SSLHostConfigCertificate certificate) throws Exception { + SSLUtil util = SSLImplementation.getInstance(sslImplementationName).getSSLUtil(certificate); + SSLHostConfig hostConfig = certificate.getSSLHostConfig(); + hostConfig.setEnabledProtocols(util.getEnabledProtocols()); + hostConfig.setEnabledCiphers(util.getEnabledCiphers()); + return util; + } + + + /* + * 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 certificatesMonitoredForReload(KeyStore keystore) throws Exception { + SSLHostConfigCertificate certificate = registeredCertificate(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(KeyStore keystore) throws Exception { + SSLHostConfigCertificate certificate = registeredCertificate(keystore); + SSLContext context = sslUtil(certificate).createSSLContext(List.of()); + try { + certificate.setSslContext(context); + return context.getServedAlias(certificate); + } finally { + context.destroy(); + } + } + + + private static SSLHostConfigCertificate registeredCertificate(KeyStore keystore) { + SSLHostConfig hostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(hostConfig, Type.UNDEFINED); + certificate.setCertificateKeystore(keystore); + certificate.setCertificateKeystoreType("JKS"); + certificate.setCertificateKeystorePassword(TesterSupport.JKS_PASS); + certificate.setCertificateKeyAlias(SSLUtilBaseCertificateSelectionTestBase.HOST); + hostConfig.addCertificate(certificate); + return certificate; + } + + + private static synchronized Map> certificatePool() throws Exception { + if (certificatePool == null) { + Map> pool = new EnumMap<>(CertType.class); + pool.put(CertType.SERVER_AUTH, List.of(load(SERVER_AUTH_KEYSTORE), load(SERVER_AUTH_KEYSTORE_2))); + pool.put(CertType.CLIENT_AUTH, List.of(load(CLIENT_AUTH_KEYSTORE))); + pool.put(CertType.ANY_AUTH, List.of(load(ANY_AUTH_KEYSTORE))); + pool.put(CertType.NO_EKU, List.of(load(NO_EKU_KEYSTORE))); + certificatePool = pool; + } + return certificatePool; + } + + + private static CertAndKey load(String keystoreFile) throws Exception { + KeyStore keystore = KeyStore.getInstance(new File(keystoreFile), TesterSupport.JKS_PASS.toCharArray()); + String alias = keystore.aliases().nextElement(); + return new CertAndKey((PrivateKey) keystore.getKey(alias, TesterSupport.JKS_PASS.toCharArray()), + (X509Certificate) keystore.getCertificate(alias)); + } + + + /* + * Populates the key store from the pre-generated pool, one certificate per entry. Repeated entries of the same + * category are given distinct certificates where the pool holds more than one (so two server-auth entries in one + * store differ, as the duplicate-selection test requires). + */ + private static void populate(KeyStore keystore, KeystoreEntry... entries) throws Exception { + Map used = new EnumMap<>(CertType.class); + for (KeystoreEntry entry : entries) { + List options = certificatePool().get(entry.certType()); + CertAndKey chosen = options.get((used.merge(entry.certType(), 1, Integer::sum) - 1) % options.size()); + if (entry.keyEntry()) { + keystore.setKeyEntry(entry.alias(), chosen.key(), TesterSupport.JKS_PASS.toCharArray(), + new X509Certificate[] { chosen.certificate() }); + } else { + keystore.setCertificateEntry(entry.alias(), chosen.certificate()); + } + } + } + + + /* + * Writes a temporary JKS file populated from the pool, for the tests that configure a certificateKeystoreFile + * rather than an in-memory key store. + */ + protected static File keystoreFile(KeystoreEntry... entries) throws Exception { + KeyStore keystore = KeyStore.getInstance("JKS"); + keystore.load(null, null); + populate(keystore, entries); + File file = File.createTempFile("cert-selection-", ".jks"); + file.deleteOnExit(); + try (OutputStream os = new FileOutputStream(file)) { + keystore.store(os, TesterSupport.JKS_PASS.toCharArray()); + } + return file; + } + + + protected static KeyStore orderedKeystore(String malformedAlias, KeystoreEntry... entries) throws Exception { + return orderedKeystore(sunMSCAPIProvider(), "Windows-MY", KeyMode.EXPORTABLE, 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", KeyMode.NON_EXPORTABLE, null, entries); + } + + + /* + * As nonExportableKeystore(KeystoreEntry...) - the keys report a null format so the original key store is kept and + * each key manager is wrapped - but getEncoded() still returns the real key material, so OpenSSL can load it. This + * reaches the wrapped path (ksUsed == ks) for the OpenSSL implementations, where a lookup of the configured alias + * would otherwise resolve the wrong certificate. + */ + protected static KeyStore wrappedExportableKeystore(KeystoreEntry... entries) throws Exception { + return orderedKeystore(sunMSCAPIProvider(), "Windows-MY", KeyMode.WRAPPED_EXPORTABLE, null, entries); + } + + + protected static KeyStore nonSunMSCAPIKeystore(KeystoreEntry... entries) throws Exception { + return orderedKeystore(new Provider("SUN", "1.0", "test") { + @Serial + private static final long serialVersionUID = 4434321038151233913L; + }, "JKS", KeyMode.EXPORTABLE, null, entries); + } + + + private static Provider sunMSCAPIProvider() { + return new Provider("SunMSCAPI", "1.0", "test") { + @Serial + private static final long serialVersionUID = -4141665633237722962L; + }; + } + + + /* + * How the mocked SunMSCAPI private keys present themselves, which selects the branch of + * SSLUtilBase.getKeyManagers() under test. EXPORTABLE keys report PKCS#8 (in-memory copy); NON_EXPORTABLE and + * WRAPPED_EXPORTABLE report a null format (original key store kept, key managers wrapped). WRAPPED_EXPORTABLE + * additionally exposes the key encoding so the OpenSSL implementations can load it. + */ + protected enum KeyMode { + EXPORTABLE, + NON_EXPORTABLE, + WRAPPED_EXPORTABLE + } + + + protected static KeyStore orderedKeystore(Provider provider, String type, KeyMode keyMode, + String malformedAlias, KeystoreEntry... entries) throws Exception { + KeyStore delegate = KeyStore.getInstance("JKS"); + delegate.load(null, null); + populate(delegate, entries); + List aliases = Stream.of(entries).map(KeystoreEntry::alias).toList(); + + // JKS type of keystore enumeration order is unspecified. + // Control that boundary while keeping real keys, certificate chains, and key managers. + // Malformed EKU must be injected here because a JKS parser can reject it while loading. + X509Certificate malformed = EasyMock.createMock(X509Certificate.class); + EasyMock.expect(malformed.getExtendedKeyUsage()).andStubThrow(new CertificateParsingException("Invalid EKU")); + EasyMock.replay(malformed); + KeyStoreSpi spi = EasyMock.createMock(KeyStoreSpi.class); + spi.engineLoad(null, null); + EasyMock.expectLastCall().once(); + EasyMock.expect(spi.engineAliases()).andStubAnswer(() -> Collections.enumeration(aliases)); + EasyMock.expect(spi.engineIsKeyEntry(EasyMock.anyString())) + .andStubAnswer(() -> delegate.isKeyEntry(EasyMock.getCurrentArgument(0))); + EasyMock.expect(spi.engineGetCertificate(EasyMock.anyString())).andStubAnswer(() -> { + String alias = EasyMock.getCurrentArgument(0); + return alias.equals(malformedAlias) ? malformed : delegate.getCertificate(alias); + }); + EasyMock.expect(spi.engineGetCertificateChain(EasyMock.anyString())) + .andStubAnswer(() -> delegate.getCertificateChain(EasyMock.getCurrentArgument(0))); + EasyMock.expect(spi.engineGetKey(EasyMock.anyString(), EasyMock.anyObject(char[].class))).andStubAnswer(() -> { + Key key = delegate.getKey(EasyMock.getCurrentArgument(0), EasyMock.getCurrentArgument(1)); + return switch (keyMode) { + case EXPORTABLE -> key; + case NON_EXPORTABLE -> nonExportableView(key); + case WRAPPED_EXPORTABLE -> wrappedExportableView(key); + }; + }); + EasyMock.replay(spi); + // A real JKS backs the mock. EXPORTABLE keys report PKCS#8, so getKeyManagers() takes its in-memory-copy + // branch; NON_EXPORTABLE and WRAPPED_EXPORTABLE keys report a null format (see nonExportableView / + // wrappedExportableView), so it keeps the original key store and wraps each key manager - WRAPPED_EXPORTABLE + // still exposes the key encoding so OpenSSL can load it. 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); + return result; + } + + + /* + * 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; + } + }; + } + + + /* + * As nonExportableView(Key) but getEncoded() returns the real key material, modeling a SunMSCAPI key that keeps + * the original key store in use (null format) yet can still be exported into OpenSSL. + */ + private static Key wrappedExportableView(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 privateKey.getEncoded(); + } + }; + } + + + protected static void assertEku(X509Certificate certificate, String oid) throws Exception { + List extendedKeyUsage = certificate.getExtendedKeyUsage(); + Assert.assertNotNull("Presented certificate has no Extended Key Usage", extendedKeyUsage); + Assert.assertTrue("Presented certificate must be valid for Extended Key Usage " + oid, + extendedKeyUsage.contains(oid)); + } +} diff --git a/test/org/apache/tomcat/util/net/TestSSLContextWrapper.java b/test/org/apache/tomcat/util/net/TestSSLContextWrapper.java new file mode 100644 index 000000000000..a9f897be69ba --- /dev/null +++ b/test/org/apache/tomcat/util/net/TestSSLContextWrapper.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://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 org.apache.tomcat.util.net; + +import java.io.File; +import java.net.Socket; +import java.security.KeyStore; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Set; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Tests expiry monitoring for a supplied {@code javax.net.ssl.SSLContext} (see + * {@link SSLUtil#createSSLContext}) whose custom key manager selects different aliases for the socket and engine + * server-alias callbacks. Tomcat serves over an {@code SSLEngine}, so monitoring must track the engine selection, not + * the socket one. + */ +public class TestSSLContextWrapper { + + private static final char[] PASSWORD = TesterSupport.JKS_PASS.toCharArray(); + + private static final String ENGINE_KEYSTORE = "test/org/apache/tomcat/util/net/localhost-rsa-serverauth.jks"; + private static final String SOCKET_KEYSTORE = "test/org/apache/tomcat/util/net/localhost-rsa-serverauth2.jks"; + + private static final X509TrustManager TRUST = new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // No-op: the test never validates a peer. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // No-op: the test never validates a peer. + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + + /* + * A key manager that deliberately disagrees between the socket and engine server-alias callbacks, as the JDK + * contract permits. The engine callback reflects the alias an SSLEngine handshake actually serves. + */ + private static final class SplitKeyManager extends X509ExtendedKeyManager { + private final X509KeyManager delegate; + + SplitKeyManager(X509KeyManager delegate) { + this.delegate = delegate; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return "RSA".equals(keyType) ? "socket" : null; + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return "RSA".equals(keyType) ? "engine" : null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return delegate.chooseClientAlias(keyType, issuers, socket); + } + } + + private static void copyKey(KeyStore target, String alias, String keystoreFile) throws Exception { + KeyStore source = KeyStore.getInstance(new File(keystoreFile), PASSWORD); + String original = source.aliases().nextElement(); + target.setKeyEntry(alias, source.getKey(original, PASSWORD), PASSWORD, source.getCertificateChain(original)); + } + + @Test + public void testExpiryMonitoringTracksEngineServedCertificate() throws Exception { + KeyStore store = KeyStore.getInstance("JKS"); + store.load(null, null); + copyKey(store, "engine", ENGINE_KEYSTORE); + copyKey(store, "socket", SOCKET_KEYSTORE); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); + kmf.init(store, PASSWORD); + X509ExtendedKeyManager keyManager = new SplitKeyManager((X509KeyManager) kmf.getKeyManagers()[0]); + + javax.net.ssl.SSLContext jsse = javax.net.ssl.SSLContext.getInstance("TLS"); + jsse.init(new KeyManager[] { keyManager }, new TrustManager[] { TRUST }, null); + + SSLContext context = SSLUtil.createSSLContext(jsse, keyManager, TRUST); + SSLHostConfig host = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(host, SSLHostConfigCertificate.Type.RSA); + certificate.setCertificateKeyAlias("engine"); + certificate.setSslContext(context); + host.addCertificate(certificate); + + X509Certificate engineCertificate = context.getCertificateChain("engine")[0]; + X509Certificate socketCertificate = context.getCertificateChain("socket")[0]; + Assert.assertNotEquals("Fixtures must provide two distinct certificates", engineCertificate, + socketCertificate); + + Set monitored = host.certificatesExpiringBefore(new Date(Long.MAX_VALUE)); + + Assert.assertTrue("The engine-served certificate must be monitored for expiry", + monitored.contains(engineCertificate)); + Assert.assertFalse("The socket-selected certificate must not be monitored", + monitored.contains(socketCertificate)); + } +} diff --git a/test/org/apache/tomcat/util/net/TestSSLUtilBaseAutomaticCertificateSelection.java b/test/org/apache/tomcat/util/net/TestSSLUtilBaseAutomaticCertificateSelection.java new file mode 100644 index 000000000000..d868c269bbf0 --- /dev/null +++ b/test/org/apache/tomcat/util/net/TestSSLUtilBaseAutomaticCertificateSelection.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://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 org.apache.tomcat.util.net; + +import java.io.File; +import java.io.IOException; +import java.security.KeyStore; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.apache.tomcat.util.net.SSLHostConfigCertificate.Type; + +/** + * Tests that, when no {@code certificateKeyAlias} is configured, {@link SSLUtilBase#getKeyManagers()} auto-selects the + * first key entry whose certificate is valid for TLS server authentication (by Extended Key Usage) and ignores the + * entries that are not. Applies to every key store type. + */ +@RunWith(Parameterized.class) +public class TestSSLUtilBaseAutomaticCertificateSelection extends SSLUtilBaseCertificateSelectionTestBase { + + @Test + public void testAutoSelectPrefersServerAuthOverClientAuth() throws Exception { + X509Certificate presented = present(null, + key("first", CertType.CLIENT_AUTH), + key("second", CertType.SERVER_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testAutoSelectAcceptsAnyExtendedKeyUsage() throws Exception { + X509Certificate presented = present(null, + key("first", CertType.CLIENT_AUTH), + key("any", CertType.ANY_AUTH)); + assertEku(presented, Constants.ANY_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testAutoSelectAcceptsCertificateWithoutExtendedKeyUsage() throws Exception { + X509Certificate presented = present(null, + key("first", CertType.CLIENT_AUTH), + key("none", CertType.NO_EKU)); + Assert.assertNull("A certificate with no Extended Key Usage is unrestricted and must be selectable", + presented.getExtendedKeyUsage()); + } + + + @Test + public void testAutoSelectSkipsTrustedEntries() { + Assert.assertThrows(IOException.class, () -> present(null, + KeystoreEntry.trusted("ca"), + key("key", CertType.CLIENT_AUTH))); + } + + + @Test + public void testAutoSelectFailsWhenNoneServerAuth() { + Assert.assertThrows(IOException.class, () -> present(null, + key("first", CertType.CLIENT_AUTH), + key("second", CertType.CLIENT_AUTH))); + } + + + @Test + public void testAutoSelectSkipsMalformedExtendedKeyUsage() throws Exception { + KeyStore keystore = orderedKeystore("malformed", key("malformed", CertType.CLIENT_AUTH), key("server", CertType.SERVER_AUTH)); + assertEku(present(null, keystore), Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testAutoSelectFailsWhenOnlyMalformedExtendedKeyUsage() throws Exception { + KeyStore keystore = orderedKeystore("malformed", key("malformed", CertType.CLIENT_AUTH)); + Assert.assertThrows(IOException.class, () -> present(null, keystore)); + } + + + @Test + public void testAutoSelectUsesFirstEligibleKey() throws Exception { + KeyStore keystore = orderedKeystore(null, key("client", CertType.CLIENT_AUTH), key("first", CertType.ANY_AUTH), + key("second", CertType.SERVER_AUTH)); + Assert.assertEquals(keystore.getCertificate("first"), present(null, keystore)); + } + + + @Test + public void testAutoSelectDoesNotSkipKeyReadFailure() throws Exception { + KeyStore keystore = orderedKeystore(null, key("first", CertType.SERVER_AUTH), key("second", CertType.SERVER_AUTH)); + SSLHostConfig hostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(hostConfig, Type.UNDEFINED); + certificate.setCertificateKeystore(keystore); + certificate.setCertificateKeystoreType("JKS"); + certificate.setCertificateKeyPassword("incorrect"); + SSLUtil util = SSLImplementation.getInstance(sslImplementationName).getSSLUtil(certificate); + Assert.assertThrows(UnrecoverableKeyException.class, util::getKeyManagers); + } + + + @Test + public void testSSLContextUsesSelectedCertificate() throws Exception { + KeyStore keystore = orderedKeystore(null, key("client", CertType.CLIENT_AUTH), key("server", CertType.SERVER_AUTH)); + SSLContext context = sslUtil(null, keystore).createSSLContext(List.of()); + try { + Assert.assertEquals(keystore.getCertificate("server"), context.getCertificateChain("server")[0]); + } finally { + context.destroy(); + } + } + + + @Test + public void testSSLContextLoadsSelectedCertificateFromKeystoreFile() throws Exception { + File file = keystoreFile(key(SSLUtilBase.DEFAULT_KEY_ALIAS, CertType.CLIENT_AUTH), key("server", CertType.SERVER_AUTH)); + KeyStore keystore = KeyStore.getInstance(file, TesterSupport.JKS_PASS.toCharArray()); + SSLHostConfig hostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(hostConfig, Type.UNDEFINED); + certificate.setCertificateKeystoreFile(file.getAbsolutePath()); + certificate.setCertificateKeystorePassword(TesterSupport.JKS_PASS); + SSLContext context = sslUtil(certificate).createSSLContext(List.of()); + try { + Assert.assertEquals(keystore.getCertificate("server"), context.getCertificateChain("server")[0]); + } finally { + context.destroy(); + } + } +} diff --git a/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java b/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java new file mode 100644 index 000000000000..fc4a37f02c91 --- /dev/null +++ b/test/org/apache/tomcat/util/net/TestSSLUtilBaseConfiguredAliasSelection.java @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://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 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; + +/** + * Tests {@link SSLUtilBase#getKeyManagers()} when a {@code certificateKeyAlias} is configured. The configured alias is + * used even if its certificate is not valid for TLS server authentication, except in a SunMSCAPI Windows-MY key store: + * there, if a numbered duplicate of the alias (e.g. {@code "alias (1)"}) carries a certificate that is valid, it is + * used instead. Only numbered duplicates of the configured alias are considered, and only for that store type. + */ +@RunWith(Parameterized.class) +public class TestSSLUtilBaseConfiguredAliasSelection extends SSLUtilBaseCertificateSelectionTestBase { + + @Test + public void testConfiguredAliasIsUsedUnchangedWhenNotServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.CLIENT_AUTH), + key("localhost-server", CertType.SERVER_AUTH)); + assertEku(presented, CLIENT_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasIsUsedWhenServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.SERVER_AUTH), + key("localhost-client", CertType.CLIENT_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasUsesNumberedDuplicateWhenBaseNotServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.CLIENT_AUTH), + key(HOST + " (1)", CertType.SERVER_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasUnchangedForNonSunMSCAPIStore() throws Exception { + KeyStore keystore = nonSunMSCAPIKeystore( + key(HOST, CertType.CLIENT_AUTH), + key(HOST + " (1)", CertType.SERVER_AUTH)); + X509Certificate presented = present(HOST, keystore); + Assert.assertEquals(keystore.getCertificate(HOST), presented); + } + + + @Test + public void testConfiguredAliasKeepsBaseWhenAlreadyServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.SERVER_AUTH), + key(HOST + " (1)", CertType.CLIENT_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasUsesSecondDuplicateWhenFirstIsNotServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.CLIENT_AUTH), + key(HOST + " (1)", CertType.CLIENT_AUTH), + key(HOST + " (2)", CertType.SERVER_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasSelectsFirstOfSeveralServerAuthDuplicates() throws Exception { + KeyStore keystore = orderedKeystore(null, + key("tomcat", CertType.CLIENT_AUTH), + key("tomcat (1)", CertType.SERVER_AUTH), + key("tomcat (2)", CertType.SERVER_AUTH)); + X509Certificate presented = present("tomcat", keystore); + Assert.assertEquals(keystore.getCertificate("tomcat (1)"), presented); + } + + + @Test + public void testConfiguredAliasSkipsTrustedDuplicateAndUsesNextKeyEntry() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.CLIENT_AUTH), + KeystoreEntry.trusted(HOST + " (1)"), + key(HOST + " (2)", CertType.SERVER_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasFallsBackToBaseWhenNoDuplicateIsServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.CLIENT_AUTH), + key(HOST + " (1)", CertType.CLIENT_AUTH)); + assertEku(presented, CLIENT_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasIgnoresAliasNotMatchingDuplicateFormat() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CertType.CLIENT_AUTH), + key(HOST + "(1)", CertType.SERVER_AUTH)); + assertEku(presented, CLIENT_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasWithMalformedExtendedKeyUsageIsUsed() throws Exception { + KeyStore keystore = orderedKeystore("malformed", key("malformed", CertType.CLIENT_AUTH), key("server", CertType.SERVER_AUTH)); + Assert.assertEquals(keystore.getCertificateChain("malformed")[0], present("malformed", keystore)); + } + + + @Test + public void testConfiguredAliasUsesNumberedDuplicateWhenBaseIsMalformed() throws Exception { + KeyStore keystore = + orderedKeystore("malformed", key("malformed", CertType.CLIENT_AUTH), key("malformed (1)", CertType.SERVER_AUTH)); + assertEku(present("malformed", keystore), Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredMissingAliasFails() { + Assert.assertThrows(IOException.class, () -> present("missing", key("server", CertType.SERVER_AUTH))); + } + + + @Test + public void testConfiguredTrustedAliasFails() { + Assert.assertThrows(IOException.class, () -> present("trusted", KeystoreEntry.trusted("trusted"), + key("server", CertType.SERVER_AUTH))); + } + + + /* + * 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, CertType.CLIENT_AUTH), key(HOST + " (1)", CertType.SERVER_AUTH)); + + Set monitored = certificatesMonitoredForReload(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 + * non-key (trusted) entry, so the server-auth numbered duplicate must still be selected rather than failing + * start-up. + */ + @Test + public void testConfiguredTrustedBaseAliasUsesServerAuthDuplicate() throws Exception { + X509Certificate presented = present(HOST, + KeystoreEntry.trusted(HOST), + key(HOST + " (1)", CertType.SERVER_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasStopsAtGapAndKeepsBase() throws Exception { + X509Certificate presented = present("tomcat", + key("tomcat", CertType.CLIENT_AUTH), + key("tomcat (2)", CertType.SERVER_AUTH)); + assertEku(presented, CLIENT_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, CertType.CLIENT_AUTH), key(HOST + " (1)", CertType.SERVER_AUTH)); + + Set monitored = certificatesMonitoredForReload(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. + */ + @Test + public void testServedAliasIsTheNumberedDuplicate() throws Exception { + Assume.assumeTrue("JSSE".equals(connectorName)); + KeyStore keystore = nonExportableKeystore(key(HOST, CertType.CLIENT_AUTH), key(HOST + " (1)", CertType.SERVER_AUTH)); + Assert.assertEquals(HOST + " (1)", servedAlias(keystore)); + } + + + /* + * A SunMSCAPI key that reports a null format but is still exportable keeps the original key store in use + * (ksUsed == ks) and wraps each key manager, like a real non-exportable key, while remaining loadable by OpenSSL. + * In that wrapped path a lookup of the configured alias still resolves its own (clientAuth) chain, so the served + * alias - not that lookup - must drive what OpenSSL installs. + */ + @Test + public void testServedAliasResolvesForOpenSSLWithOriginalKeystore() throws Exception { + Assume.assumeTrue(useOpenSSL); + KeyStore keystore = wrappedExportableKeystore(key(HOST, CertType.CLIENT_AUTH), key(HOST + " (1)", CertType.SERVER_AUTH)); + Assert.assertEquals(HOST + " (1)", servedAlias(keystore)); + } +} diff --git a/test/org/apache/tomcat/util/net/localhost-rsa-anyauth.jks b/test/org/apache/tomcat/util/net/localhost-rsa-anyauth.jks new file mode 100644 index 000000000000..9133e6551bbd Binary files /dev/null and b/test/org/apache/tomcat/util/net/localhost-rsa-anyauth.jks differ diff --git a/test/org/apache/tomcat/util/net/localhost-rsa-clientauth.jks b/test/org/apache/tomcat/util/net/localhost-rsa-clientauth.jks new file mode 100644 index 000000000000..5c330f1f8ed7 Binary files /dev/null and b/test/org/apache/tomcat/util/net/localhost-rsa-clientauth.jks differ diff --git a/test/org/apache/tomcat/util/net/localhost-rsa-noeku.jks b/test/org/apache/tomcat/util/net/localhost-rsa-noeku.jks new file mode 100644 index 000000000000..4cccd21a4bd6 Binary files /dev/null and b/test/org/apache/tomcat/util/net/localhost-rsa-noeku.jks differ diff --git a/test/org/apache/tomcat/util/net/localhost-rsa-serverauth.jks b/test/org/apache/tomcat/util/net/localhost-rsa-serverauth.jks new file mode 100644 index 000000000000..dae52f78a36b Binary files /dev/null and b/test/org/apache/tomcat/util/net/localhost-rsa-serverauth.jks differ diff --git a/test/org/apache/tomcat/util/net/localhost-rsa-serverauth2.jks b/test/org/apache/tomcat/util/net/localhost-rsa-serverauth2.jks new file mode 100644 index 000000000000..32aa3d834c5d Binary files /dev/null and b/test/org/apache/tomcat/util/net/localhost-rsa-serverauth2.jks differ diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index f5ef7143050c..4d2b724a348f 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -484,6 +484,21 @@ drop TLS 1.3 when rebuilding the list of enabled protocols during initialization. (dsoumis) + + When a Certificate is configured using a key store and no + certificateKeyAlias is set, ignore key entries whose + certificate has a malformed Extended Key Usage extension or one that + does not permit TLS server authentication when automatically selecting + which key in the key store to use. A configured + certificateKeyAlias is used even if its certificate is not + valid for TLS server authentication, except in the Windows-MY key store + accessed through the SunMSCAPI provider: there, if the configured + certificate is not valid but the store holds a valid certificate under + that alias with a numeric suffix appended - the format that provider uses + to give unique aliases to several certificates that share a name - the + suffixed certificate is used instead. + (dsoumis) + Keep the asynchronous timeout in place until the timeout event has @@ -816,6 +831,10 @@ Manager: Drop session handling dedicated to extracting the locale from Tapestry attributes, used for locale session sorting. (remm) + + Documentation: Describe how to configure the Windows certificate store + and reload externally renewed TLS certificates. (dsoumis) + Host Manager: Host names are now always converted to lower case before diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml index 98607caa227b..c235bf0372ae 100644 --- a/webapps/docs/config/http.xml +++ b/webapps/docs/config/http.xml @@ -1664,12 +1664,34 @@

JSSE only.

The alias used for the server key and certificate in the keystore. If - not specified, the first key read from the keystore will be used. The + not specified, the first eligible key read from the keystore will be used. The order in which keys are read from the keystore is implementation dependent. It may not be the case that keys are read from the keystore in the same order as they were added. If more than one key is present in the keystore it is strongly recommended that a keyAlias is configured to ensure that the correct key is used.

+

If no alias is specified, key entries whose certificate has a malformed + Extended Key Usage extension, or one that includes neither + serverAuth nor anyExtendedKeyUsage, are ignored + when selecting the key to use. Certificates with no Extended Key Usage + extension remain eligible. If no eligible key entry is found, connector + initialization fails.

+

A configured alias is used even if its certificate is not valid for TLS + server authentication. The exception is a Windows-MY store accessed through + the SunMSCAPI provider (key store type Windows-MY, + Windows-MY-CURRENTUSER or Windows-MY-LOCALMACHINE): + if the certificate at the configured alias is + not valid there, but the store also contains a key entry whose alias is the + configured alias with " (1)", " (2)", etc. appended + and whose certificate is valid, that certificate is used instead. That store + exposes such duplicates because the key store API requires aliases to be + unique even when several certificates share a name. If none of those numbered + duplicates is valid, or the key store is of any other type, the configured + alias is used unchanged.

+

The SunMSCAPI provider does not expose a certificate's original Windows + friendly name through the key store API, so the match is a heuristic based + on the appended suffix alone (see + JDK-8395755).

@@ -1859,9 +1881,13 @@

In addition to the standard key store types (JKS and PKCS12), most Java - runtimes support additional key store types such as Windows-ROOT, - Windows-My, DKS as well as hardware security modules. Generally, to use - these additional keystore types with a TLS Connector in Tomcat:

+ runtimes support additional key store types such as Windows-MY, Windows-ROOT, + DKS as well as hardware security modules. On Windows, Windows-MY and + Windows-ROOT access the current user's certificate store, while + Windows-MY-LOCALMACHINE and Windows-ROOT-LOCALMACHINE access the local machine + store (reading the local machine store from a non-elevated process requires + Java 17.0.17, 21.0.9, 24 or later). Generally, to use these additional key + store types with a TLS Connector in Tomcat:

  • Set the certificateKeystoreType and/or truststoreType Connector diff --git a/webapps/docs/ssl-howto.xml b/webapps/docs/ssl-howto.xml index 8eee9f1210a4..6dc648d1595b 100644 --- a/webapps/docs/ssl-howto.xml +++ b/webapps/docs/ssl-howto.xml @@ -446,6 +446,118 @@ mailing list.

    +
    + +

    When no certificateKeyAlias is configured, Tomcat selects the +first eligible key entry in the key store's enumeration order. A certificate +qualifies if its Extended Key Usage includes +serverAuth or anyExtendedKeyUsage, or if it has no +Extended Key Usage extension at all. Certificates whose Extended Key Usage +excludes both purposes or cannot be parsed are ignored. If no eligible key entry +is found, connector initialization fails.

    + +

    A configured certificateKeyAlias is normally used even if its +certificate is unsuitable for server authentication. The one exception is a +Windows-MY store accessed through the SunMSCAPI provider (key store type +Windows-MY, Windows-MY-CURRENTUSER or +Windows-MY-LOCALMACHINE): if the certificate at the configured alias +is unsuitable there, but the store also holds a suitable +certificate under that same name with " (1)", " (2)", +etc. appended - the way that store gives unique aliases to several certificates +that share a name, since the key store API requires aliases to be unique - the +suffixed certificate is used instead. This lets one configured alias keep working +on a store that holds more than one certificate under that name. Every +other key store type uses the configured alias as given. When several +differently-named certificates are eligible, configure an alias to ensure that the +intended certificate is used.

    + +

    Selection is made solely on the appended " (N)" suffix. The +SunMSCAPI provider does not expose a certificate's original Windows friendly name +through the key store API (see +JDK-8395755), so a suffix +that is genuinely part of a certificate's own name cannot be told apart from one +added to disambiguate a shared name. The result is therefore a best-effort +heuristic that selects the first numbered alias whose certificate is suitable.

    + +
    + +
    + +

    When certificates are renewed by an external process the +TLS certificate reload listener can load the renewed certificate without +restarting Tomcat. Add it to the Server element:

    + + + + ... +]]> + +

    The listener periodically checks each certificate and, when one is within +daysBefore days of expiry (14 by default), reloads the associated +SSLHostConfig so that the renewed certificate is picked up. The +certificate must be renewed by another process; the listener does not renew it +or re-read server.xml. See the +listener documentation for the available +attributes.

    + +
    + +
    + +

    On Windows, Tomcat can read certificates directly from the Windows certificate +store. Set certificateKeystoreType to +Windows-MY-LOCALMACHINE for the local machine store, or to +Windows-MY for the current user's store. Reading the local machine +store from a Tomcat service that is not running with elevated privileges requires +Java 17.0.17, 21.0.9, 24, or a later release; on earlier releases the service must +run with elevated privileges. See +JDK-8313367. +AD CS autoenrollment can issue and renew the machine's certificates +automatically.

    + +

    A Windows certificate store may contain several certificates for the same +machine. Configure certificateKeyAlias with the alias exposed +by Java for the intended certificate. If no alias is configured, Tomcat uses the +first eligible key read from the store, in an implementation-dependent order. The +following example uses the alias tomcat; replace it with the alias +of the certificate to use:

    + + + + + +]]> + +

    When using the +reload listener +with a configured alias, ensure that the +renewed certificate is available under that alias. Reloading does not select a +different alias or prefer a certificate with a later expiry date.

    + +

    If the certificate's private key is marked non-exportable, pin the JSSE implementation by adding +sslImplementationName="org.apache.tomcat.util.net.jsse.JSSEImplementation" +to the Connector. A connector otherwise selects an OpenSSL +implementation automatically when one is available, and OpenSSL cannot use a +private key it is unable to read, so initialization fails. An exportable key works +with either implementation.

    + +

    Two points to note:

    + +
      +
    • The account running Tomcat must have read access to the certificate's private + key. A renewed certificate may use a new key that does not inherit this + access, so grant it as part of the enrollment or renewal process.
    • +
    • Setting a unique friendly name on the intended certificate can help identify + it for use as the certificateKeyAlias. Verify the alias + exposed by Java, especially when several certificates share a name.
    • +
    + +
    +

    Tomcat supports the Online Certificate Status Protocol (OCSP) to verify the status of client provided certificates for a sub-set of connector