This is an automated email from the ASF dual-hosted git repository. dsoumis pushed a commit to branch 9.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit a65f4b6ce4132ce0bdd4ffb0e83110f67cc6a3f2 Author: Dimitris Soumis <[email protected]> AuthorDate: Thu Oct 22 18:33:53 2026 +0300 Select a key store certificate valid for TLS server authentication A key store can hold more than one certificate for a host. For example, a Windows-MY store populated by AD CS autoenrollment may contain both a TLS server certificate and a client authentication certificate. When no key alias was configured, automatic selection used the first key entry regardless of its key usage and could pick a certificate that is not valid for TLS server authentication. When no certificateKeyAlias is configured, skip key entries whose certificate is not valid for T [...] A configured certificateKeyAlias is always used unchanged, even if its certificate is not valid for server authentication. --- java/org/apache/tomcat/util/net/Constants.java | 6 + .../apache/tomcat/util/net/LocalStrings.properties | 1 + java/org/apache/tomcat/util/net/SSLUtilBase.java | 59 +++- .../util/net/TestSSLUtilBaseKeyUsageSelection.java | 343 +++++++++++++++++++++ .../tomcat/util/net/TesterKeystoreGenerator.java | 185 +++++++++++ webapps/docs/changelog.xml | 9 + webapps/docs/config/http.xml | 9 +- webapps/docs/ssl-howto.xml | 19 +- 8 files changed, 626 insertions(+), 5 deletions(-) diff --git a/java/org/apache/tomcat/util/net/Constants.java b/java/org/apache/tomcat/util/net/Constants.java index 701835b696..cfc9af27de 100644 --- a/java/org/apache/tomcat/util/net/Constants.java +++ b/java/org/apache/tomcat/util/net/Constants.java @@ -70,6 +70,12 @@ public class Constants { public static final boolean IS_SECURITY_ENABLED = (System.getSecurityManager() != null); + /** + * 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"; + /** * 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 c0f83c0e9e..724d1aa2a6 100644 --- a/java/org/apache/tomcat/util/net/LocalStrings.properties +++ b/java/org/apache/tomcat/util/net/LocalStrings.properties @@ -211,6 +211,7 @@ sslUtilBase.noKeys=No aliases for private keys found in key store 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.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/SSLUtilBase.java b/java/org/apache/tomcat/util/net/SSLUtilBase.java index f4ea8ba56f..875319bf59 100644 --- a/java/org/apache/tomcat/util/net/SSLUtilBase.java +++ b/java/org/apache/tomcat/util/net/SSLUtilBase.java @@ -36,6 +36,7 @@ import java.security.cert.CertificateException; 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; @@ -402,10 +403,18 @@ public abstract class SSLUtilBase implements SSLUtil { throw new IOException(sm.getString("sslUtilBase.noKeys")); } 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 && !isServerAuthCapable((X509Certificate) cert)) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("sslUtilBase.skippedKeyUsage", candidate)); + } + continue; + } + keyAlias = candidate; } if (keyAlias == null) { throw new IOException(sm.getString("sslUtilBase.alias_no_key_entry", (Object) null)); @@ -451,6 +460,50 @@ public abstract class SSLUtilBase implements SSLUtil { } + /** + * 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. + * <p> + * The policy is deliberately lenient about absent usage, strict about declared usage and tolerant of a malformed + * extension: + * <ul> + * <li>A certificate with <strong>no EKU extension</strong> ({@link X509Certificate#getExtendedKeyUsage()} returns + * {@code null}) is treated as usable. An absent EKU places no restriction on the certificate's purpose + * so rejecting them would needlessly exclude the many server certificates issued without an EKU.</li> + * <li>A certificate that <strong>does</strong> carry an EKU extension is usable only if that extension explicitly + * lists the {@code id-kp-serverAuth} OID ({@value Constants#SERVER_AUTH_EXTENDED_KEY_USAGE_OID}) or the + * {@code anyExtendedKeyUsage} OID ({@value Constants#ANY_EXTENDED_KEY_USAGE_OID}). Any other declared usage + * - for example a certificate limited to client authentication - is rejected.</li> + * <li>A certificate whose EKU extension <strong>cannot be decoded</strong> is ignored (treated as not usable).</li> + * </ul> + * + * @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<String> objectIdentifiers; + try { + objectIdentifiers = cert.getExtendedKeyUsage(); + } catch (CertificateParsingException 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; + } + @Override public String[] getEnabledProtocols() { return enabledProtocols; diff --git a/test/org/apache/tomcat/util/net/TestSSLUtilBaseKeyUsageSelection.java b/test/org/apache/tomcat/util/net/TestSSLUtilBaseKeyUsageSelection.java new file mode 100644 index 0000000000..84c500a7ca --- /dev/null +++ b/test/org/apache/tomcat/util/net/TestSSLUtilBaseKeyUsageSelection.java @@ -0,0 +1,343 @@ +/* + * 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.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreSpi; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.net.ssl.X509KeyManager; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +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.apache.tomcat.util.net.TesterKeystoreGenerator.CertificateExtensionsCustomizer; +import org.apache.tomcat.util.net.TesterKeystoreGenerator.KeystoreEntry; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.easymock.EasyMock; + +/** + * Tests that, when no alias is configured, a key entry whose certificate is valid for TLS server authentication is + * auto-selected and entries that are not valid (by Extended Key Usage) are ignored. A configured alias is always used + * unchanged, regardless of its Extended Key Usage. + */ +@RunWith(Parameterized.class) +public class TestSSLUtilBaseKeyUsageSelection extends TomcatBaseTest { + + @Parameterized.Parameters(name = "{0}") + public static Collection<Object[]> parameters() { + return Arrays.asList(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(); + } + + private static final String HOST = "localhost"; + + private static final String CLIENT_AUTH_EXTENDED_KEY_USAGE_OID = "1.3.6.1.5.5.7.3.2"; + + private static final CertificateExtensionsCustomizer SERVER_AUTH = (keyPair, builder) -> builder + .addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth)); + private static final CertificateExtensionsCustomizer CLIENT_AUTH = (keyPair, builder) -> builder + .addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth)); + private static final CertificateExtensionsCustomizer ANY_AUTH = (keyPair, builder) -> builder + .addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(KeyPurposeId.anyExtendedKeyUsage)); + + + private static KeystoreEntry key(String alias, CertificateExtensionsCustomizer eku) { + return KeystoreEntry.key(alias, HOST, eku); + } + + + private X509Certificate present(String alias, KeystoreEntry... entries) throws Exception { + return present(alias, orderedKeystore(null, entries)); + } + + + private 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]; + } + + + private SSLUtil sslUtil(String alias, KeyStore keystore) throws Exception { + + SSLHostConfig hostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(hostConfig, Type.UNDEFINED); + certificate.setCertificateKeystore(keystore); + certificate.setCertificateKeystorePassword(TesterSupport.JKS_PASS); + if (alias != null) { + certificate.setCertificateKeyAlias(alias); + } + + return sslUtil(certificate); + } + + + private 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; + } + + + private static KeyStore orderedKeystore(String malformedAlias, KeystoreEntry... entries) throws Exception { + File file = TesterKeystoreGenerator.generateKeystore(Arrays.asList(entries)); + KeyStore delegate = KeyStore.getInstance("JKS"); + try (InputStream is = new FileInputStream(file)) { + delegate.load(is, TesterSupport.JKS_PASS.toCharArray()); + } + List<String> aliases = Stream.of(entries).map(KeystoreEntry::alias).collect(Collectors.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(() -> delegate.getKey(EasyMock.getCurrentArgument(0), EasyMock.getCurrentArgument(1))); + EasyMock.replay(spi); + KeyStore result = new KeyStore(spi, delegate.getProvider(), "JKS") { }; + result.load(null, null); + return result; + } + + + private static void assertEku(X509Certificate certificate, String oid) throws Exception { + List<String> 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)); + } + + + /* + * A configured alias must be honored exactly, even when its certificate is not valid for server authentication and + * a server-auth certificate sharing the Subject is also present. Auto-selection must never override the user's + * choice (see the uses of SSLHostConfigCertificate.getCertificateKeyAlias() across the connectors). + */ + @Test + public void testConfiguredAliasIsUsedUnchangedWhenNotServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, CLIENT_AUTH), + key("localhost-server", SERVER_AUTH)); + assertEku(presented, CLIENT_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasIsUsedWhenServerAuth() throws Exception { + X509Certificate presented = present(HOST, + key(HOST, SERVER_AUTH), + key("localhost-client", CLIENT_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testAutoSelectPrefersServerAuthOverClientAuth() throws Exception { + X509Certificate presented = present(null, + key("first", CLIENT_AUTH), + key("second", SERVER_AUTH)); + assertEku(presented, Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testAutoSelectAcceptsAnyExtendedKeyUsage() throws Exception { + X509Certificate presented = present(null, + key("first", CLIENT_AUTH), + key("any", ANY_AUTH)); + assertEku(presented, Constants.ANY_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testAutoSelectAcceptsCertificateWithoutExtendedKeyUsage() throws Exception { + X509Certificate presented = present(null, + key("first", CLIENT_AUTH), + key("none", null)); + Assert.assertNull("A certificate with no Extended Key Usage is unrestricted and must be selectable", + presented.getExtendedKeyUsage()); + } + + + /* + * Trusted (keyless) certificate entries are not key entries, so they can never be selected as the server key even + * when they are the only server-auth-capable certificate. + */ + @Test + public void testAutoSelectSkipsTrustedEntries() { + Assert.assertThrows(IOException.class, () -> present(null, + KeystoreEntry.trusted("ca", HOST, SERVER_AUTH), + key("key", CLIENT_AUTH))); + } + + + /* + * No unsuitable key entry may be selected when all certificates exclude server authentication. + */ + @Test + public void testAutoSelectFailsWhenNoneServerAuth() { + Assert.assertThrows(IOException.class, () -> present(null, + key("first", CLIENT_AUTH), + key("second", CLIENT_AUTH))); + } + + + @Test + public void testAutoSelectSkipsMalformedExtendedKeyUsage() throws Exception { + KeyStore keystore = orderedKeystore("malformed", key("malformed", CLIENT_AUTH), key("server", SERVER_AUTH)); + assertEku(present(null, keystore), Constants.SERVER_AUTH_EXTENDED_KEY_USAGE_OID); + } + + + @Test + public void testConfiguredAliasWithMalformedExtendedKeyUsageIsUsed() throws Exception { + KeyStore keystore = orderedKeystore("malformed", key("malformed", CLIENT_AUTH), key("server", SERVER_AUTH)); + Assert.assertEquals(keystore.getCertificateChain("malformed")[0], present("malformed", keystore)); + } + + + @Test + public void testAutoSelectFailsWhenOnlyMalformedExtendedKeyUsage() throws Exception { + KeyStore keystore = orderedKeystore("malformed", key("malformed", CLIENT_AUTH)); + Assert.assertThrows(IOException.class, () -> present(null, keystore)); + } + + + @Test + public void testAutoSelectUsesFirstEligibleKey() throws Exception { + KeyStore keystore = orderedKeystore(null, key("client", CLIENT_AUTH), key("first", ANY_AUTH), + key("second", SERVER_AUTH)); + Assert.assertEquals(keystore.getCertificate("first"), present(null, keystore)); + } + + + @Test + public void testConfiguredMissingAliasFails() { + Assert.assertThrows(IOException.class, () -> present("missing", key("server", SERVER_AUTH))); + } + + + @Test + public void testConfiguredTrustedAliasFails() { + Assert.assertThrows(IOException.class, () -> present("trusted", KeystoreEntry.trusted("trusted", HOST, SERVER_AUTH), + key("server", SERVER_AUTH))); + } + + + @Test + public void testAutoSelectDoesNotSkipKeyReadFailure() throws Exception { + KeyStore keystore = orderedKeystore(null, key("first", SERVER_AUTH), key("second", SERVER_AUTH)); + SSLHostConfig hostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(hostConfig, Type.UNDEFINED); + certificate.setCertificateKeystore(keystore); + 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", CLIENT_AUTH), key("server", SERVER_AUTH)); + SSLContext context = sslUtil(null, keystore).createSSLContext(Collections.emptyList()); + try { + Assert.assertEquals(keystore.getCertificate("server"), context.getCertificateChain("server")[0]); + } finally { + context.destroy(); + } + } + + + @Test + public void testSSLContextLoadsSelectedCertificateFromKeystoreFile() throws Exception { + File file = TesterKeystoreGenerator.generateKeystore( + Arrays.asList(key(SSLUtilBase.DEFAULT_KEY_ALIAS, CLIENT_AUTH), key("server", SERVER_AUTH))); + KeyStore keystore = KeyStore.getInstance("JKS"); + try (InputStream is = new FileInputStream(file)) { + keystore.load(is, 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(Collections.emptyList()); + try { + Assert.assertEquals(keystore.getCertificate("server"), context.getCertificateChain("server")[0]); + } finally { + context.destroy(); + } + } +} diff --git a/test/org/apache/tomcat/util/net/TesterKeystoreGenerator.java b/test/org/apache/tomcat/util/net/TesterKeystoreGenerator.java new file mode 100644 index 0000000000..0bc8e6a01e --- /dev/null +++ b/test/org/apache/tomcat/util/net/TesterKeystoreGenerator.java @@ -0,0 +1,185 @@ +/* + * 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.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +public final class TesterKeystoreGenerator { + + private TesterKeystoreGenerator() {} + + @FunctionalInterface + public interface CertificateExtensionsCustomizer { + void customize(KeyPair keyPair, X509v3CertificateBuilder certBuilder) + throws Exception; + } + + /** + * Generate a temporary JKS keystore from an explicit list of entries. Each entry has its own Common Name and may be + * either a private key entry or a trusted certificate entry, which lets tests reproduce stores that mix certificates + * with different Subjects and certificate-only (keyless) entries. + * + * @param entries the entries to add, in order + * + * @return a temporary keystore file with password {@link TesterSupport#JKS_PASS} + * + * @throws Exception if certificate generation or keystore creation fails + */ + public static File generateKeystore(List<KeystoreEntry> entries) throws Exception { + KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(null, null); + + for (KeystoreEntry entry : entries) { + CertAndKey generated = generateSelfSignedCertificate(entry.cn(), null, entry.customizer()); + if (entry.keyEntry()) { + ks.setKeyEntry(entry.alias(), generated.keyPair().getPrivate(), TesterSupport.JKS_PASS.toCharArray(), + new X509Certificate[] { generated.certificate() }); + } else { + ks.setCertificateEntry(entry.alias(), generated.certificate()); + } + } + + return storeToTempFile(ks); + } + + /** + * A single entry for {@link #generateKeystore(List)}. + */ + public static final class KeystoreEntry { + + private final String alias; + private final String cn; + private final boolean keyEntry; + private final CertificateExtensionsCustomizer customizer; + + /** + * @param alias the keystore alias + * @param cn the certificate Subject Common Name + * @param keyEntry {@code true} for a private key entry, {@code false} for a trusted certificate entry + * @param customizer extensions to add to the certificate, or {@code null} for none + */ + public KeystoreEntry(String alias, String cn, boolean keyEntry, CertificateExtensionsCustomizer customizer) { + this.alias = alias; + this.cn = cn; + this.keyEntry = keyEntry; + this.customizer = customizer; + } + + public String alias() { + return alias; + } + + public String cn() { + return cn; + } + + public boolean keyEntry() { + return keyEntry; + } + + public CertificateExtensionsCustomizer customizer() { + return customizer; + } + + public static KeystoreEntry key(String alias, String cn, CertificateExtensionsCustomizer customizer) { + return new KeystoreEntry(alias, cn, true, customizer); + } + + public static KeystoreEntry trusted(String alias, String cn, CertificateExtensionsCustomizer customizer) { + return new KeystoreEntry(alias, cn, false, customizer); + } + } + + private static CertAndKey generateSelfSignedCertificate(String cn, String[] sanNames, + CertificateExtensionsCustomizer customizer) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(4096); + KeyPair keyPair = kpg.generateKeyPair(); + + X500Name subject = new X500Name("CN=" + cn); + BigInteger serial = BigInteger.valueOf(System.nanoTime()); + long oneDay = 86400000L; + Date notBefore = new Date(System.currentTimeMillis() - oneDay); + Date notAfter = new Date(System.currentTimeMillis() + 365L * oneDay); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore, notAfter, + subject, keyPair.getPublic()); + + if (sanNames != null && sanNames.length > 0) { + GeneralName[] generalNames = new GeneralName[sanNames.length]; + for (int i = 0; i < sanNames.length; i++) { + generalNames[i] = new GeneralName(GeneralName.dNSName, sanNames[i]); + } + certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(generalNames)); + } + + if (customizer != null) { + customizer.customize(keyPair, certBuilder); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer)); + return new CertAndKey(keyPair, certificate); + } + + private static final class CertAndKey { + + private final KeyPair keyPair; + private final X509Certificate certificate; + + private CertAndKey(KeyPair keyPair, X509Certificate certificate) { + this.keyPair = keyPair; + this.certificate = certificate; + } + + private KeyPair keyPair() { + return keyPair; + } + + private X509Certificate certificate() { + return certificate; + } + } + + private static File storeToTempFile(KeyStore ks) throws Exception { + File keystoreFile = File.createTempFile("test-cert-", ".jks"); + keystoreFile.deleteOnExit(); + try (FileOutputStream fos = new FileOutputStream(keystoreFile)) { + ks.store(fos, TesterSupport.JKS_PASS.toCharArray()); + } + return keystoreFile; + } +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 0052acd639..9e068da9ba 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -513,6 +513,15 @@ <bug>70305</bug>: Move callback registrations in FFM code to the SSL context. (remm) </fix> + <update> + When a <code>Certificate</code> is configured using a key store and no + <code>certificateKeyAlias</code> 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 <code>certificateKeyAlias</code> is always used unchanged. + (dsoumis) + </update> </changelog> </subsection> <subsection name="Jasper"> diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml index b2b96a573f..b45d105606 100644 --- a/webapps/docs/config/http.xml +++ b/webapps/docs/config/http.xml @@ -1908,12 +1908,19 @@ <attribute name="certificateKeyAlias" required="false"> <p>JSSE only.</p> <p>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.</p> + <p>If no alias is specified, key entries whose certificate has a malformed + Extended Key Usage extension, or one that includes neither + <code>serverAuth</code> nor <code>anyExtendedKeyUsage</code>, 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 unchanged and is not + subject to this filtering.</p> </attribute> <attribute name="certificateKeyFile" required="false"> diff --git a/webapps/docs/ssl-howto.xml b/webapps/docs/ssl-howto.xml index 79da61cb65..36196bfa30 100644 --- a/webapps/docs/ssl-howto.xml +++ b/webapps/docs/ssl-howto.xml @@ -455,6 +455,23 @@ mailing list</a>.</p> </subsection> </section> +<section name="Automatic certificate selection"> + +<p>When no <strong>certificateKeyAlias</strong> 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 +<code>serverAuth</code> or <code>anyExtendedKeyUsage</code>, 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.</p> + +<p>A configured <strong>certificateKeyAlias</strong> is used unchanged, even if +its certificate is unsuitable for server authentication. When several certificates +are eligible, configure an alias to ensure that the intended certificate is +used.</p> + +</section> + <section name="Reloading certificates before they expire"> <p>When certificates are renewed by an external process the @@ -492,7 +509,7 @@ automatically.</p> <p>A Windows certificate store may contain several certificates for the same machine. Configure <strong>certificateKeyAlias</strong> with the alias exposed by Java for the intended certificate. If no alias is configured, Tomcat uses the -first key read from the store, in an implementation-dependent order. The +first eligible key read from the store, in an implementation-dependent order. The following example uses the alias <code>tomcat</code>; replace it with the alias of the certificate to use:</p> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
